第 50 章:性能的故事

个人公众号

源码验证日期:2026-05-15,基于 commit 0d81bb6

你在第 49 章看到 MCP 作为开放协议如何塑造了工具生态的形状。但有一个维度我们一直在回避:速度。

用户在终端里敲下命令,按回车,然后等待。等多久?第一次响应(TTFT,Time To First Token)通常 1-3 秒。如果涉及工具调用,一个完整的回合可能 5-20 秒。如果上下文满了需要压缩,等 30 秒也不稀奇。

这些等待不是均匀分布的。有些快得像呼吸,有些慢得让用户想按 Ctrl+C。性能优化的故事,就是找出慢在哪里、为什么慢、怎么变快。


本章路线图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
graph LR
CH41["第41章<br/>为什么是TypeScript"] --> CH42["第42章<br/>为什么是React/Ink"]
CH42["第42章<br/>为什么是React/Ink"] --> CH43["第43章<br/>为什么用Zod"]
CH43["第43章<br/>为什么用Zod"] --> CH44["第44章<br/>工具系统的演进"]
CH44["第44章<br/>工具系统的演进"] --> CH45["第45章<br/>安全与便利"]
CH45["第45章<br/>安全与便利"] --> CH46["第46章<br/>有限窗口"]
CH46["第46章<br/>有限窗口"] --> CH47["第47章<br/>大AsyncGenerator"]
CH47["第47章<br/>大AsyncGenerator"] --> CH48["第48章<br/>Agent架构"]
CH48["第48章<br/>Agent架构"] --> CH49["第49章<br/>开放协议"]
CH49["第49章<br/>开放协议"] --> CH50["第50章<br/>性能的故事"]
CH50["第50章<br/>性能的故事"] --> CH51["第51章<br/>纵深防御"]
CH51["第51章<br/>纵深防御"] --> CH52["第52章<br/>稳定、历史与未来"]

style CH41 fill:#e0e0e0,stroke:#999
style CH42 fill:#e0e0e0,stroke:#999
style CH43 fill:#e0e0e0,stroke:#999
style CH44 fill:#e0e0e0,stroke:#999
style CH45 fill:#e0e0e0,stroke:#999
style CH46 fill:#e0e0e0,stroke:#999
style CH47 fill:#e0e0e0,stroke:#999
style CH48 fill:#e0e0e0,stroke:#999
style CH49 fill:#e0e0e0,stroke:#999
style CH50 fill:#4a90d9,stroke:#2c5f8a,color:#fff,stroke-width:3px
style CH51 fill:#e0e0e0,stroke:#999
style CH52 fill:#e0e0e0,stroke:#999

现状:性能的五条战线

战线一:Prompt Cache——最昂贵的优化

Prompt cache 是 Claude Code 性能故事的主角。没有它,每次 API 调用都要把完整的系统提示、工具定义、对话历史从头发送。有了它,重复的前缀只需要读缓存,成本降一个数量级。

但缓存是脆弱的。任何前缀的字节变化——系统提示改了一行、工具定义换了一个字段、对话历史被压缩——都会导致缓存失效。代码里有一整套机制来追踪和应对缓存失效。

promptCacheBreakDetection.ts 里的 notifyCompaction 函数专门处理压缩导致的缓存失效。压缩替换了对话前缀,下一次调用的缓存读一定会下降。如果不通知检测系统,这个下降会被误报为”缓存异常”,触发告警。注释里提到,修复这个之前,20% 的缓存异常事件是误报。

压缩 agent 自身也利用了缓存。streamCompactSummary 使用 runForkedAgent 来共享主对话的缓存前缀。注释里的实验数据触目惊心:

1
2
Experiment (Jan 2026) confirmed: false path is 98% cache miss,
costs ~0.76% of fleet cache_creation (~38B tok/day globally)

38 billion tokens per day。0.76% 就是约 288 million tokens。按当前价格计算,这是一笔不小的钱。缓存共享不是锦上添花,是运营必需品。

Fork subagent 的设计也以缓存为核心约束。forkSubagent.ts 里的 FORK_PLACEHOLDER_RESULT 必须在所有 fork 子进程之间完全一致——因为任何一个字节差异都会导致缓存前缀不匹配。这不是过度设计,而是经济学。

战线二:Microcompact——静默的性能守护

全量压缩(compactConversation)是昂贵的——它需要一次完整的 API 调用来生成摘要。Microcompact 是廉价的替代方案,它不调用 AI,只做本地文本替换。

时间触发的 microcompact 特别巧妙。它利用了一个洞察:如果距离上一次 assistant 消息已经超过了缓存过期时间(通常 5 分钟),那么服务端的缓存已经冷了。既然要重写前缀,不如先把冗余的工具结果清掉,缩小重写的范围。

1
export const TIME_BASED_MC_CLEARED_MESSAGE = '[Old tool result content cleared]'

一行替换,零 API 成本,可能省掉几千 token 的重写。这是一个以洞察换性能的典型案例。

