拆开 Claude Code 的源码后发现:它只是一个 while 循环

去年我用 Claude Code 修 bug,它在终端里读报错、搜代码、改文件、跑测试,全程我没写一行代码。

当时觉得这背后一定有什么了不起的东西。

后来我把它的引擎拆开看了。真相让我有点失望,又肃然起敬:它只是一个 while 循环。

对,一个循环。发消息给模型,模型说"我要调用这个工具",执行工具,把结果塞回去,再发一次。如此往复,直到模型觉得干完了。

一个循环,加一套工具协议,再加几十个精心设计的工程细节。我花了两个月读逆向源码,把最反直觉的五个真相写成了这篇文章。每个真相都挖到了源码层。

一、Agent Loop 没有任何魔法

引擎主循环 1600 多行,主干逻辑浓缩成 60 行骨架代码(函数名都是源码里的真实名字):

typescript 复制代码
async function* queryLoop(params): AsyncGenerator<Message, Terminal> {
  // 不可变参数(循环中永不改变)
  const { systemPrompt, userContext, canUseTool, maxTurns } = params

  // 可变状态(每次迭代整体替换)
  let state: State = {
    messages: params.messages,           // 发给 API 的完整消息历史
    toolUseContext: params.toolUseContext,
    turnCount: 1,
  }

  while (true) {
    let { messages, toolUseContext, turnCount } = state

    // ① 压缩家族:结果预算 → snip → microcompact → autocompact
    let messagesForQuery = await autocompact(messages, ...)

    // ② 流式调模型
    const assistantMessages = []
    const toolUseBlocks = []
    for await (const message of callModel({ messages: messagesForQuery, ... })) {
      yield message                          // 实时把增量吐给 UI
      if (message.type === 'assistant') {
        assistantMessages.push(message)
        toolUseBlocks.push(...message.content.filter(c => c.type === 'tool_use'))
      }
    }

    // ③ 没有工具调用 → Stop Hook → 结束
    if (toolUseBlocks.length === 0) {
      const stopHook = await handleStopHooks(...)
      if (stopHook.blockingErrors.length > 0) {
        state = { messages: [...messages, ...assistantMessages, ...stopHook.blockingErrors], ... }
        continue                            // Hook 追加反馈 → 再跑一轮
      }
      return { reason: 'completed' }
    }

    // ④ 执行工具(先过权限闸门 canUseTool)
    const toolResults = []
    for await (const update of runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)) {
      yield update.message
      toolResults.push(...normalizeMessagesForAPI([update.message]))
    }

    // ⑤ 结果回填 → 回到 ①
    state = {
      messages: messagesForQuery.concat(assistantMessages, toolResults),
      toolUseContext,
      turnCount: turnCount + 1,
    }
  }
}

所谓"智能体",拆到最底层就是:发请求 → 收工具调用 → 执行 → 回填结果 → 再发请求。 剩下的全是护栏。

为什么工具结果是 user 消息

有个协议细节值得单独说。API 只认两个角色:assistant 是模型说的,user 是喂给模型的一切。工具结果是你的电脑执行的,不是模型说的,所以只能以 user 身份拼回历史:

css 复制代码
[user: "读一下 README"] → [assistant: "我来看看" + tool_use(Read)]
→ [user: tool_result(文件内容)] → [assistant: "总结如下..."]

模型看到的对话永远只有两类声音:它自己说的,和其他一切。 理解了这一点,"记忆""技能""Hook 反馈"这些概念你已经懂了一半------它们最终都以 user 身份进请求。

为什么模型"说完了"还会继续干活

你可能注意过:Claude Code 经常在给出答案后,又自己补一句"我再跑一下测试确认",然后继续干。

让它继续的是一套 Stop Hook------循环的"刹车系统"。模型这轮没调任何工具、循环本该结束时,引擎执行 Stop Hook,相当于问一句:"你真的做完了吗?" Hook 可以回答三种:放行(正常结束)、阻断(强制结束)、追加反馈(把"你还没运行测试,请继续"塞回消息流,循环再跑一轮)。

第三种就是"自我检查"的机制来源。防死循环的设计也很妙:Hook 阻塞过一次后,引擎会告诉它"你已经干预过了"(通过 stdin 传一个 stop_hook_active: true 标记),收敛的决定权留给 Hook 自己,而不是引擎强制跳过。

