拆解 AI Agent:用 LangChain 构建你的第一个智能工具

深入理解 AI Agent:从原理到实践

引言

在 AI 技术飞速发展的今天,Agent(智能体)已经成为最具商业价值的技术方向之一。但 Agent 究竟是什么?它与直接调用大模型接口有何不同?本文将带你深入理解 Agent 的核心概念、工作流程以及开发实践。

为什么不能直接调用大模型接口?

很多人以为 AI 应用就是简单地调用大模型 API,但实际落地中,裸调 LLM 存在诸多局限:

1. 无状态问题 ------ Memory 模块

LLM 本身是无状态的(stateless)。你上周和它聊过的信息,它完全不记得。要让 LLM 具备"记忆",需要结合:

  • 数据库:持久化存储对话历史
  • 前端存储 / Redis:缓存会话状态
  • 后端服务:管理会话生命周期

这就是 Agent 中 Memory 模块 的价值------让 LLM 拥有"记性"。

2. 行动能力缺失 ------ Tool Use

让 LLM 帮你访问一个网页、做一些实际操作?LLM 只能告诉你思路,但无法亲自执行。豆包做 PPT 等场景,就需要 Tool Use(工具调用) 能力------让 LLM 能够真正"动手"。

3. 私有知识盲区 ------ RAG 模块

LLM 无法访问企业内部私有文档。通过 RAG(检索增强生成) 模块,Agent 可以从私有知识库中检索相关内容,弥补 LLM 知识的局限性。

4. 实时信息缺失 ------ MCP

最新的世界杯新闻、实时股市数据,这些不在 LLM 的预训练数据中。MCP(Model Context Protocol) 作为第三方工具的通用协议,让 Agent 能够接入外部数据源和服务。

5. 复杂任务编排 ------ Skills

做 PPT、分析股市并自动买卖等复杂任务,需要将多个能力组合成可复用的 Skills(技能),通过蒸馏和编排实现端到端的自动化。

Agent 究竟是什么?

综上,Agent 就是围绕以上问题,给 LLM 加上各种扩展能力:

ini 复制代码
Agent = LLM + Memory + Tool + RAG + MCP + Skills

一个知道内部知识、能思考能规划、能够帮你做事情的扩展后的大模型,就是 Agent。

典型的 Agent 产品案例:

  • Claude Code / Codex:Coding Agent,辅助编程
  • Manus:自动化任务 Agent

Agent 的工作流程

Agent 处理任务的典型流程如下:

markdown 复制代码
用户 Prompt(复杂任务)
    │
    ▼
  Agent 智能体
    │
    ▼
 LLM Planning + Reasoning(规划/推理)
    │
    ├──► 加载 Memory?(历史记忆)
    │
    ├──► 调用 Tool?(分步骤、多工具)
    │
    ├──► 查询 RAG?(检索增强)
    │         │
    │         ▼
    │   Prompt Template + 检索内容
    │
    ▼
  Response → 用户(任务完成)

核心在于:LLM 本身具备思考和规划能力,给它 Tool 扩展行动能力,用 Memory 管理记忆,用 RAG 补充私有知识。

Agent 开发技术栈

推荐的技术组合:

层级 技术选型 用途
后端框架 NestJS 构建可扩展的企业级后端服务
单智能体 LangChain 单一 Agent 的构建与编排
多智能体 LangGraph 多 Agent 协作与状态管理
能力扩展 MCP / Skills / RAG 工具调用、技能封装、知识检索

深入 LangChain

LLM 抽象层

LangChain 提供了统一且兼容的 LLM 接口,例如 @langchain/openai,支持按需加载不同模型,屏蔽底层差异。

Tool 工具接口

LangChain 接管了 @langchain/core 和 Zod 验证工具,将工具接口抽象为两个核心部分:

1. 函数描述对象
  • description:详细描述工具的功能、适用场景、参数需求
  • schema:参数约束(Zod 验证),LLM 必须提供符合 schema 约定的参数才能调用该工具
2. Tool 的返回格式

