前情提要
- Agent = 模型 + 工具 + 循环。模型负责决策,工具负责动手,循环把两者串起来
- 一个最小的 Agent 只要 30 行左右,核心是下面这段:
pythonfor step in range(max_steps): reply = llm(messages) # 模型决策 messages.append(reply) if not reply.tool_calls: # 觉得做完了 → 退出 return reply.content for call in reply.tool_calls: # 调用工具 result = tools[call.name](**call.args) messages.append(result) # 结果写回上下文,进入下一轮出处:第一本 第一章 第2节
都说 Claude Code 在工程上多做了大量工作,但拆开看,核心就是上面这段代码。
现在打开 src/query.ts,看看「大量工作」到底是什么。
一、从 30 行到 2000 行:骨架对照

query.ts 有 2000 多行,但骨架一眼就能认出来。压缩成 30 行,大概是这样:
// src/query.ts → queryLoop()(大幅简化)
while (true) {
let messages = state.messages
messages = await applyToolResultBudget(messages) // ① 先给上下文「减肥」(第11节)
await autocompact(messages)
for await (const msg of callModel({ // ② 调模型,流式返回
systemPrompt, tools,
messages: prependUserContext(messages, userContext), // CLAUDE.md 在这里贴上(第6节)
})) {
if (msg 里有 tool_use) needsFollowUp = true
}
if (!needsFollowUp) { // ③ 没有工具要调 → 准备结束
const stop = await handleStopHooks() // Stop Hook 可以说「先别停」(第9节)
if (stop.blockingErrors.length > 0) { state = {...}; continue }
return { reason: 'completed' }
}
for await (const r of runTools(toolUseBlocks)) toolResults.push(r) // ④ 执行工具(第3节)
state = { messages: [...messages, ...assistantMessages, ...toolResults], turnCount: turnCount + 1 }
} // ⑤ 结果写回,进入下一轮和前情提要里那段 Python 对一下:调模型 → 没有工具就结束 → 有工具就执行 → 结果写回 → 再来一轮。
一模一样。
那剩下的 1900 多行在干什么?几乎都在处理同一件事:这个循环在真实世界里会出各种状况。
二、循环为什么写成「状态机」
30 行的版本用的是 for step in range(max_steps),每一轮都一样。
query.ts 换了一种写法:循环外面有一个 state 对象,每次要「再来一轮」,就整个换一份新的 state,再 continue。
state = {
messages: [...], // 下一轮要发给模型的消息
turnCount: turnCount + 1,
stopHookActive: true, // 一些标记,下一轮要用
transition: { reason: 'stop_hook_blocking' }, // 这一轮为什么还要继续
}
continue为什么要这么麻烦?因为「再来一轮」的理由不止一种:
- 工具跑完了,正常进入下一轮
- Stop Hook 说还不能停
- 上下文太长,压缩之后重试
- 输出被截断,让模型接着写
每一种都要带上不同的东西进入下一轮。把它们都写成「换一份 state + 写明 reason」,好处有三个:
| 好处 | 说明 |
|---|---|
| 每次继续都有名字 | 出了问题,看 transition.reason 就知道循环是从哪条路转回来的 |
| 标记能跨轮传递 | 比如 stopHookActive,下一次 Stop Hook 能知道自己是不是刚拦过一次 |
| 好测试 | 每条路都是一个明确的状态变化,可以单独测 |
循环写成状态机,是为了让每一次「再来一轮」都有名字、有理由。
三、停下来的理由不止一种

30 行的版本只有两种结局:模型说做完了,或者到了步数上限。
query.ts 光是「结束原因」就有十几种,挑常见的看:
| 结束原因 | 什么时候 |
|---|---|
completed |
模型不再调用工具,Stop Hook 也放行了 |
max_turns |
到了轮数上限(比如子代理定义里的 maxTurns) |
aborted_streaming |
你按了 Esc,模型正在输出 |
aborted_tools |
你按了 Esc,工具正在执行 |
prompt_too_long |
压缩也救不回来,上下文实在太长 |
model_error |
API 报错 |
stop_hook_prevented / hook_stopped |
Hook 要求停止 |
注意,「完成」只是其中一种。真正跑起来,更多的是各种「没完成就停了」。
一个能干活的 Agent,要想清楚每一种停下来的方式。
四、换个方式再来一轮
有些状况不必停下来,换个方式就能接着干:
| 继续原因 | 什么时候 |
|---|---|
next_turn |
正常情况:工具跑完,进入下一轮 |
stop_hook_blocking |
Stop Hook 返回了 exit 2:先别停,接着干(第9节) |
reactive_compact_retry |
API 直接报「上下文太长」→ 先压缩,再重试(第11节) |
max_output_tokens_recovery |
模型一次输出太长被截断 → 补一句话让它接着写,最多 3 次 |
最后一种补的那句话很有意思,意思是:
输出到上限了。直接接着写,不用道歉,不用回顾刚才在干什么;如果是写到一半断的,就从那半句接上。把剩下的活拆小一点。