Function Calling 与 Agent 循环 —— bindTools 不会替你执行工具

核心结论:bindTools 只是把工具"注册"给模型,真正执行工具的循环要自己写。

我们要做什么

让 AI 做一件它本身做不到的事------读本地文件。整个流程设计起来很简单:

  1. 用户提问:"请读取 tool.mjs 并解释代码"
  2. 模型判断自己需要读文件 → 发起 内部工具 工具调用请求
  3. 我们的代码真正执行读取,把文件内容回传给模型
  4. 模型拿到内容 → 输出代码解释

第 3 步是关键:模型只能"请求"调用工具,执行者始终是我们的程序。

定义工具:tool() + zod 三要素

先来看工具的定义代码:

javascript 复制代码
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import fs from 'node:fs/promises';

const readFileTool = tool(
    // ① 执行函数:真正干活的地方
    async ({ filePath }) => {
        try {
            const content = await fs.readFile(filePath, 'utf-8');
            console.log(`[工具调用] 内部工具(${filePath}) 成功读取 ${content.length} 字节`);
            return content;
        } catch (err) {
            console.log(`[工具调用] 内部工具(${filePath}) 失败:${err.message}`);
            return `读取文件失败:${err.message}`;
        }
    },
    {
        // ② 名称与描述:模型靠 description 判断"什么时候用、怎么用"
        name: '内部工具',
        description: '用此工具来读取文件内容,当用户要求读取文件。查看代码,分析文件内容时调用此工具。输入文件路径(可以是相对路径或者是绝对路径)',
        // ③ 参数 schema:约束参数结构,同时给模型结构化的参数说明
        schema: z.object({
            filePath: z.string().describe('要读取的文件路径'),
        }),
    }
);

这里三个要素各司其职:

  • 执行函数:真正的逻辑,返回值会成为回传给模型的内容
  • description:模型决策依据,写得越明确,调用时机越准
  • schema(zod) :参数类型约束 + 给模型的结构化提示,.describe() 会进入模型的参数说明

另外有个刻意设计:try/catch 返回错误字符串。工具失败时,模型能"看到"失败原因并自行调整(比如换一个路径重试),程序则不会崩溃。把错误也当作工具的一种返回值

绑定模型

定义好工具后,把它绑到模型上:

php 复制代码
const model = new ChatOpenAI({
    model: 'deepseek-v4-flash',
    apiKey: process.env.DEEPSEEK_API_KEY,
    temperature: 0,
    configuration: { baseURL: 'https://api.deepseek.com/v1' },
});

const modelWithTools = model.bindTools([readFileTool]);

第一版代码的问题:工具从来没被执行

很自然的写法:

ini 复制代码
let response = await modelWithTools.invoke(messages);
console.log(JSON.stringify(response.content));

运行输出:

arduino 复制代码
"我来读取 tool.js 文件的内容。"

看似正常,实际文件根本没被读过。做一个诊断实验,打印完整的响应结构:

javascript 复制代码
console.log('content:', JSON.stringify(r.content));
console.log('tool_calls:', JSON.stringify(r.tool_calls));

输出:

css 复制代码
content: ""
tool_calls: [{"name":"内部工具","args":{"filePath":"tool.js"},"type":"tool_call","id":"call_00_xxx"}]

真相大白:模型返回的是一次工具调用请求tool_calls 数组),不是最终答案。第一版代码拿到请求后什么也没做,直接打印了 content 就结束。系统提示里写的"工作流程第 2、3 步(等待工具返回、基于内容分析)"在代码层面从未发生。

记住这个模型:bindTools 相当于把武器说明书递给模型,模型会告诉你"我要开火了",但扣扳机的必须是你。

补上 Agent 循环

完整的代码长这样:

ini 复制代码
const messages = [
    new SystemMessage(`你是一个专业的代码助手,可以使用工具读取文件并解释代码。
        工作流程:
        1.用户要求读取文件时立即调用内部工具 工具。
        2.等待工具返回文件内容。
        3.基于文件内容进行分析和解释。

        可用工具:
        - 内部工具 : 读取文件内容(使用此工具来获取文件内容)
    `),
    new HumanMessage(`请读取文件 tool.mjs 文件内容并解释代码`),
];