循环有 11 种"死法"

1600 行循环里散布着十几个 return,全部穷举在类型定义里:completed(正常完成)、aborted_streaming / aborted_tools(用户 Ctrl+C 的两个阶段)、max_turns(轮数上限)、prompt_too_long(API 413 且恢复策略耗尽)、stop_hook_prevented(Hook 说"到此为止")......更有意思的是"继续"也有 7 种名字(压缩后重试、token 预算续跑、输出超限升级......),每种带自己的状态。

"结局"和"续命"都是数据而不是控制流标记------SDK 收到 reason 后能精确渲染结果面板,max_turns 显示黄色警告,用户中断显示提示,各有各的 UI。

二、Subagent 不是独立服务,是递归

Claude Code 的"子 agent"(那个帮你并行搜索代码库的 Explore)不是独立服务,不是新写的代码。

它就是主循环本身------换了一套配置,重新跑一遍:

typescript 复制代码
for await (const message of query({
  messages: initialMessages,          // fork 的父对话 + 任务 prompt
  systemPrompt: agentSystemPrompt,    // 子 agent 专属人设
  toolUseContext: agentToolUseContext,// 换过的上下文(工具池/权限/模型)
  maxTurns: maxTurns ?? agentDefinition.maxTurns,
})) { ... }

与主循环调用的唯一区别:参数全部换过。 所以循环里的所有工程投入------压缩、重试、Hook、状态机------自动被子 agent 继承,一分钱开发成本都没多花。

子 agent 的出生过程(runAgent 七步)

  1. 选模型:Explore 类搜索任务用轻量模型(Haiku),主 agent 用更强的------递归调用时换个参数而已
  2. 生成独立身份createAgentId() 生成唯一 ID,对话记录、异步任务路由、性能追踪全部按 ID 隔离
  3. 上下文 fork:继承父对话,但过滤掉"未完成的工具调用"(API 不允许悬空的 tool_use)
  4. 权限模式覆盖 :agent 定义可以声明自己的权限模式;父 agent 的会话级批准不泄漏给子 agent------你在主 agent 批准过一次 rm,不代表子 agent 也有这个权限
  5. 工具池裁剪:Explore 只剩只读工具;引擎层裁剪工具池 + 提示词层强调人设(Explore 的 system prompt 开头写着 "READ-ONLY MODE - NO FILE MODIFICATIONS"),双保险
  6. 递归调用 query:整个机制的心脏
  7. 结果回传与清理 :最终回答包装成 tool_result 返回;finally 块里七项清理(性能追踪、MCP 服务器、Hook、文件缓存、孤儿 todo 条目------源码注释说"长会话会启动数百个 agent,每个孤儿条目都是累积的小泄漏")

省钱的细节

Explore 子 agent 连 CLAUDE.md 都不带("只读的 agent 不需要知道约定,只需要找到代码"),光这一条省 5-15 Gtok/周;gitStatus 也省掉(最多 40KB 的过期数据),再省 1-3 Gtok/周。

同步与异步

同步子 agent 共享父的 AbortController ------你 Ctrl+C,父子一起停(同步语义下父子是"一件事");异步子 agent 独立 controller------父退出后它继续在后台跑,结果完成后通过消息队列注入父 agent 的下一轮。还有 bubble 权限模式:异步子 agent 遇到需要人工裁决的操作,把权限请求转发到父终端。

三、SDK 不是"调用 API",是拉起一个子进程

我一开始以为 Python SDK 是封装好的 HTTP API。真相是:

python 复制代码
cmd = [cli_path, "--output-format", "stream-json", "--verbose"]

SDK 不调用任何网络接口。它 spawn 一个完整的 Claude Code CLI 进程,往 stdin 写 JSON Lines,从 stdout 读流式事件。

为什么?因为 Claude Code 的价值在会话状态------权限规则、CLAUDE.md、MCP 连接、压缩历史------这些东西都在 CLI 进程里。如果 SDK 直接调 API,就要在 Python 里重写一整层,等于重写一个 Claude Code。

所以 SDK 选择当遥控器:写消息、读事件、发控制信号。 这是复用最彻底的一种形态。

三层抽象

