Claude Code 源码架构深度解析(二):Claude Code 最核心的 1729 行:一个 Agent Runtime 是怎么运转的
一个请求进来到底发生了什么上一篇我们建立了一个认知Claude Code 不是 CLI 工具而是 Agent Operating System。但知道它是什么还不够。这一篇我们要打开它的引擎盖看看里面到底怎么转的。当你在 Claude Code 里输入一句帮我重构这个函数从你按下回车到模型输出结果中间到底经历了什么答案不是把你说的话拼到 prompt 里调一下 API把结果打出来。真实的链路是这样的cli.tsx经过 fast-path 分发加载main.tsxmain.tsx初始化状态、注册工具、构造 ToolUseContext你的输入进入query()函数query()调用queryLoop()这是一个while(true)主循环每次循环迭代压缩上下文 → 组装 system prompt → 调用模型 API → 流式处理响应 → 执行工具调用 → 注入附加信息 → 决定是否下一轮一个看起来简单的对话交互背后跑着一台精密的状态机。一、query.ts1729 行的状态机为什么不用递归query.ts是整个 Claude Code 的心脏。理解了它就理解了 Agent Runtime 的核心运行逻辑。为什么是状态机早期版本的 query 用的是递归模型返回了工具调用 → 执行工具 → 把结果拼回消息列表 → 再次调用 query()。这在短会话里没问题但在长会话里递归会爆栈。一个复杂的编码任务可能需要几十轮甚至上百轮的模型调用。每次递归都会压一层调用栈。在 Node.js 默认的栈大小下这是一个真实存在的问题。所以现在改成了while(true) state 对象的设计。先看query.ts里的状态定义和主循环入口// src/query.ts// 跨迭代携带的可变状态typeState{messages:Message[]toolUseContext:ToolUseContext autoCompactTracking:AutoCompactTrackingState|undefinedmaxOutputTokensRecoveryCount:numberhasAttemptedReactiveCompact:booleanmaxOutputTokensOverride:number|undefinedpendingToolUseSummary:PromiseToolUseSummaryMessage|null|undefinedstopHookActive:boolean|undefinedturnCount:number// Why the previous iteration continued. Undefined on first iteration.transition:Continue|undefined}asyncfunction*queryLoop(params:QueryParams,consumedCommandUuids:string[]){// Mutable cross-iteration state.letstate: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-conditionwhile(true){let{toolUseContext}stateconst{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 */exportclassStreamingToolExecutor{privatetools:TrackedTool[][]privatehasErroredfalseprivatesiblingAbortController:AbortController/** * Check if a tool can execute based on current concurrency state */privatecanExecuteTool(isConcurrencySafe:boolean):boolean{constexecutingToolsthis.tools.filter(tt.statusexecuting)return(executingTools.length0||(isConcurrencySafeexecutingTools.every(tt.isConcurrencySafe)))}// ...}注意canExecuteTool的判断逻辑只有当所有正在执行的工具都是并发安全的并且新工具也是并发安全的才允许并行执行。否则就排队等。这个判断依赖的就是前面Tool.ts里的isConcurrencySafe标记。为什么这很重要想象一个场景模型在一次响应里决定调用 5 个工具——读 3 个文件、搜索一下代码、看一下 git log。传统做法等模型生成完所有 5 个 tool_use可能需要 5-30 秒然后串行或并行执行这 5 个工具Streaming 做法模型生成完第 1 个 tool_use → 立刻执行读文件模型还在生成第 2 个 tool_use → 第 1 个文件已经读完了模型生成完第 3 个 tool_use → 前 2 个工具可能都跑完了…总体延迟可以减少一半以上。这个优化看起来简单实现起来不容易。你需要流式解析模型输出来判断一个 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) */exportconstSYSTEM_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(ss!null)结构一目了然——boundary 上面全是固定内容下面全是动态内容。注释写着DO NOT MOVE OR REMOVE因为移了就破坏缓存。静态部分不随会话变化可缓存Section作用getSimpleIntroSection身份定位“你是Claude”getSimpleSystemSection系统运行规范getSimpleDoingTasksSection做任务的行为规范getActionsSection风险动作规范getUsingYourToolsSection工具使用语法getSimpleToneAndStyleSection语气风格getOutputEfficiencySection输出效率动态部分随会话状态变化Session guidance当前启用了哪些工具MemoryCLAUDE.md 内容环境信息OS、shell、cwd、模型名称语言偏好、输出风格MCP server instructionsToken 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.tsDANGEROUS_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 行的执行逻辑。这不是给模型暴露几个函数那么简单。下一篇我们聊工具系统。Claude Code 源码架构深度解析三工具多不等于 Agent 强Claude Code 是怎么治理 42 个工具的本系列共 5 篇源码来自 Anthropic 泄露的 npm 包中的 source map 还原。内容为个人理解与工程分析不代表 Anthropic 官方观点。