
第一次接工具调用,栽在解析这一层。模型返回了一段带 ``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,上面那个「模型」是用数组写死的脚本,它的输出不具备参考价值。解析边界和停止条件这些是真跑出来的,模型本身的行为没有实测。