AI Agent 开发 02|用一个离线例子看清执行循环

11 小时前
/ , ,
1
摘要
用无需密钥的 Node.js 离线例子跑通请求、执行与回传,并检查失败路径和轮数上限。

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 版改变了语法,执行、等待和回传的职责保持一致。

上一篇 · 系列目录 · 下一篇

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...