最小工具循环实现:三十行代码写一个 Tool Runner

许多工程师在尝试构建轻量 Agent 时,第一反应往往是去安装 LangChain、LlamaIndex 或者 AutoGen。等把几百个依赖包拉下来之后,才发现调试一次简单的工具调用需要穿透五六层抽象类,控制台里堆满了看不懂的中间件日志,甚至连基本的请求参数修改都变得异常困难。
大模型所谓“工具调用”(Tool Calling / Function Calling)的底层逻辑极其简单,本质上就是一个基于消息数组的状态机循环。剥离掉所有花哨的框架包装,我们只需要几十行原生 TypeScript 代码,就能写出一个稳定可控的 Tool Runner。
工具调用的真实交互流程
在标准的 OpenAI 兼容协议中,工具调用的闭环只需四步:
纯 TypeScript 实现的最小循环
下面是一个不依赖任何第三方 Agent 框架、仅需 30 余行核心代码的 Tool Runner 实现:
import OpenAI from "openai";
export interface ToolDefinition {
name: string;
description: string;
parameters: Record<string, unknown>;
handler: (args: Record<string, unknown>) => Promise<string> | string;
}
export async function runToolLoop(
client: OpenAI,
model: string,
messages: OpenAI.ChatCompletionMessageParam[],
tools: ToolDefinition[],
maxSteps = 5
): Promise<string> {
const toolMap = new Map(tools.map((t) => [t.name, t.handler]));
const openAiTools: OpenAI.ChatCompletionTool[] = tools.map((t) => ({
type: "function",
function: { name: t.name, description: t.description, parameters: t.parameters },
}));
const history = […messages];
for (let step = 0; step < maxSteps; step++) {
const response = await client.chat.completions.create({
model,
messages: history,
tools: openAiTools.length > 0 ? openAiTools : undefined,
});
const choice = response.choices[0];
const message = choice.message;
history.push(message);
// 如果模型没有触发工具调用,直接返回最终文本
if (!message.tool_calls || message.tool_calls.length === 0) {
return message.content || "";
}
// 顺序执行所有被触发的工具
for (const call of message.tool_calls) {
const handler = toolMap.get(call.function.name);
let resultText = "";
if (!handler) {
resultText = JSON.stringify({ error: `Tool ${call.function.name} not found.` });
} else {
try {
const args = JSON.parse(call.function.arguments || "{}");
resultText = await handler(args);
} catch (err) {
resultText = JSON.stringify({ error: `Execution failed: ${(err as Error).message}` });
}
}
history.push({
role: "tool",
tool_call_id: call.id,
content: resultText,
});
}
}
throw new Error(`Tool loop exceeded maximum steps (${maxSteps}).`);
}
关键细节与生产防御
虽然代码量很少,但在实际使用时,有三个关键防御机制必不可少:
死循环熔断机制(maxSteps):大模型偶尔会出现逻辑迷航,在多个工具之间反复互相调用,或者由于参数错误反复重试。设置一个硬性的最大执行步数(通常设为 5 到 10 步),能够彻底避免无限循环耗尽 API 额度。
异常作为结果回传:当本地工具执行抛出异常(例如数据库连接超时、文件不存在或 JSON 解析错误)时,千万不要直接 crash 整个主进程。将错误信息序列化为 JSON 字符串,以 role: "tool" 传回给大模型。大部分具备强推理能力的大模型在看到错误提示后,会自动调整参数或换一种方式重试,具备自然的自我修复能力。
历史消息完整性:OpenAI 协议对工具消息的上下文顺序有严格校验。一旦响应中包含了 tool_calls,紧随其后的历史消息必须包含对应的每个 tool_call_id 的结果消息。如果漏传或者顺序颠倒,接口会直接返回 400 校验错误。上面的实现通过严格的 push 顺序保证了上下文的契约完整性。
为什么我们应该推崇手写 Runner
对于 90% 的实际业务场景,我们需要的只是让大模型查几个接口、读两行日志或执行一段计算,根本用不到复杂的 Plan-and-Solve、多 Agent 辩论或者嵌套黑盒路由。
自己手写的 Tool Runner 具备绝对的透明度:
- 可以精确在每次调用前后插入日志打点与耗时监控;
- 可以随意定制单个工具的权限鉴权与并发执行逻辑;
- 没有任何抽象泄漏,报错堆栈清晰可见。
越是底层的核心调度逻辑,越应该保持极简与透明。与其花两周时间去学习复杂框架的各种包装抽象,不如花半小时写一个几十行的 Runner,把系统的掌控权牢牢留在自己手中。

