qwen-code/docs/design/rt-optimization/rt-optimization-design.md
gwinthis 7bed56b9b6
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
feat(telemetry): foundation for skill-based RT optimization (P0+P1) (#4565)
* docs(design): add RT optimization design doc

Two-round review trail documenting the analysis path: original D1-D4
proposal, code-level verification in §6 that recanted the cost estimates,
and §7 ROI reordering after DashScope ephemeral cache implementation was
confirmed already in place — which collapsed D2's net benefit and led to
deferring D2 and D4 as won't-fix.

The doc is preserved as the canonical record of why the obvious-looking
directions (fast-model routing, prevalidate scheduling) turn out to be
dead ends, so future work doesn't relitigate the same conclusions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(design): add reduce-rounds-via-skill-design with spec-first gating

Companion design to rt-optimization-design.md. The core argument:
the real lever for reducing agent loop rounds is at the skill/tool
design layer, not the agent framework. Round 2 in §1.2's baseline
exists because Round 1's skill didn't return a complete answer —
fixing that per-skill collapses 3 rounds into 2, an angle the
original framework-centric proposal completely missed.

Layout:
- §0 acceptance spec is the front-loaded gate: engineering specs
  lock at P-1, statistical thresholds lock at P1.5 (after baseline),
  per-skill specs are data-driven and live in PR descriptions
- §3-§4 three-layer plan: telemetry → per-skill rewrites → prompt
  guidance for concurrent tool calls; each layer is independently
  measurable and reversible
- §5.3 stop-loss lines split into result + process metrics to catch
  the "looks like progress, no actual ROI" failure mode early

The doc was reviewed by codex twice — once on initial draft (caught
qwen-logger dead-code path, batch_size state-passing cost, prompts.ts
line drift) and once after §0 was added (caught spec rigidity, missing
per-skill template, framework boundary case). Both rounds' findings
were either applied or explicitly recorded as not-adopted with reasons
inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telemetry): connect logSkillLaunch to QwenLogger

logSkillLaunch in loggers.ts went only through the OTLP path, while
QwenLogger.logSkillLaunchEvent in qwen-logger.ts had no callers anywhere
in the repo — leaving the skill_launch event invisible to any backend
that consumes from the qwen-logger pipeline rather than OTLP.

Mirror the logToolCall pattern at loggers.ts:230: forward the event to
QwenLogger before the OTLP path so the call still reaches QwenLogger when
the OTEL SDK is not initialized.

This is P0 of docs/design/rt-optimization/reduce-rounds-via-skill-design.md
§4.1.1b — a prerequisite for the prompt_id propagation in P1 so the
SkillLaunchEvent / ToolCallEvent join in §4.1.2 has data to query against.

Tests: 2 new cases under describe('logSkillLaunch') covering forwarding
to QwenLogger plus the OTLP-uninitialized branch; loggers.test.ts now
47/47 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(telemetry): thread prompt_id through SkillLaunchEvent

To join skill_launch events with the subsequent tool_call events they
trigger, SkillLaunchEvent now carries the prompt_id of the user turn
that fired the skill. The scheduler already holds the request and its
prompt_id; the missing piece was getting that id into the invocation
that does the actual logSkillLaunch call.

Wiring:
- SkillLaunchEvent constructor adds a required prompt_id parameter so
  the field can never be silently undefined in a backend join.
- SkillToolInvocation exposes setPromptId(id) and stores the value;
  the four logSkillLaunch sites in execute() pass this.promptId through.
- CoreToolScheduler.buildInvocation grew an optional fourth promptId
  argument and duck-types setPromptId on the freshly-built invocation,
  mirroring the existing setCallId hook. The two callers (setArgs path
  at L1036 and the main schedule path at L1497) pass
  request.prompt_id / reqInfo.prompt_id.
- qwen-logger.logSkillLaunchEvent forwards prompt_id in the RUM event
  properties so the join works on the qwen-logger pipeline too.

The empty-string default on SkillToolInvocation.promptId is deliberate:
direct invocations (e.g. buildAndExecute in tests) that skip the
scheduler still log a valid event, and downstream queries can filter
prompt_id != '' to exclude non-scheduled launches from joins.

Implements P1 of docs/design/rt-optimization/reduce-rounds-via-skill-design.md
§4.1.1 — required prerequisite for the SkillFollowupRecord SQL in §4.1.2.

Tests: 2 new cases in skill.test.ts cover the setPromptId path and the
empty-default path; loggers.test.ts updated for the new 3-arg signature.
256 tests pass across loggers / skill / coreToolScheduler suites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(scheduler): cover prompt_id propagation through buildInvocation

The duck-typed setPromptId hook added in the previous commit went
through unit tests on each side independently — SkillToolInvocation
tests verified that setting the field changes the logged event, and
the loggers tests verified the SkillLaunchEvent shape — but the
integration point in CoreToolScheduler.buildInvocation that wires the
two together was only exercised indirectly. Same is true of the older
setCallId hook it mirrors, which had no test at all.

Two cases here close that gap on the scheduler side:

- A purpose-built PromptIdAwareTool whose invocation records every
  setPromptId call; the test schedules a request with a known
  prompt_id and asserts the invocation captured it. This is the
  positive contract.

- The existing TestApprovalTool (no setPromptId) scheduled through
  the same path to confirm the duck-type guard does not throw when
  the method is absent. This is the backward-compatibility contract
  that lets every existing tool keep working unchanged.

The two cases together pin both branches of the typeof check in
buildInvocation, so future refactors of that hook cannot regress
silently. 165 tests in the suite still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(telemetry,scheduler,skill): close P0/P1 coverage gaps

Three blind spots remained after the initial P0+P1 work — each one
was a path that the production code change had already touched
mechanically but no test was pinning it against future regressions.

qwen-logger.ts logSkillLaunchEvent now has two cases asserting that
prompt_id reaches the RUM event properties, on both the success and
failure branch. Previously the loggers.test.ts spy stopped at "method
was called" and never inspected the payload qwen-logger built.

skill.ts had four logSkillLaunch sites, but only the happy path and
the empty-default path were tested. The commandExecutor-success
branch (L386), not-found branch (L399), and thrown-exception branch
(L482) now each have a test that sets promptId, drives execute()
through that specific path, and asserts the emitted event carries
both the right success flag and the right prompt_id. This catches
the failure mode where someone later edits one of those branches
and forgets the promptId argument — replace_all guaranteed today's
correctness but no test would catch a regression tomorrow.

CoreToolScheduler.buildInvocation now has two direct unit tests
that exercise the method through a type-assertion cast. Reaching
the L1036 setArgs path through the public API would require mocking
modifyWithEditor + the filesystem + an editor type, which would
dwarf the change under test. The direct call covers both L1036 and
L1497 simultaneously: when promptId is supplied the duck-typed
setPromptId is invoked; when it is omitted, the captured field
stays undefined and no throw happens.

