同样的 while(true),不同的工程深度:Claude Code 源码中的 Agent 设计启示

前言

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 开始执行:

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 辅助完成。最终内容经人类审校定稿。

相关推荐
liliangcsdn2 小时前
对基于Pydantic BaseModel的实例进行JSON序列化
人工智能·json·全文检索
TDengine (老段)3 小时前
TDengine IDMP 工业数据建模 —— 元素与数据查询
大数据·数据库·人工智能·物联网·时序数据库·tdengine·涛思数据
志栋智能3 小时前
轻量级部署:低成本实现混合云环境自动化巡检
运维·网络·人工智能·自动化
点云SLAM3 小时前
Qt+PCL手把手教材(第11讲)——PCL库PCLVisualizer点云可视化以及与 VTK 交互器(Interactor)详解和代码示例
人工智能·交互·3d数据可视化·pcl点云库·qt+pcl·pclvisualizer使用·vkt
码与农3 小时前
硬件控制器是如何实现与ros2_control交互的
人工智能·机器人·自动驾驶
2020酱3 小时前
国内团队接入 Claude / GPT API 完整避坑指南
claude
搬砖者(视觉算法工程师)3 小时前
世界动作模型(WAM)的泛化能力是否优于视觉语言动作模型(VLA)?
人工智能
AI营销先锋3 小时前
AI营销SaaS榜单评测:原圈科技如何助力品牌客户破局增长?
大数据·人工智能
AI服务老曹3 小时前
GB28181 与 RTSP 深度解析:企业级 AI 视频中台的全协议接入架构
人工智能·架构·音视频