乐于分享
好东西不私藏

Claude Code 源码揭秘:一个 while 循环如何干翻所有 Agent 框架

Claude Code 源码揭秘:一个 while 循环如何干翻所有 Agent 框架

↑阅读之前记得关注+星标⭐️,😄,每天才能第一时间接收到更新

现在造 Agent 的框架一大堆,LangChain、AutoGPT、MetaGPT… 各种花里胡哨的 DAG 编排、状态机、工作流引擎。看架构图一个比一个复杂,但真正跑得好的产品没几个。

前段时间有人把 Claude Code 的代码给逆向出来了,我花了几天时间翻了翻,发现一个让我意外的事实:

Claude Code 的核心架构,就是一个 while 循环。

没有复杂的 DAG,没有花哨的状态机,没有什么编排引擎。src/core/loop.ts 大概 900 行代码撑起整个系统。

我第一反应是不信。这玩意儿能干那么多事,就靠一个循环?

直接上源码:

while (turns < maxTurns) {  turns++;  // 清理旧的大输出,防止上下文爆炸  const cleanedMessages = cleanOldPersistedOutputs(messages, 3);  // 调 API  response = await this.client.createMessage(cleanedMessages, this.tools, systemPrompt);  // 遍历响应内容  for (const block of response.content) {    if (block.type === 'text') {      finalResponse += block.text || '';    } else if (block.type === 'tool_use') {      const result = await toolRegistry.execute(block.name, block.input);      toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: result });    }  }  // 把工具结果塞回消息列表  if (toolResults.length > 0) {    this.session.addMessage({ role: 'user', content: toolResults });  }  // 模型说结束了,就退出  if (response.stopReason === 'end_turn' && toolResults.length === 0) {    break;  }}

就这么点东西。让我用一个具体的例子来说明这个循环是怎么工作的。

假设你输入:”帮我看看 package.json 里的项目名称是什么”

两轮循环,搞定。如果任务更复杂,比如”找到所有 TODO 注释并生成报告”,可能要跑十几轮,但逻辑是一样的:调 API → 执行工具 → 把结果喂回去 → 再调 API。

我之前带团队做过一个工作流引擎,当时画的架构图比这个复杂十倍。状态机、事件总线、任务队列、回滚机制… 搞了三个月,最后发现大部分场景用一个简单的循环就够了。那些复杂设计,要么是为了应对极端情况,要么纯粹是过度设计。

Claude Code 这个设计让我反思了很多。

当然,简单不代表简陋。这个循环里藏着不少精心设计的细节。


模型没有记忆——这事儿有点反直觉

第一个让我意外的点:Claude 模型是无状态的。

什么意思?每次调 API,模型都是”失忆”的状态。它不记得五秒钟前读了你的 package.json,也不记得刚才报了什么错。每一轮循环调用 API,CLI 都要把完整的对话历史从头发一遍。

第1轮: [用户问题]第2轮: [用户问题, 模型回复, 工具结果]第3轮: [用户问题, 模型回复, 工具结果, 模型回复, 工具结果]...

消息数组随着交互越滚越大。一个简单的问题”package.json 里版本号是多少”,就会产生四条消息:用户问题、模型请求读文件、工具返回文件内容、模型最终回答。复杂任务跑几十上百条消息很正常。

你可能觉得这设计挺浪费的,每次都要发完整历史。但仔细想想,这设计其实挺聪明。

CLI 成了唯一的”真相源”。它掌控对话状态,决定给模型看什么上下文。出问题了?可以操作历史记录、用不同上下文重试、截断旧消息腾空间。模型的无状态反而让整个系统变得可预测、好调试。

我之前做过一个工单系统,状态散落在各个服务里,出了 bug 排查起来要命。后来重构成单一状态源,维护成本直接降了一半。道理是相通的。


看到这你可能会问:每次都发完整的对话历史,这不是很浪费吗?几十轮对话下来,每次都要重新处理几万个 token,成本和延迟不都爆炸了?

这就是 Claude Code 的另一个精妙设计:Prompt Caching(提示缓存)

先看调用链路:

关键点:缓存不是发生在 CLI 本地,而是在 Anthropic API 服务端

模型处理 prompt 的时候,会构建一个叫 “attention states” 的内部状态映射——可以理解成”理解了这段话”的中间产物。Prompt Caching 的本质就是把这个中间产物缓存起来,下次遇到相同前缀的请求,直接加载状态,跳过重新计算。

Claude Code 在发送请求时会自动插入缓存标记:

// Claude Code 发送的请求结构示意{  "model": "claude-sonnet-4-5",  "system": [    {      "type": "text",      "text": "You are a coding assistant...",      "cache_control": {"type": "ephemeral"}  // ← 标记缓存点    }  ],  "tools": [...],  // 工具定义  "messages": [    // 历史对话...    {      "role": "user",      "content": [...],      "cache_control": {"type": "ephemeral"}  // ← 对话末尾标记    }  ]}

缓存是增量生效的。随着对话进行,前面的内容会被越来越多地缓存住:

第10轮时,可能 90% 的内容都是缓存命中,只有最新的一点点需要计算。

你可以用 /cost 命令验证缓存效果:

/cost⎿ Total cost: $0.0827⎿ cache_creation_input_tokens: 5000   ← 首次写入缓存⎿ cache_read_input_tokens: 45000      ← 从缓存读取(便宜 90%)⎿ input_tokens: 500                   ← 新增的、未缓存部分

几个关键数字:

项目
说明
缓存写入成本
比正常 input token 贵 25%
缓存读取成本
只有正常 input token 的 10%
缓存有效期
默认 5 分钟(每次命中会刷新)
最小缓存量
至少 1024 tokens
最大缓存断点
最多 4 个

所以答案是:每次确实发完整历史,但不心疼。因为大部分内容都是缓存命中,只付 10% 的钱。对话越长,缓存命中率越高,反而越划算。

什么会破坏缓存?修改 system prompt、改工具定义、改历史消息内容——任何一个变化,后续的缓存全部失效。这也解释了为什么 Claude Code 的 system prompt 和工具定义都是固定的,只在消息列表末尾追加新内容。

有个细节值得注意:在 AWS Bedrock 上使用 Claude 4 系列时,prompt caching 的支持还不完善(GitHub Issue #1347)。如果你发现成本异常高,可能就是缓存没生效。用 Anthropic 官方 API 就没这个问题。

这个设计让我想起数据库的查询缓存。道理类似:首次查询贵一点,但后续相同查询直接返回缓存结果。只不过 LLM 的”查询”是整个 prompt,缓存的是理解 prompt 的中间状态。


流式响应——不只是为了好看

Claude Code 的响应不是一坨 JSON 砸过来,而是通过 SSE(Server-Sent Events)一点点流过来,像看别人打字一样。

事件序列是固定的:

文本内容通过 text_delta 事件到达,CLI 收到一片就渲染一片。但工具调用就复杂了——输入的 JSON 是分片到达的,必须一直攒着,等 content_block_stop 信号来了才能解析完整的 JSON,才知道模型到底想调什么工具。

let toolInput = "";for await (const event of stream) {  if (event.type === "content_block_delta") {    if (event.delta.type === "input_json_delta") {      toolInput += event.delta.partial_json;    }  }  if (event.type === "content_block_stop") {    const parsed = JSON.parse(toolInput);    // 现在才知道模型想干啥  }}

流式设计还有个关键好处:支持取消。用户按 Escape,CLI 可以中途打断请求。不用流式的话,你得等完整响应回来才能说”我不要了”。

我们之前做实时数据大屏的时候也用了类似的设计。数据量大的查询,边算边推,用户能看到进度,也能随时打断。体验比干等十几秒好太多。


stop_reason——模型的”举手信号”

每个响应都带一个 stop_reason,决定接下来干啥。可以理解成模型举手打的不同手势。

end_turn:模型说”我说完了,轮到你了”。循环退出,等用户下一条输入。

tool_use:模型说”我需要先干点事”。响应里带着 tool_use 块,指明要调什么工具、传什么参数。CLI 执行工具、收集结果、打包成新消息,然后继续循环。

{  "type": "tool_use",  "id": "toolu_01ABC",  "name": "Read",  "input": { "file_path": "/project/src/main.ts" }}

max_tokens:模型话说到一半,撞上了 20000 token 的输出上限。循环自动继续,让模型接着说。不用加工具结果,模型自己知道要续上。

这个设计把决策权完全交给模型。CLI 不需要判断”用户想干嘛”、”下一步该做什么”,模型说啥就干啥。简单直接。


大输出管理——偷偷帮你省空间

你用 Claude Code 跑过大文件吗?比如让它 grep 出几千行匹配结果。按理说,这些大输出会直接塞进上下文窗口,几轮下来窗口就撑爆了。但实际用起来,跑几十轮都没问题。

秘密在这里:

const OUTPUT_THRESHOLD = 400000; // 400KBconst PREVIEW_SIZE = 2000; // 2KBfunction wrapPersistedOutput(content: string): string {  if (content.length <= OUTPUT_THRESHOLD) {    return content;  }  // 超过 400KB,只保留 2KB 预览  const { preview } = truncateOutput(content, PREVIEW_SIZE);  return `<persisted-output>\nPreview:\n${preview}\n...</persisted-output>`;}

工具输出超过 400KB?只留 2KB 预览。但光这样还不够,随着对话进行,即使每个输出都压缩到 2KB,累积起来也会很大。所以每次调 API 之前,还要清理旧的大输出,只保留最近 3 个。

这就是为什么 Claude Code 能一直跑下去而不会上下文溢出。不是窗口真的无限大,而是它在”偷偷”帮你管理。

我之前做日志审计系统的时候也遇到过类似的问题。日志量太大,查询的时候内存直接爆掉。后来也是用了类似的策略——只加载摘要,需要详情的时候再去取原文。这种”懒加载 + 摘要”的思路,在处理大数据量的时候特别有用。


并行执行——读操作可以一起跑

模型可以一次请求多个工具。比如同时读五个文件,如果串行执行就太慢了。

Claude Code 按安全等级分类工具:只读操作(Read、Glob、Grep)可以并行跑,写操作(Edit、Write、Bash)必须串行,防止竞态条件。

const readOnlyTools = toolUseBlocks.filter(t =>  ['Read', 'Glob', 'Grep', 'LS'].includes(t.name));const writingTools = toolUseBlocks.filter(t =>  ['Edit', 'Write', 'Bash'].includes(t.name));// 只读操作可以并行const readResults = await Promise.all(  readOnlyTools.map(tool => executeSingleTool(tool)));// 写操作必须串行for (const tool of writingTools) {  await executeSingleTool(tool);}

所有结果不管执行顺序,最后打包成一条用户消息,包含多个 tool_result 块。模型看到的是合并后的结果,可以综合分析。

这个设计在我们做批量数据处理的时候也用过。能并行的操作尽量并行,有依赖的必须串行。简单的原则,但效果明显。


安全机制和错误处理——生产环境的必修课

循环里有几道保险:

迭代次数上限:默认最多 100 轮。防止模型陷入死循环,反复尝试同一个失败的方法。

const MAX_ITERATIONS = 100;let iterations = 0;while (iterations < MAX_ITERATIONS) {  iterations++;  const response = await callAPI();  if (response.stop_reason === "end_turn") break;  // ... 工具执行}

用户中断:按 Escape 触发 AbortController,取消当前 API 调用。流立刻停止,循环优雅退出,不用等模型说完。

上下文窗口管理:每次调 API 前检查消息数组是不是快撑爆了(通常是 200K token 窗口的 85%)。快满了就自动压缩,把老消息总结一下腾出空间,但保留关键上下文。

API 调用会失败,这是事实。Claude Code 对常见错误有专门处理:

429 限流:API 返回 retry-after 头,告诉你等多久。循环就睡那么久,然后重试。

529 服务过载:指数退避,每次等更久。

const RETRYABLE_ERRORS = ['overloaded_error', 'rate_limit_error', 'api_error', 'timeout'];private async withRetry<T>(operation: () => Promise<T>, retryCount = 0): Promise<T> {  try {    return await operation();  } catch (error) {    if (isRetryable && retryCount < this.maxRetries) {      const delay = this.retryDelay * Math.pow(2, retryCount); // 指数退避      await this.sleep(delay);      return this.withRetry(operation, retryCount + 1);    }    throw error;  }}

指数退避,最多重试 4 次。如果主模型一直挂,还能自动切换到备用模型——Opus 挂了切 Sonnet,Sonnet 挂了至少给用户一个友好的错误信息。

工具执行失败:这个处理方式不太一样。失败了也要把结果发给模型,但标记 is_error: true。模型看到错误,可以换个方法再试。文件读取失败?模型可能会先检查文件是否存在。编辑失败?模型可能会重新读文件再试。

这种”告诉模型失败了让它自己想办法”的设计,比硬编码一堆异常处理逻辑灵活多了。

我们之前做支付系统的时候,也是类似的策略。主通道挂了切备用通道,备用通道也挂了走人工审核。这种多层兜底的设计,在生产环境里太重要了。双十一那种流量高峰,没有这些兜底机制根本扛不住。


模型当指挥官——设计哲学

再说说这个循环里最有意思的设计:模型当指挥官。

传统的 Agent 框架喜欢搞一套复杂的编排逻辑:先做意图识别,再路由到不同的处理器,然后按照预定义的流程一步步执行。

Claude Code 完全反过来——你看这个循环里,没有任何 if-else 来判断”用户想干嘛”。模型说调 Bash 就调 Bash,说读文件就读文件,说写代码就写代码。决策完全交给模型。

背后的赌注是:现代大模型的推理和规划能力已经足够好,不需要你去编排每一步。

一年前这个赌注可能输,但现在看来赌对了。Claude 3.5 Sonnet 及以后的模型,指令遵循和任务规划能力确实够强。

这让我想起我们之前做智能客服的时候,花了大量时间在意图识别上。各种分类模型、规则引擎、决策树… 现在回头看,如果当时有足够好的大模型,很多代码都是多余的。

那你可能会问:那些复杂的 Agent 框架都是白搞了?

不完全是。问题在于,很多框架是在模型能力不够的时代设计的。那时候模型的规划能力弱,你不得不用代码去”辅助”它。但现在模型能力上来了,这些”辅助”反而成了累赘。

Claude Code 的设计哲学很清晰:能让模型干的事,就让模型干。代码只负责执行和兜底。

当然这不是说状态机和 DAG 没用。如果你的业务流程真的需要严格的步骤控制,确定性比灵活性更重要,那复杂编排还是有价值的。但对于 Coding Agent 这个场景——用户需求多变、任务边界模糊、需要灵活应对——简单循环可能就是最优解。


执行节奏

理解了循环的节奏,就能预判行为。

API 调用是大头,通常 500-2000ms,取决于响应长度和服务器负载。工具执行一般更快,文件操作 10-500ms,但 shell 命令可能跑很久——跑个测试套件几分钟很正常,所以 Bash 支持后台执行和超时。

20000 token 的输出上限会影响响应模式。长解释或大段代码生成可能撞上这个限制,需要跨多轮继续。循环自动处理这种情况,但意味着一个逻辑上的完整响应可能跨好几次 API 调用。


翻完这部分代码,印象最深的是它的”克制”。没有过度设计,该简单的地方简单,该处理的边界情况一个不少。

几点收获:先用简单循环跑起来,遇到真正的瓶颈再加复杂度。很多人一上来就想搞复杂架构,结果调试困难、维护成本高。大输出一定要管理,上下文窗口再大也是有限的。模型回退是刚需,API 服务不可能 100% 可用。

核心循环讲完了,但这只是 Claude Code 的骨架。下一篇聊聊消息结构——为什么工具结果要伪装成用户消息,角色的严格交替又带来了哪些约束。

本文基于 Claude Code 2.0.76 版本源码分析,主要文件:src/core/loop.tssrc/core/client.ts

最后记得⭐️我,每天都在更新:欢迎点赞转发推荐评论,别忘了关注我

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » Claude Code 源码揭秘:一个 while 循环如何干翻所有 Agent 框架

评论 抢沙发

8 + 2 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
×
订阅图标按钮