No.351
手写一个最小的 Agent 循环
一句话回答
最小的 Agent 循环由四部分组成:消息数组保存完整的对话和工具结果;工具注册表登记每个工具的名称、描述、参数 Schema 和执行函数;循环调用模型,有工具调用就执行,把结果按调用 id 追加到消息里,没有工具调用就结束;再加上最大步数和错误处理,工具出错时把错误作为结果回传,让模型自己修正。生产环境还要补上流式输出、持久化、观测和权限控制。
详细解析
设计要点
- 消息顺序:先追加模型的回复(包含工具调用),再追加每个调用的结果,用调用 id 对应
- 错误处理:工具出错不要抛异常中断循环,把错误作为结果回传;只有模型接口不可用这类系统级错误才向上抛
- 边界:执行前按 Schema 校验参数,结果过长要截断,设置最大步数防止循环永远不结束
适配不同的 SDK
下面的代码使用通用的 llm.chat 接口,实际使用时写一层适配器,转换成各家的格式。差异主要在三处:
| 差异点 | 说明 |
|---|---|
| 工具定义 | 参数 Schema 的字段名不同,如 parameters、input_schema;支持的 JSON Schema 关键字范围也不同 |
| 调用参数 | 有的返回 JSON 字符串,需要自己解析;有的已经是对象 |
| 结果回传 | 有的用 role: 'tool' 的独立消息加调用 id;有的把结果作为内容块放进 user 消息,并可以标记错误 |
代码示例:手写 Agent 循环
TypeScript
import { z } from 'zod'
// ===== 通用的模型接口,各家 SDK 通过适配器转换成这个形状 =====
type ToolCall = { id: string; name: string; arguments: string } // arguments 是模型生成的 JSON 字符串
type AssistantMessage = { role: 'assistant'; content: string; toolCalls?: ToolCall[] }
type Message =
| { role: 'system' | 'user'; content: string }
| AssistantMessage
| { role: 'tool'; toolCallId: string; content: string; isError?: boolean }
type ToolDef = { name: string; description: string; parameters: object }
declare const llm: { chat(req: { messages: Message[]; tools: ToolDef[] }): Promise<AssistantMessage> }
// ===== 工具注册表 =====
interface Tool<S extends z.ZodType = z.ZodType> {
name: string
description: string
schema: S
execute(args: z.infer<S>): Promise<unknown>
}
const defineTool = <S extends z.ZodType>(tool: Tool<S>) => tool
const tools: Tool[] = [
defineTool({
name: 'get_weather',
description: '查询指定城市今天的天气。用户问天气、穿衣、是否带伞时使用。',
schema: z.object({ city: z.string().describe('城市名,如:北京') }),
async execute({ city }) {
return { city, weather: '小雨', temp: 18 } // 示例数据,实际应请求天气接口
},
}),
]
const toolDefs: ToolDef[] = tools.map((t) => ({
name: t.name,
description: t.description,
parameters: z.toJSONSchema(t.schema, { io: 'input' }), // 按输入类型生成,带默认值的字段不会被标成必填
}))
// ===== 执行单个工具:任何错误都转成结果,交给模型处理 =====
async function runTool(call: ToolCall): Promise<{ content: string; isError: boolean }> {
const tool = tools.find((t) => t.name === call.name)
if (!tool) return { content: `不存在工具 ${call.name}`, isError: true }
try {
const parsed = tool.schema.safeParse(JSON.parse(call.arguments || '{}'))
if (!parsed.success) {
const detail = parsed.error.issues.map((i) => `${i.path.join('.') || '参数'}:${i.message}`).join(';')
return { content: `参数校验失败:${detail}`, isError: true }
}
const result = await tool.execute(parsed.data)
const text = typeof result === 'string' ? result : (JSON.stringify(result) ?? '执行成功')
return { content: text.length > 8000 ? `${text.slice(0, 8000)}\n(结果过长,已截断)` : text, isError: false }
} catch (err) { // JSON 解析失败和执行时抛出的异常都会到这里
return { content: `出错:${err instanceof Error ? err.message : String(err)}`, isError: true }
}
}
// ===== Agent 循环:没有工具调用时结束,最多 MAX_STEPS 步 =====
const MAX_STEPS = 10
export async function runAgent(input: string): Promise<string> {
const messages: Message[] = [
{ role: 'system', content: '你是一个能使用工具的助手。工具出错时根据错误信息调整;无法完成时如实说明,不要编造结果。' },
{ role: 'user', content: input },
]
for (let step = 0; step < MAX_STEPS; step++) {
const reply = await llm.chat({ messages, tools: toolDefs })
messages.push(reply) // 先追加模型的回复,再追加工具结果
if (!reply.toolCalls?.length) return reply.content // 没有工具调用,这就是最终回答
for (const call of reply.toolCalls) {
const { content, isError } = await runTool(call)
messages.push({ role: 'tool', toolCallId: call.id, content, isError })
}
}
return `已执行 ${MAX_STEPS} 步仍未完成,请缩小问题范围后重试。`
}
生产环境还需要什么
| 方面 | 要补充的内容 |
|---|---|
| 流式输出 | 文本和工具执行状态实时推给前端,见 服务端怎么转发流式响应 |
| 持久化 | 消息按会话存库,进程重启或断线后能恢复;恢复时不能重复执行已经执行过的工具 |
| 上下文管理 | 截断或压缩历史,工具调用和它的结果不能拆开,见 多轮对话的上下文怎么管理 |
| 观测 | 记录每一步的输入输出、token、耗时和错误,见 怎么给 LLM 调用链做链路追踪 |
| 权限 | 工具以当前用户的身份执行,敏感操作人工确认,见 工具调用的权限和安全 |
| 预算和中断 | token、费用、时间上限,用户可以随时停止,见 怎么防止 Agent 失控 |
面试官可能追问
循环结束的条件有哪些?
正常结束是模型不再调用工具;异常结束包括达到最大步数、token 预算或超时,以及用户中断。也有的设计专门提供一个"提交最终答案"的工具,让结束变得显式、结构化。还要检查模型的结束原因:如果是输出达到长度上限被截断,最后一个工具调用的参数可能不完整,不能当成正常结束处理。
同一轮的多个工具调用怎么改成并行执行?
用 Promise.allSettled 并发执行同一轮的调用,限制并发数,结果按调用 id 回传;有副作用的工具仍然按顺序执行。见 并行工具调用和多轮工具调用怎么处理。
怎么支持流式输出?
改用流式接口,文本片段一到就推给前端。工具调用的参数也是分片到达的,要拼接完整后再解析和执行,见 流式输出中的工具调用怎么增量解析。执行工具期间给前端发"正在查询天气"这类状态事件,下一轮继续流式输出。
易错点
- 没有先追加模型的回复(含工具调用)就追加工具结果,或者漏回传某个调用的结果,多数接口会直接报错
JSON.parse解析参数时没有捕获异常,模型生成一次不合法的 JSON,整个 Agent 就崩溃了- 工具结果不截断,一次读取大文件或大接口响应就把上下文撑满
- 没有最大步数,模型反复调用工具时程序永远不结束
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。