SDK 内部是严格的三层,每层一个文件一个职责:

  • Transport :连接管理(connect/write/read_messages/close 六个方法),默认实现是子进程,但可以换成 WebSocket、远程 CLI------换 transport,上层一行不用改
  • Query :控制协议状态机。五个核心字段对应五种能力:请求-响应关联、结果缓存、跨进程回调注册表、有界消息队列(背压)、_inflight_tasks(未完成的后台任务)
  • Client:面向用户的公开 API

跨进程的函数调用

SDK 用户可以在 Python 里拦截权限请求:

python 复制代码
def can_use_tool(tool_name, tool_input, raw_input, call_id):
    if tool_name == "Write" and "secret" in str(tool_input):
        return PermissionResultDeny()
    return PermissionResultAllow()

实现原理:设置 permission_prompt_tool_name="stdio",告诉 CLI"权限请求不要弹窗,通过 stdio 协议发给我"。于是 CLI 决策链走到"询问"时发控制请求 → Python 回调执行 → 结果作为控制响应发回。SDK 回调与终端弹窗是同一条决策链的两种"问谁"------决策链共享,"问谁"可替换。

一个线上 bug 换来的字段

_inflight_tasks 记录"已启动但未结束的任务"。因为 result 帧只结束一轮对话,不结束整个 run------后台任务还在跑时不能关 stdin(它们还要用它传 Hook 和控制响应)。源码注释里引用了真实 issue 编号 #1088:这就是那个 bug 换来的字段。

四、记忆没有魔法,只是文本注入

你写了 CLAUDE.md 后,Claude Code 就"记住"了你的约定。它把文件读进数据库了吗?有索引吗?

都没有。它只是把文件内容拼进了每次发给模型的请求文本里,还带上一句固定前缀:

sql 复制代码
Codebase and user instructions are shown below. Be sure to adhere to
these instructions. IMPORTANT: These instructions OVERRIDE any default
behavior...

注意措辞:OVERRIDE any default behaviorCLAUDE.md 要能压过系统提示词的默认行为------"记忆"与"人设"的区别,不是存储位置,是优先级措辞。

分层记忆:越近的越重要

加载顺序写在源码文件头注释里,本身就是设计文档:

objectivec 复制代码
1. Managed memory(/etc/claude-code/CLAUDE.md)------ 企业托管,全员生效
2. User memory(~/.claude/CLAUDE.md)          ------ 用户个人,跨项目生效
3. Project memory(项目里的 CLAUDE.md 等)      ------ 项目约定,随代码入库
4. Local memory(CLAUDE.local.md)             ------ 个人私有,不进 git

加载顺序 = 反向优先级:越靠后加载的,模型越重视。
发现方式:从当前目录向上遍历到根目录,每层都可以带 CLAUDE.md。

"离任务越近,话语权越大。" 你在 src/api/ 目录下工作时,src/api/CLAUDE.mdsrc/CLAUDE.md、项目根 CLAUDE.md 全部生效,层层叠加。还支持 @include 指令拆分子文件(循环引用靠已处理集合防,不存在的 include 静默忽略)。

Skills:1% 预算的按需注入

Skill 的结构是 frontmatter + 正文的 markdown。但它的加载是两段式:

  • 第一段(发现目录) :系统提示词列出所有技能的"名字 + 描述",只占 1% 的上下文窗口 (源码常量 SKILL_BUDGET_CONTEXT_PERCENT = 0.01,单条描述上限 1536 字符)
  • 第二段(按需展开):模型判断匹配后调用 Skill 工具,完整正文注入下一轮

超预算时三级降级:全量描述 → 内置技能保全文、其余截断 → 只剩名字。源码注释解释了为什么内置技能永不全截断:"清单只是发现目录,完整内容按需加载------冗长的描述浪费 turn-1 的 token 却不会提升匹配率。"

Hook:stdin 进 JSON,stdout 出 JSON

Hook 是引擎的"外部反馈通道"。契约极其简单:引擎把工具名、输入、上下文打包成 JSON 写到 Hook 程序的 stdin;Hook 程序把决定(放行/阻断/修改输入/追加消息)写成 JSON 输出到 stdout。官方 hookify 插件里每个 Hook 都带 timeout: 10------防止 Hook 卡死阻塞主循环。更值得关注的是 Hook 里可以再调模型(官方 security-guidance 插件用 AI 审查 AI),Hook 的能力边界远超正则校验。

