AI Agent 开发 02|用一个离线例子看清执行循环
先把模型换成一段确定的程序,Agent 的执行过程就很容易观察。我们让它先请求查询项目的启动命令,收到工具结果以后再回答。这个例子不会调用真实模型,也没有语言理解能力,用来检查程序有没有把几个步骤正确连起来。
准备一个支持 ES 模块和 Array.prototype.at() 的 Node.js 环境。新建 agent-loop.mjs,保存下面的完整代码。
import { pathToFileURL } from "node:url";
const tools = new Map([
["lookup_command", async (args) => {
if (!args || args.project !== "demo") {
throw new Error("只允许查询 demo 项目");
}
return { command: "pnpm dev", source: "演示开发指南" };
}],
]);
export async function demoModel(messages) {
const last = messages.at(-1);
if (last.role !== "tool") {
return {
type: "tool_call", id: "call-1",
name: "lookup_command", args: { project: "demo" },
};
}
const result = JSON.parse(last.content);
return result.ok
? { type: "final", text: result.data.command }
: { type: "failed", text: result.error };
}
export async function runAgent(question, model = demoModel, maxTurns = 4) {
if (!Number.isInteger(maxTurns) || maxTurns < 1) {
throw new Error("maxTurns 必须是正整数");
}
const messages = [{ role: "user", content: question }];
const trace = [];
for (let turn = 0; turn < maxTurns; turn++) {
const decision = await model(messages);
if (!decision || typeof decision !== "object") {
throw new Error("模型返回了无效决策");
}
if (["final", "failed"].includes(decision.type)) {
if (typeof decision.text !== "string" || !decision.text.trim()) {
throw new Error("结束决策缺少回答");
}
return {
status: decision.type === "final" ? "completed" : "failed",
answer: decision.text, trace,
};
}
if (decision.type !== "tool_call" ||
typeof decision.id !== "string" || !decision.id.trim() ||
typeof decision.name !== "string" || !decision.name.trim()) {
throw new Error("工具请求格式错误");
}
messages.push({ role: "assistant", decision });
let observation;
try {
const execute = tools.get(decision.name);
if (!execute) throw new Error("未知工具");
observation = { ok: true, data: await execute(decision.args) };
} catch (error) {
observation = {
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
trace.push({ name: decision.name, observation });
messages.push({
role: "tool", tool_call_id: decision.id,
content: JSON.stringify(observation),
});
}
return { status: "limit_reached", answer: "任务尚未完成", trace };
}
if (process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href) {
console.log(await runAgent("demo 项目怎样启动?"));
}进入文件所在目录,运行它。
node agent-loop.mjs正常结果里会出现 status: 'completed',回答为 pnpm dev。展开 trace,可以看到一次 lookup_command 的调用。命令是演示资料中的固定数据,不能据此启动你的真实项目。
第二轮为什么有答案
第一轮,demoModel 只会提出一个工具请求。runAgent 从 Map 中找到对应函数,等待执行,再追加一条工具消息。
第二轮,模型函数看到最后一条消息的角色是 tool,解析结果,输出最终回答。两轮之间传递的就是程序维护的 messages。换成真实模型以后,模型本身不会替你维护这份数组。
消息中的调用 ID 也有用。call-1 把请求与响应对应起来。当一次模型响应提出多个工具调用时,程序要保留每条请求,并为它们分别提供对应结果。这里为了便于阅读,每轮只处理一个调用。
把成功路径故意弄坏
先把工具参数中的 project 改成别的字符串。工具会拒绝,下一轮返回 failed。程序得到了真实失败,回答就应该保留这个状态。
再把工具名改成 missing_tool。Map 查不到它,会产生“未知工具”的观察结果。模型输出的名称必须经过工具注册表检查,不能直接成为任意函数调用。
最后只允许一轮执行。
console.log(await runAgent("demo 项目怎样启动?", demoModel, 1));这时工具已经执行,模型还没获得第二次生成答案的机会,返回值应当是 limit_reached。它恰好说明执行上限与完成状态之间的区别。
如果模型函数返回 null 或者缺少调用 ID,程序直接抛出格式错误。这类错误值得在模型适配层尽早发现。工具执行失败可以交回模型处理,损坏的协议结构需要先修好。
接真实模型时需要补哪部分
这份代码里的 decision 是教学协议。你需要写一个适配器,把消息转换成所用 SDK 要求的格式,再把 SDK 返回的工具请求和最终文本转换回来。
适配器还要保留工具调用标识,处理模型请求失败,并遵循供应商对消息顺序的要求。工具的参数校验和业务权限仍留在执行侧。即使使用结构化工具调用,程序也得判断这次操作是否允许发生。
这个演示还没有超时、取消和持久化。接入网络工具后,每次请求都应该有明确的超时;服务需要取消任务时,也要把取消信号传给实际执行的函数。轮数上限只能控制调用次数,无法打断一次永久等待的请求。
延伸阅读
本篇依据冴羽《AI Agent 开发实战》学习整理稿中的离线循环重新编写,正常完成与失败路径均可独立验证。原教程见 JavaScript ReAct 实现 和 Python ReAct 实现。Python 版改变了语法,执行、等待和回传的职责保持一致。