代码语言
选择后全站课程跟随

Agent Loop

3,505 字 · 预计阅读 10 分钟

前情提要

  • Agent = 模型 + 工具 + 循环。模型负责决策,工具负责动手,循环把两者串起来
  • 一个最小的 Agent 只要 30 行左右,核心是下面这段:
python
for 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 行,大概是这样:

typescript
// 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。

typescript
state = {
  messages: [...],                              // 下一轮要发给模型的消息
  turnCount: turnCount + 1,
  stopHookActive: true,                         // 一些标记,下一轮要用
  transition: { reason: 'stop_hook_blocking' }, // 这一轮为什么还要继续
}
continue

为什么要这么麻烦?因为「再来一轮」的理由不止一种:

  • 工具跑完了,正常进入下一轮
  • Stop Hook 说还不能停
  • 上下文太长,压缩之后重试
  • 输出被截断,让模型接着写

每一种都要带上不同的东西进入下一轮。把它们都写成「换一份 state + 写明 reason」,好处有三个:

好处 说明
每次继续都有名字 出了问题,看 transition.reason 就知道循环是从哪条路转回来的
标记能跨轮传递 比如 stopHookActive,下一次 Stop Hook 能知道自己是不是刚拦过一次
好测试 每条路都是一个明确的状态变化,可以单独测

循环写成状态机,是为了让每一次「再来一轮」都有名字、有理由。


三、停下来的理由不止一种

Agent 的退出原因有完成、限制和中断等差别

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 次

最后一种补的那句话很有意思,意思是:

输出到上限了。直接接着写,不用道歉,不用回顾刚才在干什么;如果是写到一半断的,就从那半句接上。把剩下的活拆小一点。

本文是会员内容

全文 3,505 字,已试读约 29%。 开通专业版可阅读全文,并解锁全部课程与后续更新。

开通专业版

支持 7 天无理由退款 · 早鸟期前 500 名自动升级永久卡

下一节:Tools上一节