前言
2026 年 3 月底,Claude Code 的 51 万行 TypeScript 源码因为 Bun 的一个打包 bug 意外泄露。网上已经有很多文章在聊它的隐藏功能——AI 宠物、自主守护进程、夜间记忆整合。这些确实有趣,但不是这篇文章想聊的。
我之前学过一个团队成员分享的Claude Code 的拆解教程(learn-claude-code),它用 12 节课把 Agent 的核心模式讲得很清楚:一个 while(true) 循环,模型决定调用哪些工具,代码负责执行,结果喂回去,继续循环。围绕这个循环逐步叠加 harness 机制——子 Agent、上下文压缩、任务系统、团队协作。
%%{init: {'theme': 'base', 'themeVariables': {
'primaryColor': '#e8f5e9',
'primaryTextColor': '#333333',
'primaryBorderColor': '#a5d6a7',
'lineColor': '#78909c',
'fontSize': '18px'
}}}%%
flowchart LR
A("👤 User Input") --> B("📋 messages[]")
B --> C("🤖 LLM 推理")
C --> D{"stop_reason== tool_use ?"}
D -- "Yes" --> E("🔧 执行工具 & append results")
E --> B
D -- "No" --> F("📝 返回文本")
classDef user fill:#e3f2fd,stroke:#1976d2,color:#1565c0,stroke-width:2px
classDef msg fill:#fff8e1,stroke:#f9a825,color:#e65100,stroke-width:2px
classDef llm fill:#f3e5f5,stroke:#ab47bc,color:#6a1b9a,stroke-width:2px
classDef decision fill:#e8eaf6,stroke:#5c6bc0,color:#283593,stroke-width:2px
classDef tool fill:#e8f5e9,stroke:#66bb6a,color:#2e7d32,stroke-width:2px
classDef output fill:#fce4ec,stroke:#ef5350,color:#b71c1c,stroke-width:2px
class A user
class B msg
class C llm
class D decision
class E tool
class F output
这个模式已经能构建一个可用的 Agent 了。但当我打开 Claude Code 的真实源码,发现围绕这同一个循环,它做了大量在基础模式中看不到的工程投入。核心循环确实一样,区别在于循环的每个环节之间还发生了什么。
这篇文章就是想把这些"还发生了什么"梳理出来。
一、延迟工程:在等待的间隙偷偷干活
一句话版本:模型还在输出时工具就开始跑了,读操作并行、写操作独占,权限检查和记忆搜索也藏在等待时间里。
基础的 Agent 循环是严格串行的:等模型输出完毕 → 解析工具调用 → 执行工具 → 拼结果 → 再次调用模型。每一步都要等上一步结束。
Claude Code 做的第一件不同的事,是把大量工作藏进了等待时间里。
流式工具执行
最直观的例子是 StreamingToolExecutor。在 query.ts 的主循环中,模型的流式输出是一个 for await 循环。每当这个循环里出现一个 tool_use 类型的 block,它不是攒起来等模型说完——而是立即交给 StreamingToolExecutor 开始执行:
// 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 内部维护了一套并发控制——每个工具入队时会判断它是否"并发安全":
// 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 取消所有正在并行的兄弟工具——但这个取消只影响同级的工具,不会终止主循环。这种"局部失败不扩散"的设计让错误恢复变得更精确。
配合编排层的批次划分,连续的只读工具会被合并成一批并行执行,遇到写操作就自动断开成新批次:
// 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 是模块化的函数动态拼装,而且花了大量篇幅在定义"不要做什么"——用约束来对抗模型天然的过度生成倾向:
// 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 实际实现了四层,而且按从轻到重的顺序在每轮循环中依次执行:
// 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 在压缩后会做一轮状态重建:
// 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 太长、输出被截断、图片太大),它不会立即把错误抛给用户,而是先暂扣这条消息,尝试自动恢复:
// 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 模型重新请求。用户看到的是一个干净的重新开始,不会有半截输出或孤立的工具结果残留。
来自线上数据的熔断器
源码注释里有一条有意思的记录:
// 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,判定为空转,自动停止:
// 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 的流式循环里,有一段专门处理这个问题的注释:
// 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 辅助完成。最终内容经人类审校定稿。