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

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

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

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

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

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

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

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

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

* fix(telemetry): connect logSkillLaunch to QwenLogger

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

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

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

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

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

* feat(telemetry): thread prompt_id through SkillLaunchEvent

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

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

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

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

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

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

* test(scheduler): cover prompt_id propagation through buildInvocation

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

Two cases here close that gap on the scheduler side:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

1205 lines
78 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Qwen Code Agent Loop RT 优化技术方案
## 1. 背景与问题定义
### 1.1 现状
Qwen Code 的 Agent Loop 为严格串行模型:
```
User Prompt → [LLM 决策] → Tool Execution → [LLM 决策] → Tool Execution → ... → [LLM 回复] → Idle
~3-4s ~Xms-Ns ~3-4s ~Xms-Ns ~3-4s
```
每一轮 LLM 调用(含网络 RTT + 模型推理)约 3-4s是端到端 RT 的主要成本。
### 1.2 实测数据
测试场景:"我有哪些工作空间"3 轮 agent loop2 次工具调用,单次采样)
| 阶段 | 耗时 | 占比 |
| --------------------------- | --------- | ---- |
| LLM Round 1决策调 skill | 3.8s | 28% |
| Skill 执行 | 1ms | <1% |
| LLM Round 2决策调 shell | 3.0s | 22% |
| Shell 执行 | 2.5s | 19% |
| LLM Round 3文字总结 | 3.8s | 28% |
| 框架开销状态同步渲染 | 0.3s | 3% |
| **总计** | **13.4s** | 100% |
**结论**LLM 调用占 78%工具执行 19%框架 3%。优化的核心是**减少 LLM 调用次数****降低单次 LLM 调用延迟**。
> 单次采样、单一场景。19% 工具执行是 shell 慢调用支配read-heavy 场景下工具执行可降至 <5%。方案落地前需补 ≥3 类场景(写操作、跨工具推理、错误恢复)的基线。
### 1.3 当前架构关键约束
| 约束 | 代码位置 | 说明 |
| ------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
| 工具结果无后置控制 | `tools.ts` `ToolResult` 接口 (L422) | 仅有 `llmContent`/`returnDisplay`/`error`无法表达"跳过 LLM" |
| 结果无条件回传 LLM | `useGeminiStream.ts` `handleCompletedTools` (L2038) `submitQuery(ToolResult, …)` (L2355) | 所有 gemini-initiated 工具结果都回传 |
| Stream 完毕后才调度 | `useGeminiStream.ts` `processGeminiStreamEvents` (L1365) | stream 循环结束后才 `scheduleToolCalls`无增量调度 |
| 模型层选择无策略层 | `client.ts` `modelOverride ?? getModel()` (L1305, L1598) | 基础设施已贯通至 `turn.run(model, …)` (L1707)但调用方仅在 skill 显式指定时使用 |
### 1.4 已就绪的基础设施(本方案大量复用)
| 能力 | 位置 | 现状 |
| ---------------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------- |
| `fastModel` 配置 + `/model --fast <id>` | `config.ts:684`, `1987`, `2021` | 已就绪 |
| `SendMessageOptions.modelOverride` | `client.ts: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. 设计原则
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_usetool_result 不变量落地前需补单测覆盖"skip 后下一轮 user message" alternation
- Qwen / OpenAI 风格 API 容忍Anthropic 严格 alternation —— 后续若支持 Anthropic 直连需要兜底 history 注入空 assistant text
> **统一修复点**:此处和 §3.3D3 中途打断 Summarizing破坏的是**同一个历史不变量**。修复方案二选一(注入空 assistant / 接受 Qwen 容忍),两个方向必须使用相同选择。
#### 信号生态Phase 2 工作)
| 工具 | `skipLlmRound` | `resultIsTerminal` | 备注 |
| ------------------------------------- | -------------------- | ------------------ | --------------------------------------------------------------- |
| `read_file` | 配合 query-only 场景 | true | 文件内容即答案 |
| `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 完全用**对话流启发式**预测不依赖工具字段
#### 决策函数:对话流启发式 + 否决
```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 sentinelsidecar 自行丢弃 |
| `loopDetectorRef` 累积 token | 重置当前 turn 计数 |
执行顺序abort signal 触发 → 收齐上述五处清理 → 才允许新 user message 进入 `submitQuery`。竞态测试覆盖abort 触发瞬间正好收到最后一个 chunk。
#### 适用条件
batch 全员 `postExecution.resultIsTerminal === true`
#### 历史不变量(与 §3.1 同源)
中途打断 Summarizing 会产生:
```
[user_1, function_call, function_response, user_2]
↑ 无 assistant turn
```
**这与 §3.1 跳过 LLM 轮破坏的是同一个不变量**,必须使用与 D1 相同的修复策略(注入空 assistant / 接受 Qwen 容忍)。
- 复用 D1 的不变量单测覆盖
- session-load 重放(含 `repairOrphanedToolUseTurnsInHistory`)必须覆盖此形态
- Anthropic alternation直连时与 D1 同时补兜底
#### 风险与缓解
| 风险 | 严重度 | 缓解 |
| ----------------------------------- | ------ | -------------------------------------------------------------- |
| Abort 时半句 assistant 进 history | **中** | 显式丢弃 partial text仅保留 function_response单测覆盖 race |
| 历史不变量破坏(无 assistant 接续) | **中** | 与 D1 同源问题,统一修复(见 §3.1 历史不变量) |
| UI 状态复杂度增加 | 中 | Summarizing = Idle + 背景任务;输入路径复用 Idle |
| 用户感知收益依赖行为模式 | 低 | 用户若 3s 内不输入summary 已完成 → 无感知收益;但**不退化** |
#### 收益
- **理论上限**3-4s 感知 RT用户工具完成即输入
- **实际中位数**:取决于用户输入间隔——读结果 2-5s 后才输入的用户不会感受到差异,但**绝不会更慢**
---
### 3.4 方向四流式提前调度Stream-Ahead Scheduling
#### 问题
`processGeminiStreamEvents` 在 stream 完全结束后才批量调度工具。`ToolCallRequest` 事件可能在 stream 中期就已 yield。
#### 设计
在 stream 事件处理中对 `ToolCallRequest` 立即开始**前置验证**(不执行):
```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:818` 的 `CONCURRENCY_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` / `Execute` | ❌ | MUTATOR_KINDS |
| `Think` | ❌ | 含 save_memory / todo_write 等隐式写 |
| MCP 工具 | ❌ | 不可信 |
**TOCTOU为什么 Edit 不进 allowlist**
理论上 Edit 的 `shouldConfirmExecute` 是纯只读(读文件、算 diff。但 prevalidate 与 schedule 之间存在时间窗:
```
T=0 stream 收到 Edit(file=a.ts, ...) → prevalidate
T=10ms shouldConfirmExecute 读 a.ts缓存 diff_v0
T=300ms stream 结束scheduler.schedule()
T=305ms 期间其他工具/IDE/外部进程修改 a.ts
T=310ms scheduler 用 diff_v0 展示给用户
T=320ms 用户基于 v0 确认
T=330ms Edit 应用旧 params 到 v1 文件 → 内容损坏 / merge 失败
```
这是 TOCTOU。修复方向
- **A推荐**Edit 不进 allowlistprevalidate 仅覆盖 `CONCURRENCY_SAFE_KINDS` 三类。代价:收益从"50-200msEdit 主导)"降到"50-100ms仅读类"
- **B可选加强**Edit 进入 allowlist 但缓存附 `(mtime, size, content_hash)`schedule() 时校验未变才用缓存,否则重算
文档暂选 A。
#### 与现有并行调度的交互
`coreToolScheduler.attemptExecutionOfScheduledCalls`L2436+)使用 `partitionToolCalls` 把工具分成"并发安全 batch"和"串行 batch",并发 batch 通过 `runConcurrently`L2473执行。
prevalidate 必须与这个分批模型对齐:
- 缓存按 `callId` 索引(不是 `(toolName, args)`,避免并发同名调用冲突)
- prevalidate 失败的 call → 不影响其他 callschedule 时该 call 走原始 `shouldConfirmExecute` 路径
- stream 取消时按 `signal` 级联 abort 所有 in-flight prevalidate
#### 风险
| 风险 | 严重度 | 缓解 |
| ------------------------------------------ | ------ | ---------------------------------------------------------------------- |
| 缓存 diff 与确认时实际文件不一致TOCTOU | 高 | 方案 AEdit 不进 allowlist方案 B缓存附 `(mtime, size, hash)` 校验 |
| prevalidate 失败影响调度 | 低 | 失败/超时退回原 `shouldConfirmExecute` 路径,缓存缺失 ≡ 未启用 |
| 并发 prevalidate 共享 fd / 资源争抢 | 低 | `QWEN_CODE_MAX_TOOL_CONCURRENCY` 已限并发上限(默认 10 |
#### 收益
50-100ms/轮(仅 `CONCURRENCY_SAFE_KINDS` 范围)。若选方案 B 含 Edit理论收益 100-200ms。
---
## 4. 综合评估与路线图
### 4.1 综合评估
| 方向 | RT 收益 | 实施复杂度 | 质量风险 | 依赖 | 优先级 |
| -------------------- | ----------------------------- | ------------------------ | -------- | ------------------------------------------- | ------ |
| D1 工具后置指令 | 3-4s/终态轮 | 低2-3d | 低 | 无 | **P0** |
| D2 summary fast 路由 | 2-3s/summary 轮(待实测) | **中-高9d** | 中-高 | D2 自带启发式 + 主 chat 验证实验 + ACP 同步 | **P1** |
| D3 展示解耦 | 3-4s 感知改善(依赖用户行为) | 中3-5d含不变量修复 | 中 | D1 历史不变量修复 | **P1** |
| D4 流式提前调度 | 50-200ms/轮 | 高5-7d | 极低 | 无 | P2 |
#### D2 工作量细分
| 子任务 | 估时 |
| ------------------------------------------------------------------------------------------ | ------ |
| 主 chat fastModel-streaming 验证实验(含 P_compact 测量) | 1d |
| Fast 候选模型基线测量(含 TTFT、P95、`thinkingConfig` 兼容性) | 1d |
| `selectContinuationTier` + `summaryTierRef` 接入useGeminiStream | 0.5d |
| 启发式实现(含 `MUTATOR_KINDS` 复用 / `wouldTriggerCompression` 估算 / 多语言 / 状态突变) | 1d |
| `GeminiChat.retryStreamWithModel` + `discardPendingAssistant` 接口实现 | 1.5d |
| ACP Session 同步改造acp-integration/session/Session.ts | 1d |
| Telemetry span 修正(`requested` / `actual` 拆分) | 0.5d |
| User-level setting `summaryTierStrategy` + JSON schema + `/config` 集成 | 0.5d |
| 单测race、abort 时机、history 不变量、fallback 路径、ACP 路径) | 2d |
| **合计** | **9d** |
> 注:早期估时 6.5d 未含 ACP 路径、`wouldTriggerCompression` gate、清理清单、settings schema 工程化等成本。
### 4.2 实施路线
#### Phase 1D1 工具后置指令1 周)
- 扩展 `ToolResult.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 3D2 + D3约 3 周,含 ACP 同步)
> **修正**:早期路线图估 1 周,未含 fastModel-streaming 验证实验、`retryStreamWithModel` 实现、不变量统一修复、ACP 路径同步。
- 编码前:完成主 chat 验证实验 + 基线测量(含 `P_compact` 与 thinkingConfig 兼容性)
- 新增 `summaryTierRef` + `selectContinuationTier`(含 `wouldTriggerCompression` gate
- 新增 `GeminiChat.retryStreamWithModel` + `discardPendingAssistant`
- **同步改造 ACP Session 路径**acp-integration/session/Session.ts使用同一决策函数
- 新增 `StreamingState.Summarizing` + 输入路径复用 + abort 清理清单
- 历史不变量统一修复D1+D3 同源)
- Feature flag `experimental.summaryRoundFastModel: false`**Release N 默认关**
- User setting `summaryTierStrategy`
- Telemetry span 修正
- 运行时保险ToolCallRequest abort + retryStreamWithModel
#### Phase 4D4 流式提前调度(可独立插入)
- `CoreToolScheduler.prevalidate` + allowlist
- `processGeminiStreamEvents` 增量调度
---
## 5. 度量、验收与限制
### 5.1 性能指标
| 指标 | 基线 | Phase 1 | Phase 3 |
| -------------------------- | ----- | ------- | ------------------------- |
| 端到端 RT P503 轮 loop | 13.4s | <10s | <8s待实测 |
| 端到端 RT P95 | - | <13s | <12sfallback 路径上限 |
| 用户感知首结果时间 P50 | 13.4s | <10s | <5sD3 启用 |
| 用户感知首结果时间 P95 | - | <13s | <8s |
| LLM 调用次数可跳过场景 | 3 | 2 | 2更快 |
> 注:基线为单次采样,落地前需补 ≥3 类场景。
### 5.2 质量指标
| 指标 | 基线 | 允许退化 |
| -------------------------------------------- | ---- | ------------------------ |
| Tool-calling 准确率fast model summary | 100% | 98% |
| skipLlmRound 误用率用户追问"再详细些" | - | <1% |
| Fast model fallback_triggered | - | <10%>20% 自动关 flag |
| Summarizing 状态下半句 assistant 入 history | 0 | 0硬性 |
### 5.3 成本指标
| 指标 | 基线 | Phase 3 目标 |
| --------------------------------- | ---- | ------------------------------------------------------------ |
| 每千会话 token 成本summary 轮) | 100% | <70% |
| Fallback 路径浪费 tokens 占比 | 0 | <15%fallback × 单次 fast tokens / 单次 primary tokens |
### 5.4 决策日志 schema
每次 `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 tokensinput tokens 用 history 估算
- 标注:日志字段附 `tokens_source: 'usage' | 'estimated'`,事后分析需区分
### 5.5 验证方法与发布策略
#### 验证
- 复用 `/tmp/tool-timing.log` 计时框架
- 新增 `T_userIdle`(用户可再次输入时刻)
- 新增 `T_firstToken`(流式首 token 时刻)
- A/B 测试对比各 Phase 前后的 RT 与 cost 分布
#### 发布策略(适配本地 CLI
Qwen Code 是本地 CLI**没有运行时下发能力**——传统"5% / 25% / 100% 灰度"不适用。采用**阶段性 release 推进**
| 阶段 | Release 节点 | feature flag 默认值 | 触发条件 |
| --------------------- | ---------------------- | ------------------- | ----------------------------------------------------------- |
| Phase 3adogfood | Release N | `false` | 内部用户用 `summaryTierStrategy=always_fast` 自启用 |
| Phase 3bopt-in 默认 | Release N+1≥2 周后) | `false`(不变) | dogfood 阶段决策日志达标fallback <10%、 RT/cost 收益 >0 |
| Phase 3c默认开启 | Release N+2≥4 周后) | `true` | Phase 3b 用户层面无质量回归报告 |
| 回滚 | Release N+3如需 | `true → false` | 大规模 fallback >20% 或质量指标退化 |
**回滚机制**
- 无运行时下发,**回滚 = 发新 release 关 default flag**
- 用户级 `summaryTierStrategy=always_primary` 始终提供"我要立刻退出"通道,不依赖新 release
- 决策日志的 `fallback_rate` / `cost_regression` 在每个 Release 周期评估,决定下一步
### 5.6 已知限制
1. **基线数据单薄**:单次采样不能覆盖全部任务模式,落地前需补场景
2. **fast 模型前提**:不存在显著更快且 tool-calling 达标的同家族模型 → D2 不启用
3. **`skipLlmRound` 是质量换速度**:跳过 LLM = 放弃模型理解和纠错,仅适用确定性高场景
4. **D2 是质量+成本换速度**fast 模型质量低于 primaryfallback 路径反而更贵——必须以决策日志实测净收益
5. **`tryCompress` 触发可能反向恶化**fast 模型 context 小compression 自身耗 LLM 调用——`wouldTriggerCompression` gate 是必备防御
6. **展示解耦改变交互模型**:新模式需要用户适应;用户行为决定实际感知收益
7. **网络延迟不可控**:本方案减少调用次数,非优化单次调用
8. **Anthropic 直连未覆盖**:当前 alternation 容忍度依赖 Qwen / OpenAI 风格 API
9. **主 chat 上 fastModel-streaming 是首次落地**:无生产先例,需独立验证实验
10. **本地 CLI 无运行时下发**:发布策略只能阶段性 release 推进,不支持快速灰度调节
11. **D2 仅作用于交互路径**Subagent / Cron / Notification 不享收益,刻意如此
12. **混合模型 history 长期影响未知**D2 启用后 session 内 turn 在 fast/primary 间切换,长会话 resume 与上下文连贯性需观察
13. **D4 收益缩水**Edit 退出 allowlist 后prevalidate 仅覆盖纯读类工具50-100ms 收益);含 Edit 的 200ms 收益需方案 B 的 mtime/hash 校验机制
### 5.7 关键代码位置
| 文件 | 关键符号 | 位置 |
| ----------------------------------------------------- | -------------------------------------------------------- | ------------------------ |
| `packages/core/src/tools/tools.ts` | `ToolResult` interface | L422 |
| `packages/core/src/tools/tools.ts` | `Kind` enum + `MUTATOR_KINDS` + `CONCURRENCY_SAFE_KINDS` | L793, L806, L818 |
| `packages/core/src/tools/tools.ts` | `DeclarativeTool.kind: Kind`(每个 Tool 实例都带) | L165 |
| `packages/core/src/core/client.ts` | `SendMessageOptions.modelOverride` | L142 |
| `packages/core/src/core/client.ts` | `sendMessageStream` | L1216 |
| `packages/core/src/core/client.ts` | `modelOverride ?? getModel()` | L1305, L1598 |
| `packages/core/src/core/client.ts` | `turn.run(model, …)` | L1707 |
| `packages/core/src/core/geminiChat.ts` | `sendMessageStream(model, …)` | L1387 |
| `packages/core/src/core/geminiChat.ts` | `history.push(userContent)` | L1428 |
| `packages/core/src/core/geminiChat.ts` | `sendPromise` 锁 | L1392 |
| `packages/cli/src/ui/hooks/useGeminiStream.ts` | `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 streamingforked chat 先例) | L224 |
| `packages/core/src/config/config.ts` | `fastModel` + `getFastModel` + `setFastModel` | L684, L1987, L2021 |
| `packages/core/src/core/coreToolScheduler.ts` | `attemptExecutionOfScheduledCalls` | L2436 |
| `packages/core/src/core/coreToolScheduler.ts` | `runConcurrently` + `partitionToolCalls` | L2473 |
| `packages/cli/src/acp-integration/session/Session.ts` | `sendMessageStream` 调用点ACP / IDE 路径) | L705, L965, L1182, L1423 |
| `packages/core/src/agents/runtime/agent-core.ts` | Subagent `sendMessageStream`(不受 D2 影响) | L614 |
---
## 6. Review 验证记录2026-05-26
### 6.1 验证方法
针对设计文档中**只声明、未量化**的几条前置数据质量假设与收益估算,启动 4 个并行 Explore subagent 做只读代码调研。每个 subagent 只回答一个事实问题,不做判断,不给优化建议。调研基于当前 `main` 分支HEAD: `026f2f768`)。
| 验证问题 | 关联章节 |
| ---------------------------------------------------------------------- | ---------------------------------- |
| Q3 当前所有工具的 `ToolResult.error` 字段填充率 | §3.2 `hasUnresolvedError` 前置依赖 |
| Q4 stream abort 后 `usageMetadata` 实际可得性 | §5.4 `fast_tokens_consumed` 测量 |
| Q5 "用户追问 / clarification" 埋点存在性 | §5.2 fast 质量回归监控信号 |
| Q6 `CONCURRENCY_SAFE_KINDS` 工具 `shouldConfirmExecute` 实际 IO 工作量 | §3.4 D4 收益估算 |
### 6.2 发现 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` 循环只在完整遍历时记录 turnL1286abort 中断意味着最后的 usage-only chunk通常携带完整 metadata**被直接丢弃**
- 主聊天路径**无任何 chunk-level token 累计兜底**;仅 subagent 层(`agent.ts:731-744`)有累计,无法复用
- 结论abort 时 `usageMetadata` **零获取**,只能靠 `chars/4` 估算±20% 误差)
**对设计的影响**
- §5.4 末尾的"优先 / 兜底 / 标注"三层方案中,**"优先" 路径在当前代码不可达**——需先改 `sendMessageStream` 生成器结构加 finally工作量约 1d设计文档没体现这笔成本
- §5.3 把 "每千会话 token 成本 <70%" 列为 Phase 3 目标但若指标本身 ±20% 误差**"70%" "82%" 落在测量噪声内**
**建议修正**
- §5.3 改写为**趋势指标**不作为 release gate改用 "决策日志的 `fallback_triggered` + `fast_tokens_consumed` 同向趋势" 双指标联合判断
- §5.4 增补`fast_tokens_consumed` 实现需先改造 turn.ts abort 路径加 finally + `stream.return()`作为 §3.2 工作量补充+1d
### 6.4 发现 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 发现 4D4 设计内在矛盾——allowlist 与收益归因不对齐(影响 D4 / §3.4
**事实**
- `Kind.Read`read_file)、`Kind.Search`glob / grep)、`Kind.Fetch`web_fetch三类工具的 `shouldConfirmExecute` / `getConfirmationDetails`**绝大多数继承 `BaseToolInvocation` 默认实现做零 IO**read_file / glob / grep 完全没 overrideweb_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 几乎无关
- 收益归因错误会带来两个问题
1. **allowlist 可以更宽**——凡是 idempotent prevalidate 的工具都行不必绑定 `CONCURRENCY_SAFE_KINDS`
2. **5-7d 投入难以自洽**——如果真实收益只有调度模型改变的 ~50msEdit 又不在 allowlist 这笔投入的 ROI 比设计文档暗示的低
**建议修正**:§3.4 重写收益归因——
- 拆分为两部分(a) 调度模型改变省下的 stream 等待 ~50ms(b) 工具端 IO 前置可省的工作量 ~0msallowlist / ~20ms Edit allowlist
- §4.1 综合评估表里把 D4 RT 收益从 "50-200ms" 改为 "30-80ms方案 A主要来自调度模型/ 100-200ms方案 B Edit"
- §4.2 路线图中把 D4 进一步降级——纯调度模型改造可独立做不必强行绑定 prevalidate 概念
### 6.6 对路线图的合并影响
| 章节 | 原估时 | 验证后估时 | 增量来源 |
| ----------------------------- | ------ | ------------ | ------------------------------------------------------------------------------------------------ |
| D2 §3.2 工作量(§4.1 细分表 | 9d | **14-16d** | +2d发现 1 前置工具改造+1d发现 2 turn.ts finally 改造+3d发现 3 输入分类器如取硬路径 |
| D4 §3.4 综合评估 | 5-7d | 5-7d不变 | 工作量不变 **RT 收益归因从"工具端 IO"改为"调度模型"**投入 ROI 下调 |
| Phase 3 总时长(§4.2 | ~3 | **~4-5 ** | D2 工作量上调 + 前置工具改造 PR 单独走 review 周期 |
**对原路线图的修正建议**
1. **保持 D1P0和 D3 紧随其后**——本次验证未触及它们的核心假设ROI 判断不变
2. **D2 启动条件加严**——把发现 1/2/3 的前置工作 ~6d作为 "D2 启动 gate"未完成不进入 §3.2 前置实验
3. **D4 重新评估优先级**——既然真实收益是调度模型改变而非工具端 IO要么 (a) 接受 30-80ms D4 降到 P3 后置要么 (b) 考虑方案 BEdit + mtime/hash拿回 100-200ms 但额外 5-7d
4. **不修改 §1.2 单次采样基线**—— §5.1 P95 一栏在 D1 落地补完 3 类场景基线之前不写具体数字
### 6.7 验证未覆盖的追问点
以下追问点属于主观判断或作者意图问题本次验证未通过 subagent 处理留作后续 design review 讨论
- D2 实施次序应否后置于 D3主观次序
- D1/D3 是否应合并到 Phase 1 一起做实施策略
- §3.2 `needsCrossResultReasoning` 阈值 3 是否反向拟合 §1.2 基线场景作者意图
- §5.7 关键代码位置表的行号锚点是否应改为符号锚点文档稳定性
---
## 7. 浮油评估与下一步2026-05-26 二次 review
### 7.1 触发本次重排的事实
§6 验证之后又发现两个**改变 ROI 判断的事实**
1. **DashScope `cache_control` 已实装**`packages/core/src/core/openaiContentGenerator/provider/dashscope.ts:172-181`
- streaming 请求标记 `system + 最后一条 message + 最后一个 tool definition`
- 命中数据 `cached_tokens` 已采集到 `usageMetadata.cachedContentTokenCount``converter.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.2s | summary 轮的当前实际表现 |
| fast cache跨模型不共享 | ~1.5-2s | D2 切换后的实际表现 |
**净差距:几百毫秒,甚至可能 fast 反而慢**叠加 14-16d 工程成本 + 质量风险 + fallback 浪费**D2 净收益接近 0 或负**。
§3.2 前置条件**必须新增**基线测量必须对比 primary **cached** vs fast **uncached** `T_primary_cached < T_fast_uncached × 1.5` D2 不应启用
### 7.3 候选清单(按浮油性重排)
**真·浮油(立刻动手,< 1d 投入,极低风险,确定收益)**
| | 投入 | 收益 | 操作位置 |
| ----------------------------- | ----- | --------------------------------- | --------------------------------------------------------------------------- |
| 简洁回复指令 | 30min | ~2s/summary 输出 token 减半 | `prompts.ts` Final Reminder 段加一句 |
| 暴露 cache hit rate telemetry | 0.5d | 0s 直接是后续决策 **enabler** | `cachedContentTokenCount` 已采集缺暴露并应识别 `save_memory` 后单独打标 |
**近浮油等数据决定0.5-1d 投入)**
| | 投入 | 收益 | 决策前置 |
| ------------------------------- | --------------------- | --------------------------------------- | --------------------------------------------------------------------- |
| summary `tool_choice='none'` | 0.5-1d | 0.3-1ssampling 跳过 tool_call token | " summary "判定逻辑错判风险低 |
| summary 轮关 thinking | 1d | 0.5-2s | 仅对启用 thinking 的模型有意义qwen3.5-plusglm-4.7kimi-k2.5 |
| UI 渲染层 chunk batching | 0.5d 调研 + 0.5d 实施 | 待验证 | 假设 summary `useGeminiStream` token 渲染累计开销不小 |
**待调研(可能是大鱼)**
| | 调研投入 | 潜在收益 | 关键未知 |
| ------------------------------------ | ------------------------ | ------------------- | ------------------------------------------------------------------------------------------ |
| ~~DashScope `scope: 'global'` 支持~~ | ~~0.5d 文档 + 0.5d A/B~~ | ~~跨 session 命中~~ | **已调研,结论 (c) 不可行** §7.4 发现 B 调研结果)。此行保留作为决策记录不要重启调研 |
**中等改造(不算浮油,单独评估)**
| | 投入 | 风险 | 收益 |
| --------------------------------- | ---------------- | ---- | ----------- |
| D1 `skipLlmRound`终态查询场景 | 2-3d | | 3-4s/终态轮 |
| summary 轮工具结果裁剪D5 子集 | 2d | | 1-2s |
| D3 `Summarizing` 状态 | 3-5d | | 感知改善 3s |
| system prompt 减肥 | 2-3d A/B 测试 | | 0.5-1s |
**已废弃方向(不要再做)**
| | 废弃原因 |
| ------------------------------------------ | ------------------------------------------------------ |
| D2 fast model 路由 | DashScope cache 抵消净收益接近 0 或负 |
| D4 prevalidate | 收益归因错真实仅 ~50ms 来自调度模型5-7d 投入不值 |
| system prompt 稳定化 | 已稳态无事可做 |
| 流式提前 terminal提前 abort 收尾客套话 | 高误判风险用户感知答案被切断 |
### 7.4 三个值得展开的新发现
#### 发现 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 cacheTTL 远大于 ephemeral 5min
- ** session 命中**启动延迟也降
- 单这一条收益可能超过原 D2 全部假设收益
##### 调研结果2026-05-26结论(c) 不可行,关闭此线)
通过查阿里云百炼官方文档 `help.aliyun.com/zh/model-studio/context-cache` 得到的事实清单
| 问题 | 结论 | 证据 |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `scope` 字段支持 | **不支持**仅识别 `type: 'ephemeral'`任何 `scope`/`persistent`/`global` 会被 silently dropped | 官方文档原文"仅支持将 `type` 设置为 `ephemeral`" |
| ephemeral 实际 TTL | **5 分钟滑动窗口**命中后重置 | 百炼文档明确说明 |
| TTL / 全局机制 | **无任何公有云 API 端机制** `persistent` type 无独立预上传 API `prompt_cache_key`唯一"全局持久"产品是 PAI 全局上下文缓存自部署 + vLLM + 灵骏 + 共享 Redis DashScope API 无关 | PAI 文档 |
| session 共享 | 同账号 + 同模型 + 内容匹配 已经命中这就是 `ephemeral` 已经在做的不同账号绝对不共享 | 百炼文档 |
| 定价 | cache write 125%、显式 cache read 10%、**隐式 cache read 20%** `cache_control` 标记也能拿到隐式 20% 折扣 | 百炼定价文档 |
| 最小可缓存 prompt | **1024 tokens** | 百炼文档 |
| 模型支持显式 cache | qwen3.7-max / qwen3.6-plus / qwen3.5-plus / qwen3-coder-plus / qwen3-vl-plus / deepseek-v3.2 / kimi-k2.5 / glm-5.1 均显式列出。**qwen3.6-plus qwen3.7-max 同样享受 90% 显式 cache 折扣** | 百炼模型列表2026-05-26 重核 |
**几条副发现的连带意义**
1. **TTL 滑动窗口** agent loop 是好消息——loop 内连续调用间隔通常 < 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 原假设更明确为负
#### 发现 CUI 渲染层是被忽视的盲区
§1.2 基线把"框架开销"标为 0.3s3%但这是粗估Ink 7 + React 19.2 在每个 chunk 触发 setState re-render summary 累计可能 200-500ms需要查 `useGeminiStream` 怎么处理 token 有没有 `requestAnimationFrame` / `useDeferredValue` 合并 chunk
### 7.5 待数据 checkpoint —— 数据到了该看哪个决策
本节是**这份文档的活动入口**后续有任何度量数据对照下表决定该回看哪个决策
#### Checkpoint 1cache hit rate 数据出来后
**触发条件**浮油"暴露 cache hit rate telemetry"上线 3 决策日志含 `cached_tokens` / `prompt_tokens` 分布
**该看的数据**
- 整体命中率cached / prompt P50P90 分布
- 按轮次划分Round 1 / Round 2 / Round 3 (summary) 各自命中率
- `save_memory` 触发后下一轮命中率应该接近 0
- `/model` 切换后下一轮命中率应该接近 0
**决策路径**
| 整体命中率 | 含义 | 行动 |
| ---------- | -------------------- | --------------------------------------------------------------------------- |
| > 70% | 现状已经接近理论上限 | 只做 #1 简洁指令 + 发现 B 调研;其余浮油按需 |
| 40-70% | 还有空间但来源不明 | 分析按轮次命中率,找出哪一段在 miss |
| < 40% | 有动态点在打 cache | 重新审计 system prompt / userMemory 触发频率可能 `save_memory` 比预期频繁 |
#### Checkpoint 2DashScope `scope: 'global'` 文档调研结果 ✅ 已完成2026-05-26
**结果****完全不识别**。详见 §7.4 发现 B "调研结果"
**已执行行动**接受现状跳过此项`dashscope.ts:288` 维持现有 `ephemeral` 标记无需改造
**后续不要重新启动此调研**——除非 DashScope 官方公告新增持久化机制
#### Checkpoint 3UI 渲染层调研结果
**触发条件**发现 C 调研完成 `useGeminiStream` token 流处理 + Ink/React DevTools 实测)。
**决策路径**
| 结果 | 行动 |
| ---------------------------------- | ------------------------------------------------ |
| summary stream 渲染累计 > 200ms | 改用 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 | 重新审视是否浮油本身被高估还是有未识别的瓶颈网络 RTTprovider 端延迟 |
### 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)`
```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 有大改动本节需要同步更新