五、权限闸门有 11 步,连"信任一切"模式都拦不住三类请求

每个工具执行前都要过一条 11 步的决策链。完整走一遍(来自 permissions.tshasPermissionsToUseToolInner):

bash 复制代码
第 0 步:会话已被中止 → 直接抛错
第 1 步:deny 规则命中 → 拒绝(用户说"永远不许用 WebFetch")
第 2 步:ask 规则命中 → 询问("Bash 要先问我")
第 3 步:问工具自己的意见(checkPermissions)
第 4 步:工具自己说 deny → 拒绝
第 5 步:工具要求必须用户交互 → 询问
第 6 步:内容级 ask 规则命中 → 询问("Bash(npm publish:*) 先问我")
第 7 步:安全检查命中 → 询问(.git/.env 等敏感路径)
第 8 步:bypass 模式 → 放行
第 9 步:allow 规则命中 → 放行
第 10 步:以上都没裁决 → 询问用户

最反直觉的是第 5、6、7 步------三个"免疫设计":即使你开了 bypassPermissions(信任一切模式),这三类请求依然会被拦住

  1. 用户明说的命令级规则 ------你配置过"Bash(npm publish:*) 先问我",bypass 不能替你撤销这条指令
  2. 工具声明的强制交互
  3. 敏感路径安全检查(.git/.env 等)

优先级是:用户明说的规则 > 安全底线 > 信任模型。 顺序即语义------所以这三步排在 bypass 之前。

六种信任档位

default(正常询问)、acceptEdits(编辑自动放行)、plan(只读放行写入拒绝)、bypassPermissions(全部放行除免疫三步)、dontAsk(询问变拒绝,无人值守)、auto(AI 分类器代替弹窗)。

权限是引擎级闸门

工具只管声明"我做什么"(isReadOnlyisDestructive 等字段),闸门统一决定"能不能做"。换模式、换规则,都不需要改任何工具代码。权限决策与执行逻辑彻底解耦。


写在最后

这五个真相,来自我写的一本开源书《解剖 Claude Code:Agent 引擎的设计思想》------全书 11 章,从 Agent Loop 讲到上下文管理、工具系统、Subagent、MCP、SDK、插件生态,最后用设计思想八讲收束:

  • 源码级的机制拆解(每个结论都对应真实代码,行号可查)
  • 每章四道思考题(基础/进阶/开放三层)
  • 附带 300 行可运行的 Mini Claude Code(含冒烟测试,不需要 API key)

书里有两种读法:快览(一个下午,序言 + 地图 + 设计思想八讲)和精读(一周,全部 11 章)。

GitHubgithub.com/ouqin-live/... PDF 下载github.com/ouqin-live/...

如果这篇文章对你有帮助,欢迎给仓库一个 Star。

相关推荐
洞窝技术2 小时前
DeepSeek‑Harness 实测:Agent 自己写代码自己评测,得分只有 23 分
ai编程
飞哥数智坊2 小时前
交付的,正在从软件变成能力
人工智能·ai编程
承渊政道2 小时前
10:30提交预约会不会撞上10:00的会议?我用飞算JavaAI3.9.1和3.9.9跑了四个时间段
java·springboot·ai编程·飞算javaai·java代码生成
海兰2 小时前
【 Kafka进阶3】Apache Kafka 分布式事件流平台:架构原理与微服务解耦机制简要分析
分布式·架构·kafka
江畔柳前堤2 小时前
LLM + Agent 模型效果评估:从入门到工业级体系构建的完整指南
开发语言·人工智能·自然语言处理·chatgpt·架构·json·batch
阿沐沐,2 小时前
PowerShell 里改了 config.toml,Codex CLI 仍用旧值:先分清该改哪一层
java·服务器·数据库·人工智能·ai·ai编程
倔强的石头_2 小时前
从零封装一个合同审查助手:用 WorkBuddy + 腾讯云 OCR 三件套跑通 5 种格式合同的自动审阅
ai编程
Rain的Java大神之路2 小时前
如何避免订单重复提交
java·redis·后端·面试·架构·rabbitmq·rocketmq
chaochaoIT1233 小时前
2026中小企业进销存技术选型标准|从架构、数据、运维多维度商用能力核验
大数据·运维·架构·能源·制造·零售·交通物流