前言
2026 年 3 月底,Claude Code 的 51 万行 TypeScript 源码因为 Bun 的一个打包 bug 意外泄露。网上已经有很多文章在聊它的隐藏功能------AI 宠物、自主守护进程、夜间记忆整合。这些确实有趣,但不是这篇文章想聊的。
我之前学过一个团队成员分享的Claude Code 的拆解教程(learn-claude-code),它用 12 节课把 Agent 的核心模式讲得很清楚:一个 while(true) 循环,模型决定调用哪些工具,代码负责执行,结果喂回去,继续循环。围绕这个循环逐步叠加 harness 机制------子 Agent、上下文压缩、任务系统、团队协作。
这个模式已经能构建一个可用的 Agent 了。但当我打开 Claude Code 的真实源码,发现围绕这同一个循环,它做了大量在基础模式中看不到的工程投入。核心循环确实一样,区别在于循环的每个环节之间还发生了什么。
这篇文章就是想把这些"还发生了什么"梳理出来。
一、延迟工程:在等待的间隙偷偷干活
一句话版本:模型还在输出时工具就开始跑了,读操作并行、写操作独占,权限检查和记忆搜索也藏在等待时间里。
基础的 Agent 循环是严格串行的:等模型输出完毕 → 解析工具调用 → 执行工具 → 拼结果 → 再次调用模型。每一步都要等上一步结束。
Claude Code 做的第一件不同的事,是把大量工作藏进了等待时间里。
流式工具执行
最直观的例子是 StreamingToolExecutor。在 query.ts 的主循环中,模型的流式输出是一个 for await 循环。每当这个循环里出现一个 tool_use 类型的 block,它不是攒起来等模型说完------而是立即交给 StreamingToolExecutor 开始执行:
typescript
// src/query.ts --- 流式接收过程中,识别到工具调用就立即入队
for await (const message of deps.callModel({ ... })) {
if (message.type === 'assistant') {
const msgToolUseBlocks = message.message.content
.filter(content => content.type === 'tool_use')
for (const toolBlock of msgToolUseBlocks) {
streamingToolExecutor.addTool(toolBlock, message) // 立即执行,不等模型说完
}
}
// 同时检查已完成的工具结果,按顺序 yield
for (const result of streamingToolExecutor.getCompletedResults()) {
yield result.message
}
}
StreamingToolExecutor 内部维护了一套并发控制------每个工具入队时会判断它是否"并发安全":
typescript
// src/services/tools/StreamingToolExecutor.ts
private canExecuteTool(isConcurrencySafe: boolean): boolean {
const executingTools = this.tools.filter(t => t.status === 'executing')
return (
executingTools.length === 0 ||
(isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
)
}
规则很直白:如果当前没有工具在跑,或者当前在跑的全是只读工具且新工具也是只读的,就可以并行。否则必须排队。这让读操作尽可能并行,写操作保证独占。
同时还有一个细节:当某个 Bash 命令出错时,它会通过 siblingAbortController 取消所有正在并行的兄弟工具------但这个取消只影响同级的工具,不会终止主循环。这种"局部失败不扩散"的设计让错误恢复变得更精确。
配合编排层的批次划分,连续的只读工具会被合并成一批并行执行,遇到写操作就自动断开成新批次:
typescript
// src/services/tools/toolOrchestration.ts
/**
* Partition tool calls into batches where each batch is either:
* 1. A single non-read-only tool, or
* 2. Multiple consecutive read-only tools
*/
function partitionToolCalls(toolUseMessages, toolUseContext): Batch[] { ... }
效果是:模型同时输出 ReadFile(a.ts) + ReadFile(b.ts) + Grep(pattern) 时,这三个操作几乎同时完成,而不是串行等三次 I/O。
推测性预计算
不只是工具执行被提前了,权限检查也是。模型还在流式输出一条 bash 命令的过程中,后台已经在跑安全分类器了。如果命令输出完毕前分类器就给出了"安全"的高置信度判断,用户根本不会看到确认弹窗------命令直接执行,感知延迟少了 1-3 秒。
类似的还有记忆预取:每轮对话开始时,在模型思考的同时就去异步搜索可能相关的历史记忆。搜到了就注入到下一轮上下文,搜不到就丢弃,这个搜索延迟被完全藏在了模型推理时间里。
启发
串行执行是最容易实现的方式,但很多步骤之间并没有真正的依赖关系。Agent 的性能瓶颈往往不是单个操作太慢,而是大量操作被不必要地串行等待了。识别出哪些工作可以重叠在等待时间里,是 Agent 性能优化的第一步。
二、上下文生命周期:模型"看到的东西"比 prompt 更多
一句话版本:上下文管理有四层从轻到重的压缩,压缩后还会自动重建工作状态,大结果存磁盘而不是截断。
先说一点:Claude Code 的 prompt 确实写得好。它的 system prompt 是模块化的函数动态拼装,而且花了大量篇幅在定义"不要做什么"------用约束来对抗模型天然的过度生成倾向:
typescript
// src/constants/prompts.ts
// 模型天然倾向于过度设计,所以 prompt 用大量"不要"来约束
`Don't add features, refactor code, or make "improvements" beyond what was asked.`
`Don't create helpers, utilities, or abstractions for one-time operations.`
`Three similar lines of code is better than a premature abstraction.`
但 prompt 只是模型输入的一部分。工具执行的结果、历史对话、压缩摘要------这些同样是模型"看到的东西",它们的质量直接影响输出。Claude Code 在管理这些内容上的投入远超 prompt 本身。
四层渐进式压缩
learn-claude-code 的 s06 课讲了三层上下文压缩策略。Claude Code 实际实现了四层,而且按从轻到重的顺序在每轮循环中依次执行:
typescript
// src/query.ts --- 每轮循环开头,四层压缩按顺序执行
// L0: HISTORY_SNIP --- 精确裁剪特定消息范围
snipModule.snipCompactIfNeeded(messagesForQuery)
// L1: Microcompact --- 零成本清理旧工具结果(不调用模型)
deps.microcompact(messagesForQuery, toolUseContext, querySource)
// L2: CONTEXT_COLLAPSE --- 将对话轮次归档为结构化摘要
contextCollapse.applyCollapsesIfNeeded(messagesForQuery, ...)
// L3: Autocompact --- 兜底,调用模型生成完整摘要(成本最高)
deps.autocompact(messagesForQuery, ...)
这个分层设计的关键思路是:能用轻量方式解决的,就不动用重量级手段。 Microcompact 只是把旧的工具结果替换为 [Old tool result content cleared],保留最近几个,完全不需要调用模型,零 API 成本。Context Collapse 把对话轮次归档为结构化摘要,比 Autocompact 更轻。只有当前面三层都不够时,才触发 Autocompact 调用模型做完整摘要。
其中 Microcompact 还有一个巧妙的判断:如果用户已经离开超过 5 分钟,服务端的 prompt cache 肯定已经过期了。这时与其花钱重建一个臃肿的 prompt 缓存,不如先把不需要的工具结果清掉------反正 cache 要重建,不如用一个更精简的版本重建。
压缩后的状态重建
但更让我意外的是压缩之后 发生的事。大多数实现做完摘要就结束了------把 summary 丢进上下文继续对话。Claude Code 在压缩后会做一轮状态重建:
typescript
// src/services/compact/compact.ts
// 压缩后不仅有 summary,还会并行重建工作状态
const [fileAttachments, asyncAgentAttachments] = await Promise.all([
createPostCompactFileAttachments( // 重新注入最近读取过的文件
preCompactReadFileState, context,
POST_COMPACT_MAX_FILES_TO_RESTORE,
),
createAsyncAgentAttachmentsIfNeeded(context), // 恢复异步 agent 状态
])
// 最终的 post-compact 消息序列是有固定顺序的:
// boundaryMarker → summaryMessages → messagesToKeep → attachments → hookResults
createPostCompactFileAttachments 会把最近读过的 5 个文件重新读取并注入到上下文中(在 token 预算内),这样模型在压缩后不需要重新 ReadFile 就能继续工作。压缩后还会恢复进行中的 plan、已加载的 skills(每个 skill 有 5000 token 的截断预算)、MCP 指令等。
这意味着压缩不只是"丢掉旧的",而是"丢掉旧的 + 重建当前工作状态"。模型在压缩后几乎无感------它不需要重新问"你刚才说的那个文件在哪?"
工具结果不截断------持久化
当一个工具返回超大结果(比如 git diff 输出 50KB),简单的做法是直接截断。Claude Code 的做法不同:完整结果通过 toolResultStorage 存到磁盘,上下文里只留一条 "Output too large (50KB). Full output saved to: /path/to/file" 加上前 2KB 的预览。模型需要看完整内容时,可以用 ReadFile 工具去读。信息没有丢失,只是变成了按需获取。
而且这里有一个容易忽略的细节:每个工具结果一旦被决定"保留原文"还是"替换为预览",这个决定就永远不变(状态冻结在一个 Map 里)。为什么?因为如果同一个工具结果这轮被保留、下轮被替换,发给 API 的 prompt 前缀就变了,缓存就失效了。这个设计和后面要说的"缓存稳定性"是同一套思路。
启发
上下文管理不是在"保留"和"丢弃"之间二选一,可以有更多中间态------降级但不丢失、压缩但重建状态。随着对话变长,模型"看到的东西"的质量管理会变得和 prompt 本身一样重要。
三、安全治理:用权限约束行为,而不是用 prompt
一句话版本:不靠 prompt 说服模型"别做坏事",而是直接移除做坏事的能力------不给工具比给了工具说别用可靠得多。
工具调用最简单的做法是模型说调什么就调什么。Claude Code 在每个工具调用前有一条完整的治理管线,核心思路是通过能力边界来约束行为,而不是靠 prompt 说服模型。
多源规则合并
权限规则来自 7 个不同的来源:用户全局配置、项目配置、企业策略、命令行参数、Feature Flag、会话级设置、交互式命令。这些规则合并后,对每个工具调用产生 allow / deny / ask 三种决策。
比如可以在项目配置里设置 Bash(prefix:git) 只允许 git 相关命令------这不是告诉模型"请只执行 git 命令",而是在系统层面让非 git 命令根本执行不了。
当规则无法直接判定时(比如一条不在白名单里但也不在黑名单里的命令),Claude Code 会用一个 AI 分类器来判断安全性。这个分类器和用户的确认弹窗是并发启动的------如果分类器在用户做出选择前就返回了高置信度的"安全"结果,弹窗直接跳过。
子 Agent 的权限隔离
子 Agent 最值得注意的设计不是"分工",而是通过工具集来限制行为。Explore Agent 不是被 prompt 告知"你只能读不能写"------它的工具集里就没有写文件的工具。这比任何 prompt 约束都可靠。
进一步看子 Agent 的隔离机制,createSubagentContext 函数做了精细的状态隔离:
- 文件读取状态是克隆的(不是共享引用),防止子 Agent 的文件操作污染父 Agent 的缓存
- AbortController 是链式的------父 Agent 取消时子 Agent 也会取消,但子 Agent 取消不影响父 Agent
- 所有 UI 操作(通知、对话框)在子 Agent 中被设为 undefined------后台运行的子 Agent 不应该弹出任何东西
启发
约束 Agent 行为最可靠的方式不是在 prompt 里写"请不要做 X",而是直接移除做 X 的能力。"不给工具"比"给了工具但说别用"安全得多。子 Agent 的隔离不仅是上下文的隔离,还包括状态、权限、UI 交互能力的全面隔离。
四、韧性设计:AI 系统独有的失败模式
一句话版本:能自动恢复的错误就不让用户看到------暂扣错误消息、分级重试、熔断空转,大部分异常用户完全无感。
LLM 系统有一类很特殊的情况:"半成功" ------模型输出到一半被截断了,上下文太长 API 返回 413 了,服务过载需要切换模型了。这些不是传统的"成功/失败"二分法能覆盖的。Claude Code 的处理思路是能自己恢复的就不让用户看到。
错误暂扣 + 定向恢复
在流式接收模型输出时,Claude Code 有一个 withheld(暂扣)机制。当检测到可能可恢复的错误(prompt 太长、输出被截断、图片太大),它不会立即把错误抛给用户,而是先暂扣这条消息,尝试自动恢复:
typescript
// src/query.ts --- 流式接收中的错误暂扣
let withheld = false
// prompt 太长?暂扣,尝试 context collapse
if (contextCollapse?.isWithheldPromptTooLong(message, ...)) withheld = true
// 图片太大?暂扣,尝试 reactive compact
if (reactiveCompact?.isWithheldMediaSizeError(message)) withheld = true
// 输出截断?暂扣,尝试恢复
if (isWithheldMaxOutputTokens(message)) withheld = true
if (!withheld) yield yieldMessage // 只有无法恢复时才展示给用户
恢复策略是分级的:先尝试 Context Collapse 消化暂存的压缩操作 → 不行就触发 Reactive Compact 即时压缩 → 还不行才把错误展示给用户。大部分 413 错误用户根本感知不到。
Streaming Fallback
模型过载需要切换备用模型时,处理逻辑更精细:把前一次的部分输出用 tombstone 标记从 UI 中清除 → 调用 streamingToolExecutor.discard() 丢弃所有进行中和排队的工具(排队的不再启动,进行中的收到合成错误) → 创建全新的 StreamingToolExecutor → 用 fallback 模型重新请求。用户看到的是一个干净的重新开始,不会有半截输出或孤立的工具结果残留。
来自线上数据的熔断器
源码注释里有一条有意思的记录:
typescript
// src/services/compact/autoCompact.ts
// BQ 2026-03-10: 1,279 sessions had 50+ consecutive failures (up to 3,272)
// in a single session, wasting ~250K API calls/day globally.
const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3
曾经有 1279 个会话在上下文压缩时连续失败了超过 50 次(最高 3272 次),每天浪费 25 万次 API 调用。解决方案:连续失败 3 次就停止重试。
还有收益递减检测------模型连续 3 轮新增 token 都少于 500,判定为空转,自动停止:
typescript
// src/query/tokenBudget.ts
const isDiminishing =
tracker.continuationCount >= 3 &&
deltaSinceLastCheck < DIMINISHING_THRESHOLD && // < 500 tokens
tracker.lastDeltaTokens < DIMINISHING_THRESHOLD
这条规则很朴素,但解决了一个 LLM 系统特有的问题:模型不一定知道自己该停了。它可能在反复输出无实质内容的"补充说明",消耗 token 但没有推进任务。收益递减检测就是一个外部的"叫停"机制。
启发
AI 系统需要一套比传统软件更细的错误分类------"半成功""空转""缓存失效""模型过载"都是独立的状态,各自需要不同的恢复策略。而且有些恢复策略来自线上数据的反馈(比如熔断器的阈值),不是提前能设计出来的。
五、缓存即金钱:一个容易忽略的维度
一句话版本:缓存命中比全价便宜 90%,所以几乎所有设计决策都在围绕"不破坏 prompt 前缀的字节稳定性"展开。
这一点放到最后,因为它不容易被直观感知到,但可能是源码中工程量投入最大的方向。
Anthropic 的 API 有 prompt cache 机制------如果请求和上一次有相同的前缀,服务端会缓存这个前缀的计算结果。缓存命中的 token 比全价便宜 90%。 Claude Code 几乎所有的设计都在围绕"不破坏缓存前缀"展开。
Prompt 静态/动态分区
System prompt 被一个显式的边界标记 __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__ 切成两半:标记之前是所有用户共享的静态内容(身份定义、工具描述、行为规范),可以用 cacheScope: 'global' 跨用户甚至跨组织缓存;标记之后是会话特定的动态内容(语言偏好、MCP 指令),每次可能变化。
这意味着不管哪个用户在用 Claude Code,只要版本相同,system prompt 的前半段就是完全一样的------全球共享一份缓存。
缓存稳定性渗透到每个设计决策
理解了缓存机制之后,再回头看前面提到的很多设计,会发现它们的底层动机是统一的:
- 工具列表被刻意排序------保持 prompt 前缀字节稳定
- 工具结果的"保留/替换"决定一旦做出就永远不变(状态冻结)------避免前缀变化
- 子 Agent 和后台任务通过
CacheSafeParams共享父 Agent 的 prompt 前缀------自动压缩、会话记忆提取等后台操作几乎零额外缓存成本 - 流式输出中,原始 message 对象不做修改(需要修改的地方用 clone 副本)------因为这个对象会流回下一轮 API 调用,任何改动都会破坏前缀匹配
甚至在 query.ts 的流式循环里,有一段专门处理这个问题的注释:
typescript
// src/query.ts
// Backfill tool_use inputs on a cloned message before yield so SDK stream
// output and transcript serialization see legacy/derived fields.
// The original `message` is left untouched for assistantMessages.push
// below --- it flows back to the API and mutating it would break prompt
// caching (byte mismatch).
为了 UI 展示需要给工具调用补充一些字段,但不能修改原始消息对象------因为这个对象会被用在下一次 API 请求中。如果修改了哪怕一个字段,整个 prompt 前缀的字节就变了,缓存就失效了。所以必须 clone 一份用于展示,原始对象保持不动。
这种"缓存稳定性优先"的思维渗透到了代码的方方面面,几乎可以说是 Claude Code 工程的第一优先级 。甚至为了守护缓存命中率,源码中有一个 700 多行的 promptCacheBreakDetection 模块,专门在每次 API 调用后检测缓存是否断裂------它会对比 system prompt hash、工具 schema hash、cache control 策略等 12 个维度的变化,一旦检测到断裂就上报 tengu_prompt_cache_break 事件并生成 diff 用于排查。前面提到的那些 BQ 注释("20% 的事件是误报""1279 个会话连续失败"),就是这套监控体系的产出。
启发
如果你在用带缓存机制的 LLM API,prompt 的结构稳定性和内容正确性一样重要。很多看起来正常的工程操作(修改消息对象、调整工具顺序、动态增删 prompt section)都可能无意中破坏缓存前缀。一次缓存未命中,对于 Claude Code 动辄几万 token 的 prompt,就是实打实的钱。
总结
Claude Code 和基础 Agent 实现(比如 learn-claude-code 教的模式)共享同一个核心循环------while(true) + LLM + Tools。但围绕这个循环,Claude Code 在五个维度做了更深的工程投入:
| 维度 | Claude Code 的做法 |
|---|---|
| 延迟 | 流式并行执行工具 + 读写分离的并发控制 + 推测性预计算(权限、记忆预取) |
| 上下文 | 四层渐进压缩 + 压缩后重建工作状态 + 大结果持久化而非截断 |
| 安全 | 多源权限规则合并 + AI 分类器与人工确认并发竞速 + 通过工具集而非 prompt 约束行为 |
| 韧性 | 错误暂扣 + 分级恢复 + 熔断器 + 收益递减检测 |
| 成本 | prompt 静态/动态分区 + 全局缓存共享 + 状态冻结保前缀字节稳定 |
这五个维度和模型能力无关,和 prompt 技巧也关系不大------它们是软件工程问题。模型会越来越强,但"如何在流式输出中做并发控制""如何管理越来越长的上下文""如何设计 LLM 系统的错误恢复"这些问题不会因为模型变强而消失。
如果你在做 AI 相关的产品或工具,这些可能比"怎么写更好的 prompt"更值得花时间去思考。
AI 创作声明:本文由人类作者确定选题方向和分析框架,源码阅读、代码引用提取、初稿生成和表达优化均由 AI 辅助完成。最终内容经人类审校定稿。