02. 最小 Agent Loop
理解了工具协议之后,Agent 的核心就是 loop。它不是 while true 地让模型说话,而是由模型返回的信号驱动状态转移。模型说需要工具,运行时执行工具;模型说完成,运行时结束;模型超长或被中止,运行时把状态记录下来,让上层决定怎么处理。
失败模式:把工具调用当成普通文本
最常见的错误是只处理 assistant 文本,忽略 tool call。这样模型会认真地说“我需要读取文件”,但系统什么也不做。第二种错误是执行一次工具就结束,忘了把结果发回模型。此时模型永远没有机会基于观察继续推理。第三种错误是工具失败时抛异常退出,导致模型无法自我修正。
Agent loop 的责任是把这些边界固定下来:
- 模型响应是一个 turn。
- 一个 turn 可以包含零个或多个 tool call。
- tool call 执行完必须形成 tool result 消息。
- tool result 进入下一轮上下文。
- 没有更多 tool call、也没有排队输入时,任务才结束。
注意最后一条的措辞。教学实现常写成“stopReason 不是 toolUse 就结束”,但成熟系统的判断依据是“这条 assistant 消息里有没有 tool call”。两者大多数时候等价,但模型偶尔会返回 stopReason: "stop" 却仍带着 tool call。以内容为准的 loop 会照常执行这些工具;以 stop reason 为准的 loop 会把它们悄悄丢掉,并且留下一条“有 tool call 却没有 tool result”的非法上下文,下一次请求直接被 provider 拒绝。防御性原则:stop reason 用于解释状态,tool call 的存在与否决定控制流。
最小结构
先定义模型边界和工具边界:
type Message = UserMessage | AssistantMessage | ToolResultMessage;
type ModelClient = {
complete(input: { messages: Message[]; tools: ToolDefinition[] }): Promise<AssistantMessage>;
};
type ToolDefinition = {
name: string;
description: string;
parameters: unknown;
};
type ToolRuntime = {
definition: ToolDefinition;
execute(input: unknown, signal: AbortSignal): Promise<ToolResultMessage>;
};
然后实现 loop。教学版可以先串行执行工具,后面再讲并行和文件写队列:
async function runAgent(input: {
model: ModelClient;
tools: ToolRuntime[];
messages: Message[];
signal: AbortSignal;
maxTurns: number;
}): Promise<Message[]> {
const messages = [...input.messages];
const toolsByName = new Map(input.tools.map((tool) => [tool.definition.name, tool]));
for (let turn = 0; turn < input.maxTurns; turn += 1) {
const assistant = await input.model.complete({
messages,
tools: input.tools.map((tool) => tool.definition),
});
messages.push(assistant);
const toolCalls = assistant.content.filter(
(block): block is ToolCallBlock => block.type === "toolCall",
);
if (toolCalls.length === 0) {
return messages;
}
for (const block of toolCalls) {
const tool = toolsByName.get(block.name);
if (!tool) {
messages.push({
role: "toolResult",
toolCallId: block.id,
toolName: block.name,
isError: true,
content: [{ type: "text", text: `Unknown tool: ${block.name}` }],
});
continue;
}
const result = await tool.execute(block.input, input.signal);
messages.push({ ...result, toolCallId: block.id, toolName: block.name });
}
}
throw new Error(`Agent exceeded ${input.maxTurns} turns`);
}
这段代码的重点不是完整性,而是控制流:assistant 消息先入历史,再执行工具,再把 tool result 入历史,再回到模型。这个顺序不能随意调换。否则会话日志、UI 事件、恢复和调试都会失去统一依据。
所有失败都汇入同一条出口
工具执行有五种典型的失败路径:工具名不存在、参数校验失败、执行中抛异常、被权限钩子阻止、执行中收到 abort。成熟系统会让这五条路径汇入同一个出口——构造一条 isError: true 的 tool result。这不是代码洁癖,而是协议要求:上一章说过,每个 tool call 必须有对应结果消息。任何一条失败路径“逃逸”成未捕获异常,都会留下一个没有结果的 tool call,污染整个会话。
判断错误应该回喂还是终止,标准是:模型是否能靠更多推理修复这个错误。路径写错、参数缺字段、oldText 不匹配,回喂后模型通常能修正;权限拒绝、用户取消、provider 认证失败,回喂只会让模型无限重试。前者写进 tool result,后者写进日志并停止或等待用户。
工具失败的反馈要可行动。假设模型调用 read_file 时写错路径,运行时应该返回:
toolResult(read_file, isError=true):
File not found: src/maths.ts. Did you mean src/math.ts?
模型下一轮多半会改用正确路径。这个闭环是 Agent 和普通脚本的差异之一:脚本遇错退出,Agent 遇错把可修正信息放回上下文。
中止的正确姿势
用户按下停止时,AbortSignal 会传进 provider 请求和正在执行的工具。这里有一个容易做错的细节:中止不等于立刻返回。已经开始执行的工具需要走完清理逻辑(杀掉子进程、关闭文件句柄),并且各自形成一条“Operation aborted”的错误 tool result;尚未开始的 tool call 可以不执行,但如果这条 assistant 消息要留在会话里继续使用,投影上下文时就必须为它们补上占位结果。
更好的做法是把 abort 变成一条带 stopReason: "aborted" 的 assistant 消息(或在现有部分消息上标记),写入日志,并让 UI 知道这次 turn 是被用户中断,而不是模型失败。异常一路裸奔到顶层的 loop,恢复会话时没有任何办法解释“上次停在哪里”。
Turn 与 run 的分界
教学实现里 loop 就是全部,但值得提前建立一对概念:一个 turn 是“一次模型响应加上它触发的一批工具执行”;一个 run 是“从用户输入开始,到没有更多工具调用、也没有排队输入为止的若干个 turn”。这个分界后面会变得重要——UI 在每个 turn 结束时刷新,插话在 turn 边界注入,压缩在 turn 边界切分,统计按 run 聚合。现在先在代码里保留清晰的 turn 边界,第六、七章会用到它。
步数上限与终止
最小 loop 必须有 maxTurns。没有上限的 Agent 可能因为工具失败、模型重复、压缩缺失或 prompt 误导而无限运行。值得说明的是,成熟系统往往不用硬编码上限,而是用可插拔的“是否继续”钩子(比如每个 turn 后询问上层要不要停),加上完善的可观测性让用户随时看到 Agent 在做什么、随时可以中止。但那是在事件流、日志和 UI 都齐备之后的选择。在那之前,maxTurns 是最便宜的安全阀,产品层可以把“超过上限”渲染成用户可理解的状态:当前任务没有收敛,最后一次操作是什么,建议用户如何继续。
检查点
完成本章后,你应该有一个可以跑通这段脚本的 Agent:
user: 列出工作区文件
assistant: toolCall list_files {}
toolResult: README.md, src/index.ts
assistant: 工作区包含 README.md 和 src/index.ts。
验收标准:
- assistant 的 tool call 会被执行,即使 stopReason 不是
toolUse。 - tool result 会进入下一次模型请求。
- 未知工具、参数校验失败和工具异常都形成错误 tool result,进程不崩溃。
- abort 后,每个已开始的 tool call 都有结果消息,会话状态可解释。
- 超过 turn 上限时有明确错误。
- 所有消息按发生顺序保留。