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.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:142 → 1598 → turn.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 chat(createForkedChat),与主 chat 隔离 |
关键空白:没有任何生产代码在主 chat 上以 fast model 跑 streaming。本方案 D2 是首个 case,需先做验证实验(详见 §3.2 前置条件)。
2. 设计原则
- 通用性:方案不绑定特定 tool/skill
- 向后兼容:现有工具无需修改即可继续工作
- 渐进式 + 显式信号:策略默认 conservative,由工具作者通过显式字段 opt-in 优化
- 可回滚:所有优化通过 feature flag 控制;用户级别可强制关闭
- 诚实的权衡:明确标注质量风险、成本风险和适用边界
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>。
- 复核
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 工作)
| 工具 | skipLlmRound | resultIsTerminal | 备注 |
|---|---|---|---|
read_file | 配合 query-only 场景 | true | 文件内容即答案 |
cat(via shell) | 视场景 | true | 同 read_file |
grep / glob / ls | false | false(默认) | 结果常需模型挑选/排序/总结;skill 层在已知”纯查询”场景显式 true |
git status / git log(via 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 仅作用于:
- 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 触发 turn(
SendMessageType.Cron, client.ts:127):非交互,无 RT 紧迫性 - Notification turn(
SendMessageType.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:含”分析/解释/为什么/对比/诊断”类关键词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 状态:
geminiChat.ts:1428在 stream 启动时就 pushuserContent到 history;重起会再 push 一次导致 history 出现重复function_responsesendPromise锁(geminiChat.ts:1392, 1398)—— abort 后需要确保streamDoneResolver被调用pendingPartialState等 PR #4176 引入的不变量 marker 需要正确清理- 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.modelOverrideRef(L376, 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),
},
);生命周期:
| 时机 | 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 数据失真。需要:
// 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):
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
- mock 一个
- 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 时启用
- 跑 100 条 summary 轮 prompt(输入含
- 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 模型必须与 primary 在
风险与缓解
| 风险 | 严重度 | 缓解 |
|---|---|---|
| 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 状态:
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 立即开始前置验证(不执行):
case ServerGeminiEventType.ToolCallRequest:
toolCallRequests.push(event.value);
scheduler.prevalidate(event.value, signal); // 新增
break;CoreToolScheduler.prevalidate(request):
- 查找工具注册
- 构建 invocation
- 执行
shouldConfirmExecute(缓存结果) schedule()时直接使用缓存结果
纯度契约与 Allowlist
prevalidate 要求 shouldConfirmExecute 是 side-effect-free 且结果在 prevalidate→schedule 间隙不会被外部修改使之失效。
直接复用 tools.ts:818 的 CONCURRENCY_SAFE_KINDS:
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 / 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 不进 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 路径、
wouldTriggerCompressiongate、清理清单、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(含wouldTriggerCompressiongate) - 新增
GeminiChat.retryStreamWithModel+discardPendingAssistant - 同步改造 ACP Session 路径(acp-integration/session/Session.ts)使用同一决策函数
- 新增
StreamingState.Summarizing+ 输入路径复用 + abort 清理清单 - 历史不变量统一修复(D1+D3 同源)
- Feature flag
experimental.summaryRoundFastModel: false,Release N 默认关 - User setting
summaryTierStrategy - Telemetry span 修正
- 运行时保险(ToolCallRequest abort + retryStreamWithModel)
Phase 4:D4 流式提前调度(可独立插入)
CoreToolScheduler.prevalidate+ allowlistprocessGeminiStreamEvents增量调度
5. 度量、验收与限制
5.1 性能指标
| 指标 | 基线 | Phase 1 | Phase 3 |
|---|---|---|---|
| 端到端 RT P50(3 轮 loop) | 13.4s | <10s | <8s(待实测) |
| 端到端 RT P95 | - | <13s | <12s(fallback 路径上限) |
| 用户感知首结果时间 P50 | 13.4s | <10s | <5s(D3 启用) |
| 用户感知首结果时间 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
每次 selectContinuationTier 与 handleCompletedTools 的关键判定写一条结构化日志:
{
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:dogfood | Release N | false | 内部用户用 summaryTierStrategy=always_fast 自启用 |
| Phase 3b:opt-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 已知限制
- 基线数据单薄:单次采样不能覆盖全部任务模式,落地前需补场景
- fast 模型前提:不存在显著更快且 tool-calling 达标的同家族模型 → D2 不启用
skipLlmRound是质量换速度:跳过 LLM = 放弃模型理解和纠错,仅适用确定性高场景- D2 是质量+成本换速度:fast 模型质量低于 primary;fallback 路径反而更贵——必须以决策日志实测净收益
tryCompress触发可能反向恶化:fast 模型 context 小,compression 自身耗 LLM 调用——wouldTriggerCompressiongate 是必备防御- 展示解耦改变交互模型:新模式需要用户适应;用户行为决定实际感知收益
- 网络延迟不可控:本方案减少调用次数,非优化单次调用
- Anthropic 直连未覆盖:当前 alternation 容忍度依赖 Qwen / OpenAI 风格 API
- 主 chat 上 fastModel-streaming 是首次落地:无生产先例,需独立验证实验
- 本地 CLI 无运行时下发:发布策略只能阶段性 release 推进,不支持快速灰度调节
- D2 仅作用于交互路径:Subagent / Cron / Notification 不享收益,刻意如此
- 混合模型 history 长期影响未知:D2 启用后 session 内 turn 在 fast/primary 间切换,长会话 resume 与上下文连贯性需观察
- 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 | modelOverrideRef(skill 选模型) | 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 streaming(forked 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 发现 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 字符串:askUserQuestion、monitor、skill、lsp、exitPlanMode、todoWrite 等。
不存在统一的 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:processStreamResponse的for 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_classification与requestImpliesFurtherAction都缺数据 - 或者接受在 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/WriteFile(calculateEdit+readTextFile+Diff.createPatch,典型 ~20ms),但 §3.4 方案 A 把它们排除出 allowlist 以规避 TOCTOU - 结果:留在 allowlist 里的三类工具,prevalidate 与不 prevalidate 工作量基本相同——allowlist 实际拦截的是”唯一有 IO 可省的 Edit”,留下”本来就零成本的工具”
对设计的影响:
- §3.4 的”前置 IO 验证”叙事不成立:50-100ms 收益的真正来源是 “stream 完全结束 → 才批量 schedule” 这段调度等待被消除,与工具端 IO 几乎无关
- 收益归因错误会带来两个问题:
- allowlist 可以更宽——凡是 idempotent prevalidate 的工具都行,不必绑定
CONCURRENCY_SAFE_KINDS - 5-7d 投入难以自洽——如果真实收益只有调度模型改变的 ~50ms,Edit 又不在 allowlist 里,这笔投入的 ROI 比设计文档暗示的低
- allowlist 可以更宽——凡是 idempotent prevalidate 的工具都行,不必绑定
建议修正:§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 细分表) | 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 周期 |
对原路线图的修正建议:
- 保持 D1(P0)和 D3 紧随其后——本次验证未触及它们的核心假设,ROI 判断不变
- D2 启动条件加严——把发现 1/2/3 的前置工作(共 ~6d)作为 “D2 启动 gate”,未完成不进入 §3.2 前置实验
- D4 重新评估优先级——既然真实收益是调度模型改变而非工具端 IO,要么 (a) 接受 30-80ms 把 D4 降到 P3 后置,要么 (b) 考虑方案 B(Edit + mtime/hash)拿回 100-200ms 但额外 5-7d
- 不修改 §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 判断的事实:
-
DashScope
cache_control已实装(packages/core/src/core/openaiContentGenerator/provider/dashscope.ts:172-181)- streaming 请求标记
system + 最后一条 message + 最后一个 tool definition - 命中数据
cached_tokens已采集到usageMetadata.cachedContentTokenCount(converter.ts:1124-1149) - 这是 prefix cache 机制:Round N+1 自动命中 Round N 写入的前缀
- summary 轮恰好是命中前缀最长的一轮
- streaming 请求标记
-
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.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-1s(sampling 跳过 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 渲染累计开销不小 |
待调研(可能是大鱼):
| 项 | 调研投入 | 潜在收益 | 关键未知 |
|---|---|---|---|
scope: 'global' 支持 | 已调研,结论 (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 三个值得展开的新发现
发现 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:
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 实际 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 重核) |
几条副发现的连带意义:
- TTL 滑动窗口 对 agent loop 是好消息——loop 内连续调用间隔通常 < 30s,cache 永远新鲜,不会 5min 失效
- 隐式 cache 20% 折扣 是免费红利——即使没标
cache_control也能拿;但精细控制需要显式 —— 更正(2026-05-26):经重核,qwen3.6-plus 确实在显式 cache 列表里,享受 90% 折扣。前一轮报告此处错误,已于本节首张表更正qwen3.6-plus未在显式列表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 工具后置指令 | 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.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 内不变
| 位置 | 内容 | 何时可能变 |
|---|---|---|
| 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) | 进程内不变 |
事件触发(低频)
| 参数 | 触发条件 | 频率估计 |
|---|---|---|
userMemory(getCoreSystemPrompt 第 1 参) | save_memory 工具 / /memory refresh / 扩展加载 | 0-3 次/session |
model 名(影响 getToolCallExamples 选哪一支) | /model 切换 | 罕见 |
appendInstruction | 配置项,session 内基本不变 | 几乎从不 |
deferredTools(buildDeferredToolsSection) | 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 友好性”结论,仅作为已知性能小坑记录
连带结论
- system prompt 在稳态 session 内每次产出 byte-for-byte 一致 → DashScope ephemeral cache key(基于内容 hash)整段稳定 → system 段 cache 命中率几乎 100%
- 唯一打 cache 的事件是
save_memory——核心功能,不能为 cache 让路 - 浮油 #1(简洁回复指令)的代价分析:把指令加到 Final Reminder 段(L389-390)→ system prompt 内容改变一次 → 首次请求 cache miss(一次性预热成本),之后所有请求继续命中
- §7 的 “system prompt 稳定化” 已废弃判断得到正式证据支持——不仅没必要做,连”理论上做了能进一步降低 cache miss 率”都不成立,因为本来就 ≈ 0
- 本审计可作为后续相关讨论的引用基线,避免重复 grep;若 prompts.ts 有大改动,本节需要同步更新