Back to Qwen Code

Qwen Code Agent Loop RT 优化技术方案

docs/design/rt-optimization/rt-optimization-design.md

0.17.077.8 KB
Original Source

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 loop,2 次工具调用,单次采样)

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

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

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

1.3 当前架构关键约束

约束代码位置说明
工具结果无后置控制tools.ts ToolResult 接口 (L422)仅有 llmContent/returnDisplay/error,无法表达"跳过 LLM"
结果无条件回传 LLMuseGeminiStream.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.modelOverrideclient.ts:1421598turn.run端到端贯通至 geminiChat.sendMessageStream(model, …)
钩子层 modelOverrideRef(承载 skill 选模型)useGeminiStream.ts:376, 2225, 1841已贯通
fast-model 非流式 side query 先例services/toolUseSummary.ts:108(via 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):

typescript
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>

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

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

信号生态(Phase 2 工作)

工具skipLlmRoundresultIsTerminal备注
read_file配合 query-only 场景true文件内容即答案
cat(via shell)视场景true同 read_file
grep / glob / lsfalsefalse(默认)结果常需模型挑选/排序/总结;skill 层在已知"纯查询"场景显式 true
git status / git log(via shell)falsetrue输出已格式化
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 仅作用于:

  • useGeminiStream(TUI 主路径)—— sendMessageStream 调用点 L1841
  • ACP Session(IDE 集成路径)—— acp-integration/session/Session.ts:1182,Phase 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 完全用对话流启发式预测,不依赖工具字段。

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

typescript
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:含"分析/解释/为什么/对比/诊断"类关键词
  • needsCrossResultReasoning:distinct 工具调用 ≥3(同工具同参数视为同一次)
  • 输出 tokens > 4000:经验阈值,待 fast 模型基线实测后调整
  • wouldTriggerCompression:fast 模型 context window 通常小于 primary,相同 history 在 fast 上会更早触发 tryCompress(geminiChat.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):

