侧边栏壁纸
博主头像
一笑痕

仙人之下我无敌,
仙人之上一换一。

  • 累计撰写 52 篇文章
  • 累计收到 7 条评论

手写一个最小的 agent 循环,把工具调用跑通

2026-9-15 / 0 评论 / 17 阅读

手写一个最小的 agent 循环,把工具调用跑通

第一次接工具调用,栽在解析这一层。模型返回了一段带 ``json 围栏的文本,整段丢给JSON.parse,报Unexpected token,然后半个下午都在怀疑模型没按格式输出。后来翻实现才确认,所谓工具调用在多数框架里就是一段文本加一个解析器,厂商只是把解析器写稳了。索性把依赖删干净,用 node 写了个百来行的循环,把容易出错的地方挨个踩一遍。脚本在tools/verify-minimal-agent-loop.mjs`,引的输出都是本机真跑的;模型那部分拿数组写死,这样每次结果一致,边界一眼可见。

循环本体只有四步:把消息发出去,看输出里有没有工具调用;有就执行,结果追回上下文;没有就把输出当最终答案结束,外面兜一个步数上限。工具表是个普通对象,run 干的是真活,读本机文件、算数:

const TOOLS = {
  count_chars: {
    description: '统计一个本地文件有多少个字符',
    run: ({ path }) => {
      const text = readFileSync(path, 'utf8');
      return { path, chars: text.length, bytes: statSync(path).size };
    },
  },
  calc: {
    description: '计算一个四则运算表达式',
    run: ({ expr }) => {
      if (typeof expr !== 'string' || !/^[\d\s+\-*/().]+$/.test(expr)) {
        throw new Error(`表达式含非数字字符,拒绝执行: ${expr}`);
      }
      return Function(`"use strict";return (${expr})`)();
    },
  },
  boom: {
    description: '永远抛错的工具,用来观察错误怎么回灌',
    run: () => {
      throw new Error('磁盘满了(这是我故意抛的)');
    },
  },
};

description 在这里是摆设,因为驱动它的是写死的脚本。真接模型时这行字会进请求里的 tools 参数,模型靠它决定调哪个,见过把 description 写成「工具一」然后抱怨模型老调错的。

主循环没什么神秘:

function runAgent(scripted, { maxSteps = 6 } = {}) {
  const messages = [{ role: 'user', content: '帮我把这件事做了' }];
  let lastSig = null;
  let stop = 'max-steps';
  let answer = null;

  for (let step = 1; step <= maxSteps; step++) {
    const out = scripted(messages, step);   // 真实场景里这里是 fetch 一次模型
    const call = extractCall(out);

    if (!call) {
      answer = out.trim();
      stop = 'final';
      break;
    }
    const sig = JSON.stringify(call);
    if (sig === lastSig) {
      stop = 'loop-guard';
      break;
    }
    lastSig = sig;

    let result;
    try {
      const fn = TOOLS[call.tool]?.run;
      if (!fn) throw new Error(`没有名为 ${call.tool} 的工具`);
      result = { ok: true, value: fn(call.args ?? {}) };
    } catch (e) {
      result = { ok: false, error: e.message };
    }

    messages.push({ role: 'assistant', content: out });
    messages.push({ role: 'tool', name: call.tool, content: JSON.stringify(result) });
  }
  return { answer, stop, messages };
}

跑一次两步调用,真实输出是这样:

step1 调 count_chars({"path":"/Users/yxh/blog/写作规范.md"}) → {"path":"...","chars":2228,"bytes":4535}
      上下文累计 315 字符
step2 调 calc({"expr":"4535/2228"}) → 2.0354578096947935
      上下文累计 509 字符
step3 模型没给工具调用 → 当最终回答,循环结束
最终上下文 509 字符 / 5 条消息

每追一条消息,上下文涨两百来字符。这数字不吓人,问题是它只涨不降,工具返回的原始文本和模型的废话全堆在里面,现在的做法是工具返回前先把字段裁一遍,宁可让模型少看一点,也别让它在一堆用不上的字段里挑。

容易写错的地方集中在 extractCall。别用 re.search(r'{.*}'),也别用 indexOf('{')lastIndexOf('}'):模型一次吐两个调用时,前者把两段粘成一个对象直接报错,后者会把中间的话也包进去。这里用配平扫描,字符串内部的括号跳过。它本身只有十几行,麻烦的是想清楚要覆盖哪些边界,字符串里的括号算一类,参数里的转义引号算一类,连着写在一起的两个对象和被截断的半个对象也各算一类。

function extractCall(text) {
  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
  const candidate = (fenced ? fenced[1] : text).trim();
  const start = candidate.indexOf('{');
  if (start === -1) return null;
  let depth = 0, inStr = false, esc = false;
  for (let i = start; i < candidate.length; i++) {
    const ch = candidate[i];
    if (inStr) {
      if (esc) esc = false;
      else if (ch === '\\') esc = true;
      else if (ch === '"') inStr = false;
      continue;
    }
    if (ch === '"') inStr = true;
    else if (ch === '{') depth++;
    else if (ch === '}') {
      depth--;
      if (depth === 0) {
        try {
          const obj = JSON.parse(candidate.slice(start, i + 1));
          return obj && obj.tool ? obj : null;
        } catch {
          return null;
        }
      }
    }
  }
  return null;
}

拿八种输入压了一遍,实测结果:

裸 JSON             → {"tool":"calc","args":{"expr":"1+1"}}
包在 json 围栏里     → {"tool":"calc","args":{"expr":"1+1"}}
前面有客套话          → {"tool":"calc","args":{"expr":"1+1"}}
参数里有右花括号       → {"tool":"calc","args":{"expr":"{1+1}"}}
参数里有转义引号       → {"tool":"calc","args":{"expr":"1+1","note":"他说\"随便\""}}
两个 JSON 连着写      → {"tool":"calc","args":{"expr":"1"}}
JSON 被截断           → null(判定为最终回答)
只是普通文本          → null(判定为最终回答)

最后两行合起来的意思才是重点。被截断的 JSON 和纯文本都返回 null,循环会把一段残缺的括号直接当最终答案交给用户,不报错也不重试;后来改成让 extractCall 返回状态而不是 null,截断单独走一次重试。现在它返回三种状态,第三种才触发重试:拿到调用、判定为最终回答,或者解析不出来。重试走全新的一次请求,不复用原输出。还有一处:两个调用连着写的时候只有第一个生效,第二个被静默丢掉,模型同时要读文件和查时间就会这样,拿到手的结果看着正常,缺的那半没人会提。

工具抛错不往外扔,循环里用 try/catch 把异常包成 { ok: false, error: ... },当工具结果塞回上下文。真实输出:

step1 调 boom({}) → 失败: 磁盘满了(这是我故意抛的)
      上下文累计 188 字符
step2 调 calc({"expr":"1/0"}) → null
      上下文累计 354 字符

第一行是设计好的:异常没往外扔,模型看到报错原文,下一轮自己换了路子;异常直接抛穿循环的话,会话崩掉,攒的上下文一起丢,用户还得重问。第二行是个没预料到的坑。1/0 在 JS 里不抛错,结果是 Infinity,而 JSON.stringify 会把 Infinity 变成 null。模型收到的是 {"ok":true,"value":null},它分不清工具返回了 null、返回了 Infinity、还是字段压根不存在,这种静默失败比抛异常难查,因为一切看起来都正常。

顺手把工具结果本身的序列化情况也测了一遍,几个静默改值的坑都在这里:

JSON.stringify({ok:true,value:Infinity})  → {"ok":true,"value":null}
JSON.stringify({ok:true,value:NaN})       → {"ok":true,"value":null}
JSON.stringify({ok:true,value:undefined}) → {"ok":true}
JSON.stringify({ok:true,value:1n})        → TypeError: Do not know how to serialize a BigInt
JSON.stringify(循环引用的工具结果)         → TypeError: Converting circular structure to JSON

BigInt 和循环引用会让 JSON.stringify 直接抛错,也就是循环本身崩了;工具数据从数据库或者 ORM 里捞出来的时候,循环引用出现的概率不低。现在统一过一个自定义序列化:

const seen = new WeakSet();
const safe = v => JSON.stringify(v, (k, val) => {
  if (typeof val === 'bigint') return String(val);
  if (typeof val === 'number' && !Number.isFinite(val)) return String(val);
  if (val && typeof val === 'object') {
    if (seen.has(val)) return '[循环引用]';
    seen.add(val);
  }
  return val;
});

代价是模型得自己解析字符串和数字,而且 null 和字符串 "null" 成了两种东西,提示词里得写清楚。

死循环护栏加在步数上限之前,maxSteps 太晚,模型原地打转的时候第二步就该停。判断是这一步的调用签名和上一步完全相同:

step1 调 count_chars({"path":"/Users/yxh/blog/README.md"}) → {"path":"...","chars":2582,"bytes":4174}
      上下文累计 292 字符
step2 和上一步完全相同的调用 → 死循环护栏触发,循环结束

只比较相邻两步是有意的。一开始比对全部历史,结果模型在长任务里合法地回头再读同一个文件时被误杀;相邻比较漏掉 A-B-A-B 那种交替打转,那种情况归 maxSteps 管。两个方案都不完美,我选了误杀少的那个。

接商业模型就用官方 SDK 的结构化工具调用,那层解析由厂商负责,比自己拼字符串稳得多。自己手写的价值在两处:解析这一层在哪、会怎么坏,是自己掌着的;工具必须跑在别的环境里的时候(独立进程、别的语言写的服务),协议得自己定,SDK 管不到这一块。

交代一句,这台机器上没配任何模型的 API key,上面那个「模型」是用数组写死的脚本,它的输出不具备参考价值。解析边界和停止条件这些是真跑出来的,模型本身的行为没有实测。

    🤞 分享