手写一个 AI 编程助手:Tool Calling 与 ReAct Agent 全解
引言
Agent 是什么------它是 LLM + Memory + Tool + RAG + MCP + Skills 的复合体。这篇文章我们下沉一层,聚焦 Agent 最核心的能力------Tool Calling(工具调用),并动手实现一个简化版的 AI 编程助手。
读完本文你将理解:
- LLM 为什么需要 Tool,以及 Tool 调用的完整通信协议
- ReAct(Reasoning + Acting)Agent 的工作循环
- 四种 Message 在对话上下文中各司什么职
- 如何使用 LangChain 将上述概念变成可运行的代码
- Promise / async 在 Agent 开发中的最佳实践
一、Tool:让大模型从"说"到"做"
1.1 大模型的天然边界
LLM 本质上是一个无状态的概率模型。它有两重根本限制:
| 限制 | 说明 | 后果 |
|---|---|---|
| 无状态(Stateless) | 每次调用都是全新的,不记得上一轮聊了什么 | 无法维护上下文,无法"记住" |
| 无行动能力 | 只能输出文本,不能操作文件、执行命令、访问网络 | 只能"说思路",不能"动手做" |
这就是为什么要给 LLM 配上 Tool。
1.2 一个直观的例子
假设你对 AI 编程助手说:"创建一个 React + Vite 的 TodoList"
如果直接调 LLM API,它只能吐出一段代码文本。但一个真正的编程助手需要:
- 写入文件 Tool --- 把生成的代码写入
App.jsx、main.jsx等文件 - 执行命令 Tool(CLI) --- 运行
npm create vite@latest、npm install、npm run dev
这就是 Tool Calling 的本质:让 LLM 不再只输出文本,而是输出"行动指令",由后端执行后,将结果反馈给 LLM 继续推理。
1.3 简化版 Claude Code 的架构
Claude Code、Codex 这类 AI 编程助手的核心架构其实出奇地简单:
ini
Claude Code = LLM + Tool(fs 文件系统 + CLI 命令行)
就这么简单。LLM 负责"想"(规划代码结构),文件系统 Tool 负责"写"(生成文件),CLI Tool 负责"跑"(执行命令)。三者协作,完成从需求到运行的全流程。
二、Message:LLM 与 Tool 之间的通信协议
Tool Calling 不是魔法,它依赖一套精确定义的消息协议。理解四种 Message,是理解 Agent 工作原理的关键。
2.1 四种 Message 类型
| 类型 | 角色 | 谁产生的 | 用途 |
|---|---|---|---|
| SystemMessage | system |
开发者 | 定义 AI 是谁、能干什么、行为规范 |
| HumanMessage | user |
用户 | 用户的自然语言问题 |
| AIMessage | assistant |
LLM | AI 的回复,也可能包含 tool_calls |
| ToolMessage | tool |
工具执行器 | 工具调用的返回结果,通过 tool_call_id 关联 |
2.2 消息流动的全过程
css
┌──────────────────────────────────────────────────────────────────┐
│ messages 数组(对话上下文) │
├──────────────────────────────────────────────────────────────────┤
│ [0] SystemMessage │ "你是代码助手,可用 read_file、write_file" │
│ [1] HumanMessage │ "帮我读一下 app.js 并解释" │
│ [2] AIMessage │ tool_calls: [{ name: "read_file", ... }] │
│ [3] ToolMessage │ tool_call_id: "xxx", content: "文件内容..." │
│ [4] AIMessage │ "这个文件实现了一个计数器..." │
└──────────────────────────────────────────────────────────────────┘
关键细节 :每条消息按时间顺序追加到数组中,这个不断增长的数组就是 Agent 的"记忆"。LLM 本身是无状态的,但通过逐轮把完整历史发回去,模拟出了"有记忆"的效果。
2.3 Tool Call 的底层结构
当 LLM 决定调用工具时,它不生成文本,而是输出结构化数据。以 OpenAI 协议为例:
json
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\"filePath\": \"./app.js\"}"
}
}
]
}
三个字段各有用处:
id:唯一标识,用于将工具执行结果关联回来(填入 ToolMessage 的tool_call_id)name:工具名称,后端据此查找对应的工具函数arguments:JSON 字符串,LLM 根据工具 schema 生成的参数
原生 OpenAI SDK 返回时,tool_calls 放在 additional_kwargs 里,结构较深。LangChain 将其提升为 AIMessage.tool_calls 直接属性,同时保留原始数据------既提升了可读性,又不丢失信息。
三、LangChain:LLM 工程的"统一接口层"
3.1 为什么需要 LangChain?
LLM 领域有太多模型厂商(OpenAI、DeepSeek、通义千问、Moonshot......),每家 API 格式略有差异。LangChain 的诞生甚至早于 OpenAI 的 transformers 和 generative 库------它的初心就是提供一套统一接口,屏蔽底层差异。
bash
你的代码
│
▼
LangChain 抽象层(ChatOpenAI、bindTools、invoke)
│
├──► DeepSeek API(baseURL: https://api.deepseek.com/v1)
├──► OpenAI API(baseURL: https://api.openai.com/v1)
├──► Moonshot API(baseURL: https://api.moonshot.cn/v1)
└──► 本地 vLLM(baseURL: http://localhost:8000/v1)
只需改一行 baseURL + modelName,代码完全不用动。
3.2 核心工作流
LangChain 的 Tool Calling 工作流可以概括为一条链:
javascript
// 1. 创建模型实例
const model = new ChatOpenAI({
modelName: 'deepseek-v4-flash',
apiKey: process.env.DEEPSEEK_API_KEY,
temperature: 0, // Agent 场景必须设为 0 ------ 工具调用决策不能有随机性
configuration: {
baseURL: 'https://api.deepseek.com/v1',
}
})
// 2. 定义工具(async 函数 + Zod schema)
const readFileTool = tool(
async ({ filePath }) => {
const content = await fs.readFile(filePath, 'utf-8')
return content
},
{
name: 'read_file',
description: '用此工具来读取文件内容,传入文件路径',
schema: z.object({
filePath: z.string().describe('要读取的文件路径')
})
}
)
// 3. 绑定工具到模型
const tools = [readFileTool]
const modelWithTools = model.bindTools(tools)
// 4. 调用 ------ LLM 自动判断是否需要工具
const response = await modelWithTools.invoke(messages)
tool() 的设计哲学 :将"做什么"(函数体,给机器)与"如何描述"(元数据,给 LLM)彻底分离。LLM 不需要知道 fs.readFile 怎么工作的,只需要知道"有个工具叫 read_file,参数是 filePath,用来读文件"。
3.3 LangChain 对原生返回的处理
对比原生 OpenAI 返回和 LangChain 处理后的区别:
| 层面 | tool_calls 位置 | 开发者体验 |
|---|---|---|
| 原生 OpenAI | response.choices[0].message.tool_calls |
嵌套深,需要手动解析 |
| LangChain | response.tool_calls |
直接访问,类型完备 |
LangChain 的 invoke() 在返回 AIMessage 时,已经帮你解析好了 tool_calls 数组,同时保留了原始 additional_kwargs 以备不时之需。这种"既简化又保留原始数据"的设计,对工程开发的便捷性和可读性都有很大帮助。
四、ReAct Agent:Reason → Act → Observe 的循环
4.1 什么是 ReAct?
ReAct(Reasoning + Acting)是 Agent 最经典的工作模式。它不像传统的"一次性问答",而是让 LLM 在推理---行动---观察的循环中逐步逼近目标。
┌──────────────────────────────────────────────┐
│ ReAct 循环 │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ REASON │────►│ ACT │ │
│ │ 推理 │ │ 执行工具 │ │
│ └──────────┘ └────┬─────┘ │
│ ▲ │ │
│ │ ▼ │
│ │ ┌──────────┐ │
│ └────────│ OBSERVE │ │
│ │ 观察结果 │ │
│ └──────────┘ │
└──────────────────────────────────────────────┘
每一步的含义:
- Reason(推理):LLM 分析当前状态,决定下一步做什么。如果有可用工具且需要调用工具 → 生成 tool_calls。如果信息已足够 → 生成最终答案。
- Act(行动):后端执行 LLM 指定的工具,获取结果。
- Observe(观察):工具结果以 ToolMessage 形式注入对话上下文,LLM 据此更新自己的"认知",进入下一轮推理。
4.2 Agent 的核心执行循环
用伪代码表达就是:
javascript
// 初始化:用户请求 + 系统指令
const messages = [
new SystemMessage("你是一个代码助手..."),
new HumanMessage("帮我创建一个 React TodoList")
]
while (true) {
// Reason:LLM 推理
const response = await modelWithTools.invoke(messages)
messages.push(response)
// 检查是否需要调用工具
if (!response.tool_calls || response.tool_calls.length === 0) {
// 没有工具调用 → LLM 已经给出最终答案 → 结束循环
console.log(response.content)
break
}
// Act + Observe:执行工具并收集结果
for (const toolCall of response.tool_calls) {
const tool = tools.find(t => t.name === toolCall.name)
if (!tool) {
// 严谨性:工具不存在时返回错误信息,而非让程序崩溃
messages.push(new ToolMessage({
tool_call_id: toolCall.id,
content: `错误:未找到工具 ${toolCall.name}`
}))
continue
}
const result = await tool.invoke(toolCall.arguments)
messages.push(new ToolMessage({
tool_call_id: toolCall.id,
content: result
}))
}
// 循环回到 Reason ------ LLM 看到 ToolMessage 后继续推理
}
4.3 循环终止条件
这个 while 循环什么时候停?
- 正常终止 :LLM 返回的
AIMessage中tool_calls为空(或不存在),说明 LLM 认为信息充足,直接生成了文本回复。 - 异常保护 (生产环境必须考虑):
- 设置最大循环次数(如 20 轮),防止 LLM 陷入死循环反复调用同一工具
- Token 预算上限 ------ 防止 messages 数组无限增长导致上下文溢出窗口
五、AI 工程的项目结构
5.1 典型工程目录
perl
hello-langchain/
├── package.json # 项目配置、依赖声明
├── node_modules/ # 依赖包
├── .env # 环境变量(API Key 等敏感信息)
└── src/ # 开发代码目录
├── main.mjs # 入口文件
├── tools/
│ ├── read-file.mjs # 文件读取工具
│ └── write-file.mjs# 文件写入工具
└── utils/
└── logger.mjs # 日志工具
5.2 核心依赖
json
{
"dependencies": {
"@langchain/openai": "latest", // LLM 统一接口
"@langchain/core": "latest", // Tool、Message 等核心抽象
"zod": "latest", // 参数校验 schema
"dotenv": "latest" // 环境变量管理
}
}
六、Promise 与异步处理:Agent 开发的性能基石
6.1 为什么异步很重要?
LLM 的一个关键特性是:当它判断需要调用工具时,会一次性返回所有 tool_calls,而不是一个一个来。这意味着多个工具调用之间通常是相互独立的------这给了我们并行优化空间。
6.2 async 函数 = Promise 实例
ES2017 引入的 async/await 本质上是对 Promise 的语法糖:
javascript
// 这两段代码完全等价
async function readFile(path) {
return await fs.readFile(path, 'utf-8') // return = resolve
}
// 等价于
function readFile(path) {
return new Promise((resolve) => {
fs.readFile(path, 'utf-8', (err, data) => resolve(data))
})
}
核心认知 :async 函数执行后返回的就是 Promise 实例,函数体内的 return 等同于 resolve(),抛出的异常等同于 reject()。
6.3 并行执行多个 Tool:Promise.all
当 LLM 一次返回了多个 tool_calls,串行执行会浪费时间:
javascript
// ❌ 串行:3 个工具各 1 秒 → 总耗时 3 秒
for (const tc of toolCalls) {
const result = await executeTool(tc)
results.push(result)
}
// ✅ 并行:3 个工具各 1 秒 → 总耗时 1 秒
const results = await Promise.all(
toolCalls.map(tc => executeTool(tc))
)
6.4 陷阱:map 中的 async
javascript
// ⚠️ 常见错误
const promises = response.tool_calls.map(async (toolCall) => {
const tool = tools.find(t => t.name === toolCall.name)
if (!tool) {
return `错误:未找到工具 ${toolCall.name}` // async 函数里 return = resolve
}
const result = await tool.invoke(toolCall.arguments)
return result
})
// 此时 promises 是 Promise 数组,尚未执行完成
const results = await Promise.all(promises) // 等待全部完成
关键理解 :map 的回调是 async 函数,所以每次迭代返回的是一个 Promise。虽然看起来 async 里用了 await,但 map 不会等待每个回调完成------它立即返回 Promise 数组。必须配合 Promise.all 才能真正等待全部完成并拿到结果。
6.5 Promise 的三种状态
javascript
new Promise()
│
┌────▼────┐
│ PENDING │ 等待中...
└────┬────┘
│
┌─────┴─────┐
▼ ▼
┌───────┐ ┌────────┐
│FULFILLED│ │REJECTED│
│ 成功 │ │ 失败 │
└───────┘ └────────┘
状态只能从 PENDING 单向转换到 FULFILLED 或 REJECTED,不可逆。
- Pending →
resolve()→ Fulfilled - Pending →
reject()→ Rejected - 一旦 settled(敲定),状态永不改变
七、完整实战:构建一个简化版 AI 编程助手
让我们将以上所有概念串联起来,实现一个能读文件、写文件、执行命令的简易 AI 编程助手。
7.1 架构总览
css
┌─────────────────────────────────────────────┐
│ messages[] 对话上下文 │
│ SystemMessage + HumanMessage + ... │
└──────────────────┬──────────────────────────┘
│
▼
┌─────────────────┐
│ modelWithTools │ LLM + 工具定义
└────────┬────────┘
│
┌─────────▼─────────┐
│ LLM 推理决策 │
│ 需要调工具? │
└─────────┬─────────┘
│
┌──────────┴──────────┐
│ 是 │ 否
▼ ▼
┌──────────────┐ ┌──────────────┐
│ 执行工具 │ │ 返回最终答案 │
│ - read_file │ │ 结束循环 │
│ - write_file │ └──────────────┘
│ - run_cmd │
└──────┬───────┘
│
▼
┌──────────────┐
│ ToolMessage │ 结果注入上下文
│ → push 回 │
│ messages │
└──────┬───────┘
│
└──────► 循环回到 LLM 推理
7.2 三个核心 Tool
javascript
// Tool 1: 读取文件
const readFileTool = tool(
async ({ filePath }) => await fs.readFile(filePath, 'utf-8'),
{
name: 'read_file',
description: '读取指定路径的文件内容,用于理解已有代码',
schema: z.object({
filePath: z.string().describe('文件的完整路径')
})
}
)
// Tool 2: 写入文件
const writeFileTool = tool(
async ({ filePath, content }) => {
await fs.writeFile(filePath, content, 'utf-8')
return `文件 ${filePath} 写入成功`
},
{
name: 'write_file',
description: '创建或覆盖文件,用于生成代码文件',
schema: z.object({
filePath: z.string().describe('要创建/覆盖的文件路径'),
content: z.string().describe('要写入文件的完整内容')
})
}
)
// Tool 3: 执行命令
const runCommandTool = tool(
async ({ command }) => {
const { execSync } = await import('child_process')
const output = execSync(command, { encoding: 'utf-8' })
return output
},
{
name: 'run_command',
description: '在终端执行命令,用于创建项目、安装依赖、运行代码',
schema: z.object({
command: z.string().describe('要执行的 shell 命令')
})
}
)
7.3 System Prompt 的设计
System Prompt 是 Agent 的"岗位说明书",设计好坏直接决定 Agent 的行为质量:
javascript
const SYSTEM_PROMPT = `
你是一个 AI 编程助手,可以使用以下工具帮助用户完成编程任务:
工具列表:
- read_file:读取文件内容
- write_file:写入代码到文件
- run_command:执行终端命令
工作流程:
1. 收到用户需求后,分析需要做什么
2. 如需创建项目 → 使用 run_command 执行 npm create 等命令
3. 如需生成代码 → 使用 write_file 写入文件
4. 如需理解已有代码 → 使用 read_file 读取文件
5. 所有步骤完成后,总结你做了什么
重要原则:
- 一次只调用必要的工具,不要多余调用
- 写入文件前确保目录已存在
- 执行命令前说明你要执行什么
`
八、总结
本文从 Tool Calling 的底层原理出发,逐层揭开 AI Agent 的实现面纱:
| 层级 | 核心概念 | 一句话总结 |
|---|---|---|
| 动机 | LLM 是 Stateless + 无行动能力 | Tool 让 LLM 从"说"到"做" |
| 协议 | 四种 Message | System → Human → AI(含 tool_calls)→ Tool ------ 不断追加的对话上下文就是 Agent 的记忆 |
| 框架 | LangChain | 统一 LLM 接口 + Tool 抽象 + Message 封装,让 Agent 开发像搭积木 |
| 模式 | ReAct 循环 | Reason → Act → Observe → Reason → ... 直到任务完成 |
| 性能 | Promise.all 并行 | 多个独立工具调用应并行执行,async/await 是最优雅的异步方案 |
| 工程 | 目录 + 错误处理 + 循环上限 | Agent 工程化需要严谨的边界保护 |
Agent 并不神秘。本质上就是:
- 维护一个不断增长的 messages 数组(记忆)
- LLM 推理,决定是否调用工具
- 如果有 tool_calls → 执行 工具 → 结果以 ToolMessage 形式回填
- 如果没有 tool_calls → 任务完成,返回答案
- 这个循环就是 ReAct Agent
简化版 Claude Code = LLM + fs Tool + CLI Tool + ReAct 循环
掌握了这些,你就掌握了所有 Agent 框架的底层工作原理。无论是 LangChain 还是 LangGraph,无论是单 Agent 还是多 Agent 协作------万变不离其宗,核心永远是:Reason → Act → Observe 的循环。