Chapter 3: Agent Loop 解剖:一轮对话的完整旅程
Agent 的本质就是一个循环。理解这个循环,就理解了 AI Agent 的心跳。
┌─────────────── Harness ───────────────┐
│ │
│ User ──▶ ★ Agent Loop ★ ──▶ LLM │
│ │ ▲ │
│ Tool Use Result │
│ ▼ │ │
│ [ Tools ] ──┘ │
│ │
└───────────────────────────────────────┘
本章聚焦:Agent Loop 的内部结构
3.1 Agent 不是一问一答
问题
当你在终端敲下"帮我重构这个函数",该 Agent 系统不是简单地调一次 API 返回答案。它可能要读文件、理解上下文、写代码、执行测试、发现报错、再改代码......这一连串动作怎么串起来?
思路
学术界有一个被广泛接受的 Agent 模型:Message -> Think -> Act -> Observe -> Loop (or Stop)。翻译成人话:接收指令、思考做什么、执行动作、观察结果、决定继续还是停下。
该系统忠实地实现了这个模型,但加了一层关键的工程抽象:把整个循环拆成两层。外层查询引擎负责"会话生命周期"——状态管理、消息录制、预算控制;内层查询函数负责"单轮推理循环"——调 API、执行工具、决定是否继续。
这就像一家餐厅:查询引擎是前厅经理,负责接待顾客、记账、控制翻台率;查询函数是厨房,负责实际做菜。前厅不关心菜怎么做,厨房不操心账怎么算。
实现
这种分层体现在代码结构上。查询引擎类拥有会话状态:
// 查询引擎的核心状态(概念示意)
class QueryEngine:
private mutableMessages: List<Message>
private abortController: AbortController
private totalUsage: UsageAccumulator
// ...
一个查询引擎实例对应一个完整对话。每次提交消息开启一轮新的交互,但消息历史、Token 用量、文件缓存都跨轮持久化。
而具体的推理循环,提交消息方法交给查询函数去做:
// 查询引擎流式消费查询循环(概念示意)
for await (message in query({
messages, systemPrompt, userContext, systemContext,
canUseTool: wrappedCanUseTool,
// ...
})):
// 处理每条从查询循环产出的消息
注意这里用的是 for await...of。这不是一次性拿到结果,而是流式消费——查询函数每产出一条消息,查询引擎就处理一条。这个设计决策的意义我们稍后会看到。
3.2 AsyncGenerator:为什么选这个模式?
问题
消息在系统里怎么流动?为什么不用回调、事件发射器或 Promise?
思路
该系统面临一个独特的挑战:消息的种类繁多(LLM 输出、工具结果、流式事件、进度报告、系统通知......),而且消费者的节奏和生产者不同。UI 需要逐字显示,日志需要完整记录,SDK 调用者需要结构化数据。
AsyncGenerator 恰好解决这个问题。生产者(查询函数)通过 yield 推送消息,消费者(查询引擎)按自己的节奏拉取。这形成了一条背压友好的管道:如果消费者处理不过来,生产者自然暂停。
更巧妙的是,AsyncGenerator 支持嵌套组合。循环函数 yield 给查询函数,查询函数 yield 给引擎的提交方法,提交方法 yield 给外部调用者。每一层可以拦截、转换、过滤消息,而不破坏流式语义。
实现
查询函数的签名揭示了这条管道的类型:
// 查询函数签名(概念示意)
async generator function query(params: QueryParams):
yields:
StreamEvent // LLM 流式输出的增量片段
| RequestStartEvent // 一次 API 请求即将开始
| Message // 完整的 assistant/user 消息
| TombstoneMessage // 标记需要删除的孤儿消息
| ToolUseSummary // 工具使用摘要
returns:
Terminal // 终止原因
五种产出类型,一种返回类型。产出是"过程中的事件",返回是"最终的结论"。
查询引擎的消息分发器则根据类型做不同处理:assistant 消息推入历史并转发,stream_event 用于追踪 Token 用量,system 消息处理压缩边界和 API 错误,等等。每种消息类型都有明确的职责,互不干扰。
3.3 循环的心脏:while(true)
问题
查询函数内部的循环到底怎么运转?一轮迭代做了什么?
思路
真正的循环逻辑在循环函数里。这是一个 while(true) 无限循环——听起来危险,但其实很合理:Agent 不知道要调多少次工具才能完成任务,循环次数由 LLM 的决策动态决定。
每轮迭代可以概括为五步:预处理上下文 -> 调 API -> 处理响应 -> 执行工具 -> 决定是否继续。但魔鬼在细节里——每一步都有大量的边界情况处理。
循环通过一个状态对象管理跨迭代的状态:
// 循环状态定义(概念示意)
LoopState = {
messages: List<Message>
turnCount: Number
maxOutputRecoveryCount: Number
hasAttemptedReactiveCompact: Boolean
transition: ContinueReason or undefined // 上一次迭代为什么继续
// ...
}
transition 字段特别值得注意。它记录了循环继续的原因:next_turn(正常工具调用后继续)、max_output_tokens_recovery(输出被截断,恢复重试)、reactive_compact_retry(上下文过长,压缩后重试)。这不只是调试信息——它让每轮迭代知道自己是"正常的下一步"还是"某种异常恢复",从而调整行为。
实现
循环开头有一个关键步骤:上下文预处理管线。消息在到达 API 之前要过好几道工序:
原始消息 -> compactBoundary截断 -> toolResultBudget -> snipCompact
-> microcompact -> contextCollapse -> autoCompact -> API
这条管线的存在是因为 Agent 的上下文增长速度远超聊天场景。每次工具调用都会注入几百到几千 token 的输出。不经过压缩,几十轮交互就能撑爆 200K 的窗口。第 5 章会详细展开这个话题。
循环体内,API 调用和工具执行之间有一个精妙的优化——流式工具并行执行。当 LLM 流式输出中出现一个完整的 tool_use block 时,不等整个响应结束就开始执行:
// 流式工具并行执行(概念示意)
if streamingToolExecutor and not aborted:
for each toolBlock in messageToolUseBlocks:
streamingToolExecutor.addTool(toolBlock, message)
想象 LLM 说"我要同时读三个文件"。传统方式是等 LLM 说完,再依次读取。流式执行则在 LLM 还在输出第二个 tool_use block 时,第一个文件已经开始读了。在多文件操作场景下,这能显著缩短延迟。
3.4 循环怎么知道该停?
问题
Agent Loop 不能永远转下去。什么时候停?谁来决定?
思路
终止条件的设计体现了一个原则:多重保险。不能只靠一个条件来停止循环,因为任何单一机制都可能失效。该系统至少有七种终止方式,分布在查询函数和查询引擎两层。
最核心的判断很简单:LLM 的回复里有没有 tool_use block。有就继续(needsFollowUp = true),没有说明 LLM 认为任务完成了。但即使 LLM 说"我做完了",还要过一关——Stop Hooks。
实现
needsFollowUp 的赋值逻辑在流式处理中:
// 判断是否需要后续循环(概念示意)
toolUseBlocks = message.content.filter(block -> block.type == 'tool_use')
if toolUseBlocks.length > 0:
allToolUseBlocks.append(toolUseBlocks)
needsFollowUp = true
当 needsFollowUp 为 false 时,循环在结束前会经过 Stop Hooks 检查:
// Stop Hooks 检查(概念示意)
stopHookResult = yield* handleStopHooks(
messagesForQuery, assistantMessages, ...
)
if stopHookResult.preventContinuation:
return { reason: 'stop_hook_prevented' }
Stop Hooks 是用户自定义的验证逻辑。比如"每次写代码后必须跑测试"——如果 LLM 写了代码但没跑测试就要停下,Hook 会阻止终止,注入一条错误消息让循环继续。
在查询引擎层面还有额外的硬性限制:
- USD 预算:累计花费达到上限时立即终止
- 最大轮数:轮数超出设定值时终止
- 结构化输出重试上限:JSON Schema 验证失败超 5 次时终止
这些是"断路器"——防止 Agent 因为 LLM 幻觉或 Hook 死循环而无限运转。
3.5 从用户输入到 API 调用之间
问题
用户敲下一句话到第一次 API 调用之间,发生了什么?
思路
在调用查询函数之前,查询引擎做了大量准备工作。这些看似琐碎的初始化步骤实际上决定了整个对话的"人格"和"能力范围"。
最重要的一步是用户输入处理。它把原始用户输入转化为结构化数据,处理斜杠命令、附件、模型切换等。返回值中的 shouldQuery 标志决定了是否需要调用 LLM——如果用户输入的是 /compact 或 /model,本地就能处理,不用花钱调 API。
另一个关键步骤是系统提示词的组装。该系统的 System Prompt 不是一段静态文本,而是从三个来源并行拉取、分层组合的:
- defaultSystemPrompt:工具说明、行为规范等固定内容
- userContext:AGENT.md 内容、环境信息——注入到消息最前面
- systemContext:系统级指令——追加到系统提示词末尾
这种分层设计是为 Prompt Caching 服务的。系统提示词保持稳定,变化的 userContext 放在消息里,这样 API 服务端可以复用缓存的前缀,节省大量 token 计费。
实现
权限检查的包装也值得一提。原始的权限检查函数被包了一层:
// 权限检查包装(概念示意)
wrappedCanUseTool = async function(tool, input, ...):
result = await canUseTool(tool, input, ...)
if result.behavior != 'allow':
this.permissionDenials.append({
tool_name: tool.name,
tool_use_id: toolUseID,
tool_input: input,
})
return result
每次权限拒绝都被记录下来,最后通过 result 消息返回给 SDK 调用者。这不是可选的诊断——在 SDK 场景下,调用者需要知道哪些操作被拒了,以便决定是否调整权限策略。
3.6 状态的流转全景
把整个流程串起来,一轮完整的"用户说帮我读 package.json"涉及以下状态流转:
用户输入
|
v
processUserInput() --> shouldQuery=true
|
v
组装系统提示词 + 用户上下文
|
v
query() 循环 ---- 第 1 轮迭代 ----
| 上下文预处理管线
| 调 LLM API(流式)
| LLM 决定调 Read 工具 --> needsFollowUp=true
| 权限检查 --> allow
| 执行 Read,读到文件内容
| yield user message(tool_result)
|
| state.transition = { reason: 'next_turn' }
|
v ---- 第 2 轮迭代 ----
| 上下文预处理管线(含文件内容)
| 调 LLM API(流式)
| LLM 生成最终回复,无 tool_use --> needsFollowUp=false
| Stop Hooks 检查 --> 允许结束
| return { reason: 'completed' }
|
v
QueryEngine 产出 result { subtype: 'success' }
两次 API 调用,两轮循环。第一轮 LLM 决定行动,第二轮 LLM 基于观察生成回答。这就是 Think-Act-Observe 模型的具体实例。
3.7 小结
该系统的 Agent Loop 有三个核心设计决策:
-
两层架构。查询引擎管生命周期,查询函数管推理循环。职责分离让每一层都可以独立演进。
-
AsyncGenerator 管道。消息在循环函数 -> 查询函数 -> 查询引擎 -> 调用者之间流式传递,天然支持背压和中间处理。
-
多重终止保险。LLM 决策、Stop Hooks、轮数限制、USD 预算——四道防线确保循环不会失控。
理解了这个循环,后面两章就是深入它的两个关键部件:怎么跟 LLM 对话(第 4 章),以及对话太长时怎么压缩(第 5 章)。
思考题
-
为什么
transition字段要记录循环继续的原因?如果只用一个 booleanshouldContinue会有什么问题? -
流式工具并行执行(StreamingToolExecutor)在什么场景下反而可能更慢?提示:考虑工具执行需要权限确认的情况。
-
查询引擎的消息数组在压缩边界之后会被截断。为什么要这么做?这对 GC 有什么影响?