去年我用 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 七步)
- 选模型:Explore 类搜索任务用轻量模型(Haiku),主 agent 用更强的------递归调用时换个参数而已
- 生成独立身份 :
createAgentId()生成唯一 ID,对话记录、异步任务路由、性能追踪全部按 ID 隔离 - 上下文 fork:继承父对话,但过滤掉"未完成的工具调用"(API 不允许悬空的 tool_use)
- 权限模式覆盖 :agent 定义可以声明自己的权限模式;父 agent 的会话级批准不泄漏给子 agent------你在主 agent 批准过一次 rm,不代表子 agent 也有这个权限
- 工具池裁剪:Explore 只剩只读工具;引擎层裁剪工具池 + 提示词层强调人设(Explore 的 system prompt 开头写着 "READ-ONLY MODE - NO FILE MODIFICATIONS"),双保险
- 递归调用 query:整个机制的心脏
- 结果回传与清理 :最终回答包装成 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 behavior。CLAUDE.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.md、src/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.ts 的 hasPermissionsToUseToolInner):
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(信任一切模式),这三类请求依然会被拦住:
- 用户明说的命令级规则 ------你配置过"
Bash(npm publish:*)先问我",bypass 不能替你撤销这条指令 - 工具声明的强制交互
- 敏感路径安全检查(.git/.env 等)
优先级是:用户明说的规则 > 安全底线 > 信任模型。 顺序即语义------所以这三步排在 bypass 之前。
六种信任档位
default(正常询问)、acceptEdits(编辑自动放行)、plan(只读放行写入拒绝)、bypassPermissions(全部放行除免疫三步)、dontAsk(询问变拒绝,无人值守)、auto(AI 分类器代替弹窗)。
权限是引擎级闸门
工具只管声明"我做什么"(isReadOnly、isDestructive 等字段),闸门统一决定"能不能做"。换模式、换规则,都不需要改任何工具代码。权限决策与执行逻辑彻底解耦。
写在最后
这五个真相,来自我写的一本开源书《解剖 Claude Code:Agent 引擎的设计思想》------全书 11 章,从 Agent Loop 讲到上下文管理、工具系统、Subagent、MCP、SDK、插件生态,最后用设计思想八讲收束:
- 源码级的机制拆解(每个结论都对应真实代码,行号可查)
- 每章四道思考题(基础/进阶/开放三层)
- 附带 300 行可运行的 Mini Claude Code(含冒烟测试,不需要 API key)
书里有两种读法:快览(一个下午,序言 + 地图 + 设计思想八讲)和精读(一周,全部 11 章)。
GitHub :github.com/ouqin-live/... PDF 下载 :github.com/ouqin-live/...
如果这篇文章对你有帮助,欢迎给仓库一个 Star。