298 tests pass across loggers / qwen-logger / skill / scheduler suites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review(PR #4565): address Copilot + github-actions feedback

Five spots flagged by the automated review on #4565. Three were real
and worth fixing; two were comment-quality touch-ups that traveled
along with the same patch.

- SkillLaunchEvent.prompt_id is now optional with a default of '',
  removing the breaking-change footprint on the exported telemetry API.
  All current internal callers still pass the value explicitly through
  the SkillToolInvocation.promptId field, so the §0.1 spec ("prompt_id
  串联") is still enforced in production paths — type-level enforcement
  just steps aside in favor of API stability, with §0.5 治理 covering
  the discipline at the process layer.

- The skill-design doc §4.1.1 used to claim "BaseToolInvocation 已有
  request.prompt_id" which is wrong: BaseToolInvocation only holds
  params, and the prompt_id flows through CoreToolScheduler's duck-typed
  setPromptId hook (mirroring setCallId). The doc now reflects the
  actual implementation and notes that the earlier text was the bug.

- CoreToolScheduler.buildInvocation gained a short JSDoc explaining
  why the two extra args (callId, promptId) are optional — they
  match the existing duck-type pattern that lets older tools and
  non-scheduler call sites work without implementing the setters.

- skill.test.ts adds a one-comment note next to the first setPromptId
  cast explaining that setPromptId is a scheduler-only hook, not part
  of the public ToolInvocation interface.

- SkillToolInvocation.promptId field comment shrank from 8 lines to 2
  with a pointer to the design doc so the inline noise drops without
  losing the empty-string semantics.

Pre-existing scope-creep findings (Chinese-only doc, mock-config
duplication in scheduler tests, redundant optional-chain comment,
prompt_id sanitization for an internally-generated UUID) are
deliberately not addressed here — see the reply on PR #4565 for
disposition per item.

298 tests still pass across loggers / qwen-logger / skill / scheduler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 02:17:40 +08:00

78 KiB
Raw Permalink Blame History

Qwen Code Agent Loop RT 优化技术方案

1. 背景与问题定义

1.1 现状

Qwen Code 的 Agent Loop 为严格串行模型:

User Prompt → [LLM 决策] → Tool Execution → [LLM 决策] → Tool Execution → ... → [LLM 回复] → Idle
               ~3-4s          ~Xms-Ns          ~3-4s          ~Xms-Ns            ~3-4s

每一轮 LLM 调用(含网络 RTT + 模型推理)约 3-4s是端到端 RT 的主要成本。

1.2 实测数据

测试场景:"我有哪些工作空间"3 轮 agent loop2 次工具调用,单次采样)

阶段 耗时 占比
LLM Round 1决策调 skill 3.8s 28%
Skill 执行 1ms <1%
LLM Round 2决策调 shell 3.0s 22%
Shell 执行 2.5s 19%
LLM Round 3文字总结 3.8s 28%
框架开销(状态同步、渲染) 0.3s 3%
总计 13.4s 100%

结论LLM 调用占 78%,工具执行 19%,框架 3%。优化的核心是减少 LLM 调用次数降低单次 LLM 调用延迟

单次采样、单一场景。19% 工具执行是 shell 慢调用支配read-heavy 场景下工具执行可降至 <5%。方案落地前需补 ≥3 类场景(写操作、跨工具推理、错误恢复)的基线。

1.3 当前架构关键约束

约束 代码位置 说明
工具结果无后置控制 tools.ts ToolResult 接口 (L422) 仅有 llmContent/returnDisplay/error,无法表达"跳过 LLM"
结果无条件回传 LLM useGeminiStream.ts handleCompletedTools (L2038) → submitQuery(ToolResult, …) (L2355) 所有 gemini-initiated 工具结果都回传
Stream 完毕后才调度 useGeminiStream.ts processGeminiStreamEvents (L1365) stream 循环结束后才 scheduleToolCalls,无增量调度
模型层选择无策略层 client.ts modelOverride ?? getModel() (L1305, L1598) 基础设施已贯通至 turn.run(model, …) (L1707),但调用方仅在 skill 显式指定时使用

1.4 已就绪的基础设施(本方案大量复用)

能力 位置 现状
fastModel 配置 + /model --fast <id> config.ts:684, 1987, 2021 已就绪
SendMessageOptions.modelOverride client.ts:1421598turn.run 端到端贯通至 geminiChat.sendMessageStream(model, …)
钩子层 modelOverrideRef(承载 skill 选模型) useGeminiStream.ts:376, 2225, 1841 已贯通
fast-model 非流式 side query 先例 services/toolUseSummary.ts:108via runSideQuery 已上线,证明 fast 模型配置健全;但非流式路径
fast-model 流式 先例 followup/speculation.ts:224 已上线,但用的是 forked chatcreateForkedChat),与主 chat 隔离

关键空白没有任何生产代码在主 chat 上以 fast model 跑 streaming。本方案 D2 是首个 case需先做验证实验详见 §3.2 前置条件)。


2. 设计原则

  1. 通用性:方案不绑定特定 tool/skill
  2. 向后兼容:现有工具无需修改即可继续工作
  3. 渐进式 + 显式信号:策略默认 conservative由工具作者通过显式字段 opt-in 优化
  4. 可回滚:所有优化通过 feature flag 控制;用户级别可强制关闭
  5. 诚实的权衡:明确标注质量风险、成本风险和适用边界

3. 优化方案

3.1 方向一工具后置执行指令ToolResult Post-Execution Directive

问题

当前 ToolResult 不包含任何关于"接下来该怎么做"的信息。无论工具结果是否自解释,都无条件触发一轮 LLM。

设计

扩展 ToolResult 接口(packages/core/src/tools/tools.ts L422

export interface ToolResult {
  llmContent: PartListUnion;
  returnDisplay: ToolResultDisplay;
  error?: { message: string; type?: ToolErrorType };

  // 新增:后置执行指令
  postExecution?: {
    /**
     * 工具结果不回传 LLM直接作为最终回复展示给用户。
     * 适用于结果完全自包含、不需要模型再解读的场景。
     * 是 ToolResult 局部属性。
     */
    skipLlmRound?: boolean;

    /**
     * 工具结果"自包含、可直接展示给用户"——即 `returnDisplay` 已经是
     * 用户期望看到的最终形态,不需要模型加工。
     * 是 ToolResult 局部属性,**不**预测"下一轮是否 summary"。
     * 与方向三展示解耦联动true → 进入 Summarizing 状态允许用户输入。
     */
    resultIsTerminal?: boolean;
  };
}

设计修正:早期版本曾把单一 selfExplanatory 字段同时承担"工具产物属性"和"对话流预测信号"两份职责,但二者并不重合(例:用户 prompt 是"读 X 然后修 Y"read_file 输出自包含,但下一轮显然不是 summary预测信号属于对话流全局属性不应通过工具字段表达——D2 改为完全用对话流启发式(见 §3.2)。

行为变更

handleCompletedTools 中新增判断:

工具批次完成
  → 检查 batch 中所有工具的 postExecution.skipLlmRound
  → 全部为 true?
    → YES: markToolsAsSubmitted, 不调 submitQuery, 直接 idle
    → NO: 保持现有行为 (submitQuery)

重要约束skipLlmRound 仅在当前 batch 的所有工具都声明 skip 时才生效。混合 batch 仍然回传。

历史不变量

跳过 LLM 后历史形如:user → function_call → function_response → <无 assistant>

  • 复核 repairOrphanedToolUseTurnsInHistorysession-load 时调用)是否容忍此形态
  • 复核 auto-compaction 在缺少 assistant 文本时的行为
  • PR #4176 刚关闭过 tool_use↔tool_result 不变量,落地前需补单测覆盖"skip 后下一轮 user message"的 alternation
  • Qwen / OpenAI 风格 API 容忍Anthropic 严格 alternation —— 后续若支持 Anthropic 直连需要兜底(向 history 注入空 assistant text

统一修复点:此处和 §3.3D3 中途打断 Summarizing破坏的是同一个历史不变量。修复方案二选一(注入空 assistant / 接受 Qwen 容忍),两个方向必须使用相同选择。

信号生态Phase 2 工作)

工具 skipLlmRound resultIsTerminal 备注
read_file 配合 query-only 场景 true 文件内容即答案
catvia shell 视场景 true 同 read_file
grep / glob / ls false false默认 结果常需模型挑选/排序/总结skill 层在已知"纯查询"场景显式 true
git status / git logvia shell false true 输出已格式化
Skill 工具 各 skill 自决 各 skill 自决 查询类 skill 倾向 true
MCP 工具 默认 false 默认 false 通过 allowlist 显式 opt-in

第三方/MCP 工具不可信任,默认不打标;通过 config.toolPostExecAllowlist 显式启用。

grep/glob/ls 默认 false 是从严选择:避免 D2/D3 在需要模型总结排序的场景误判。

适用与不适用

  • 适用终态查询read/cat/print 类型、自包含结果skill 已格式化输出)
  • 不适用:多步任务中间步骤、写操作确认、需解读的复杂日志

风险与缓解

风险 严重度 缓解
工具错误设置 skipLlmRound 导致多步任务中断 batch 级语义 + llmContent 仍在历史中可恢复
第三方工具滥用 MCP 默认禁用allowlist 显式开启
历史不变量破坏 落地前补单测session-load 重放覆盖
用户预期不一致(期望总结但没有) setting alwaysSummarize: true 可覆盖

收益

终态查询场景节省 3-4s跳过最后一轮 LLM


3.2 方向二summary 轮 fast-model 路由策略

定位

本方向不引入新管道,但需要扩展 GeminiChat 接口以支持运行时模型切换

§1.4 的基础设施提供了 fast 模型配置和 modelOverride 端到端贯通,但主 chat 上跑 fastModel + streaming 没有先例,需要:

  • 决策函数:何时把 config.getFastModel() 作为 override 传下去
  • 安全回退:GeminiChat.retryStreamWithModel 新接口(处理 chat 内部状态)
  • 实验验证:主 chat 切换 fast/primary 不破坏 compaction / history-recording

应用范围

D2 仅作用于:

  • useGeminiStreamTUI 主路径)—— sendMessageStream 调用点 L1841
  • ACP SessionIDE 集成路径)—— acp-integration/session/Session.ts:1182Phase 3 同步改造

D2 不作用于以下路径,避免在非交互或独立上下文里引入额外失败模式:

  • Subagent 运行时agents/runtime/agent-core.ts:614):子 agent 已带独立模型配置
  • Cron 触发 turnSendMessageType.Cron, client.ts:127非交互无 RT 紧迫性
  • Notification turnSendMessageType.Notification, client.ts:129同上

核心难点

submitQuery 调用时我们并不知道模型看完结果后是发起新工具还是直接出文字。如果用 fast model 调而模型实际还要调工具——后果是静默的fast 可能调错工具或参数错,错误不会有明显信号。

任何工具级别的字段都无法可靠预测"下一轮是否 summary"因为它取决于对话流user prompt + 累计上下文),不是工具产物的局部属性。例:

用户:"读 utils.ts 然后把里面的 console.log 都改成 logger.info"
  → Tool 1: read_file → 结果自包含
  → 但下一轮显然不是 summary

