02. 最小 Agent Loop
理解了工具协议之后,Agent 的核心就是 loop。它不是 while true 地让模型说话,而是由模型返回的 stop reason 驱动状态转移。模型说需要工具,运行时执行工具;模型说完成,运行时结束;模型超长或被中止,运行时把状态记录下来,让上层决定怎么处理。
失败模式:把工具调用当成普通文本
最常见的错误是只处理 assistant 文本,忽略 tool call。这样模型会认真地说“我需要读取文件”,但系统什么也不做。第二种错误是执行一次工具就结束,忘了把结果发回模型。此时模型永远没有机会基于观察继续推理。第三种错误是工具失败时抛异常退出,导致模型无法自我修正。
Agent loop 的责任是把这些边界固定下来:
- 模型响应是一个 turn。
- 一个 turn 可以包含零个或多个 tool call。
- tool call 执行完必须形成 tool result 消息。
- tool result 进入下一轮上下文。
- 只有 stop reason 表示完成时,任务才结束。
最小结构
先定义模型边界和工具边界:
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);
if (assistant.stopReason !== "toolUse") {
return messages;
}
for (const block of assistant.content) {
if (block.type !== "toolCall") {
continue;
}
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 事件、恢复和调试都会失去统一依据。
工具错误要回喂模型
工具失败不等于任务失败。假设模型调用 read_file 时写错路径,运行时应该返回:
toolResult(read_file, isError=true):
File not found: src/maths.ts. Did you mean src/math.ts?
模型下一轮可能会改用正确路径。这个闭环是 Agent 和普通脚本的差异之一。脚本遇错通常退出;Agent 遇错应该把可修正信息放回上下文,让模型继续尝试。
当然,不是所有错误都应该回喂后继续。权限拒绝、用户取消、危险命令被拦截,通常需要把错误写入日志并结束或等待用户确认。判断标准是:模型是否能靠更多推理修复这个错误。如果不能,就不要让它无限重试。
步数上限与终止
最小 loop 必须有 maxTurns。没有上限的 Agent 可能因为工具失败、模型重复、压缩缺失或 prompt 误导而无限运行。上限不是用户体验的最终方案,但它是运行时安全阀。产品层可以把“超过上限”渲染成用户可理解的状态:当前任务没有收敛,最后一次操作是什么,建议用户如何继续。
中止也不能简单地让异常飞出所有层。更好的做法是把 abort 变成一条带 stopReason: "aborted" 的 assistant 消息,写入日志,并让 UI 知道这次 turn 是被用户中断,而不是模型失败。
检查点
完成本章后,你应该有一个可以跑通这段脚本的 Agent:
user: 列出工作区文件
assistant: toolCall list_files {}
toolResult: README.md, src/index.ts
assistant: 工作区包含 README.md 和 src/index.ts。
验收标准:
- assistant 的 tool call 会被执行。
- tool result 会进入下一次模型请求。
- 未知工具不会让进程崩溃,而是形成错误 tool result。
- 超过 turn 上限时有明确错误。
- 所有消息按发生顺序保留。