cachedMicrocompact(缓存编辑 microcompact)更进一步。它使用 API 的缓存编辑能力,直接在服务端标记删除旧的工具结果,而不需要修改客户端的消息内容。这意味着不需要重发修改后的消息,缓存前缀不变——这是最优解,但依赖于 API 特定的功能,不是所有模型都支持。

战线三:延迟加载——启动时间的故事

Claude Code 的启动不是一次性的。它分阶段加载资源,把非关键的初始化推迟到需要的时候。

工具定义的延迟加载是一个典型例子。不是所有工具都在第一轮就可用。MCP 工具需要在服务器连接后才能发现,某些工具只在特定条件下激活。代码里的 defer_loading 标记和 ToolSearchTool 的设计,让系统能够在不发送全部工具定义的情况下开始对话,按需加载。

Agent 定义的省略也服务于性能。runAgent 里对 Explore 和 Plan agent 的上下文裁剪——省略 CLAUDE.md(节省 5-15 Gtok/week)、省略 gitStatus(节省 1-3 Gtok/week)——是在每次 API 调用上做减法。这些信息对只读 agent 没用,但占上下文空间,增加输入 token 数和延迟。

战线四:流式响应——感知速度

流式响应(streaming)不减少总延迟,但改善感知速度。用户看到一个字符一个字符地输出,比等十秒后看到一段完整的文本要”快”得多。

压缩过程也使用流式响应。streamCompactSummary 函数在压缩时更新进度指示器:

1
2
3
4
5
6
if (!hasStartedStreaming &&
event.type === 'stream_event' &&
event.event.type === 'content_block_start') {
hasStartedStreaming = true
context.setStreamMode?.('responding')
}

这确保了用户在压缩过程中看到动态的进度反馈,而不是静止的等待。

流式还有一个微妙的好处:提前检测失败。如果模型在压缩请求中尝试调用工具(在某些模型上有 2.79% 的发生率),流式响应可以在第一个 token 就检测到问题,提前切换到回退路径。如果等整个响应完成再检查,就浪费了完整的生成时间。

战线五:预测性工具执行——流式执行引擎

StreamingToolExecutor 是 Claude Code 性能故事里最精巧的篇章。模型仍在流式输出时,只读工具就已经开始执行。

源码证据在 StreamingToolExecutor.ts——约 517 行的流式工具执行器。addTool() 在收到 tool_use block 时立即添加并尝试执行。canExecuteTool() 实现了一个并发分区算法——只读工具并行执行,写操作串行执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
模型同时输出 3 个工具调用:Read("a.ts"), Grep("pattern"), Write("b.ts")

串行执行:
模型输出: 5s
Read: 0.1s
Grep: 0.2s
Write: 0.1s
总计: 5.4s

流式执行(实际):
模型输出: 5s(Read 和 Grep 在 2s 时就开始执行)
Read: 0.1s(在模型第 2-2.1s 完成)
Grep: 0.2s(在模型第 2.5-2.7s 完成)
Write: 0.1s(等模型说完后独占执行)
总计: 5.1s(省 0.3s)

被否决的方案有三种。方案 A 是等模型说完再执行——简单但慢,总延迟串行叠加。方案 B 是全部并行执行——可能导致写操作之间的竞态条件。方案 C 是两阶段执行——增加不必要的延迟。最终选择的是预测性流式执行 + 并发分区:只读工具在模型输出期间就开始执行,写操作等模型完成后串行执行。


当时还有什么选择

选择一:批量处理,不流式

每次 API 调用等完整响应再显示。这在批处理场景里可以——提交任务,等结果。但在交互式终端里,这是不可接受的。用户需要看到过程,不是等一个突然出现的结果。

选择二:更激进的缓存预热

在对话开始前,预发送系统提示和工具定义来预热缓存。这样第一次用户输入就能命中缓存。

问题是缓存有 TTL(生存时间)。如果预热得太早,在用户输入到达时缓存已经过期了。如果预热得太晚,用户还是要等。精确的时机控制需要预测用户的输入时间,这不可能做到。

选择三:完全不做 microcompact

把所有上下文管理工作都交给全量压缩。简单直接,少一层复杂性。

问题是全量压缩太贵。每次都要一次 API 调用,而 API 调用是按 token 计费的。如果你有 100K token 的对话需要压缩,压缩调用本身可能消耗 100K+ 的输入 token(因为要把整个对话作为输入)。Microcompact 避免了这笔费用——它不调用 API,只修改本地消息。

选择四:串行工具执行

等模型完整输出后,按顺序一个一个执行工具调用。这是最简单的方案,也是大多数竞品的方案(Aider、Cursor、AutoGPT 都用串行执行)。

问题在延迟。如果模型输出 3 个只读工具调用,每个 0.1-0.2 秒,串行执行的总时间就是 0.3-0.6 秒——加上模型输出的 5 秒,总计 5.3-5.6 秒。预测性执行可以把这个缩短到 5.1 秒。差距看起来不大,但在高频交互场景里,每一百毫秒都算数。


为什么选了这些

理由一:成本与体验的帕累托前沿