因此 D2 完全用对话流启发式预测,不依赖工具字段。

决策函数:对话流启发式 + 否决

import { Kind, MUTATOR_KINDS } from '../tools/tools.js';

function selectContinuationTier(
  turn: Turn,
  userPrompt: string,
  batch: ToolCall[],
): 'fast' | 'primary' {
  // ===== 用户级别强制开关(最高优先级) =====
  const userPref = config.getSummaryTierStrategy();
  if (userPref === 'always_primary') return 'primary';
  if (userPref === 'always_fast') return 'fast'; // 仍受运行时保险约束

  // ===== 用户意图否决 =====
  // 1. user prompt 含动作动词 → 下一轮大概率还要调工具
  if (requestImpliesFurtherAction(userPrompt)) return 'primary';

  // 2. 本轮已有 mutator 工具 → 大概率有验证/读后续
  if (batch.some((c) => MUTATOR_KINDS.includes(c.tool.kind))) return 'primary';

  // 3. 本轮或历史有未解决 error → 模型需要 primary 诊断
  if (hasUnresolvedError(turn.toolResults, batch)) return 'primary';

  // ===== 输出复杂度否决 =====
  // 4. user prompt 要求深度分析(解释/对比/为什么类)
  if (needsDeepReasoning(userPrompt)) return 'primary';

  // 5. 工具调用 ≥3 个不同工具 → 跨结果叙述靠 primary
  if (needsCrossResultReasoning(turn)) return 'primary';

  // 6. 工具输出过长 → 长内容总结靠 primary
  if (estimateTotalToolOutputTokens(turn) > 4000) return 'primary';

  // ===== 模型可行性否决 =====
  // 7. fast 模型 context window 不够 → 切到 fast 会触发 compression
  //    compression 自身要 LLM 调用,反而拖慢且增加成本)
  if (wouldTriggerCompression(turn.history, config.getFastModel()))
    return 'primary';

  // ===== 多语言兜底 =====
  if (!isPromptLanguageSupported(userPrompt)) return 'primary';

  // ===== Session 状态兜底 =====
  if (turn.justCompacted || turn.justCleared) return 'primary';

  return 'fast';
}

八个否决项含义:

  • requestImpliesFurtherAction:动作动词(改|删|加|替换|修复|实现|新建|create|fix|change|add|remove|implement|write|update)→ 多步任务
  • MUTATOR_KINDS 命中:本轮已经写过 → 大概率紧跟一次读/校验。复用 tools.ts:806 已有的 MUTATOR_KINDS = [Edit, Delete, Move, Execute](每个 Tool 实例的 kind: Kind 属性是权威分类,不要重新发明 isWriteTool
  • hasUnresolvedError(turnResults, currentBatch):判定二段——
    • 当前批次任何 error → 总是未解决(不假设并行批次能自我纠错)
    • 历史按 (toolName, args fingerprint) 去重,最后一次仍 error 视为未解决(仅按 toolName 在同名不同参数下会判错)
    • shell 等需正确填 ToolResult.error(前置数据质量依赖)
  • needsDeepReasoning:含"分析/解释/为什么/对比/诊断"类关键词
  • needsCrossResultReasoningdistinct 工具调用 ≥3同工具同参数视为同一次
  • 输出 tokens > 4000:经验阈值,待 fast 模型基线实测后调整
  • wouldTriggerCompressionfast 模型 context window 通常小于 primary相同 history 在 fast 上会更早触发 tryCompressgeminiChat.ts:1418—— compression 自身需要一次 LLM 调用,可能反向恶化 RT 和成本。预算估算:estimateHistoryTokens(history) > fastModelContextWindow × COMPACTION_THRESHOLD 即视为会触发
  • 未支持语言:仅检测中英文关键词,其他语言(日韩等)默认 primary
  • session 状态突变:刚 /compact/clear 后第一次 continuation → primary 重建 mental model

否决方向偏向 primary(宁可多 2s 不要降质)。

关键实现:GeminiChat.retryStreamWithModel

问题:直接 abort + 调 client.sendMessageStream 会破坏 chat 状态:

  1. geminiChat.ts:1428 在 stream 启动时就 push userContent 到 history重起会再 push 一次导致 history 出现重复 function_response
  2. sendPromise 锁(geminiChat.ts:1392, 1398)—— abort 后需要确保 streamDoneResolver 被调用
  3. pendingPartialState 等 PR #4176 引入的不变量 marker 需要正确清理
  4. Telemetry span 的 model 属性需要更新

新增接口packages/core/src/core/geminiChat.ts

/**
 * Retry an in-flight or just-aborted streaming send with a different model.
 * Does NOT re-push userContent (kept from original send).
 * Resets pendingPartialState; releases stale sendPromise; re-opens span.
 */
async retryStreamWithModel(
  model: string,
  signal: AbortSignal,
): Promise<AsyncGenerator<StreamEvent>>;

调用契约:

  • 仅在原 send 已经 abort 后调用(不并发)
  • prompt_id 复用(同一用户意图)
  • 历史中已经 push 的 userContent 不再 push

实现工作量约 1.5d 加单测。

运行时保险

selectContinuationTier 返回 'fast' 但 stream 中出现 ServerGeminiEventType.ToolCallRequest 事件 → 立即 abort 当前流,调 retryStreamWithModel(primaryModel)

这覆盖"预测为 summary 实际仍需工具"的唯一静默放错场景。代价:一次 fast 调用浪费的 tokens成本归因见 §5.3)。

与 skill modelOverride 解耦

useGeminiStream.modelOverrideRefL376, L2225当前承载 skill 显式选择的模型,属"业务语义"。本方向的 fast 路由属"优化语义",两者必须分离

// 新增独立 ref
const summaryTierRef = useRef<'fast' | 'primary' | undefined>(undefined);

// 调用点合并(不复用 modelOverrideRef
const stream = geminiClient.sendMessageStream(
  finalQueryToSend,
  abortSignal,
  prompt_id!,
  {
    type: submitType,
    notificationDisplayText: metadata?.notificationDisplayText,
    modelOverride:
      modelOverrideRef.current ?? // skill 显式选择优先
      (summaryTierRef.current === 'fast' ? config.getFastModel() : undefined),
  },
);

生命周期:

时机 modelOverrideRefskill summaryTierReffast 路由)
新 user turn (!Retry && !ToolResult) 清空 清空
skill 工具返回 modelOverride 字段 写入 不变
tool batch 完成 → selectContinuationTier 不变 写入
Runtime fallback看到 ToolCallRequest 不变 升级为 'primary'
Retry用户手动 Ctrl+Y 保留 升级为 'primary'fast 失败不再 fast

skill 显式选择永远赢——用户的显式意图优先于优化策略。

Telemetry 修正

client.ts:1303 的 interaction span 在 turn 启动时记录 model 属性。fallback 触发时 model 实际变了span 数据失真。需要:

// fallback 触发时
span.setAttribute('llm.model.requested', fastModel);
span.setAttribute('llm.model.actual', primaryModel);
span.setAttribute('llm.fallback.reason', 'tool_call_seen');

并在 addUserPromptAttributes 中区分 requested / actual 模型,避免计费/审计混淆。

用户级别强制开关

新增 settingpackages/cli/src/config/settingsSchema.ts

summaryTierStrategy: 'auto' | 'always_primary' | 'always_fast';
// default: 'auto'
  • 'auto':使用 selectContinuationTier(推荐)
  • 'always_primary':完全禁用 D2 优化(生产敏感场景)
  • 'always_fast':跳过 vetoes仍受运行时保险约束(高级用户)

理由D2 是质量换速度,部分用户/场景需要明确退出权。

前置条件

  • config.getFastModel() 已配置
  • 主 chat fastModel-streaming 验证实验(编码前 1d
    • mock 一个 resultIsTerminal=true 工具,在主 chat 反复触发 summary 轮
    • 观察 tryCompress 是否被错误触发fast 模型 context window 小可能提前触发)
    • 观察 chatRecordingService 输出是否有 model mismatch
    • 观察单次 fast 调用后下一次 primary 调用是否能正常读 history
  • Fast 候选模型基线测量1d
    • 跑 100 条 summary 轮 prompt输入含 function_response),测 P50/P95 端到端延迟与 time-to-first-token
    • tryCompress 触发率 P_compact,验证净 RT 收益 = (1 - P_compact) × ΔRT P_compact × compression_RT > 0
    • 仅当 fast P50 ≤ primary P50 × 0.5 且 P95 ≤ primary P95 × 0.6 时启用
  • Fast model 与 primary model 同家族(避免 function_response 编码差异);跨家族需 getFastModel() 层校验拒绝
  • thinkingConfig 兼容性
    • Fast 模型必须与 primary 在 thinkingConfig.includeThoughts 支持上一致;或
    • Fast 路径强制 includeThoughts: false(与 sideQuery.ts:118-122 对齐)
    • 验证history 含 thought parts 时 fast 模型能正确处理(不报错、不把 thought 当用户输入)