typescript
/**
 * 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.modelOverrideRef(L376, L2225)当前承载 skill 显式选择的模型,属"业务语义"。本方向的 fast 路由属"优化语义",两者必须分离

typescript
// 新增独立 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),
  },
);

生命周期:

时机modelOverrideRef(skill)summaryTierRef(fast 路由)
新 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 数据失真。需要:

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

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

用户级别强制开关

新增 setting(packages/cli/src/config/settingsSchema.ts):

typescript
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

收益(待实测)

  • RT:summary 轮节省 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 状态:

typescript
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.current(useGeminiStream React state)null,不调 addItem
GeminiChat.history 内部累积abort 前若已 push 部分 assistant content,需通过新的 discardPendingAssistant() 接口回滚
ChatRecordingService buffered turn标记为 cancelled,不写入 JSONL
dualOutput.emitText(如启用)发送 abort sentinel,sidecar 自行丢弃
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 立即开始前置验证(不执行):

typescript
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

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

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

工具 Kind是否在 allowlist理由
Read(read_file 等)纯读
Search(grep / glob)纯读
Fetch(web_fetch 等)远程读,无写副作用
Edit(见下文 TOCTOU)shouldConfirmExecute 纯只读,但 diff 在调度间隙可能失效
Delete / Move / ExecuteMUTATOR_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 不进 allowlist,prevalidate 仅覆盖 CONCURRENCY_SAFE_KINDS 三类。代价:收益从"50-200ms(Edit 主导)"降到"50-100ms(仅读类)"
  • B(可选加强):Edit 进入 allowlist 但缓存附 (mtime, size, content_hash);schedule() 时校验未变才用缓存,否则重算

文档暂选 A。

与现有并行调度的交互

coreToolScheduler.attemptExecutionOfScheduledCalls(L2436+)使用 partitionToolCalls 把工具分成"并发安全 batch"和"串行 batch",并发 batch 通过 runConcurrently(L2473)执行。

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

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

风险

风险严重度缓解
缓存 diff 与确认时实际文件不一致(TOCTOU)方案 A:Edit 不进 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 1:D1 工具后置指令(1 周)

  • 扩展 ToolResult.postExecution(tools.ts L422):skipLlmRound + 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 3:D2 + 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 4:D4 流式提前调度(可独立插入)

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

5. 度量、验收与限制

5.1 性能指标

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

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

5.2 质量指标

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

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 tokens;input 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 3a:dogfoodRelease Nfalse内部用户用 summaryTierStrategy=always_fast 自启用
Phase 3b:opt-in 默认Release N+1(≥2 周后)false(不变)dogfood 阶段决策日志达标:fallback <10%、净 RT/cost 收益 >0
Phase 3c:默认开启Release N+2(≥4 周后)truePhase 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 模型质量低于 primary;fallback 路径反而更贵——必须以决策日志实测净收益
  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.tsToolResult interfaceL422
packages/core/src/tools/tools.tsKind enum + MUTATOR_KINDS + CONCURRENCY_SAFE_KINDSL793, L806, L818
packages/core/src/tools/tools.tsDeclarativeTool.kind: Kind(每个 Tool 实例都带)L165
packages/core/src/core/client.tsSendMessageOptions.modelOverrideL142
packages/core/src/core/client.tssendMessageStreamL1216
packages/core/src/core/client.tsmodelOverride ?? getModel()L1305, L1598
packages/core/src/core/client.tsturn.run(model, …)L1707
packages/core/src/core/geminiChat.tssendMessageStream(model, …)L1387
packages/core/src/core/geminiChat.tshistory.push(userContent)L1428
packages/core/src/core/geminiChat.tssendPromiseL1392
packages/cli/src/ui/hooks/useGeminiStream.tsmodelOverrideRef(skill 选模型)L376, L2225
packages/cli/src/ui/hooks/useGeminiStream.tsprocessGeminiStreamEventsL1365
packages/cli/src/ui/hooks/useGeminiStream.tssendMessageStream 调用点L1841
packages/cli/src/ui/hooks/useGeminiStream.tshandleCompletedToolsL2038
packages/cli/src/ui/hooks/useGeminiStream.tssubmitQuery(ToolResult, …)L2355
packages/core/src/services/toolUseSummary.tsfast-model side query(非流式先例)L108
packages/core/src/followup/speculation.tsfast-model streaming(forked chat 先例)L224
packages/core/src/config/config.tsfastModel + getFastModel + setFastModelL684, L1987, L2021
packages/core/src/core/coreToolScheduler.tsattemptExecutionOfScheduledCallsL2436
packages/core/src/core/coreToolScheduler.tsrunConcurrently + partitionToolCallsL2473
packages/cli/src/acp-integration/session/Session.tssendMessageStream 调用点(ACP / IDE 路径)L705, L965, L1182, L1423
packages/core/src/agents/runtime/agent-core.tsSubagent 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 发现 1:hasUnresolvedError 启发式存在 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 发现 2:fast_tokens_consumed 指标实现成本被低估(影响 D2 / §5.3)

事实

  • turn.ts 的 abort 路径(L289-291)直接 return没有 finally 块,也没有 stream.return() 调用——文档 §5.4 暗示的 "abort 前 stream.return() 让生成器走 finally" 在当前代码中不存在该入口
  • geminiChat.ts:processStreamResponsefor await 循环只在完整遍历时记录 turn(L1286),abort 中断意味着最后的 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 发现 3:user_prompt_classification 与"用户追问"埋点需新建(影响 D2 / §5.2)

事实

  • packages/core/src/followup/ 已存在 speculation.ts / suggestionGenerator.ts / followupState.ts,但其 telemetry(PromptSuggestionEvent)记录的是 "系统建议被采纳/忽略",不是"用户主动追问"
  • 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 发现 4:D4 设计内在矛盾——allowlist 与收益归因不对齐(影响 D4 / §3.4)

事实

  • Kind.Read(read_file)、Kind.Search(glob / grep)、Kind.Fetch(web_fetch)三类工具的 shouldConfirmExecute / getConfirmationDetails绝大多数继承 BaseToolInvocation 默认实现,做零 IO(read_file / glob / grep 完全没 override,web_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 投入难以自洽——如果真实收益只有调度模型改变的 ~50ms,Edit 又不在 allowlist 里,这笔投入的 ROI 比设计文档暗示的低

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

  • 拆分为两部分:(a) 调度模型改变省下的 stream 等待 ~50ms,(b) 工具端 IO 前置可省的工作量 ~0ms(allowlist 内)/ ~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 细分表)9d14-16d+2d(发现 1 前置工具改造)+1d(发现 2 turn.ts finally 改造)+3d(发现 3 输入分类器,如取硬路径)
D4 §3.4 综合评估5-7d5-7d(不变)工作量不变,但 RT 收益归因从"工具端 IO"改为"调度模型",投入 ROI 下调
Phase 3 总时长(§4.2)~3 周~4-5 周D2 工作量上调 + 前置工具改造 PR 单独走 review 周期

对原路线图的修正建议

  1. 保持 D1(P0)和 D3 紧随其后——本次验证未触及它们的核心假设,ROI 判断不变
  2. D2 启动条件加严——把发现 1/2/3 的前置工作(共 ~6d)作为 "D2 启动 gate",未完成不进入 §3.2 前置实验
  3. D4 重新评估优先级——既然真实收益是调度模型改变而非工具端 IO,要么 (a) 接受 30-80ms 把 D4 降到 P3 后置,要么 (b) 考虑方案 B(Edit + 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 是 cached(summary 轮恰好命中最强),所以正确对照是:

primary cached vs fast uncached

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

净差距:几百毫秒,甚至可能 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 telemetry0.5d0s 直接,是后续决策 enablercachedContentTokenCount 已采集,缺暴露;并应识别 save_memory 后单独打标

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

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

待调研(可能是大鱼)

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

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

投入风险收益
D1 skipLlmRound(终态查询场景)2-3d3-4s/终态轮
summary 轮工具结果裁剪(D5 子集)2d1-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 三个值得展开的新发现

发现 A:tool_choice='none' 的真实机制

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

发现 B:scope: '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

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

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

  • system + tools 升级为 global cache(TTL 远大于 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 实际 TTL5 分钟滑动窗口(命中后重置)百炼文档明确说明
长 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% 折扣)百炼定价文档
最小可缓存 prompt1024 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 内连续调用间隔通常 < 30s,cache 永远新鲜,不会 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 原假设更明确为负。

发现 C:UI 渲染层是被忽视的盲区

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

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

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

Checkpoint 1:cache 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 2:DashScope scope: 'global' 文档调研结果 ✅ 已完成(2026-05-26)

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

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

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

Checkpoint 3:UI 渲染层调研结果

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

决策路径

结果行动
长 summary stream 渲染累计 > 200ms改用 batching(useDeferredValue 或自定义节流)
渲染开销 < 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 工具后置指令P0P0 保留,但等浮油完成后再评估ROI 仍然好,但不再"立刻就做"——先把更便宜的浮油拿掉
D2 summary fast 路由P1Defer / Won't Fix被 DashScope cache 抵消,14-16d 投入换接近 0 收益
D3 展示解耦P1保留为可选,看 Checkpoint 4 数据感知改善确定,但绝对 RT 不变,依赖用户行为
D4 流式提前调度P2Defer收益归因错,真实 ~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.ts(1169 行)的系统性审计,列清单作为后续 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 内不变

位置内容何时可能变
L190process.env['QWEN_SYSTEM_MD'] 决定 basePrompt 来源(默认 vs 用户 system.md)进程内不变
L342-343process.env['SANDBOX'] 决定 sandbox 段选哪一版(Seatbelt / Sandbox / Outside)进程内不变
L366isGitRepository(process.cwd()) 决定 git 段是否插入cwd 同 session 内通常不变
L871process.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 内基本不变几乎从不
deferredToolsbuildDeferredToolsSectionMCP 工具动态加载session 启动期居多

一个隐蔽的小坑

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

typescript
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 有大改动,本节需要同步更新