欢迎光临
我们一直在努力

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

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

封面信息图

许多工程师在尝试构建轻量 Agent 时,第一反应往往是去安装 LangChain、LlamaIndex 或者 AutoGen。等把几百个依赖包拉下来之后,才发现调试一次简单的工具调用需要穿透五六层抽象类,控制台里堆满了看不懂的中间件日志,甚至连基本的请求参数修改都变得异常困难。

大模型所谓“工具调用”(Tool Calling / Function Calling)的底层逻辑极其简单,本质上就是一个基于消息数组的状态机循环。剥离掉所有花哨的框架包装,我们只需要几十行原生 TypeScript 代码,就能写出一个稳定可控的 Tool Runner。

工具调用的真实交互流程

在标准的 OpenAI 兼容协议中,工具调用的闭环只需四步:

  • 注册与传参:客户端将工具的名称、描述以及 JSON Schema 参数格式定义连同当前用户提示词一起发送给大模型。
  • 模型决策:大模型根据语义判断是否需要调用工具。如果需要,它在响应中返回 finish_reason: "tool_calls" 以及对应的工具名称和 JSON 参数字符串。
  • 本地执行:客户端拦截该响应,在本地环境中执行对应的实际逻辑(例如读取文件、查询数据库或发起 HTTP 请求),拿到执行结果。
  • 结果回传与递归:客户端将执行结果构造为一条 role: "tool" 的消息追加到当前会话数组末尾,再次发起模型请求。大模型结合工具返回的真实数据生成最终回复,或者继续发起下一次工具调用。
  • 纯 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,把系统的掌控权牢牢留在自己手中。

    赞(0)
    未经允许不得转载:171主机测评 » 最小工具循环实现:三十行代码写一个 Tool Runner
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址