风险与缓解

风险 严重度 缓解
Fast 模型 tool-calling 静默放错 对话流启发式 + 运行时 ToolCallRequest abort 保险
Fast 在含 error 的输入上幻觉成"对用户可见的错误回答" hasUnresolvedError 否决;监控用户追问率(注:emitToolUseSummaries 的同类风险只影响 60 token 标签,本风险影响最终回答,量级更高)
Fast 路径触发 tryCompress → 多一次 LLM 调用,反向恶化 RT 和成本 wouldTriggerCompression 预判 gate见决策函数 #7前置基线测量 P_compact 阈值
Compression 自身用谁的模型 触发 compression 即放弃 fast 路由gate #7 兜底);避免回答出问题
主 chat 切模型让 chat 内部状态/recording 异常 前置验证实验覆盖session resume 重放测试
D2 与 emitToolUseSummaries 同时触发 concurrent fast 调用,超 rate-limit 二选一D2 启用时禁用 emitToolUseSummaries(标题不影响功能),或共享 rate-limit token bucket
thinkingConfig 在 fast / primary 间不一致导致 history 解析异常 同家族 + fast 路径强制 includeThoughts: false(见前置条件)
Fallback 路径反而更贵fast tokens 浪费 + primary 全程) fast_tokens_consumed 决策日志监控fallback 率 >20% 自动关 flag
Telemetry span model 失真 requested / actual 拆分(见 Telemetry 修正)
上下文格式不兼容(跨家族) getFastModel() 拒绝跨家族选择
与 skill modelOverride 语义冲突 独立 ref + skill 优先
/model 运行时切换主模型后 summaryTierRef 决策失效 /model 命令处理时同步清空 summaryTierRef
fast tokens/s 反而更慢 实测时同时测 TTFT不只总 RT

收益(待实测)

  • RTsummary 轮节省 2-3s实测前不写入 PR 标题)
  • 成本fast 模型单价通常显著低于 primary高频 summary 场景下 token 成本可能下降 30-50%;但 fallback 路径浪费会抵消部分收益,需用 fast_tokens_consumed 实测确认净收益

3.3 方向三结果展示与交互解耦Presentation Decoupling

问题

用户从工具完成到可以再次输入,必须等 LLM 总结轮完成:

工具完成 → [渲染结果] → [submitQuery] → [等 LLM 流式回复 3-4s] → Idle → 可输入
                                         ~~~~~~~~~~~~~~~~~~~~~~~~
                                         用户已看到结果但无法操作

设计

新增 StreamingState.Summarizing 状态:

export enum StreamingState {
  Idle = 'idle',
  Responding = 'responding',
  WaitingForConfirmation = 'waiting_for_confirmation',
  Summarizing = 'summarizing', // 新增
}

状态机变更

工具完成且结果已展示
  → 若 batch 全员 postExecution.resultIsTerminal === true:
    → 进入 Summarizing用户可输入
    → submitQuery 异步执行
    → LLM 总结追加到 history或被用户新消息取消
  → 否则:
    → 保持 Responding用户不可输入

用户新消息处理

  • Summarizing 状态下用户提交新消息 → abort 当前总结 → 处理新消息
  • 已生成的部分总结文本丢弃(不入 history避免半句 assistant 污染上下文
  • function_response 仍保留在 history模型知道工具执行了
  • followup suggestion 等 Summarizing 完成或被取消后再触发

Abort 时 partial text 清理清单

partial text 分布在多处,需同时清理,缺一会导致状态不一致:

位置 清理动作
pendingHistoryItemRef.currentuseGeminiStream React state null,不调 addItem
GeminiChat.history 内部累积 abort 前若已 push 部分 assistant content需通过新的 discardPendingAssistant() 接口回滚
ChatRecordingService buffered turn 标记为 cancelled不写入 JSONL
dualOutput.emitText(如启用) 发送 abort sentinelsidecar 自行丢弃
loopDetectorRef 累积 token 重置当前 turn 计数

执行顺序abort signal 触发 → 收齐上述五处清理 → 才允许新 user message 进入 submitQuery。竞态测试覆盖abort 触发瞬间正好收到最后一个 chunk。

适用条件

batch 全员 postExecution.resultIsTerminal === true

历史不变量(与 §3.1 同源)

中途打断 Summarizing 会产生:

[user_1, function_call, function_response, user_2]
                                          ↑ 无 assistant turn

这与 §3.1 跳过 LLM 轮破坏的是同一个不变量,必须使用与 D1 相同的修复策略(注入空 assistant / 接受 Qwen 容忍)。

  • 复用 D1 的不变量单测覆盖
  • session-load 重放(含 repairOrphanedToolUseTurnsInHistory)必须覆盖此形态
  • Anthropic alternation直连时与 D1 同时补兜底

风险与缓解

风险 严重度 缓解
Abort 时半句 assistant 进 history 显式丢弃 partial text仅保留 function_response单测覆盖 race
历史不变量破坏(无 assistant 接续) 与 D1 同源问题,统一修复(见 §3.1 历史不变量)
UI 状态复杂度增加 Summarizing = Idle + 背景任务;输入路径复用 Idle
用户感知收益依赖行为模式 用户若 3s 内不输入summary 已完成 → 无感知收益;但不退化

收益

  • 理论上限3-4s 感知 RT用户工具完成即输入
  • 实际中位数:取决于用户输入间隔——读结果 2-5s 后才输入的用户不会感受到差异,但绝不会更慢

3.4 方向四流式提前调度Stream-Ahead Scheduling

问题

processGeminiStreamEvents 在 stream 完全结束后才批量调度工具。ToolCallRequest 事件可能在 stream 中期就已 yield。

设计

在 stream 事件处理中对 ToolCallRequest 立即开始前置验证(不执行):

case ServerGeminiEventType.ToolCallRequest:
  toolCallRequests.push(event.value);
  scheduler.prevalidate(event.value, signal);  // 新增
  break;

CoreToolScheduler.prevalidate(request)

  1. 查找工具注册
  2. 构建 invocation
  3. 执行 shouldConfirmExecute(缓存结果)
  4. schedule() 时直接使用缓存结果

纯度契约与 Allowlist

prevalidate 要求 shouldConfirmExecute 是 side-effect-free 结果在 prevalidate→schedule 间隙不会被外部修改使之失效。

直接复用 tools.ts:818CONCURRENCY_SAFE_KINDS

export const CONCURRENCY_SAFE_KINDS: ReadonlySet<Kind> = new Set([
  Kind.Read,
  Kind.Search,
  Kind.Fetch,
]);

这是项目已有的"无副作用 + 可并发"分类,正好匹配 prevalidate 需求。

工具 Kind 是否在 allowlist 理由
Readread_file 等) 纯读
Searchgrep / glob 纯读
Fetchweb_fetch 等) 远程读,无写副作用
Edit (见下文 TOCTOU shouldConfirmExecute 纯只读,但 diff 在调度间隙可能失效
Delete / Move / Execute MUTATOR_KINDS
Think 含 save_memory / todo_write 等隐式写
MCP 工具 不可信

TOCTOU为什么 Edit 不进 allowlist

理论上 Edit 的 shouldConfirmExecute 是纯只读(读文件、算 diff。但 prevalidate 与 schedule 之间存在时间窗:

T=0      stream 收到 Edit(file=a.ts, ...) → prevalidate
T=10ms   shouldConfirmExecute 读 a.ts缓存 diff_v0
T=300ms  stream 结束scheduler.schedule()
T=305ms  期间其他工具/IDE/外部进程修改 a.ts
T=310ms  scheduler 用 diff_v0 展示给用户
T=320ms  用户基于 v0 确认
T=330ms  Edit 应用旧 params 到 v1 文件 → 内容损坏 / merge 失败

这是 TOCTOU。修复方向

  • A推荐Edit 不进 allowlistprevalidate 仅覆盖 CONCURRENCY_SAFE_KINDS 三类。代价:收益从"50-200msEdit 主导)"降到"50-100ms仅读类"
  • B可选加强Edit 进入 allowlist 但缓存附 (mtime, size, content_hash)schedule() 时校验未变才用缓存,否则重算

文档暂选 A。

与现有并行调度的交互

coreToolScheduler.attemptExecutionOfScheduledCallsL2436+)使用 partitionToolCalls 把工具分成"并发安全 batch"和"串行 batch",并发 batch 通过 runConcurrentlyL2473执行。

prevalidate 必须与这个分批模型对齐:

  • 缓存按 callId 索引(不是 (toolName, args),避免并发同名调用冲突)
  • prevalidate 失败的 call → 不影响其他 callschedule 时该 call 走原始 shouldConfirmExecute 路径
  • stream 取消时按 signal 级联 abort 所有 in-flight prevalidate

风险

风险 严重度 缓解
缓存 diff 与确认时实际文件不一致TOCTOU 方案 AEdit 不进 allowlist方案 B缓存附 (mtime, size, hash) 校验
prevalidate 失败影响调度 失败/超时退回原 shouldConfirmExecute 路径,缓存缺失 ≡ 未启用
并发 prevalidate 共享 fd / 资源争抢 QWEN_CODE_MAX_TOOL_CONCURRENCY 已限并发上限(默认 10

收益

50-100ms/轮(仅 CONCURRENCY_SAFE_KINDS 范围)。若选方案 B 含 Edit理论收益 100-200ms。


4. 综合评估与路线图

4.1 综合评估

方向 RT 收益 实施复杂度 质量风险 依赖 优先级
D1 工具后置指令 3-4s/终态轮 2-3d P0
D2 summary fast 路由 2-3s/summary 轮(待实测) 中-高9d 中-高 D2 自带启发式 + 主 chat 验证实验 + ACP 同步 P1
D3 展示解耦 3-4s 感知改善(依赖用户行为) 3-5d含不变量修复 D1 历史不变量修复 P1
D4 流式提前调度 50-200ms/轮 5-7d 极低 P2

D2 工作量细分

子任务 估时
主 chat fastModel-streaming 验证实验(含 P_compact 测量) 1d
Fast 候选模型基线测量(含 TTFT、P95、thinkingConfig 兼容性) 1d
selectContinuationTier + summaryTierRef 接入useGeminiStream 0.5d
启发式实现(含 MUTATOR_KINDS 复用 / wouldTriggerCompression 估算 / 多语言 / 状态突变) 1d
GeminiChat.retryStreamWithModel + discardPendingAssistant 接口实现 1.5d
ACP Session 同步改造acp-integration/session/Session.ts 1d
Telemetry span 修正(requested / actual 拆分) 0.5d
User-level setting summaryTierStrategy + JSON schema + /config 集成 0.5d
单测race、abort 时机、history 不变量、fallback 路径、ACP 路径) 2d
合计 9d

注:早期估时 6.5d 未含 ACP 路径、wouldTriggerCompression gate、清理清单、settings schema 工程化等成本。

4.2 实施路线

Phase 1D1 工具后置指令1 周)

  • 扩展 ToolResult.postExecutiontools.ts L422skipLlmRound + resultIsTerminal
  • handleCompletedTools 实现 skipLlmRound 短路useGeminiStream.ts L2038
  • 单测覆盖历史不变量
  • Phase 1 不消费 resultIsTerminal(留给 Phase 3

Phase 2信号生态建设2 周,与 Phase 4 并行)

  • 内置工具陆续打标 skipLlmRound / resultIsTerminal(见 §3.1 表)
  • 验证打标覆盖率 ≥60%(按 turn 数加权,非按调用次数)
  • 收集 production 数据,校准 §3.2 否决 gate 阈值
  • Phase 2 末期跑 §3.2 主 chat 验证实验和基线测量

