Claude Code 泄露源码分析(上)
Claude Code Agent 设计精要(上): 运行时引擎
基于 Claude Code 泄露源码的深度阅读,提炼 Agent 系统设计中值得沉淀的技术点。聚焦”为什么这样设计”和”解决了什么真实工程问题”。
上篇聚焦 Agent 运行时引擎的核心机制: Tool 并发、错误恢复、上下文管理、Agent 隔离、边界处理、Prompt Cache。
1. Tool 并发编排: 不是全并行也不是全串行
问题
Agent 一次回复中可能同时发起多个 Tool 调用(比如同时搜三个目录)。朴素做法是等模型说完,再一个一个执行 Tool。但这浪费了模型 streaming 期间的等待时间。
边流式边执行
Claude Code 的关键洞察: 模型还在吐后面的 token,已经输出完整的 tool_use 可以立即执行。
模型streaming: [tool_A完整][tool_B完整][tool_C还在吐...]|| v vtool_A执行tool_B执行<-与streaming并行
每个 Tool 经历四个生命周期: 排队 -> 执行中 -> 完成 -> 已输出。模型每吐完一个 tool_use block,立即推入队列尝试启动。
并发控制完全由工程代码决定,模型不参与
模型只负责决定调哪些工具、调几个。并发策略完全由工程代码控制 — 模型输出的只是一个扁平的 tool_use 列表,没有任何并行/串行标注。
|
|
|
|---|---|
|
|
|
|
|
isConcurrencySafe) |
|
|
|
|
|
|
|
|
|
System Prompt 唯一做的是鼓励模型一次多输出几个 tool_use,但怎么执行它管不了。
两套并存的执行模式
通过 feature flag 切换:
- StreamingToolExecutor(新模式)
: 模型每吐完一个 tool_use block 立即推入执行器,不等所有工具到齐。结果按接收顺序保序输出。 - Batch Orchestration(旧模式)
: 等所有 tool_use 收齐后,按顺序扫描分区再逐批执行。结果按完成顺序输出(谁快谁先)。
读写锁式的并发控制
每个 Tool 在注册时声明自己是否并发安全。Grep/Glob/Read 返回 true(只读),Edit/Write 返回 false(写入),Bash 根据命令内容动态判定。
执行器的核心规则: 当前没有工具在跑,或者”我是只读 + 正在跑的也都是只读”,才允许启动。遇到写入工具直接 break,不会跳过它去执行后面的,保证写入顺序性。
举例: 模型同时调了 [Grep, Grep, Edit, Grep, Grep],会被分成三个批次: [Grep+Grep并行] -> [Edit独占] -> [Grep+Grep并行]。
本质就是数据库里常见的读写锁 — 多读共存,一写独占。只是不用传统的 Mutex,而是用状态机实现。
并行执行引擎
Batch 模式的并行批次用 all() 生成器函数执行,核心是一个有限并发池: 用 Promise.race() 驱动,谁先产出就先 yield,一个完成自动启动下一个。并发上限默认 10,可通过 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY 环境变量配置。不是简单的 Promise.all() 等全部完成,而是流式地逐个输出结果。
兄弟错误级联
并行跑的 Bash 命令之一失败了(比如 mkdir 失败),后面的 Bash 大概率也会失败(隐式依赖)。Claude Code 建了一棵 AbortController 父子树:
查询级 abort (整个对话轮次) | +-- 兄弟级 abort (同一批 Tool 共享) | +-- Tool A 的 abort +-- Tool B 的 abort
关键决策: 只有 Bash 错误会级联取消兄弟。文件读取、网页抓取的错误是独立的 — 一个文件没找到,不应该取消其他文件的读取。这比”全部取消”或”互不影响”都更合理。
双通道输出: 结果排队,进度立即
Tool 的最终结果必须按顺序输出(API 要求 tool_use 和 tool_result 一一对应),但执行进度应该立即显示给用户。所以用了两条路:
- 进度通道
: 有进度就立即 yield,不管顺序 - 结果通道
: 缓冲起来,等前面的 Tool 都完成了才按序输出
等待时用 Promise.race 同时监听”任意 Tool 完成”和”有新进度”,哪个先来响应哪个。
2. 错误恢复: 不是 try-catch 就完了
问题
Agent 运行中会遇到各种错误: 模型输出被截断、上下文太长、模型服务过载、图片太大…朴素的 try-catch 只能终止会话,但用户期望 Agent 能自动恢复。
Withhold 模式: 错误先别急着告诉用户
这是最精妙的设计。当收到错误(比如”上下文太长”)时,不立即展示给用户,而是先藏起来,悄悄尝试恢复。只有所有恢复手段都失败后,才把错误展示出来。
为什么? 因为 SDK 消费者(比如 IDE 插件)看到 error 消息可能直接终止会话。如果我们能自动恢复,用户根本不需要知道曾经出过错。
输出截断的三级恢复
当模型写到一半被 max_output_tokens 截断:
- 静默提升上限
: 把输出上限从 8k 提到 64k,同一个请求重试一次。用户完全无感知。 - 注入继续指令
: 保留截断的内容,告诉模型”你被截断了,直接从断点继续,不要道歉,不要回顾”。最多重试 3 次。 - 放弃
: 输出截断的结果。
关键: 第1级和第2级策略完全不同。第1级是”给你更大的空间重来”,第2级是”你被打断了,接着说”。先试轻量的,不行再升级。
上下文超长的级联恢复
收到 "上下文太长" 错误 | +--> 先尝试: 折叠暂存的上下文 (最轻,不丢信息) | 成功? -> 重新请求 | +--> 再尝试: 紧急压缩历史 (调 LLM 生成摘要) | 成功? -> 用压缩后的历史重试 | 标记"已尝试过压缩"防止无限循环 | +--> 都不行: 告诉用户出错了
每次循环都记录”上一次是因为什么原因重试的”。这样当前迭代就能做不同的决策 — 比如折叠已经试过了还是超长,就直接跳到压缩,不再折叠。
模型降级
主模型服务过载时,自动切到备用模型。但有个容易忽略的坑: 不同模型的 thinking block 签名不兼容。模型 A 的加密签名发给模型 B 会报 400 错误。所以降级前必须把签名块清除干净。
3. 上下文窗口管理: 五层从轻到重的压缩
问题本质
LLM 的记忆空间(上下文窗口)是有限的。Agent 多轮对话加上 Tool 的输出,很快就会填满。如何在”不丢关键信息”和”不超上限”之间平衡?
五层渐进式策略
Claude Code 不用单一方案,而是构建了一个从轻到重的压缩梯队:
代价低/精度高──────────────────────>代价高/精度低Layer 1:大结果落盘超大的Tool输出写到磁盘,模型只看前2000字符的摘要完全无损--需要时还能用Read读回来Layer 2:裁剪最老的消息直接砍掉最早的几条消息快速,不需要调LLMLayer 3:上下文折叠把一组相关消息折叠成摘要折叠前的完整内容还保留着(可展开)Layer 4:自动压缩调LLM把整个历史浓缩成一段摘要不可逆,原始消息被替换掉Layer 5:紧急压缩收到"上下文超长"错误后才触发本质和Layer4一样,但是在错误恢复路径中
决策冻结: 为了 Prompt Cache 的极致一致性
这是最巧妙的细节。Claude API 有个 Prompt Cache — 如果两次请求的消息前缀完全一样(逐字节一致),第二次可以省掉前缀的计算费用。
问题来了: 如果第1次请求发了一条原始的 tool_result(很长),第2次请求把它替换成了摘要,那前缀就变了,Cache 就废了。
所以 Claude Code 用了一个**”命运冻结”**机制:
-
每条 tool_result 第一次被发送给 API 时,它的”命运”就被冻结了 -
第一次没被替换? 以后也永远不替换(即使后来超预算了) -
第一次被替换了? 以后每次都用完全一样的替换文本(从缓存取,零 I/O,逐字节一致)
恢复会话时,替换决策从 transcript 文件重建,存的是精确的替换字符串本身而不是”根据模板重新生成”。源码注释解释了原因: 如果代码更新了模板格式、文件大小显示方式、路径结构,就会生成不同的替换文本,悄悄破坏 Cache。
熔断器: 别无限重试
Auto Compact 需要调 LLM 来生成摘要,本身消耗 API 资源。如果上下文”不可恢复地超长”(比如 System Prompt 本身就快撑满了),压缩会反复失败。
解决: 连续失败 3 次就停止尝试。源码注释揭示背景: 曾经有 1,279 个会话出现了 50 次以上的连续失败(最高 3,272 次),全球每天浪费约 25 万次无效 API 调用。一个简单的计数器就解决了。
递归保护
Auto Compact 本身是通过”派生出一个小 Agent 来生成摘要”实现的。如果这个小 Agent 自己的上下文也超长,会陷入无限递归。所以凡是 querySource 为 compact 或 session_memory 的派生查询,直接跳过 Auto Compact。
4. Agent 隔离: 不用全局变量怎么共享状态
问题
主 Agent 可以 spawn 子 Agent,子 Agent 还能再 spawn。它们需要:
-
共享一些东西(全局配置、MCP 连接、Tool 列表) -
隔离一些东西(取消控制、文件缓存、对话历史) -
子 Agent 不应该弄脏父 Agent 的状态
用全局变量? 子 Agent 改了全局状态,父 Agent 就莫名其妙地受影响了。
方案: 一个大上下文对象,按层克隆
ToolUseContext 是一个约 50 个字段的上下文对象,每个 Tool 调用都通过参数接收它。创建子 Agent 时,做一次”选择性克隆”:
- 共享层
直接引用同一份(Tool 列表、模型配置、MCP 连接) - 隔离层
克隆一份新的(AbortController、文件缓存、对话历史) - 基础设施层
始终指向主线程(后台任务注册)
一个精细的区分: 普通的”写全局状态”在子 Agent 中是空操作(防止泄漏),但”注册后台任务”的写入永远能到达主线程 — 因为后台任务的生命周期跨越了 Agent 的生命周期。
子 Agent 的文件缓存也很讲究: 创建时克隆父 Agent 的缓存(避免重复读文件),但之后各自独立(子 Agent 的新读取不会污染父级)。
防止编辑未完整读取的文件
文件缓存里有个 isPartialView 标记。当 CLAUDE.md 被自动注入时,注入的可能是裁剪版(去掉了 HTML 注释等)。这个标记告诉 Edit/Write 工具: “你看到的不是完整内容,先 Read 一次完整版再编辑。” 防止模型基于残缺信息做修改。
5. 空结果注入: 一个小细节引发的大问题
Tool 执行完毕没有任何输出(比如一个静默的 shell 命令),直觉上返回空字符串就好了。但 Claude Code 偏偏要注入一句 (toolName completed with no output)。
为什么? 源码注释记录了一个生产 bug: 空的 tool_result 放在 prompt 末尾,某些模型会把它误认为对话轮次的结束标记(\n\nHuman:),然后直接沉默不输出任何东西。
根本原因: 服务端渲染器在 tool_result 后面不会自动插入 \n\nAssistant: 标记。内容为空时,prompt 末尾的格式刚好匹配了”一轮对话结束”的模式。
启示: 在 Agent 系统中,”空”不是”无” — 空值会被模型解读为特殊信号。这种问题只有在大规模生产中才能发现。
6. Prompt Cache 稳定性: 一个影响全局的隐形约束
Claude API 的 Prompt Cache 要求两次请求的消息前缀逐字节一致。这听起来简单,实际上是一个影响了整个架构的约束:
Tool 结果不能事后替换: 第一次发的是原文,以后就永远发原文。第一次发的是摘要,以后就永远发一模一样的摘要。(见第 3 节的”决策冻结”)
消息对象不能原地修改: 给 SDK 消费者看的消息需要加衍生字段? 克隆一份再加,原件不动 — 因为原件要回传给 API,改了就 Cache 不一致了。
恢复会话时存精确字符串: 替换文本不能”根据当前代码逻辑重新生成”,必须存当时的精确字符串。否则代码升级后模板变了,生成的替换文本就不一样了。
Thinking block 签名不能碰: 模型的思考过程带加密签名,修改任何一个字节都会导致 API 报错。模型降级时整段清除。
Prompt Cache 不是一个可以”之后再优化”的东西 — 它是一个架构级约束,必须从第一天就贯穿到消息处理、错误恢复、会话恢复的每一个环节。
上篇总结
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
这些是 Agent 运行时引擎的核心。下篇将聚焦工程细节(编译安全、流式撤回、幂等写入)和代码检索架构(三层检索协作、结果裁剪预算、模型与工程的分工边界)。
夜雨聆风