当 LLM 判断需要调用工具时:

  • 不会直接生成回答,而是停下来,告诉用户需要调用的工具列表(tool-calls)
  • 每个 tool-call 包含:idnamearguments
  • id 用于关联------将工具调用的返回结果与对应的 tool-call 匹配,组成完整的任务上下文

工作流程:

bash 复制代码
用户提问 → LLM 推理 → 识别需要工具 → 返回 tool-calls(id + name + arguments)
    → 后端执行工具 → 将结果按 id 关联回会话 → LLM 基于完整上下文生成最终回答

实战:构建文件读取 Tool

下面通过一个完整的 LangChain Tool 示例(tool.mjs),逐行拆解 Agent 工具调用的每一个环节。该示例创建一个 read_file 工具,让 Agent 能够读取本地文件并解释代码。完整代码约 70 行,我们将其拆为 5 个部分逐一分析。


第一部分:依赖导入(第 1-10 行)
javascript 复制代码
import 'dotenv/config'                        // ①
import {ChatOpenAI} from '@langchain/openai'  // ②
import {tool} from '@langchain/core/tools'    // ③
import {HumanMessage,                         // ④
        ToolMessage,
        SystemMessage,
        AIMessage,
     } from '@langchain/core/messages'
import fs from 'fs/promises'                  // ⑤
import {z} from 'zod'                         // ⑥

逐行解析:

导入内容 作用 为什么需要它
dotenv/config 自动读取项目根目录 .env 文件,将环境变量注入 process.env 避免在代码中硬编码 API Key,保证密钥安全
ChatOpenAI LangChain 对 OpenAI 兼容模型的统一封装类 屏蔽不同模型 API 的差异,通过一套接口调用多种模型
tool LangChain 核心工具构造函数 将普通 JS 函数包装为 LLM 可识别、可调用的 Tool 对象
HumanMessage / ToolMessage / SystemMessage / AIMessage 四种对话消息类,分别代表用户输入、工具返回结果、系统指令、LLM 回复 对话上下文由消息数组组成,不同类型的消息让 LLM 区分每条内容的来源和角色
fs/promises Node.js 内置的文件系统 Promise 版 API 提供异步读文件能力(readFile),避免阻塞事件循环
z(Zod) TypeScript 优先的运行时校验库 定义工具参数的"契约"------LLM 传参必须符合 schema 约束,否则执行前就能拦截

第二部分:初始化模型(第 12-22 行)
javascript 复制代码
const model = new ChatOpenAI({                 // ① 创建模型实例
  modelName: 'deepseek-v4-flash',              // ② 指定模型名称
  apiKey: process.env.DEEPSEEK_API_KEY,        // ③ 从环境变量读取密钥
  temperature: 0,                              // ④ 控制输出随机性
  configuration: {                             // ⑤ 自定义 HTTP 配置
    baseURL: 'https://api.deepseek.com/v1',    // ⑥ 重定向 API 端点
  }
})

逐行解析:

配置项 含义
new ChatOpenAI() --- 实例化一个 Chat 模型客户端,后续所有 LLM 调用都通过此对象发起
modelName 'deepseek-v4-flash' 告诉 DeepSeek API 使用哪个具体模型。flash 后缀表示轻量快速版,适合 Agent 场景下对延迟敏感的 Tool Calling
apiKey process.env.DEEPSEEK_API_KEY API 鉴权凭证。从 .env 文件注入,代码中不暴露明文,安全且便于环境切换
temperature 0 设为 0 意味着 LLM 输出完全确定性(每次同样输入得到同样输出)。Agent 场景强烈建议设 0 或极低值------你不想让工具调用决策带有随机性
configuration { baseURL: ... } 透传给底层 HTTP 客户端的配置对象,用于自定义 API 地址
baseURL 'https://api.deepseek.com/v1' 这是关键 :将 OpenAI SDK 的默认请求地址 https://api.openai.com/v1 重定向到 DeepSeek 的 API。因为 DeepSeek 接口完全兼容 OpenAI 协议,所以 ChatOpenAI 不需要任何修改就能直接调用它