Phase 3D2 + D3约 3 周,含 ACP 同步)

修正:早期路线图估 1 周,未含 fastModel-streaming 验证实验、retryStreamWithModel 实现、不变量统一修复、ACP 路径同步。

  • 编码前:完成主 chat 验证实验 + 基线测量(含 P_compact 与 thinkingConfig 兼容性)
  • 新增 summaryTierRef + selectContinuationTier(含 wouldTriggerCompression gate
  • 新增 GeminiChat.retryStreamWithModel + discardPendingAssistant
  • 同步改造 ACP Session 路径acp-integration/session/Session.ts使用同一决策函数
  • 新增 StreamingState.Summarizing + 输入路径复用 + abort 清理清单
  • 历史不变量统一修复D1+D3 同源)
  • Feature flag experimental.summaryRoundFastModel: falseRelease N 默认关
  • User setting summaryTierStrategy
  • Telemetry span 修正
  • 运行时保险ToolCallRequest abort + retryStreamWithModel

Phase 4D4 流式提前调度(可独立插入)

  • CoreToolScheduler.prevalidate + allowlist
  • processGeminiStreamEvents 增量调度

5. 度量、验收与限制

5.1 性能指标

指标 基线 Phase 1 Phase 3
端到端 RT P503 轮 loop 13.4s <10s <8s待实测
端到端 RT P95 - <13s <12sfallback 路径上限)
用户感知首结果时间 P50 13.4s <10s <5sD3 启用)
用户感知首结果时间 P95 - <13s <8s
LLM 调用次数(可跳过场景) 3 2 2更快

注:基线为单次采样,落地前需补 ≥3 类场景。

5.2 质量指标

指标 基线 允许退化
Tool-calling 准确率fast model summary 轮) 100% ≥98%
skipLlmRound 误用率(用户追问"再详细些" - <1%
Fast model fallback_triggered 率 - <10%>20% 自动关 flag
Summarizing 状态下半句 assistant 入 history 0 0硬性

5.3 成本指标

指标 基线 Phase 3 目标
每千会话 token 成本summary 轮) 100% <70%
Fallback 路径浪费 tokens 占比 0 <15%fallback 率 × 单次 fast tokens / 单次 primary tokens

5.4 决策日志 schema

每次 selectContinuationTierhandleCompletedTools 的关键判定写一条结构化日志:

{
  turn_id, prompt_id,
  decision: 'skip' | 'fast' | 'primary',
  tier_requested: 'fast' | 'primary',          // 决策fallback 前)
  tier_actual:    'fast' | 'primary',          // 实际跑fallback 后)
  signal_skipLlmRound: bool,
  signal_resultIsTerminal: bool,
  user_strategy: 'auto' | 'always_primary' | 'always_fast',
  veto_reason: 'further_action' | 'write_tool' | 'unresolved_error' |
               'deep_reasoning' | 'cross_result' | 'output_tokens' |
               'lang_unsupported' | 'compact_or_clear' | null,
  tool_count, distinct_tool_count,
  has_write_tool: bool,
  has_error: bool, has_cancel: bool,
  output_tokens_est: int,
  user_prompt_classification: 'query' | 'action' | 'analysis',
  fast_ttft_ms, primary_ttft_ms,                // fallback 时双份
  fast_tokens_consumed: int,                    // fallback 浪费的 tokens成本归因
  total_rt_ms,
  fallback_triggered: bool,
  fallback_reason: 'tool_call_seen' | 'timeout' | 'error' | null,
}

观察指标:

  • fast 触发率(预期 30-50%
  • fallback_triggered 率(预期 <10%>20% 提示在下个 release 关 default flag
  • 各 veto 占比(识别过严/过松)
  • fast_tokens_consumed × fallback_rate成本反向风险
  • 用户追问"再详细些"频次fast 质量回归信号)

fast_tokens_consumed 测量说明

abort 中断的 stream 大概率收不到 finishReason / usageMetadata——后者只在 stream 完整结束时填充。实现需估算:

  • 优先abort 前尝试 stream.return() 让生成器走 finally 路径,可能拿到 partial usage
  • 兜底:累计已收 chunk 的文本长度 × 4 估算 output tokensinput tokens 用 history 估算
  • 标注:日志字段附 tokens_source: 'usage' | 'estimated',事后分析需区分

5.5 验证方法与发布策略

验证

  • 复用 /tmp/tool-timing.log 计时框架
  • 新增 T_userIdle(用户可再次输入时刻)
  • 新增 T_firstToken(流式首 token 时刻)
  • A/B 测试对比各 Phase 前后的 RT 与 cost 分布

发布策略(适配本地 CLI

Qwen Code 是本地 CLI没有运行时下发能力——传统"5% / 25% / 100% 灰度"不适用。采用阶段性 release 推进

阶段 Release 节点 feature flag 默认值 触发条件
Phase 3adogfood Release N false 内部用户用 summaryTierStrategy=always_fast 自启用
Phase 3bopt-in 默认 Release N+1≥2 周后) false(不变) dogfood 阶段决策日志达标fallback <10%、净 RT/cost 收益 >0
Phase 3c默认开启 Release N+2≥4 周后) true Phase 3b 用户层面无质量回归报告
回滚 Release N+3如需 true → false 大规模 fallback >20% 或质量指标退化

回滚机制

  • 无运行时下发,回滚 = 发新 release 关 default flag
  • 用户级 summaryTierStrategy=always_primary 始终提供"我要立刻退出"通道,不依赖新 release
  • 决策日志的 fallback_rate / cost_regression 在每个 Release 周期评估,决定下一步

5.6 已知限制

  1. 基线数据单薄:单次采样不能覆盖全部任务模式,落地前需补场景
  2. fast 模型前提:不存在显著更快且 tool-calling 达标的同家族模型 → D2 不启用
  3. skipLlmRound 是质量换速度:跳过 LLM = 放弃模型理解和纠错,仅适用确定性高场景
  4. D2 是质量+成本换速度fast 模型质量低于 primaryfallback 路径反而更贵——必须以决策日志实测净收益
  5. tryCompress 触发可能反向恶化fast 模型 context 小compression 自身耗 LLM 调用——wouldTriggerCompression gate 是必备防御
  6. 展示解耦改变交互模型:新模式需要用户适应;用户行为决定实际感知收益
  7. 网络延迟不可控:本方案减少调用次数,非优化单次调用
  8. Anthropic 直连未覆盖:当前 alternation 容忍度依赖 Qwen / OpenAI 风格 API
  9. 主 chat 上 fastModel-streaming 是首次落地:无生产先例,需独立验证实验
  10. 本地 CLI 无运行时下发:发布策略只能阶段性 release 推进,不支持快速灰度调节
  11. D2 仅作用于交互路径Subagent / Cron / Notification 不享收益,刻意如此
  12. 混合模型 history 长期影响未知D2 启用后 session 内 turn 在 fast/primary 间切换,长会话 resume 与上下文连贯性需观察
  13. D4 收益缩水Edit 退出 allowlist 后prevalidate 仅覆盖纯读类工具50-100ms 收益);含 Edit 的 200ms 收益需方案 B 的 mtime/hash 校验机制

