手搓 LangChain 工具调用:原来 Agent 的核心逻辑,就是个 while 循环

昨天想写个能自己读本地代码的 AI 助手,用的 DeepSeek + LangChain,结果卡了快一小时。我心想不就是把读文件的工具注册给模型吗,调完 invoke 它自己就该去读文件然后给我解释啊。结果每次调用完,模型都规规矩矩返回一段话,说 "好的我这就去读文件",然后就没下文了,半字不提文件内容。当时我懵了,难道是我工具绑错了?还是 DeepSeek 不支持工具调用?

直到我把完整的 response 打印出来,才发现返回的 AIMessage 里藏了个tool_calls数组,里面清清楚楚写着要调用read_file,参数就是我传的文件路径。合着模型根本没执行工具,它只是 "决定了要调用这个工具",真正去读文件的代码,得我自己写。

先搞懂:bindTools 到底干了啥

说实话我之前一直对bindTools有误解,以为调用完这个方法,模型就自带了执行能力。后来翻了 LangChain 的源码片段才明白,这东西干的事特别简单:它把你定义的所有工具,包括名字、描述、参数格式,按照模型要求的协议拼好,在每次调用的时候一起发给大模型。说白了,就是跟模型说一声:"你现在有这些工具可以用,需要的话就按格式告诉我。"

它只负责 "通知",不负责 "执行"。模型收到之后,会根据用户的问题判断要不要用工具、用哪个、参数传啥,然后把结果放在tool_calls里返回给你。执行?那是你的事。

写工具定义的两个坑

我一开始写read_file工具的时候,踩了两个小坑,你们别踩。第一个是 schema 没写清楚。最开始我图省事只写了参数名,没加describe,结果模型偶尔会传一些奇奇怪怪的路径过来,比如带引号的,或者相对路径写错的。后来加上 zod 的字段描述,把参数讲明白,就稳多了。

第二个是 description 写得太笼统。一开始我就写了 "读取文件",结果模型有时候问个普通问题它也瞎调用工具。后来改成 "当用户要求读取文件、查看代码、分析文件内容时调用",就精准多了。

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

// 读文件工具
const readFileTool = tool(
    async({ filePath }) => {
        const content = await fs.readFile(filePath, 'utf-8');
        // 打个日志,不然跑半天没动静,用户以为卡了
        console.log(`[工具调用] read_file(${filePath}) 成功读取 ${content.length} 字节`)
        return content;
    },
    {
        name: 'read_file',
        description: `用此工具来读取文件内容,当用户要求读取文件、查看代码、分析文件内容时,调用此工具。输入文件路径(可以是相对路径或绝对路径)`,
        schema: z.object({
            filePath: z.string().describe('要读取的文件路径')
        })
    }
)

工具的 description 是写给模型看的,越具体越好。你得用模型能听懂的话,告诉它什么时候该用、不该用,这比你在 SystemMessage 里反复强调管用。

除了工具定义,System Prompt 也得跟上。我最开始就写了一句 "你是代码助手,可以使用工具",结果模型经常偷懒,让我自己把代码贴出来。后来我把工作流程一条条写清楚,明确要求它必须调用工具,就正常多了。不是模型不听话,是你没把规矩给它讲明白。

整个 Agent 的心脏:就是这个 while 循环

搞懂了模型只返回调用指令,那剩下的事就顺理成章了。我们要做的,就是写一个循环,帮模型把 "想法" 落地成 "动作":

  1. 把对话历史发给模型,拿到回复
  2. 看回复里有没有工具调用指令
  3. 有就挨个执行,把执行结果塞回对话历史
  4. 再把新的对话历史发给模型,重复上面的步骤
  5. 直到模型不再调用工具,给出最终回答

这个 "调用 - 执行 - 反馈" 的闭环,就是单 Agent 最核心的执行逻辑。

我一开始写的是 for 循环,每次只执行一个工具调用,后来发现模型可能一次返回好几个工具调用,就改成了Promise.all并行跑,效率高不少。

javascript 复制代码
    import 'dotenv/config';
    import { ChatOpenAI } from '@langchain/openai';
    import { 
        HumanMessage,
        SystemMessage,
        ToolMessage,
        AIMessage 
    } from '@langchain/core/messages';

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

    // 注册工具
    const tools = [readFileTool];
    const modelWithTools = model.bindTools(tools);

    const messages = [
        new SystemMessage(`
            你是一个代码助手,可以使用工具读取文件并解释代码。
            工作流程:
            1. 用户要求读取文件时,立即调用read_file工具。
            2. 等待工具返回文件内容。
            3. 基于文件内容进行分析和解释。
        `),
        new HumanMessage('请读取 src/tool.mjs 文件内容并解释代码'),
    ];

    // 第一次调用模型
    let response = await modelWithTools.invoke(messages);
    messages.push(response);

    // 核心循环:只要还有工具调用,就一直执行
    while(response.tool_calls && response.tool_calls.length > 0) {
        console.log(`\n[检测到 ${response.tool_calls.length} 个工具调用]`);
        
        // 并行执行所有工具调用
        const toolResults = await Promise.all(
            response.tool_calls.map(async (toolCall) => {
                // 找到对应的工具
                const tool = tools.find(t => t.name === toolCall.name);
                if (!tool) {
                    return `错误:找不到工具 ${toolCall.name}`
                }
                console.log(`[执行工具] ${toolCall.name}(${JSON.stringify(toolCall.args)})`);
                
                // 容错处理,工具报错不能让整个Agent崩了
                try {
                    return await tool.invoke(toolCall.args);
                } catch(err) {
                    return `错误:${err.message}`
                }
            })
        );

        // 把工具执行结果包装成 ToolMessage 塞回对话
        response.tool_calls.forEach((toolCall, index) => {
            messages.push(
                new ToolMessage({
                    content: toolResults[index],
                    tool_call_id: toolCall.id // 注意这行!id 一定要对上,坑了我半小时
                })
            )
        });

        // 把结果还给模型,继续思考
        response = await modelWithTools.invoke(messages);
        messages.push(response);
    }

    // 到这里就是最终回答了
    console.log(response.content);