核心认知 :LangChain 的 ChatOpenAI 并不是"OpenAI 专用"------它本质上是 OpenAI 协议客户端 。任何实现了 OpenAI 兼容接口的模型服务(DeepSeek、Moonshot、通义千问、本地 vLLM 等),只需改一行 baseURL + modelName 即可无缝切换。这就是 LangChain 模型抽象层的真正价值。


第三部分:定义 Tool(第 24-41 行)

这是整个文件最核心的部分------将一个普通函数"注册"为 LLM 可用的工具。

javascript 复制代码
const readFileTool = tool(                     // ① tool() 构造函数
  async ({filePath}) => {                      // ② 第一个参数:功能函数
    const content = await fs.readFile(         // ③ 实际执行:读取文件
      filePath,                                // ④ 入参来自 LLM 生成的 arguments
      'utf-8'                                  // ⑤ 指定编码,返回字符串而非 Buffer
    )
    console.log(                               // ⑥ 进度反馈日志
      `[工具调用] read_file(${filePath}) 成功读取 ${content.length} 字节`
    )
    return content                             // ⑦ 返回值作为 ToolMessage 内容回传给 LLM
  },
  {                                            // ⑧ 第二个参数:元数据对象
    name: 'read_file',                         // ⑨ 工具的唯一标识名
    description: `用此工具来读取文件内容...`,   // ⑩ 功能描述 ------ LLM 据此判断何时调用
    schema: z.object({                         // ⑪ Zod schema:参数"契约"
      filePath: z.string()                     // ⑫ 参数名 + 类型约束
        .describe('要读取的文件路径')           // ⑬ 参数说明 ------ LLM 据此填充参数值
    })
  }
)

逐行深度解析:

代码 作用 深层原理
tool(...) 来自 @langchain/core/tools,将一个 JS 函数包装成 LangChain 的 Tool 对象 返回的 Tool 对象同时包含可执行逻辑(给运行时)和结构化元数据(给 LLM),实现了"描述与实现"的分离
async ({filePath}) => {...} 第一个参数:功能函数 。异步执行,从参数对象中解构出 filePath 这个函数只在后端运行,LLM 看不到它的内部实现。filePath 的值由 LLM 根据 schema 生成,经过 Zod 校验后传入
fs.readFile(...) Node.js 异步文件读取 Promise 版本(fs/promises),配合 await 同步风格写出异步逻辑
filePath 文件路径参数 来自 LLM 生成的 tool-call 的 arguments.filePath。可以是绝对路径(/home/user/foo.txt)或相对路径(./tool.mjs),由 description 和 schema 告知 LLM
'utf-8' 编码参数 不传此参数将返回原始 Buffer 对象(字节数组),LLM 无法直接理解。必须指定文本编码
console.log(...) 终端日志 不是可选的------Agent 执行工具时用户处于等待状态,如果没有反馈,用户不知道 Agent 是卡住了还是在正常工作。这个日志让执行过程透明可见
return content 函数返回值 返回值会被自动包装为 ToolMessage,注入到对话上下文(messages 数组)中,供 LLM 下一步推理使用
{ name, description, schema } 第二个参数:元数据对象 这个对象会在 bindTools() 时被序列化并注入到 LLM 的请求中,告诉 LLM:"我这里有这样一个工具,你可以调用它"
name: 'read_file' 工具的唯一标识符 LLM 生成的 tool-call 中 name 字段就是它。后端根据 name 查找对应的工具函数执行。必须全局唯一
description: '用此工具来...' 工具的功能描述 这是 LLM 决定是否调用该工具的唯一依据。描述越精确(什么场景用、什么场景不用),LLM 越不容易误调用或漏调用。写 description 是开发 Agent 最关键的工作之一
schema: z.object({...}) Zod 定义的参数约束对象 定义该工具接受哪些参数、每个参数的类型和含义。LLM 生成的 arguments 必须符合此 schema
filePath: z.string() 参数名 + 类型 filePath 是参数名(与功能函数的解构参数名一致),z.string() 约束该参数必须是字符串
.describe('要读取的文件路径') 参数说明 告诉 LLM 这个参数的语义------它应该填什么,从而让 LLM 从用户自然语言中提取正确的值填入

