乐于分享
好东西不私藏

Claude Code 泄露源码分析(上)

Claude Code 泄露源码分析(上)

Claude Code Agent 设计精要(上): 运行时引擎

基于 Claude Code 泄露源码的深度阅读,提炼 Agent 系统设计中值得沉淀的技术点。聚焦”为什么这样设计”和”解决了什么真实工程问题”。

上篇聚焦 Agent 运行时引擎的核心机制: Tool 并发、错误恢复、上下文管理、Agent 隔离、边界处理、Prompt Cache。


1. Tool 并发编排: 不是全并行也不是全串行

问题

Agent 一次回复中可能同时发起多个 Tool 调用(比如同时搜三个目录)。朴素做法是等模型说完,再一个一个执行 Tool。但这浪费了模型 streaming 期间的等待时间。

边流式边执行

Claude Code 的关键洞察: 模型还在吐后面的 token,已经输出完整的 tool_use 可以立即执行。

CODE
模型streaming: [tool_A完整][tool_B完整][tool_C还在吐...]||                    v             vtool_A执行tool_B执行<-streaming并行

每个 Tool 经历四个生命周期: 排队 -> 执行中 -> 完成 -> 已输出。模型每吐完一个 tool_use block,立即推入队列尝试启动。

并发控制完全由工程代码决定,模型不参与

模型只负责决定调哪些工具、调几个。并发策略完全由工程代码控制 — 模型输出的只是一个扁平的 tool_use 列表,没有任何并行/串行标注。

职责
谁负责
决定调哪些工具、调几个
模型
决定并行还是串行
工程代码(isConcurrencySafe
决定并发上限
工程代码(环境变量,默认10)
决定结果输出顺序
工程代码
错误级联取消
工程代码(只有Bash级联)

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 父子树:

CODE
查询级 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 截断:

  1. 静默提升上限
    : 把输出上限从 8k 提到 64k,同一个请求重试一次。用户完全无感知。
  2. 注入继续指令
    : 保留截断的内容,告诉模型”你被截断了,直接从断点继续,不要道歉,不要回顾”。最多重试 3 次。
  3. 放弃
    : 输出截断的结果。

关键: 第1级和第2级策略完全不同。第1级是”给你更大的空间重来”,第2级是”你被打断了,接着说”。先试轻量的,不行再升级。

上下文超长的级联恢复

CODE
收到 "上下文太长" 错误  |  +--> 先尝试: 折叠暂存的上下文 (最轻,不丢信息)  |     成功? -> 重新请求  |  +--> 再尝试: 紧急压缩历史 (调 LLM 生成摘要)  |     成功? -> 用压缩后的历史重试  |     标记"已尝试过压缩"防止无限循环  |  +--> 都不行: 告诉用户出错了

每次循环都记录”上一次是因为什么原因重试的”。这样当前迭代就能做不同的决策 — 比如折叠已经试过了还是超长,就直接跳到压缩,不再折叠。

模型降级

主模型服务过载时,自动切到备用模型。但有个容易忽略的坑: 不同模型的 thinking block 签名不兼容。模型 A 的加密签名发给模型 B 会报 400 错误。所以降级前必须把签名块清除干净。


3. 上下文窗口管理: 五层从轻到重的压缩

问题本质

LLM 的记忆空间(上下文窗口)是有限的。Agent 多轮对话加上 Tool 的输出,很快就会填满。如何在”不丢关键信息”和”不超上限”之间平衡?

五层渐进式策略

Claude Code 不用单一方案,而是构建了一个从轻到重的压缩梯队:

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 不是一个可以”之后再优化”的东西 — 它是一个架构级约束,必须从第一天就贯穿到消息处理、错误恢复、会话恢复的每一个环节。


上篇总结

挑战
Claude Code 的应对
本质
Tool 并发
读写锁调度 + 双模式执行器 + 兄弟错误级联
工程代码全权控制,模型只管”调几个”
错误恢复
多级降级 + Withhold + 熔断器
能自动恢复的就别告诉用户出了错
上下文有限
五层渐进压缩
从轻到重,能不丢信息就不丢
Cache 稳定性
决策冻结 + 只读克隆 + 精确存储
逐字节一致是全局架构约束,不是局部优化
Agent 隔离
大上下文对象 + 选择性克隆
共享配置,隔离状态,基础设施穿透
空值边界
空结果注入 + 幂等写入
“空”不是”无”,空值会被模型解读为信号

这些是 Agent 运行时引擎的核心。下篇将聚焦工程细节(编译安全、流式撤回、幂等写入)和代码检索架构(三层检索协作、结果裁剪预算、模型与工程的分工边界)。