性能优化不是”越快越好”。每一次优化都有成本——开发成本、维护成本、复杂性成本。好的架构选择落在帕累托前沿上:给定成本下体验最好,或给定体验下成本最低。

Prompt cache 共享是帕累托前沿上的最优解。它几乎不增加复杂性(runForkedAgent 是已有基础设施),但节省了巨大的运营成本。Microcompact 是另一个例子:一个简单的字符串替换,省掉了几千 token 的重写。

理由二:渐进降级

性能优化不是全有或全无。系统在最优情况下(缓存命中、microcompact 生效、流式工具执行)很快,在次优情况下(缓存失效、需要全量压缩、串行工具执行)也不至于崩溃。

streamCompactSummary 的回退逻辑就是渐进降级的体现。首选路径是 runForkedAgent 共享缓存;如果失败,回退到普通流式路径;如果流式也失败(比如网络中断),重试最多 MAX_COMPACT_STREAMING_RETRIES 次。

autoCompact.ts 里的熔断器(circuit breaker)也是同样的哲学:连续失败 3 次就停止尝试,不再浪费 API 调用。注释里说,在引入熔断器之前,有些会话连续尝试 50+ 次压缩,每次都失败,每天浪费约 25 万次 API 调用。

理由三:Token 经济学驱动设计

在传统的性能优化里,瓶颈通常是 CPU 或内存。在 AI 编程助手的世界里,瓶颈是 token。每一个 token 都有成本——输入 token、输出 token、缓存创建 token、缓存读取 token,各有各的价格。

这改变了优化的方向。传统的优化想的是”怎么减少计算”,AI 助手的优化想的是”怎么减少 token 发送”。Microcompact 减少了重发的 token。上下文裁剪(省略 CLAUDE.md、gitStatus)减少了输入 token。缓存共享减少了缓存创建 token。流式工具执行减少了用户等待的 token 产出时间。每一个优化都可以折算成”省了多少 token”。


如果重新设计

更智能的缓存失效预测

当前的缓存失效检测是事后反应式的——缓存坏了,检测到了,记下来。一个更强大的设计是事前预测:根据上下文的变化模式,预测下一次调用是否可能缓存命中。

如果预测命中,可以安全地发送更多上下文(因为不增加输入成本)。如果预测未命中,提前触发 microcompact,在发送前就缩小请求体积。

并行化压缩的后处理

compactConversation 的后处理是串行的:生成摘要 -> 创建文件附件 -> 创建计划附件 -> 创建技能附件 -> 重新注入工具定义。实际上,很多后处理步骤是独立的,可以并行化。

代码里已经有一些并行化:createPostCompactFileAttachmentscreateAsyncAgentAttachmentsIfNeeded 是通过 Promise.all 并行执行的。但其他的步骤(计划附件、技能附件、MCP 指令重新注入)仍然是串行的。

预压缩

当前的压缩是被动的——上下文满了才触发。一个更激进的设计是主动压缩:在上下文达到 50-60% 时就开始做轻量级压缩,而不是等到 90%+ 才做全量压缩。

这类似于垃圾回收里的”增量收集”vs”全量收集”。增量收集每次做一点,全量收集一次做完。增量收集的每次暂停更短,但总工作量一样。


试试看

练习一:观察缓存命中

promptCacheBreakDetection.ts 中找到缓存命中/未命中的日志点。运行一次长对话,观察缓存命中率如何随对话轮次变化。特别注意压缩发生前后的缓存命中率。

练习二:追踪 Microcompact 的触发

microcompactMessages 函数中加日志,观察时间触发和缓存编辑 microcompact 的触发条件。计算每次 microcompact 节省了多少 token。

练习三:对比流式 vs 非流式工具执行

StreamingToolExecutor.ts 中找到 canExecuteTool 的并发分区逻辑。手动模拟一个场景:模型输出 5 个只读工具调用 + 1 个写操作。计算流式执行 vs 串行执行的时间差。


检查点

  • Prompt Cache 是性能的主角:缓存命中省 90% 输入成本,缓存失效是最大的风险
  • Microcompact 是免费的优化:零 API 成本,可能省几千 token
  • 流式工具执行是延迟杀手:预测性执行让只读工具在模型输出期间就开始
  • 被否决的方案:批量处理(体验差)、缓存预热(时机不可控)、串行工具执行(慢)
  • Token 经济学驱动设计:每个优化都可以折算成”省了多少 token”
  • 渐进降级:缓存命中/未命中、流式/串行、轻量/全量压缩——系统在任何退化下都不崩溃
  • 熔断器:连续失败 3 次停止压缩,每天省 25 万次 API 调用

下一站预告:性能优化让你的 Agent 跑得快。但如果一台跑得飞快的 Agent 没有安全防线,它的速度只会让灾难来得更快。下一章,我们盘点 Claude Code 的全部攻击面——从 prompt injection 到供给链后门——以及它用哪些层层嵌套的防线来抵御这些威胁。

导航

上一章:第 49 章:开放协议的价值

下一章:第 51 章:安全的纵深防御