手写一个 AI 编程助手:Tool Calling 与 ReAct Agent 全解

手写一个 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,它只能吐出一段代码文本。但一个真正的编程助手需要:

  1. 写入文件 Tool --- 把生成的代码写入 App.jsxmain.jsx 等文件
  2. 执行命令 Tool(CLI) --- 运行 npm create vite@latestnpm installnpm run dev

这就是 Tool Calling 的本质:让 LLM 不再只输出文本,而是输出"行动指令",由后端执行后,将结果反馈给 LLM 继续推理。

flowchart LR User[&#34;用户:创建一个 React + Vite 的 TodoList&#34;] LLM[&#34;LLM 规划&#34;] T1[&#34;Tool 1: write_file<br/>写入项目文件&#34;] T2[&#34;Tool 2: run_command<br/>执行 npm 命令&#34;] Result[&#34;项目创建完成 🎉&#34;] User --> LLM LLM --> T1 T1 --> T2 T2 --> Result

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 的 transformersgenerative 库------它的初心就是提供一套统一接口,屏蔽底层差异

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 返回的 AIMessagetool_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,不可逆。
  • Pendingresolve()Fulfilled
  • Pendingreject()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 并不神秘。本质上就是:

  1. 维护一个不断增长的 messages 数组(记忆)
  2. LLM 推理,决定是否调用工具
  3. 如果有 tool_calls → 执行 工具 → 结果以 ToolMessage 形式回填
  4. 如果没有 tool_calls → 任务完成,返回答案
  5. 这个循环就是 ReAct Agent

简化版 Claude Code = LLM + fs Tool + CLI Tool + ReAct 循环

掌握了这些,你就掌握了所有 Agent 框架的底层工作原理。无论是 LangChain 还是 LangGraph,无论是单 Agent 还是多 Agent 协作------万变不离其宗,核心永远是:Reason → Act → Observe 的循环


相关推荐
千帆_Evan2 小时前
🤖 AI 写代码总在重复造轮子?我们把 Utils 复用门禁做进了 Agent 运行时(已开源)
前端·agent
ckjoker2 小时前
23个测试全绿,一个Token没花——LLM应用的单元测试该怎么写?
单元测试·agent
带刺的坐椅3 小时前
代码审查 Agent Harness 实战:AI 自动 Code Review
java·ai·llm·agent·solon·soloncode·harness
米小虾15 小时前
为什么 AI 的下一个突破口,不是更大的模型,而是更好的"世界"?
人工智能·agent
Darling噜啦啦17 小时前
ReAct 框架深度解析:让 AI 真正"自己干活"的思考-行动-观察循环
aigc·agent
不好听61317 小时前
从零打造 AI Agent:第一天,我学到了什么
agent
阿里云云原生17 小时前
不改代码也能监控 AI Agent?揭秘 OBI 如何在内核层精准解析 GenAI 语义流量
云原生·agent
MicrosoftReactor18 小时前
技术速递|Token 经济学:智能体 AI 时代的新 FinOps
人工智能·ai·agent·token·finops
SelectDB18 小时前
Apache Doris 在 AgentLogsBench 中领先,支撑 Agent 可观测性生产负载
运维·数据库·agent