tool() 的设计哲学:将"做什么"(功能函数,给机器执行)和"怎么描述"(元数据,给 LLM 理解)彻底解耦。LLM 不需要知道文件是怎么读的,只需要知道"有个工具叫 read_file,需要一个文件路径,用来读取文件内容"------描述得越清楚,LLM 用起来越准确。


第四部分:注册工具并绑定到模型(第 43-48 行)
javascript 复制代码
const tools = [readFileTool]                // ① 工具注册表
const modelWithTools = model.bindTools(tools) // ② 绑定工具到模型

逐行解析:

代码 作用 深层原理
const tools = [readFileTool] 将所有已定义的 Tool 对象放入数组 本示例只有一个工具,但实际项目中这里可能包含多个工具([readFileTool, searchTool, calculatorTool, ...])。每个工具都会被 LLM 感知
model.bindTools(tools) 调用 bindTools() 返回一个新的模型实例 这是 LangChain 最精妙的设计bindTools() 不是修改原 model 对象,而是返回一个"携带工具能力"的新模型实例。它内部做了两件事:(1)遍历 tools 数组,将每个工具的 namedescriptionschema 序列化为 OpenAI Function Calling 格式;(2)在后续每次 invoke() 时,自动将这些工具定义附加到 API 请求中

bindTools() 的不可变性:返回新实例而非修改原对象,意味着你可以同时持有一个"不带工具的基础模型"和一个"带工具的增强模型",在不同场景灵活使用。这在复杂 Agent 系统中尤为重要------有时你只需要纯文本对话,不需要浪费 token 传输工具定义。


第五部分:构建对话上下文并执行(第 49-72 行)
javascript 复制代码
const messages = [                              // ① 对话上下文数组
  new SystemMessage(`                           // ② 系统消息:定义 Agent 人设
    你是一个代码助手,可以使用工具读取并解释代码。

    工作流程:                                   // ③ 引导 LLM 按步骤执行
    1. 用户要求读取文件时,立即调用 read_file 工具
    2. 等待工具返回文件内容
    3. 基于文件内容进行分析和解释

    可用工具:                                   // ④ 显式声明工具(双重保险)
    - read_file:读取文件内容(使用此工具来读取文件内容)
  `),
  new HumanMessage(                             // ⑤ 用户消息
    '读取 tool.mjs 文件内容并解释代码'
  ),
]

let response = await modelWithTools.invoke(messages)  // ⑥ 调用模型
messages.push(response)                                // ⑦ 追加回复到对话历史

逐行解析:

代码 作用 深层原理
const messages = [...] 创建一个消息数组 这是发给 LLM 的完整上下文。数组中的每条消息按顺序排列,LLM 按时间线理解对话。Agent 的所有"记忆"本质上就是这个 messages 数组
new SystemMessage(...) 系统级指令,定义 Agent 的角色和行为边界 SystemMessage 是整个对话的"最高优先级指令"。它不会被后续对话覆盖,LLM 必须始终遵守其中的约束。类比:这是给员工的《岗位说明书》
工作流程:1. 2. 3. 分步骤引导 LLM 行为 虽然 bindTools() 已经让 LLM 知道了工具,但在 System Prompt 中显式描述使用流程,能显著提高 LLM 按预期调用工具的概率。这是 Prompt Engineering 的实践:明确告诉 LLM "什么时候该做什么"
可用工具:- read_file 在文本中也声明一次工具 为什么要在 System Prompt 里再写一遍?因为 bindTools() 注入的工具定义是结构化的(JSON Schema 格式),而自然语言的工具列表能帮助 LLM 更快建立"意图 → 工具"的映射。两者互补,提升调用准确率
new HumanMessage(...) 用户的对话输入 HumanMessage 对应对话角色 role: 'user'。LLM 会将其视为"人类对我的提问",据此生成回复或决定调用工具
modelWithTools.invoke(messages) 向模型发送请求并等待响应 invoke() 是 LangChain 的标准调用方法(遵循 LCEL --- LangChain Expression Language)。执行流程:序列化 messages → 附加工具定义 → 发送 HTTP 请求 → 解析响应 → 返回 AIMessage 对象
messages.push(response) 将 LLM 回复追加到对话数组 这一步至关重要 ------如果不 push,下一次 invoke() 时 LLM 就丢失了上一轮的上下文(包括它自己生成的 tool-calls)。这就是为什么 Agent 的"记忆"本质上是不断增长的 messages 数组

