Claude Code 源码架构深度解析(二):Claude Code 最核心的 1729 行:一个 Agent Runtime 是怎么运转的

0 阅读14分钟

一个请求进来,到底发生了什么

上一篇我们建立了一个认知:Claude Code 不是 CLI 工具,而是 Agent Operating System。

但知道它"是什么"还不够。这一篇,我们要打开它的引擎盖,看看里面到底怎么转的。

当你在 Claude Code 里输入一句"帮我重构这个函数",从你按下回车到模型输出结果,中间到底经历了什么?

答案不是"把你说的话拼到 prompt 里,调一下 API,把结果打出来"。

真实的链路是这样的:

  1. cli.tsx 经过 fast-path 分发,加载 main.tsx
  2. main.tsx 初始化状态、注册工具、构造 ToolUseContext
  3. 你的输入进入 query() 函数
  4. query() 调用 queryLoop(),这是一个 while(true) 主循环
  5. 每次循环迭代:压缩上下文 → 组装 system prompt → 调用模型 API → 流式处理响应 → 执行工具调用 → 注入附加信息 → 决定是否下一轮

一个看起来简单的对话交互,背后跑着一台精密的状态机。


一、query.ts:1729 行的状态机,为什么不用递归

query.ts 是整个 Claude Code 的心脏。理解了它,就理解了 Agent Runtime 的核心运行逻辑。

为什么是状态机

早期版本的 query 用的是递归:模型返回了工具调用 → 执行工具 → 把结果拼回消息列表 → 再次调用 query()。这在短会话里没问题,但在长会话里,递归会爆栈

一个复杂的编码任务,可能需要几十轮甚至上百轮的模型调用。每次递归都会压一层调用栈。在 Node.js 默认的栈大小下,这是一个真实存在的问题。

所以现在改成了 while(true) + state 对象 的设计。先看 query.ts 里的状态定义和主循环入口:

// src/query.ts

// 跨迭代携带的可变状态
type State = {
  messages: Message[]
  toolUseContext: ToolUseContext
  autoCompactTracking: AutoCompactTrackingState | undefined
  maxOutputTokensRecoveryCount: number
  hasAttemptedReactiveCompact: boolean
  maxOutputTokensOverride: number | undefined
  pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
  stopHookActive: boolean | undefined
  turnCount: number
  // Why the previous iteration continued. Undefined on first iteration.
  transition: Continue | undefined
}

async function* queryLoop(params: QueryParams, consumedCommandUuids: string[]) {
  // Mutable cross-iteration state.
  let state: State = {
    messages: params.messages,
    toolUseContext: params.toolUseContext,
    maxOutputTokensOverride: params.maxOutputTokensOverride,
    autoCompactTracking: undefined,
    stopHookActive: undefined,
    maxOutputTokensRecoveryCount: 0,
    hasAttemptedReactiveCompact: false,
    turnCount: 1,
    pendingToolUseSummary: undefined,
    transition: undefined,
  }

  // eslint-disable-next-line no-constant-condition
  while (true) {
    let { toolUseContext } = state
    const { messages, turnCount, hasAttemptedReactiveCompact, ... } = state
    // ... 每一轮循环的处理逻辑
  }
}

query.ts 是一个 async generator,内部是死循环,通过 state 对象在迭代之间传递状态。每次 continue 就是一个 state transition。

代码里有 9 个不同的 continue 点,每个对应一种"为什么要再跑一轮"的原因:

Continue 原因触发场景
next_turn正常的工具调用后继续
reactive_compactAPI 返回 413 后紧急压缩重试
max_output_tokens模型输出超限,注入恢复消息继续
stop_hookstop hook 要求模型继续
token_budgettoken 预算未用完,继续工作
context_collapse_drain上下文折叠后重试
fallback_model模型降级后重试
......

每轮循环做什么

每次循环迭代的执行步骤,按顺序:

第一步:上下文预处理

四道压缩机制依次执行:snip compact → micro compact → context collapse → auto compact。目的是在有限的上下文窗口里塞进最有用的信息。(这部分后面第五篇会详细讲。)

第二步:Token 预算检查

如果 auto compact 被关了,检查是否接近硬限制。

第三步:组装 system prompt

把静态区和动态区的内容拼接成完整的 system prompt。(后面会详细讲这个组装过程。)

第四步:调用模型 API

把消息列表、system prompt、工具定义一起发给模型。

第四步:流式处理响应

模型的输出是流式的。关键在于:如果流中出现了 tool_use block,不等模型说完就开始执行工具。这就是 Streaming Tool Execution,下面会详细讲。