let response = await modelWithTools.invoke(messages);

// agent 循环:模型发起工具调用 → 真正执行工具 → 把结果回传给模型 → 直到模型给出最终回答
const maxRounds = 5; // 防止无限循环
for (let round = 0; round < maxRounds && response.tool_calls?.length; round++) {
    messages.push(response); // 把模型的工具调用请求(AIMessage)加入对话
    for (const call of response.tool_calls) {
        const result = await readFileTool.invoke(call.args); // 真正执行工具
        messages.push(new ToolMessage({
            content: result,
            tool_call_id: call.id, // 告诉模型这条结果对应哪次调用
        }));
    }
    response = await modelWithTools.invoke(messages); // 带着工具结果再次询问模型
}
console.log(response.content);

循环里的四个关键动作:

  1. messages.push(response) ------ 把含 tool_calls 的 AIMessage 放回对话历史。OpenAI 兼容接口要求:有 tool_call 就必须有对应的 tool 结果,顺序不能乱。
  2. readFileTool.invoke(call.args) ------ 真正执行工具,拿到文件内容。
  3. new ToolMessage({ content, tool_call_id }) ------ 用 tool_call_id 把结果和请求一一对应。多工具、多轮调用时全靠这个 id 对号入座。
  4. 再次 invoke ------ 模型看到工具结果后继续思考:可能给出最终回答,也可能再发起下一次工具调用。

循环退出条件有两个:模型不再返回 tool_calls(给出最终回答),或达到 maxRounds 上限。上限必须有------否则模型若陷入"反复要求调工具"的死循环,程序和账单都会失控。

运行结果

ini 复制代码
[工具调用] 内部工具(tool.mjs) 成功读取 2397 字节
# tool.mjs 代码解释
这是一个基于 LangChain 构建的 AI Agent(智能体)示例......

这次 [工具调用] 日志出现了,文件真的被读了(2397 字节),模型也基于真实内容给出了逐段代码解析。

顺手修掉的两个小坑

  1. 提示词里的文件名写错 :让模型读 tool.js,实际文件是 tool.mjs。就算循环写对了,工具执行也会 ENOENT。提示词里的每个细节都会成为模型的"事实"。
  2. 工具函数没有错误处理fs.readFile 失败会直接抛异常。改成 try/catch 返回错误字符串后,模型能感知失败并作出反应。

小结

一个最小的 Function Calling Agent = 工具定义(执行函数 + description + zod schema)+ bindTools + 手写执行循环

理解的关键在于角色分工:模型负责"决策"(要不要调工具、传什么参数),你的代码负责"执行"(跑工具、回传结果)。所谓 Agent,本质就是这样一个"模型决策 + 代码执行"的循环,市面上的 Agent 框架(LangGraph、createReactAgent 等)封装的都是这个循环。


相关推荐
云烟成雨TD1 小时前
LlamaIndex 系列【29】检索增强策略:路由(Routing)机制
ai·agent·rag·llamaindex
初学AI的小高1 小时前
从LangGraph到DeepAgents:理解AgentHarness
后端·agent
KimLiu1 小时前
LCODER之AI Agent开发实战一 :问数项目智能体搭建(3)元数据知识库的构建
langchain·llm·agent
wangjialelele2 小时前
LLM Agent 全景图:MCP、ReAct、Planner、Skill 与 ANN 检索核心原理
ai·agent·hnsw·skill·ivf·mcp
Together_CZ2 小时前
在线蒸馏(OPD)、递归自我改进(RSI)与递归自我学习(RSL)整体学习理解
llm·agent·opd·rsi·在线蒸馏·rsl·递归自我学习
七夜zippoe2 小时前
Agent 输出质量保障:格式控制、校验机制与自动重试策略
ai·agent·自动重试·质量保障·格式控制·校验机制
烛之武2 小时前
LangChain笔记
langchain·大模型·agent·mcp
爱上纯净的蓝天2 小时前
自包含推理一体机四层架构拆解:128G 统一内存、256K 上下文与实测口径
人工智能·大模型·私有化部署·agent·大模型部署
一心同学2 小时前
Hermes架构拆解之Agent Loop
人工智能·agent·loop·hermes