5.7 关键代码位置

文件 关键符号 位置
packages/core/src/tools/tools.ts ToolResult interface L422
packages/core/src/tools/tools.ts Kind enum + MUTATOR_KINDS + CONCURRENCY_SAFE_KINDS L793, L806, L818
packages/core/src/tools/tools.ts DeclarativeTool.kind: Kind(每个 Tool 实例都带) L165
packages/core/src/core/client.ts SendMessageOptions.modelOverride L142
packages/core/src/core/client.ts sendMessageStream L1216
packages/core/src/core/client.ts modelOverride ?? getModel() L1305, L1598
packages/core/src/core/client.ts turn.run(model, …) L1707
packages/core/src/core/geminiChat.ts sendMessageStream(model, …) L1387
packages/core/src/core/geminiChat.ts history.push(userContent) L1428
packages/core/src/core/geminiChat.ts sendPromise L1392
packages/cli/src/ui/hooks/useGeminiStream.ts modelOverrideRefskill 选模型) L376, L2225
packages/cli/src/ui/hooks/useGeminiStream.ts processGeminiStreamEvents L1365
packages/cli/src/ui/hooks/useGeminiStream.ts sendMessageStream 调用点 L1841
packages/cli/src/ui/hooks/useGeminiStream.ts handleCompletedTools L2038
packages/cli/src/ui/hooks/useGeminiStream.ts submitQuery(ToolResult, …) L2355
packages/core/src/services/toolUseSummary.ts fast-model side query非流式先例 L108
packages/core/src/followup/speculation.ts fast-model streamingforked chat 先例) L224
packages/core/src/config/config.ts fastModel + getFastModel + setFastModel L684, L1987, L2021
packages/core/src/core/coreToolScheduler.ts attemptExecutionOfScheduledCalls L2436
packages/core/src/core/coreToolScheduler.ts runConcurrently + partitionToolCalls L2473
packages/cli/src/acp-integration/session/Session.ts sendMessageStream 调用点ACP / IDE 路径) L705, L965, L1182, L1423
packages/core/src/agents/runtime/agent-core.ts Subagent sendMessageStream(不受 D2 影响) L614

6. Review 验证记录2026-05-26

6.1 验证方法

针对设计文档中只声明、未量化的几条前置数据质量假设与收益估算,启动 4 个并行 Explore subagent 做只读代码调研。每个 subagent 只回答一个事实问题,不做判断,不给优化建议。调研基于当前 main 分支HEAD: 026f2f768)。

验证问题 关联章节
Q3 当前所有工具的 ToolResult.error 字段填充率 §3.2 hasUnresolvedError 前置依赖
Q4 stream abort 后 usageMetadata 实际可得性 §5.4 fast_tokens_consumed 测量
Q5 "用户追问 / clarification" 埋点存在性 §5.2 fast 质量回归监控信号
Q6 CONCURRENCY_SAFE_KINDS 工具 shouldConfirmExecute 实际 IO 工作量 §3.4 D4 收益估算

6.2 发现 1hasUnresolvedError 启发式存在 32% 工具盲区(影响 D2

事实:在 22 个有错误路径的工具中,15 个68%)规范填 ToolResult.error 字段shell、read-file、write-file、edit、grep、glob、ls、web-fetch、mcp-tool、cron-* 等核心 I/O 工具齐备),7 个32%)仅把错误塞进 llmContent 字符串askUserQuestionmonitorskilllspexitPlanModetodoWrite 等。

不存在统一的 createErrorResult helper每个工具独立实现错误构造。

对设计的影响

  • §3.2 的 hasUnresolvedError 否决项若仅检查 ToolResult.error 字段,这 7 个工具的失败永远不会触发"切回 primary"——下一轮仍会被路由到 fast model
  • 其中 skill 工具的失败被 fast model 错误总结是高优风险场景(本仓库大量 skill 驱动的工作流会被影响)
  • §3.2 列出的"shell 等需正确填 ToolResult.error前置数据质量依赖" 范围太窄shell 实际已规范,真正漏报的是 skill / lsp / todoWrite 等

建议修正:把 "将 7 个仅靠 llmContent 传错的工具改造为规范填 error 字段" 列为 D2 的硬前置依赖§3.2 前置条件),估时 ~2d不接受 "用 llmContent.match(/^Error:/i) 兜底" 的脏路径(误判风险高)。

6.3 发现 2fast_tokens_consumed 指标实现成本被低估(影响 D2 / §5.3

事实

  • turn.ts 的 abort 路径L289-291直接 return没有 finally 块,也没有 stream.return() 调用——文档 §5.4 暗示的 "abort 前 stream.return() 让生成器走 finally" 在当前代码中不存在该入口
  • geminiChat.ts:processStreamResponsefor await 循环只在完整遍历时记录 turnL1286abort 中断意味着最后的 usage-only chunk通常携带完整 metadata被直接丢弃
  • 主聊天路径无任何 chunk-level token 累计兜底;仅 subagent 层(agent.ts:731-744)有累计,无法复用
  • 结论abort 时 usageMetadata 零获取,只能靠 chars/4 估算±20% 误差)

对设计的影响

  • §5.4 末尾的"优先 / 兜底 / 标注"三层方案中,"优先" 路径在当前代码不可达——需先改 sendMessageStream 生成器结构加 finally工作量约 1d设计文档没体现这笔成本
  • §5.3 把 "每千会话 token 成本 <70%" 列为 Phase 3 目标,但若指标本身 ±20% 误差,"70%" 与 "82%" 落在测量噪声内

建议修正

  • §5.3 改写为趋势指标,不作为 release gate改用 "决策日志的 fallback_triggered 率 + fast_tokens_consumed 同向趋势" 双指标联合判断
  • §5.4 增补:fast_tokens_consumed 实现需先改造 turn.ts abort 路径加 finally + stream.return(),作为 §3.2 工作量补充(+1d

6.4 发现 3user_prompt_classification 与"用户追问"埋点需新建(影响 D2 / §5.2

事实

  • packages/core/src/followup/ 已存在 speculation.ts / suggestionGenerator.ts / followupState.ts,但其 telemetryPromptSuggestionEvent)记录的是 "系统建议被采纳/忽略",不是"用户主动追问"
  • ChatRecordingService 存储用户消息但不打分类标签
  • 全仓库 grep 无 user_prompt_classification、无中英文追问模式匹配、无 clarif* / intentDetect 类机制

对设计的影响

  • §5.4 决策日志 schema 里 user_prompt_classification: 'query' | 'action' | 'analysis' 字段没有数据源——既不能从现有 PromptSuggestionEvent 推导,也不能从 ChatRecord 读出
  • §5.2 "用户追问'再详细些'频次" 监控信号同上,最接近的现有锚点 followupState.onOutcome 不可复用

建议修正

  • §3.2 前置条件中追加"用户输入分类器最小实现"(中英文模式匹配,~3d否则 §5.4 决策日志的 user_prompt_classificationrequestImpliesFurtherAction 都缺数据
  • 或者接受在 Phase 3a dogfood 阶段没有这两个信号,仅靠 fallback_triggered 率监控质量回归——成本低但风险高

6.5 发现 4D4 设计内在矛盾——allowlist 与收益归因不对齐(影响 D4 / §3.4

事实

  • Kind.Readread_fileKind.Searchglob / grepKind.Fetchweb_fetch三类工具的 shouldConfirmExecute / getConfirmationDetails绝大多数继承 BaseToolInvocation 默认实现,做零 IOread_file / glob / grep 完全没 overrideweb_fetch 只做 5-10 行字符串解析 URL hostname
  • 真正有 IO 的是 Edit / WriteFilecalculateEdit + readTextFile + Diff.createPatch,典型 ~20ms但 §3.4 方案 A 把它们排除出 allowlist 以规避 TOCTOU
  • 结果:留在 allowlist 里的三类工具prevalidate 与不 prevalidate 工作量基本相同——allowlist 实际拦截的是"唯一有 IO 可省的 Edit",留下"本来就零成本的工具"

对设计的影响

  • §3.4 的"前置 IO 验证"叙事不成立50-100ms 收益的真正来源是 "stream 完全结束 → 才批量 schedule" 这段调度等待被消除,与工具端 IO 几乎无关
  • 收益归因错误会带来两个问题:
    1. allowlist 可以更宽——凡是 idempotent prevalidate 的工具都行,不必绑定 CONCURRENCY_SAFE_KINDS
    2. 5-7d 投入难以自洽——如果真实收益只有调度模型改变的 ~50msEdit 又不在 allowlist 里,这笔投入的 ROI 比设计文档暗示的低

建议修正§3.4 重写收益归因——

  • 拆分为两部分:(a) 调度模型改变省下的 stream 等待 ~50ms(b) 工具端 IO 前置可省的工作量 ~0msallowlist 内)/ ~20ms若 Edit 入 allowlist
  • 在 §4.1 综合评估表里把 D4 RT 收益从 "50-200ms" 改为 "30-80ms方案 A主要来自调度模型/ 100-200ms方案 B含 Edit"
  • 在 §4.2 路线图中把 D4 进一步降级——纯调度模型改造可独立做,不必强行绑定 prevalidate 概念

6.6 对路线图的合并影响

章节 原估时 验证后估时 增量来源
D2 §3.2 工作量§4.1 细分表) 9d 14-16d +2d发现 1 前置工具改造)+1d发现 2 turn.ts finally 改造)+3d发现 3 输入分类器,如取硬路径)
D4 §3.4 综合评估 5-7d 5-7d不变 工作量不变,但 RT 收益归因从"工具端 IO"改为"调度模型",投入 ROI 下调
Phase 3 总时长§4.2 ~3 周 ~4-5 周 D2 工作量上调 + 前置工具改造 PR 单独走 review 周期