完整执行流程(串联所有部分)

将这 70 行代码的实际运行过程可视化如下:

css 复制代码
┌─────────────────────────────────────────────────────────────┐
│ messages 数组(对话上下文)                                     │
├─────────────────────────────────────────────────────────────┤
│ [0] SystemMessage:  "你是代码助手... 工作流程..."              │
│ [1] HumanMessage:   "读取 tool.mjs 文件内容并解释代码"         │
└─────────────────────────────────────────────────────────────┘
                          │
                          ▼
              modelWithTools.invoke(messages)
                          │
            ┌─────────────┴─────────────┐
            │  发送到 DeepSeek API 的请求  │
            │  - messages (含 system + user)│
            │  - tools: [{                   │
            │      name: "read_file",        │  ← bindTools() 注入
            │      description: "...",       │
            │      parameters: { filePath }  │
            │    }]                           │
            └─────────────┬───────────────┘
                          │
                          ▼
                   LLM 推理决策
                          │
          ┌───────────────┴───────────────┐
          │ LLM 判断:用户要求读取文件,    │
          │ 我需要调用 read_file 工具      │
          └───────────────┬───────────────┘
                          │
                          ▼
              LLM 返回 AIMessage:
              ┌─────────────────────────────────────┐
              │ role: "assistant"                    │
              │ content: ""  (无文本,仅有 tool_call) │
              │ tool_calls: [{                        │
              │   id: "call_abc123",                  │
              │   name: "read_file",                  │
              │   arguments: { filePath: "tool.mjs" } │
              │ }]                                    │
              └─────────────────────────────────────┘
                          │
                          ▼
                messages.push(response)
                          │
              ┌───────────┴───────────┐
              │ 后端检测到 tool_calls   │
              │ 不是 null / 非空数组    │
              └───────────┬───────────┘
                          │
                          ▼
              执行 readFileTool("tool.mjs")
              ┌─────────────────────────────┐
              │ fs.readFile("tool.mjs")      │
              │   → 文件内容字符串            │
              │ console.log("[工具调用]...")  │  ← 用户看到进度
              │ return content                │
              └─────────────┬───────────────┘
                          │
                          ▼
              创建 ToolMessage 并 push:
              ┌─────────────────────────────────────┐
              │ role: "tool"                         │
              │ tool_call_id: "call_abc123"          │  ← 与 tool-call 的 id 对应
              │ content: "import 'dotenv/config'..." │  ← 文件实际内容
              └─────────────────────────────────────┘
                          │
                          ▼
              再次 modelWithTools.invoke(messages)
                          │
      ┌───────────────────┴───────────────────┐
      │ 此时 messages 包含:                    │
      │ [0] SystemMessage (系统指令)            │
      │ [1] HumanMessage (用户请求)             │
      │ [2] AIMessage (tool_call: read_file)   │
      │ [3] ToolMessage (文件内容)              │
      └───────────────────┬───────────────────┘
                          │
                          ▼
                   LLM 最终推理
                          │
        ┌─────────────────┴─────────────────┐
        │ LLM 看到完整上下文(用户请求 +      │
        │ 文件内容),生成自然语言解释:       │
        │ "这个文件是一个 LangChain Tool      │
        │  示例,它导入了..."                 │
        └─────────────────┬─────────────────┘
                          │
                          ▼
              返回 AIMessage(最终回答)
              content: "该文件展示了 LangChain 中..."
                          │
                          ▼
                    用户看到代码解释