第五步:错误恢复

这一步的分支非常多:

  • prompt 太长?先试 context collapse drain,再试 reactive compact
  • 输出 token 超限?注入恢复消息让模型继续
  • 模型降级?切 fallback model

第六步:Stop hooks

模型停止输出后,运行 stop hooks,决定要不要让模型继续。

第七步:工具执行

批量执行本轮所有工具调用。

第八步:附件注入

工具执行完后,注入 memory attachments、skill discovery 结果、排队中的命令。

第九步:决定下一轮

把结果组装成新的消息列表,回到循环开头。

我的理解

很多 Agent 框架(包括 LangChain、AutoGen 的早期版本)用的还是递归或者简单的 for 循环。在 demo 场景下无所谓,但一旦要支持长时间运行的复杂任务,你就必须面对状态管理问题。

while(true) + state 对象 的好处是:

  • 不会爆栈
  • 每个 continue 点都有明确语义,方便调试
  • 状态可序列化,理论上可以做断点续传
  • 错误恢复逻辑可以在循环内自然实现,不需要在递归层之间传递异常

这不是什么高深的技术,就是工程成熟度的体现。不过话说回来,很多成熟的工程方案事后看都不难,难的是在一开始就意识到需要这样设计。


二、Streaming Tool Execution:边收边跑

传统做法:等模型完整输出 → 收齐所有 tool_use block → 批量执行工具。

Claude Code 做了一个明显的优化:StreamingToolExecutor

模型还在输出的时候,已经完成的 tool_use block 就开始执行了。

看看 StreamingToolExecutor 的核心并发控制逻辑(src/services/tools/StreamingToolExecutor.ts):

/**
 * Executes tools as they stream in with concurrency control.
 * - Concurrent-safe tools can execute in parallel with other concurrent-safe tools
 * - Non-concurrent tools must execute alone (exclusive access)
 * - Results are buffered and emitted in the order tools were received
 */
export class StreamingToolExecutor {
  private tools: TrackedTool[] = []
  private hasErrored = false
  private siblingAbortController: AbortController

  /**
   * Check if a tool can execute based on current concurrency state
   */
  private canExecuteTool(isConcurrencySafe: boolean): boolean {
    const executingTools = this.tools.filter(t => t.status === 'executing')
    return (
      executingTools.length === 0 ||
      (isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
    )
  }
  // ...
}

注意 canExecuteTool 的判断逻辑:只有当所有正在执行的工具都是并发安全的,并且新工具也是并发安全的,才允许并行执行。否则就排队等。这个判断依赖的就是前面 Tool.ts 里的 isConcurrencySafe 标记。

为什么这很重要

想象一个场景:模型在一次响应里决定调用 5 个工具——读 3 个文件、搜索一下代码、看一下 git log。

传统做法:

  1. 等模型生成完所有 5 个 tool_use(可能需要 5-30 秒)
  2. 然后串行或并行执行这 5 个工具

Streaming 做法:

