Skip to content
从一次性脚本到可复用引擎
在上一篇《在 Node.js 中实现第一个 Tool-Calling Agent》中,我们用 OpenAI SDK 实现了一个加法工具调用闭环。那段代码的核心结构大致如下:
javascript
// 简化示意:硬编码工具处理的循环
const tools = [addToolSchema];
let messages = [...];
const response = await client.chat.completions.create({ model, messages, tools });
const toolCalls = response.choices[0].message.tool_calls;
if (toolCalls) {
for (const tc of toolCalls) {
if (tc.function.name === 'add') {
const [a, b] = JSON.parse(tc.function.arguments).args;
const result = a + b;
messages.push({ role: 'tool', tool_call_id: tc.id, content: String(result) });
}
}
const final = await client.chat.completions.create({ model, messages });
console.log(final.choices[0].message.content);
}这段代码可以跑,但两个问题很快就暴露出来:
- 工具与循环强耦合。
if (tc.function.name === 'add')这行把工具名称和具体执行逻辑绑定在循环里。每加一个工具都要修改主循环,工具数量一多,if / else if链条就会变得难以维护。 - 没有重用可能。每次写新 Agent 都要把整个循环重新复制一份,再硬编码自己的工具。正确的工程思路应该是模块化,通过导出与加载复用代码,而不是粘贴一大堆重复逻辑。
在实际运行中,更麻烦的情况是工具执行失败时,模型可能会在错误的观测结果上反复调用,或者陷入循环出不来。如果连读取文件这种简单操作都会触发死循环,就需要循环本身有明确的退出机制,而不是指望模型自己"想明白"。
要解决这些问题,需要把三个关注点拆开:工具的定义与执行、模型调度与结果回填的循环逻辑、终止条件。三者之间通过稳定的接口沟通,这样新增工具只影响定义部分,循环主体可以一次编写、多次复用。
工具注册表
工具注册表(Tool Registry)的职责很简单:存下每个工具的 JSON Schema 和执行函数,通过工具名称快速查找。注册表本身不关心循环怎么跑,也不关心模型怎么选的工具。
注册表结构
一个最简实现就是 Map<string, { schema: Tool, execute: Function }>。其中 Tool 类型来自 OpenAI 的要求:
type: 'function'function: { name: string, description: string, parameters: object }
执行函数接收解析后的参数对象,返回工具的结果(字符串或可以序列化的值)。
javascript
class ToolRegistry {
constructor() {
this._tools = new Map();
}
register(schema, execute) {
const name = schema.function.name;
this._tools.set(name, { schema, execute });
}
getSchemaList() {
return Array.from(this._tools.values()).map(t => t.schema);
}
getTool(name) {
return this._tools.get(name);
}
}register 把 Schema 和函数绑定在一起。getSchemaList 提供给 OpenAI 的 tools 参数。getTool 在循环中按模型返回的工具名称查询执行函数。
注册与获取工具
向注册表添加工具时,只需传入 Schema 和执行函数。例如加法工具:
javascript
const addSchema = {
type: 'function',
function: {
name: 'add',
description: '求两数之和',
parameters: {
type: 'object',
properties: {
args: {
type: 'array',
items: { type: 'number' },
minItems: 2,
maxItems: 2
}
},
required: ['args']
}
}
};
function addExecute({ args }) {
return args[0] + args[1];
}
registry.register(addSchema, addExecute);后面要加一个新工具(比如 multiply),方法完全相同,只多调用一次 register。循环代码完全不需要感知具体工具。这种设计在 hello-agents 项目中被提炼成 ToolRegistry 组件,负责工具注册与调用分发,新增工具无需修改循环。
封装执行循环
有了注册表,接下来把模型调用、工具调度和结果回填封装成一个可复用的异步函数。
函数签名
runAgent 的三个核心入参是:工具注册表、已初始化的 OpenAI 客户端、初始消息列表(通常以 system 和 user 消息打头)。这样依赖的是注入,函数内部不创建客户端,也不预设任何工具。
typescript
async function runAgent(
registry: ToolRegistry,
client: OpenAI,
messages: ChatCompletionMessageParam[],
options?: { maxIterations?: number }
): Promise<ChatCompletionMessageParam[]>返回最终的消息列表,调用方可以从最后一条 assistant 消息中读取最终回复。
主循环
循环体遵循 ReAct 模式:模型生成动作 → 应用执行工具 → 结果回填 → 再次调用模型。核心 API 行为参考 OpenAI 的 Function Calling 文档:模型返回的 tool_calls 包含 id、function.name、function.arguments(JSON 字符串);执行工具后,将 role: 'tool' 的消息追加到对话中,再次请求模型。
实现如下:
javascript
async function runAgent(registry, client, messages, { maxIterations = 10 } = {}) {
let iteration = 0;
while (true) {
iteration++;
const response = await client.chat.completions.create({
model: 'gpt-4o',
messages,
tools: registry.getSchemaList(),
tool_choice: 'auto'
});
const msg = response.choices[0].message;
// 自然终止:模型不再要求调用工具
if (!msg.tool_calls) {
return messages;
}
// 工具调用调度
for (const tc of msg.tool_calls) {
const tool = registry.getTool(tc.function.name);
if (!tool) {
throw new Error(`Unknown tool: ${tc.function.name}`);
}
const args = JSON.parse(tc.function.arguments);
try {
const result = await tool.execute(args);
messages.push({
role: 'tool',
tool_call_id: tc.id,
content: String(result)
});
} catch (err) {
messages.push({
role: 'tool',
tool_call_id: tc.id,
content: `Error: ${err.message}`
});
}
}
}
}注意几个细节:
tools参数直接从注册表获取 Schema 列表,每次调用都传递相同的工具集合。- 模型可能一次返回多个
tool_calls,这些调用可以顺序执行。并行执行留待后续讨论。 - 工具执行的结果以字符串形式塞进
role: 'tool'消息里,模型在下一次调用时会看到这些结果。 - 如果工具执行抛出异常,将错误信息作为工具结果返回,而不是中止整个循环。这样模型可以尝试调整参数或选择其他工具。
终止条件
上面的循环依赖 while(true),需要明确的退出点。
自然终止
当 response.choices[0].finish_reason === 'stop' 并且 msg.tool_calls 为空时,说明模型认为任务已完成,可以退出循环。上面已经通过 if (!msg.tool_calls) return messages; 实现。
强制终止
模型可能在任务未完成时重复请求工具,也可能因为误差多次重试失败后"放弃"但仍然返回 tool_calls。必须设置最大迭代次数兜底。
javascript
if (iteration > maxIterations) {
throw new Error(`Agent loop exceeded max iterations (${maxIterations})`);
}还有一种策略:允许工具返回一个特殊信号(例如 { __stop: true, result: ... }),注册表或循环识别该信号后立即终止。实现方式可以在工具执行函数中返回特定结构,然后在循环里检查:
javascript
if (typeof result === 'object' && result.__stop) {
messages.push({ role: 'tool', content: String(result.result) });
return messages;
}这适用于需要在某一轮工具执行后直接结束的场景(比如一个"最终回答"工具)。
重构示例
现在我们用 runAgent 重写上一章的加法 Agent。差别主要在:不再有硬编码的 if (tc.function.name === 'add'),而是一次性注册工具,然后调用循环函数。
javascript
const registry = new ToolRegistry();
registry.register(addSchema, addExecute);
const messages = [
{ role: 'system', content: '你是一个计算助手,使用工具进行计算。' },
{ role: 'user', content: '计算 2 加 3。' }
];
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const finalMessages = await runAgent(registry, client, messages);
const finalReply = finalMessages[finalMessages.length - 1].content;
console.log(finalReply);和原来的硬编码版本相比,循环逻辑被彻底抽离出来,脚本只关心配置系统提示、用户问题和工具集合。
验证循环复用性
现在向 ToolRegistry 增加一个乘法工具,测试循环的复用性。
javascript
const multiplySchema = {
type: 'function',
function: {
name: 'multiply',
description: '求两数之积',
parameters: {
type: 'object',
properties: {
args: {
type: 'array',
items: { type: 'number' },
minItems: 2,
maxItems: 2
}
},
required: ['args']
}
}
};
function multiplyExecute({ args }) {
return args[0] * args[1];
}
registry.register(multiplySchema, multiplyExecute);
// 再次调用 runAgent,这次问一个乘法问题
messages.push({ role: 'user', content: '计算 7 × 8。' });
const final2 = await runAgent(registry, client, messages);
console.log(final2[final2.length - 1].content);runAgent 的实现一行都没改。模型会自动判断这次该用 multiply,注册表找到对应的执行函数并返回结果。
错误处理与异常恢复
工具执行可能因为参数解析失败、外部服务异常或其他原因报错。如果直接让异常中断循环,Agent 就无法自行恢复。较好的方式是在循环内捕获异常,将其作为工具结果回传给模型,让模型根据错误信息决定下一步动作。
上面 runAgent 的实现已经包含了基础的 try / catch,它将错误消息填入 content。这样模型会看到类似 Error: Cannot read property 'foo' of undefined 的信息,可能会自行修正参数后再次调用,或者坦承无法完成。
除此之外,还应该考虑:
- 模型 API 调用失败。网络抖动或配额耗尽时
chat.completions.create也会抛出异常。是否重试、重试几次,可以交给外部调用方决定,也可以在runAgent内部简单包裹一层重试逻辑。 - 工具未注册。如果模型返回的
function.name在注册表中找不到,这是一个程序错误,应当快速失败(抛错)。此处不适合静默跳过。 - 记录执行轨迹(Trace)。当循环行为不符合预期时,仅打印
[Step 3] Action: Edit、[Step 3] Result: tool failed这类日志无法还原发生了什么。应当记录每一轮的模型输入、输出、工具调用参数、工具返回值和 token 用量,帮助事后排查。简单的实现可以在循环中构造一个trace数组,记录每轮的摘要信息。