关键要点总结
概念 一句话理解
dotenv/config .env 文件安全加载密钥,避免硬编码
ChatOpenAI + baseURL 一套代码接入任意兼容 OpenAI 协议的模型
tool() 第一个参数 后端执行的实际逻辑(LLM 看不见内部实现)
tool() 第二个参数 给 LLM 看的"说明书"(name + description + schema)
zod schema 参数"契约":LLM 填入的参数必须通过此校验
bindTools() 将工具说明书注入到每次 API 请求中,LLM 据此决策
SystemMessage 定义 Agent 角色和行为约束,最高优先级指令
HumanMessage 用户的自然语言输入
AIMessage(含 tool_calls) LLM 决定调用工具,不生成文本,只输出 tool-call 结构
ToolMessage 工具执行结果,通过 tool_call_id 关联回对应的 tool-call
messages.push() 不断追加消息,维持上下文------这就是 Agent 的"记忆"
console.log(工具内) 给用户实时反馈,避免长时间无响应造成的焦虑
temperature: 0 确定性输出,Agent 场景下工具调用决策必须稳定可靠

这个 70 行的例子完整展示了 Agent Tool Use 的核心循环:LLM 推理 → 生成 tool-calls → 后端执行 → 结果反馈 → LLM 整合输出。理解了它,就理解了所有 Agent 框架的底层工作原理。

LLM Tool 调用性能优化

当任务复杂时,LLM 可能需要调用多个工具,或同一个工具多次调用。此时可以利用异步并发优化性能。

Promise 与异步处理

ES6 引入的 Promise 有三种状态:

  • Pending(等待中):异步操作进行中
  • Fulfilled (已成功):resolve() 被调用,pending → fulfilled
  • Rejected (已失败):reject() 被调用,pending → rejected

状态只能从 pending 单向转换到 fulfilled 或 rejected,不可逆转。

并行优化

javascript 复制代码
// Promise.all 并行执行多个工具调用
const results = await Promise.all([
  toolCall1(),
  toolCall2(),
  toolCall3(),
]);
// 返回结果顺序与 Promise 数组顺序一致

使用 Promise.all 可以并行执行多个独立的工具调用,等待所有操作完成后统一返回结果,配合 await(ES8)优雅地实现异步转同步的写法。

总结

Agent 并不复杂------它的本质是对 LLM 的能力扩展:

  1. Memory 让它记住你
  2. Tool 让它能做事
  3. RAG 让它懂你的业务
  4. MCP 让它连接世界
  5. Skills 让它精通特定任务

结合 NestJS 后端框架 + LangChain/LangGraph 开发框架,你可以打造出真正落地的 AI 全栈 Agent 产品,让 AI 技术通过 Harness Engineering 实现商业价值(FDE)。


相关推荐
Darling噜啦啦1 小时前
从零手写 AI Agent:LLM + Tool + LangChain,打造能干活的大模型
langchain·llm·agent
_冷眸_1 小时前
用 OpenAI SDK 统一调用 Claude、Gemini、DeepSeek:聊聊 AI API 聚合网关的实践
人工智能·ai·llm·agent
梦想4811 小时前
三次迭代,把一个本地检索引擎的排序准确率从 77.8% 做到 100%
agent
文言一心2 小时前
业务数据对话查询智能体系统
langchain·agent
武子康2 小时前
调查研究-217 Fortress:当浏览器 Agent 开始被网站识别,开源 Chromium 反拦截引擎来了
人工智能·爬虫·agent
新知图书2 小时前
多模态智能体开发的核心挑战与解决方案
人工智能·agent·多模态·ai agent·智能体·langgraph
gis分享者2 小时前
easy-agent 轻量级 Agent 组件深度评测
agent·测评·easy-agent
阿拉斯攀登3 小时前
RAG 性能优化:缓存、批量与并发
人工智能·缓存·性能优化·embedding·agent·loop·rag