  1. 模型生成完第 1 个 tool_use → 立刻执行读文件
  2. 模型还在生成第 2 个 tool_use → 第 1 个文件已经读完了
  3. 模型生成完第 3 个 tool_use → 前 2 个工具可能都跑完了
  4. ...

总体延迟可以减少一半以上。

这个优化看起来简单,实现起来不容易。你需要流式解析模型输出来判断一个 tool_use block 是否已经完整,需要管理并发执行的工具的生命周期,需要处理工具执行出错时的回滚,还要确保结果按正确顺序拼回消息列表。

很多团队做 Agent 的时候,精力全花在"让模型更聪明"上。但用户感知到的"快",很多时候不是模型推理快,而是工程层面的并发优化做得好。


三、Prompt 组装:不是一段文本,是一台拼装机器

看到很多人讨论 prompt engineering,讨论的还是"措辞"层面:用什么语气、加什么 few-shot 例子、怎么写 CoT。

Claude Code 的 prompt 工程已经远远超出了"措辞"范畴。它是一套系统化的组装流程

静态区与动态区

prompts.ts 里的 getSystemPrompt() 返回的是一个字符串数组,每个元素对应一个 section。整个 prompt 分成两大块。

直接看组装代码(src/constants/prompts.ts):

// Boundary 定义
/**
 * WARNING: Do not remove or reorder this marker without updating cache logic in:
 * - src/utils/api.ts (splitSysPromptPrefix)
 * - src/services/api/claude.ts (buildSystemPromptBlocks)
 */
export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY =
  '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'
// getSystemPrompt() 的返回值
return [
  // --- Static content (cacheable) ---
  getSimpleIntroSection(outputStyleConfig),
  getSimpleSystemSection(),
  getSimpleDoingTasksSection(),
  getActionsSection(),
  getUsingYourToolsSection(enabledTools),
  getSimpleToneAndStyleSection(),
  getOutputEfficiencySection(),
  // === BOUNDARY MARKER - DO NOT MOVE OR REMOVE ===
  ...(shouldUseGlobalCacheScope() ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
  // --- Dynamic content (registry-managed) ---
  ...resolvedDynamicSections,
].filter(s => s !== null)

结构一目了然——boundary 上面全是固定内容,下面全是动态内容。注释写着 DO NOT MOVE OR REMOVE,因为移了就破坏缓存。

静态部分(不随会话变化,可缓存):

Section作用
getSimpleIntroSection身份定位("你是Claude")
getSimpleSystemSection系统运行规范
getSimpleDoingTasksSection做任务的行为规范
getActionsSection风险动作规范
getUsingYourToolsSection工具使用语法
getSimpleToneAndStyleSection语气风格
getOutputEfficiencySection输出效率

动态部分(随会话状态变化):

  • Session guidance(当前启用了哪些工具)
  • Memory(CLAUDE.md 内容)
  • 环境信息(OS、shell、cwd、模型名称)
  • 语言偏好、输出风格
  • MCP server instructions
  • Token budget 说明
  • ...

中间用一个 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 标记隔开。

为什么要分成两块

这不是代码组织美学,这是真金白银的成本优化

Anthropic 的 API 支持 system prompt 的前缀缓存。如果两次 API 请求的 system prompt 前缀完全一样(字节级一致),第二次请求可以跳过对前缀部分的处理,节省计算成本。

所以:

  • 不变的内容放前面(静态区)→ 缓存命中率高
  • 会变的内容放后面(动态区)→ 不影响前缀匹配

源码注释里写得很直白:不要随意修改 boundary 之前的内容,否则会破坏缓存。

Section Registry:不是每次都重算

动态区也不是每次都从头计算。systemPromptSections.ts 里有一个 section registry

  • systemPromptSection() 创建的 section 会被缓存,直到 /clear/compact
  • 只有用 DANGEROUS_uncachedSystemPromptSection() 创建的才会每次重算

什么东西需要每次重算?MCP instructions。因为 MCP server 可能在两个 turn 之间连接或断开,它的状态是真正动态的。

源码里的用法(src/constants/prompts.ts):

DANGEROUS_uncachedSystemPromptSection(
  'mcp_instructions',
  () => isMcpInstructionsDeltaEnabled()
    ? null
    : getMcpInstructionsSection(mcpClients),
  'MCP servers connect/disconnect between turns',  // 说明为什么不能缓存
),

这个函数名里带 DANGEROUS_ 前缀,暗示开发者:你确定需要每次重算吗?如果不确定,就用缓存版本。

说到这里,我想坦诚一点:我刚看到 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 这个设计的时候,第一反应是"至于吗,就为了省点缓存"。但仔细算一笔账就明白了——假设一个 system prompt 有 3000 tokens,缓存命中省的是每次请求对这 3000 tokens 的处理成本。日调百万次,这就不是小数字了。

Prompt 工程走到后期,拼的真不是"文案"。它拼的是分层(哪些静态哪些动态)、缓存(怎么最大化命中率)、组装(怎么根据状态精确拼出这一轮的 prompt)、预算(每个 section 占多少 token)。Prompt 已经从"写作"变成了工程。


四、行为约束:怎么让 AI 工程师"不乱来"

getSimpleDoingTasksSection() 这个函数可能是整个 prompt 里最有价值的部分。

它做的事情就一件:告诉模型什么该做、什么不该做。

我把源码里的规则归纳成三类,你会发现它们指向同一个核心目标——克制

克制"好心办坏事"的冲动: 用户让你改一行,你就改一行。不要顺便加个你觉得"应该有"的功能,不要看到代码不够优雅就忍不住重构,不要觉得"没有错误处理不专业"就到处加 try-catch。三行重复代码在很多场景下,比一个提前抽象出来的 helper 更好维护。

克制"偷懒走捷径"的倾向: 改代码之前必须先读代码,不能凭记忆或猜测直接动手。执行失败了要分析原因,不能换个姿势盲目重试,但也不能试一次就宣布放弃。不要给出时间估计——因为你估不准,而用户会当真。

克制"包装结果"的本能: 跑没跑测试、改没改成功、有没有验证过,如实说。模型有一种天然倾向是把事情说得比实际更好,这条规则就是在对抗这种倾向。

为什么需要这些规则

用过任何一个 coding agent 的人应该都遇到过这些问题:

  • 你让它改个 bug,它顺手重构了半个文件
  • 你让它加一个功能,它加了三层抽象和五个你没要求的错误处理
  • 你让它读一个文件,它说"我已经检查过了,看起来没问题"——但它根本没跑
  • 你让它改一行代码,它顺手给所有函数都加了 docstring

这些问题的根源不是模型"不够聪明"。恰恰相反,是模型太"聪明"了——它知道"好的代码应该有错误处理"、"好的代码应该有文档"、"好的代码应该抽象",所以它会主动去做这些事情。

但在帮用户完成一个具体任务的场景下,这种"好心"反而是干扰。

Claude Code 的做法:写成制度

Claude Code 的解决方案不是"希望模型自觉",而是把行为规范写成制度

这些规则不是建议,而是指令。写在 system prompt 里,每次调用都会发给模型。

这个设计理念可以用一句话总结:不要指望一个 LLM 每次都"想到"该怎么做。制度化的行为比临场发挥稳定得多。

从 coding standards 到 prompt standards

这让我联想到软件工程里的 coding standards。没有人指望每个程序员都"自觉"遵守代码规范。你需要写成文档(ESLint config)、在 CI 里强制执行、违反了就报错。

Claude Code 对模型的管理思路也是一样的:写成 prompt 规则、在运行时强制执行(工具治理 pipeline)、违反了有兜底(权限系统、Hook 系统)。管理一个 AI Agent,本质上和管理一个工程团队没有那么大区别——区别在于,人可以通过文化来约束,模型只能通过制度来约束。


五、整体架构图:一个请求的完整旅程

把上面所有内容串起来,一个请求在 Claude Code 内部的完整旅程是:

用户输入
    ↓
cli.tsx (fast-path 分发)
    ↓
main.tsx (初始化状态、注册工具)
    ↓
query() → queryLoop() [while(true)]
    ↓
┌─────────────────────────────────────────────┐
│  1. 四道上下文压缩                           │
│  2. Token 预算检查                           │
│  3. 组装 system prompt (静态区 + 动态区)      │
│  4. 调用模型 API                             │
│  5. 流式处理响应 + Streaming Tool Execution  │
│  6. 错误恢复 (413/超限/降级)                 │
│  7. Stop hooks 检查                          │
│  8. 批量工具执行                             │
│  9. 附件注入 (memory/skill/commands)         │
│ 10. 决定是否 continue → 回到第 1 步          │
└─────────────────────────────────────────────┘
    ↓ (模型输出 end_turn,无工具调用)
输出结果给用户

每一轮循环都可能因为 9 种不同的原因继续下一轮。每一轮都有四道压缩保护上下文不溢出。每一次模型调用都有 prompt 缓存优化控制成本。

这就是一个 Agent Runtime 的引擎。

这篇讲了很多技术细节,最后收三个我觉得最值得记住的点:

Runtime 稳定性决定了 Agent 上限。 很多 Agent 产品失败不是模型不够强,是 runtime 不够稳定——长会话爆栈、上下文溢出没兜底、工具执行超时没处理。9 种 continue reason 和多层错误恢复,就是在解决这类问题。

Streaming 不只是体验优化。 它改变了工具执行的调度模型,从"批量串行"变成"流式并发",带动了工具并发安全标记、消息乱序组装、部分失败处理一整条链路的升级。

Prompt 缓存命中率值得认真对待。 百万级请求量下,命中率每提升 1% 都意味着可观的成本节省。这不是锦上添花,是决定商业模型能不能跑通的因素之一。


下一篇预告

主循环解决了"怎么跑"的问题。但模型跑起来之后要"动手干活"——调用工具读文件、写代码、执行命令。

问题来了:模型说要调用一个工具,就真的直接调吗?

不是。在 Claude Code 里,从模型说"我要调用 BashTool"到 BashTool 真正执行,中间有一条 14 步的治理流水线。输入校验、权限检查、风险预判、Hook 策略、用户交互——每一步都有可能拦住这次调用。

42 个工具,1745 行的执行逻辑。这不是"给模型暴露几个函数"那么简单。

下一篇,我们聊工具系统。


本系列共 5 篇,源码来自 Anthropic 泄露的 npm 包中的 source map 还原。内容为个人理解与工程分析,不代表 Anthropic 官方观点。