对原路线图的修正建议

  1. 保持 D1P0和 D3 紧随其后——本次验证未触及它们的核心假设ROI 判断不变
  2. D2 启动条件加严——把发现 1/2/3 的前置工作(共 ~6d作为 "D2 启动 gate",未完成不进入 §3.2 前置实验
  3. D4 重新评估优先级——既然真实收益是调度模型改变而非工具端 IO要么 (a) 接受 30-80ms 把 D4 降到 P3 后置,要么 (b) 考虑方案 BEdit + mtime/hash拿回 100-200ms 但额外 5-7d
  4. 不修改 §1.2 单次采样基线——但 §5.1 P95 一栏在 D1 落地、补完 ≥3 类场景基线之前不写具体数字

6.7 验证未覆盖的追问点

以下追问点属于主观判断或作者意图问题,本次验证未通过 subagent 处理,留作后续 design review 讨论:

  • D2 实施次序应否后置于 D3主观次序
  • D1/D3 是否应合并到 Phase 1 一起做(实施策略)
  • §3.2 needsCrossResultReasoning 阈值 ≥3 是否反向拟合 §1.2 基线场景(作者意图)
  • §5.7 关键代码位置表的行号锚点是否应改为符号锚点(文档稳定性)

7. 浮油评估与下一步2026-05-26 二次 review

7.1 触发本次重排的事实

§6 验证之后,又发现两个改变 ROI 判断的事实

  1. DashScope cache_control 已实装packages/core/src/core/openaiContentGenerator/provider/dashscope.ts:172-181

    • streaming 请求标记 system + 最后一条 message + 最后一个 tool definition
    • 命中数据 cached_tokens 已采集到 usageMetadata.cachedContentTokenCountconverter.ts:1124-1149
    • 这是 prefix cache 机制Round N+1 自动命中 Round N 写入的前缀
    • summary 轮恰好是命中前缀最长的一轮
  2. system prompt 已经稳态prompts.ts 审计结果)

    • 没有 cwd / timestamp / git status / 文件列表 / LSP 状态等"每 turn 都变"的硬伤
    • process.cwd() 仅用作 isGitRepository() 开关,不写入 prompt 内容
    • 唯一动态点:save_memory 工具触发 / /model 切换 / MCP 动态加载(均事件性,低频)

7.2 这两条事实改变了 D2 的 ROI 判断

§3.2 文档假设 "fast model 比 primary 快 ~2s",对照基线是 primary uncached vs fast uncached

但现实运行中 primary 是 cachedsummary 轮恰好命中最强),所以正确对照是:

primary cached vs fast uncached

路由 估算延迟 备注
primary 命中 80% 前缀 cache ~1.8-2.2s summary 轮的当前实际表现
fast 无 cache跨模型不共享 ~1.5-2s D2 切换后的实际表现

净差距:几百毫秒,甚至可能 fast 反而慢。叠加 14-16d 工程成本 + 质量风险 + fallback 浪费,D2 净收益接近 0 或负

§3.2 前置条件必须新增:基线测量必须对比 primary cached vs fast uncached,且 T_primary_cached < T_fast_uncached × 1.5 时 D2 不应启用。

7.3 候选清单(按浮油性重排)

真·浮油(立刻动手,< 1d 投入,极低风险,确定收益)

投入 收益 操作位置
简洁回复指令 30min ~2s/summary 轮(输出 token 减半) prompts.ts Final Reminder 段加一句
暴露 cache hit rate telemetry 0.5d 0s 直接,是后续决策 enabler cachedContentTokenCount 已采集,缺暴露;并应识别 save_memory 后单独打标

近浮油等数据决定0.5-1d 投入)

投入 收益 决策前置
summary 轮 tool_choice='none' 0.5-1d 0.3-1ssampling 跳过 tool_call token 需"是 summary 轮"判定逻辑,错判风险低
summary 轮关 thinking 1d 0.5-2s 仅对启用 thinking 的模型有意义qwen3.5-plus、glm-4.7、kimi-k2.5 等)
UI 渲染层 chunk batching 0.5d 调研 + 0.5d 实施 待验证 假设:长 summary 的 useGeminiStream token 渲染累计开销不小

待调研(可能是大鱼)

调研投入 潜在收益 关键未知
DashScope scope: 'global' 支持 0.5d 文档 + 0.5d A/B 跨 session 命中 已调研,结论 (c) 不可行(见 §7.4 发现 B 调研结果)。此行保留作为决策记录,不要重启调研

中等改造(不算浮油,单独评估)

投入 风险 收益
D1 skipLlmRound(终态查询场景) 2-3d 3-4s/终态轮
summary 轮工具结果裁剪D5 子集) 2d 1-2s
D3 Summarizing 状态 3-5d 感知改善 3s
system prompt 减肥 2-3d 含 A/B 测试 0.5-1s

已废弃方向(不要再做)

废弃原因
D2 fast model 路由 被 DashScope cache 抵消,净收益接近 0 或负
D4 prevalidate 收益归因错(真实仅 ~50ms 来自调度模型5-7d 投入不值
system prompt 稳定化 已稳态,无事可做
流式提前 terminal提前 abort 收尾客套话) 高误判风险,用户感知答案被切断

7.4 三个值得展开的新发现

发现 Atool_choice='none' 的真实机制

OpenAI / DashScope API 里 tool_choice='none' 不仅是"禁止调工具"——模型 sampling 阶段会完全跳过 <tool_call> 特殊 token 的概率分配decoder 直接走自然语言生成路径。收益不在"省一两次 retry",而在 sampling 本身更快。

发现 Bscope: 'global' 在仓库已有 Anthropic 先例

packages/core/src/core/anthropicContentGenerator/converter.test.ts:85, 1543 已有 cache_control: { type: 'ephemeral', scope: 'global' } 用法。但 provider/dashscope.ts:288 标 cache_control 时没传 scope

cache_control: { type: 'ephemeral' },   // 没有 scope

若 DashScope 服务端识别 scope: 'global'

  • system + tools 升级为 global cacheTTL 远大于 ephemeral 的 5min
  • 跨 session 命中,启动延迟也降
  • 单这一条收益可能超过原 D2 全部假设收益
调研结果2026-05-26结论(c) 不可行,关闭此线)

通过查阿里云百炼官方文档 help.aliyun.com/zh/model-studio/context-cache 得到的事实清单:

