昨天想写个能自己读本地代码的 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 循环
搞懂了模型只返回调用指令,那剩下的事就顺理成章了。我们要做的,就是写一个循环,帮模型把 "想法" 落地成 "动作":
- 把对话历史发给模型,拿到回复
- 看回复里有没有工具调用指令
- 有就挨个执行,把执行结果塞回对话历史
- 再把新的对话历史发给模型,重复上面的步骤
- 直到模型不再调用工具,给出最终回答
这个 "调用 - 执行 - 反馈" 的闭环,就是单 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 约束越严格,出错概率越低。第二,ToolMessage的tool_call_id必须和对应工具调用的 id 完全一致,不然模型会不认这个结果,大概率会重复调用。第三,每次调用完模型,一定要把返回的 AIMessage push 进 messages 数组,不然上下文丢了,模型根本不知道自己之前要干嘛。第四,工具执行一定要加 try/catch。文件不存在、路径错误、网络异常都很常见,直接抛错的话整个 Agent 就崩了。
:::warning千万不要不加校验就直接执行工具传过来的参数。比如文件路径如果不做限制,模型可能会被诱导去读系统敏感文件。生产环境一定要加白名单或者路径校验。:::
最后说两句
其实写下来你会发现,Agent 开发没那么多玄乎的东西。很多人一听到智能体就觉得很高深,不敢上手。其实从一个读文件工具、一个 while 循环开始,跑通第一次工具调用的那一刻,你就已经摸到 Agent 的门了。剩下的,无非是往里面加更多工具、补更多能力、优化规划逻辑而已。先跑起来,再慢慢优化,比对着概念文档啃三天管用多了。
你要是也在写 LangChain Agent,踩过什么奇葩的坑?评论区聊聊,我也长长见识。