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