问题 结论 证据
scope 字段支持 不支持。仅识别 type: 'ephemeral',任何 scope/persistent/global 会被 silently dropped 官方文档原文:"仅支持将 type 设置为 ephemeral"
ephemeral 实际 TTL 5 分钟滑动窗口(命中后重置) 百炼文档明确说明
长 TTL / 全局机制 无任何公有云 API 端机制。无 persistent type 值、无独立预上传 API、无 prompt_cache_key;唯一"全局持久"产品是 PAI 全局上下文缓存(自部署 + vLLM + 灵骏 + 共享 Redis与 DashScope API 无关 PAI 文档
跨 session 共享 同账号 + 同模型 + 内容匹配 → 已经命中(这就是 ephemeral 已经在做的);不同账号绝对不共享 百炼文档
定价 cache write 125%、显式 cache read 10%、隐式 cache read 20%(无 cache_control 标记也能拿到隐式 20% 折扣) 百炼定价文档
最小可缓存 prompt 1024 tokens 百炼文档
模型支持(显式 cache qwen3.7-max / qwen3.6-plus / qwen3.5-plus / qwen3-coder-plus / qwen3-vl-plus / deepseek-v3.2 / kimi-k2.5 / glm-5.1 均显式列出。qwen3.6-plus 与 qwen3.7-max 同样享受 90% 显式 cache 折扣 百炼模型列表2026-05-26 重核)

几条副发现的连带意义

  1. TTL 滑动窗口 对 agent loop 是好消息——loop 内连续调用间隔通常 < 30scache 永远新鲜,不会 5min 失效
  2. 隐式 cache 20% 折扣 是免费红利——即使没标 cache_control 也能拿;但精细控制需要显式
  3. qwen3.6-plus 未在显式列表 —— 更正2026-05-26经重核qwen3.6-plus 确实在显式 cache 列表里,享受 90% 折扣。前一轮报告此处错误,已于本节首张表更正
  4. dashscope.ts:288 当前做法已经是 DashScope 公有云 API 的能力上限——没有继续榨的空间

对 §7.2 D2 判断的连带加强

TTL 滑动窗口意味着 agent loop 内 summary 轮几乎 100% 命中 primary 的 cache前几轮刚刚命中过、5min 内。D2 切 fast model 不仅会打碎累计的 cache 写入链,还会让 summary 轮从"近 100% 命中"退化为"完全 miss"——净收益判断比 §7.2 原假设更明确为负。

发现 CUI 渲染层是被忽视的盲区

§1.2 基线把"框架开销"标为 0.3s3%但这是粗估。Ink 7 + React 19.2 在每个 chunk 触发 setState → re-render长 summary 累计可能 200-500ms。需要查 useGeminiStream 怎么处理 token 流,有没有 requestAnimationFrame / useDeferredValue 合并 chunk。

7.5 待数据 checkpoint —— 数据到了该看哪个决策

本节是这份文档的活动入口:后续有任何度量数据,对照下表决定该回看哪个决策。

Checkpoint 1cache hit rate 数据出来后

触发条件:浮油"暴露 cache hit rate telemetry"上线 ≥3 天,决策日志含 cached_tokens / prompt_tokens 分布。

该看的数据

  • 整体命中率cached / prompt的 P50、P90 分布
  • 按轮次划分Round 1 / Round 2 / Round 3 (summary) 各自命中率
  • save_memory 触发后下一轮命中率(应该接近 0
  • /model 切换后下一轮命中率(应该接近 0

决策路径

整体命中率 含义 行动
> 70% 现状已经接近理论上限 只做 #1 简洁指令 + 发现 B 调研;其余浮油按需
40-70% 还有空间但来源不明 分析按轮次命中率,找出哪一段在 miss
< 40% 有动态点在打 cache 重新审计 system prompt / userMemory 触发频率;可能 save_memory 比预期频繁

Checkpoint 2DashScope scope: 'global' 文档调研结果 已完成2026-05-26

结果完全不识别。详见 §7.4 发现 B 的"调研结果"段。

已执行行动:接受现状,跳过此项。dashscope.ts:288 维持现有 ephemeral 标记,无需改造。

后续不要重新启动此调研——除非 DashScope 官方公告新增持久化机制。

Checkpoint 3UI 渲染层调研结果

触发条件:发现 C 调研完成(看 useGeminiStream token 流处理 + Ink/React DevTools 实测)。

决策路径

结果 行动
长 summary stream 渲染累计 > 200ms 改用 batchinguseDeferredValue 或自定义节流)
渲染开销 < 100ms 关闭此线索

Checkpoint 4完成"真·浮油"后的二次基线测量

触发条件#1 简洁指令 + Checkpoint 1/2/3 决策完成 ≥1 周。

该看的数据

  • 端到端 RT P50 与 §1.2 单次采样基线13.4s)对比
  • summary 轮单独的 P50 / P95
  • 用户追问率(如果浮油 A 顺带做了用户输入分类)

决策路径

累计节省 行动
> 4s达到 9.6s 端到端 P50 评估 D1 skipLlmRound(再省 3-4s/终态轮)
2-4s 接受现状,评估 D3 感知改善是否值得做
< 2s 重新审视:是否浮油本身被高估,还是有未识别的瓶颈(网络 RTT、provider 端延迟)

7.6 与 §3 各方向的最终判定

基于 §6 验证 + 本节 ROI 重排:

方向 §3 原优先级 本节判定 理由
D1 工具后置指令 P0 P0 保留,但等浮油完成后再评估 ROI 仍然好,但不再"立刻就做"——先把更便宜的浮油拿掉
D2 summary fast 路由 P1 Defer / Won't Fix 被 DashScope cache 抵消14-16d 投入换接近 0 收益
D3 展示解耦 P1 保留为可选,看 Checkpoint 4 数据 感知改善确定,但绝对 RT 不变,依赖用户行为
D4 流式提前调度 P2 Defer 收益归因错,真实 ~50ms 不值 5-7d

7.7 推荐执行顺序

Day 1(可单人单日完成):

  • prompts.ts 加简洁回复指令30min
  • cachedContentTokenCount 暴露到 telemetry + save_memory / /model 切换打标0.5d
  • 启动发现 B 调研DashScope scope: 'global' 文档查询 + 现有 Anthropic 用法对照0.5d

Day 2-3

  • 收第一批 cache hit rate 数据
  • 启动发现 C 调研:useGeminiStream 的 React 渲染路径
  • 根据 Checkpoint 2 决定要不要做 scope: 'global' 改造

Week 1 末

  • Checkpoint 1 数据决策(看分布)
  • 决定要不要做 tool_choice='none' / 关 thinking根据 hit rate 数据)

Week 2-3

  • Checkpoint 4 二次基线测量
  • 决定是否启动 D1最大的非浮油项3-4s/终态轮)

始终不做D2 / D4 / system prompt 稳定化。

7.8 prompts.ts 动态内容审计2026-05-27

§7.1 给出 "system prompt 已稳态" 的结论时只做了粗略 grep。本节是对 packages/core/src/core/prompts.ts1169 行)的系统性审计,列清单作为后续 cache 命中率分析与浮油决策的依据。

审计方法:枚举所有 ${...} 插值表达式、IIFE、process.* / new Date / Date.now / Math.random / fs.* 调用,对每一处判断"在同一 session 内是否会变化"。

完全没有(常被怀疑的硬伤)

候选 代码事实
Date.now() / new Date() 全文 零次出现rg 全无匹配)
Math.random() 零次出现
process.cwd() 值写入 prompt 仅 L366 if (isGitRepository(process.cwd())) { ... }值不写入字符串,只作开关
git status / git branch 子进程调用 零次git 段是静态指导文本
当前文件列表 / 项目结构注入 零次
LSP 状态 / 错误数 零次
用户输入历史 零次history 走 messages不在 system

启动时一次session 内不变

位置 内容 何时可能变
L190 process.env['QWEN_SYSTEM_MD'] 决定 basePrompt 来源(默认 vs 用户 system.md 进程内不变
L342-343 process.env['SANDBOX'] 决定 sandbox 段选哪一版Seatbelt / Sandbox / Outside 进程内不变
L366 isGitRepository(process.cwd()) 决定 git 段是否插入 cwd 同 session 内通常不变
L871 process.env['QWEN_CODE_TOOL_CALL_STYLE'] 决定 tool call 风格qwen-coder / qwen-vl / general 进程内不变

事件触发(低频)

参数 触发条件 频率估计
userMemorygetCoreSystemPrompt 第 1 参) save_memory 工具 / /memory refresh / 扩展加载 0-3 次/session
model 名(影响 getToolCallExamples 选哪一支) /model 切换 罕见
appendInstruction 配置项session 内基本不变 几乎从不
deferredToolsbuildDeferredToolsSection MCP 工具动态加载 session 启动期居多

一个隐蔽的小坑

L207-209若设置了 QWEN_SYSTEM_MD env每次 getCoreSystemPrompt 都会 fs.readFileSync(systemMdPath)

const basePrompt = systemMdEnabled
  ? fs.readFileSync(systemMdPath, 'utf8')
  : `...`;
  • 文件不变时内容稳定 → cache 命中不受影响
  • 但每轮 LLM 调用都有一次同步 IO默认 .qwen/system.md,网络挂载文件会更慢)
  • 不影响本节"cache 友好性"结论,仅作为已知性能小坑记录

连带结论

  1. system prompt 在稳态 session 内每次产出 byte-for-byte 一致 → DashScope ephemeral cache key基于内容 hash整段稳定 → system 段 cache 命中率几乎 100%
  2. 唯一打 cache 的事件是 save_memory——核心功能,不能为 cache 让路
  3. 浮油 #1简洁回复指令的代价分析:把指令加到 Final Reminder 段L389-390→ system prompt 内容改变一次 → 首次请求 cache miss一次性预热成本之后所有请求继续命中
  4. §7 的 "system prompt 稳定化" 已废弃判断得到正式证据支持——不仅没必要做,连"理论上做了能进一步降低 cache miss 率"都不成立,因为本来就 ≈ 0
  5. 本审计可作为后续相关讨论的引用基线,避免重复 grep若 prompts.ts 有大改动,本节需要同步更新