我踩过的死循环坑

这里有个巨容易踩的坑,我踩了两次。第一次是忘了把 response push 进 messages 数组。结果每次循环都用最开始的消息列表调用模型,模型每次都返回同样的工具调用,死循环了。第二次是ToolMessage里的tool_call_id写错了,没对应上。模型收到结果之后不认,觉得自己还没调用过工具,于是又调用一遍,又死循环了。别问我为什么知道,试了三次才反应过来。

跑通之后控制台的输出长这样,一步步看着特别有成就感:

跑通那一刻,我突然懂了 Agent 是什么

之前看各种文章,Agent、智能体、MCP、RAG 一堆概念堆上来,总觉得特别高大上,遥不可及。等自己手搓完这个最简单的工具调用循环,突然就通透了。

什么是 Agent?就是给原本只会聊天的大模型,装上各种 "外设":

  • 它记不住东西?给它加个 Memory 模块,把历史对话存起来,每次调用都带上
  • 它碰不到本地文件、访问不了网络?给它加 Tool 工具调用,让它能指挥我们干活
  • 它不知道内部文档、最新消息?给它加个 RAG 工具,需要的时候去知识库查
  • 它干复杂活容易乱?给它加规划能力,拆成一步步来

说白了,Agent = 会思考的大模型 + 一堆能落地的工具 + 一个负责执行闭环的循环。模型负责动脑,循环负责动手,两者配合起来,就能干很多单靠聊天干不成的事。

你看现在市面上那些代码助手、自动化 Agent,本质上也就是这套逻辑,只不过工具更多、记忆更强、规划更复杂而已。核心的骨架,还是这个 "调用 - 执行 - 反馈" 的循环。

从单工具到完整 Agent,还差啥

现在我们这个 demo 只有一个读文件的工具,算是最简陋的 Agent 原型。如果想让它更实用,还可以往里面加东西。比如加个写文件的工具,它就能边读边改代码了;加个终端执行工具,它就能自己跑命令、改 bug;加个搜索工具,它就能边查资料边写东西。

再往上走,加上记忆模块,它就能记住你上次改了什么;加上 RAG,它就能按照你们团队的代码规范来写;用上 LangGraph 做多智能体调度,就能让几个 Agent 分工干不同的活。但不管加多少东西,最底层的逻辑还是没变:模型做决策,我们执行,结果喂回去,循环往复。

几个踩过的坑,给你们避避

写这个 demo 的一下午,踩了不少坑,挑几个最容易犯的说。第一,不要迷信模型一定会按你的要求调用工具。工具的 description 写得越清晰,schema 约束越严格,出错概率越低。第二,ToolMessagetool_call_id必须和对应工具调用的 id 完全一致,不然模型会不认这个结果,大概率会重复调用。第三,每次调用完模型,一定要把返回的 AIMessage push 进 messages 数组,不然上下文丢了,模型根本不知道自己之前要干嘛。第四,工具执行一定要加 try/catch。文件不存在、路径错误、网络异常都很常见,直接抛错的话整个 Agent 就崩了。

:::warning千万不要不加校验就直接执行工具传过来的参数。比如文件路径如果不做限制,模型可能会被诱导去读系统敏感文件。生产环境一定要加白名单或者路径校验。:::

最后说两句

其实写下来你会发现,Agent 开发没那么多玄乎的东西。很多人一听到智能体就觉得很高深,不敢上手。其实从一个读文件工具、一个 while 循环开始,跑通第一次工具调用的那一刻,你就已经摸到 Agent 的门了。剩下的,无非是往里面加更多工具、补更多能力、优化规划逻辑而已。先跑起来,再慢慢优化,比对着概念文档啃三天管用多了。

你要是也在写 LangChain Agent,踩过什么奇葩的坑?评论区聊聊,我也长长见识。

相关推荐
Drgfd1 小时前
机器人从工厂走进生活:通用机器人开启大众商用时代
人工智能
硕迪科技SOLIDWORKS1 小时前
SOLIDWORKS与ERP对接不再是研发孤岛:硕迪科技数据同步方案让BOM流转提速百分之三百
人工智能·科技·solidworks与erp
RestCloud1 小时前
借助ETL工具,实现AI智能体+数据的落地
数据仓库·人工智能·sql·etl·etlcloud·数据集成平台·java脚本
AIGS0011 小时前
企业AI落地的关键认知:向量空间JBoltAI的本体语义平台
java·人工智能·人工智能ai大模型应用
大龄牛码1 小时前
GPT-5.6 模型家族:分层矩阵与多智能体时代的全面开启
人工智能·gpt
记忆停留w2 小时前
从单体到微服务:Redis 协同 MySQL、Milvus、MinIO 搭建企业级RAG/AI Agent脚手架架构
大数据·人工智能·redis·微服务·ai·架构·milvus
承受失2 小时前
Cloud Agent 开发笔记(4):Skill 与 MCP 集成、项目后记
人工智能·prompt
进击的横打2 小时前
【人工智能】AI基础知识(第七节:RAG)
人工智能
KaMeidebaby2 小时前
卡梅德生物技术快报|抗体亲和力成熟工业化调控新机制:差异性浆细胞增殖工艺优化思路
java·开发语言·人工智能·算法·机器学习·架构·spark