别再傻傻调 API 了!手把手教你搓一个能干活的 AI Agent

3 阅读7分钟

别再傻傻调 API 了!手把手教你搓一个能干活的 AI Agent

前几天我想让 AI 帮我读个文件,它跟我说"您可以使用 fs.readFile 方法"。我当场就愣住了——我是让你读,不是让我读啊!

如果你也有过类似的体验,那这篇文章就是为你写的。


先搞清楚一个问题:Agent 到底是个啥?

很多人以为 Agent 是什么高深莫测的东西。其实吧,你把大模型想象成一个很聪明但啥也不会的实习生

  • 脑子很好使,但没有记忆——上次聊的全忘了
  • 懂很多道理,但不会动手——你让它读文件,它给你写教程
  • 不了解你们公司的情况——你问它内部 API 怎么用,它开始瞎编

所以 Agent 干的事情很简单:给这个实习生配工具、装记忆、补知识

Agent = 大模型 + 记忆 + 工具 + 知识库

就这么简单。别被那些花里胡哨的名词吓到。


大模型的三大"不能"

来,我们一个个看大模型到底哪儿不行:

1. 它记不住你

大模型是 stateless 的——每次对话都是一张白纸。你上周跟它聊了两小时的方案,今天它完全不记得。所以你得自己维护一个 messages 数组,假装它"记住了"。

就像跟一个每天早上失忆的人共事,你得每天早上把昨天的会议记录重新念一遍。

实现上也很直白——就是每次把完整对话历史塞进请求:

// 每次调用都带上完整历史
const messages = [
  new SystemMessage('你是...'),
  new HumanMessage('你好'),       // 第1轮
  new AIMessage('你好!有什么...'), // 第1轮回复
  new HumanMessage('帮我读...'),   // 第2轮
];
const response = await model.invoke(messages);

但这带来一个问题:对话越来越长,token 开销指数级增长。真正的 Agent 还需要 Memory 模块——用 Redis、数据库、或者滑动窗口策略来管理历史,不能真的每次都全塞。

2. 它干不了活

你跟它说"帮我把这段代码重构一下",它会给你一大段分析——然后呢?然后就没有然后了。它只会说,不会做。

3. 它不知道你的东西

你公司的私有文档、内部 API、数据库结构,它一概不知。训练数据里没有的东西,它只能靠猜。


Agent 的工作流:一个不停"转圈"的过程

Agent 的执行逻辑其实就是一个循环:

用户甩来一个任务
    ↓
LLM 开始动脑子:我需要干嘛?
    ↓
  要调用工具吗?
  ├── 是 → 调工具 → 拿到结果 → 塞回 messages → 继续想
  └── 否 → 直接回复用户,结束

这就是经典的 ReAct 模式:Reason(想)→ Act(做)→ Observe(看结果)→ 再想

循环往复,直到任务完成。


开搞!LangChain + DeepSeek 造一个 Agent

技术栈:

  • LangChain JS:LLM 开发框架,兼容各家大模型
  • DeepSeek:便宜又好用的大模型
  • Node.js:跑后端逻辑

第一步:跑通基础调用

mkdir hello-langchain && cd hello-langchain
pnpm init
pnpm add @langchain/core @langchain/openai dotenv zod

然后写个最简单的调用:

import { ChatOpenAI } from '@langchain/openai';
import 'dotenv/config';

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

const response = await model.invoke('棍王杯台球比赛应该设什么奖励?');
console.log(response.content);

LangChain 的 ChatOpenAI 是个统一接口——换模型改两行就行,不用重写业务逻辑。它内部做了什么?其实就是一个适配层,把 LangChain 的消息格式翻译成 OpenAI 兼容的 API 格式,然后发 HTTP 请求,再把响应翻译回来。


第二步:给它装一个工具

光能聊天没用,得让它能干实事。我们给它装一个"读文件"工具。

LangChain 里一个 Tool 由两部分组成:

  1. 功能函数:实际干活的 async 函数
  2. 描述对象:告诉 LLM 这工具是干嘛的、需要什么参数
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('要读取的文件路径'),
    }),
  }
);

这里有几个细节值得展开:

Zod schema 做了什么?

z.object({ filePath: z.string().describe('...') }) 不只是参数校验。当 LLM 收到这个 schema 时,它会把它转换成 OpenAI function calling 的 JSON Schema 格式,变成这样:

{
  "type": "function",
  "function": {
    "name": "read_file",
    "description": "用此工具来读取文件内容...",
    "parameters": {
      "type": "object",
      "properties": {
        "filePath": {
          "type": "string",
          "description": "要读取的文件路径"
        }
      },
      "required": ["filePath"]
    }
  }
}

LLM 看到这个结构,就知道:调这个工具时必须传一个 filePath 字符串。Schema 就是工具和 LLM 之间的契约

description 为什么重要?

LLM 判断"该不该调这个工具"的依据就是 description。写得越清晰,LLM 的判断越准确。比如:

  • "读文件" → 太模糊,LLM 不知道什么时候该调
  • "当用户要求读取文件、查看代码、分析文件内容时,调用此工具" → 覆盖了使用场景

第三步:LLM 是怎么"决定"调用工具的?

这里有个很精妙的设计——LLM 不会硬编工具的返回值

当你把工具绑定到模型上:

const modelWithTools = model.bindTools([readFileTool]);

bindTools 做的事情是:把工具的 JSON Schema 注入到 LLM 的请求参数中(通过 OpenAI 的 tools 字段)。LLM 在推理时就"知道"自己有这些工具可用。

当 LLM 判断"我需要调工具"时,它返回的不是文本,而是这样的结构:

{
  "tool_calls": [
    {
      "id": "call_abc123",
      "name": "read_file",
      "type": "function",
      "arguments": { "filePath": "src/index.mjs" }
    }
  ]
}

它承认自己不行,然后甩出工具调用请求。 这种"自知之明"是 Agent 能工作的基础。

而且注意 id 字段——后面工具执行结果要用这个 id 关联回去,否则 LLM 搞不清楚哪个结果对应哪个调用。


第四步:Message 的四种面孔

LangChain 里对话由四种消息类型组成,搞清楚它们就理解了 Agent 的对话结构:

消息类型谁说的干什么关键字段
SystemMessage开发者(你)定义 AI 的角色、能力、规矩无特殊字段
HumanMessage用户用户的输入无特殊字段
AIMessageLLMAI 的回复(可能夹带 tool_calls)tool_calls 数组
ToolMessageAgent 框架工具执行的结果tool_call_id(必须匹配)

一个完整的 Agent 会话,就是这四种消息的排列组合。随着对话推进,messages 数组越来越长:

[SystemMessage, HumanMessage, AIMessage(tool_calls), ToolMessage, AIMessage(tool_calls), ToolMessage, AIMessage]

最终那个 AIMessage 没有 tool_calls,说明 LLM 认为任务完成,直接输出了文本结果。


第五步:ReAct 循环——Agent 的心脏

这是整个项目最核心的代码,也是理解 Agent 最关键的一步:

const messages = [
  new SystemMessage('你是一个代码助手,可以使用工具读取文件并解释代码。'),
  new HumanMessage('请读取 src/tool.mjs 文件内容并解释代码'),
];

let response = await modelWithTools.invoke(messages);
messages.push(response);

// ReAct 循环
while (response.tool_calls && response.tool_calls.length > 0) {
  console.log(`[检测到 ${response.tool_calls.length} 个工具调用]`);

  // 并行执行所有工具调用
  const toolResults = await Promise.all(
    response.tool_calls.map(async (toolCall) => {
      const targetTool = tools.find(t => t.name === toolCall.name);
      if (!targetTool) return `错误:找不到工具 ${toolCall.name}`;

      try {
        return await targetTool.invoke(toolCall.args);
      } catch (err) {
        return `错误:${err.message}`;
      }
    })
  );

  // 把工具结果塞回 messages,用 tool_call_id 关联
  response.tool_calls.forEach((toolCall, index) => {
    messages.push(new ToolMessage({
      content: toolResults[index],
      tool_call_id: toolCall.id,
      tool_call_type: toolCall.type,
    }));
  });

  // 继续调用 LLM,让它决定下一步
  response = await modelWithTools.invoke(messages);
  messages.push(response);
}

// 循环结束,LLM 不再需要工具,输出最终结果
console.log(response.content);

几个容易踩坑的地方:

1. 为什么要用 Promise.all?

一次任务可能调多个工具。比如用户说"读 index.mjs 和 tool.mjs",LLM 会返回两个 tool_calls。用 Promise.all 并行执行,比串行快多了。

简单回顾一下 Promise 的三种状态:

  • Pending:等待中
  • Fulfilled(成功):调 resolve() 触发
  • Rejected(失败):调 reject() 触发

Promise.all 等所有 Promise 都完成后,一次性返回结果数组。结果顺序和 Promise 数组顺序一致——即使第一个 Promise 比第二个晚完成,返回顺序还是 [result1, result2]

2. tool_call_id 必须关联

ToolMessage 里的 tool_call_id 必须和 tool_calls 里的 id 一致,否则 LLM 搞不清哪个结果对应哪个调用。这是最容易出 bug 的地方。

3. 循环的退出条件

当 LLM 返回的 tool_calls 为空或不存在时,循环结束。说明 LLM 认为任务已经完成了,直接输出最终文本。

4. messages 数组会越来越长

每轮循环都会往 messages 里追加 ToolMessageAIMessage。如果工具返回的内容很长(比如读了一个大文件),token 开销会迅速爆炸。真实项目中需要做 消息裁剪——比如只保留最近 N 轮,或者对早期消息做摘要压缩。


一个容易忽略的细节:给用户反馈

Agent 任务可能很耗时——读大文件、调接口、执行命令。如果你不给用户反馈,用户以为程序卡死了就关掉了。

所以工具函数里记得加日志:

console.log(`[工具调用] read_file(${filePath}) 成功读取 ${content.length} 字节`);

看起来不起眼,但这是用户体验的关键细节。Claude Code 为什么体验好?就是因为每一步都有清晰的执行反馈。


进阶:Node.js 子进程干 CLI 的活

如果你想让 Agent 能执行 npm installvite create 这类命令,Node.js 主线程是扛不住的(它单线程嘛),需要用 child_process 创建子进程:

import { spawn } from 'node:child_process';

function execCommand(cmd, args = []) {
  return new Promise((resolve, reject) => {
    const child = spawn(cmd, args, { shell: true, stdio: 'pipe' });

    let stdout = '';
    let stderr = '';

    child.stdout.on('data', (data) => { stdout += data; });
    child.stderr.on('data', (data) => { stderr += data; });

    child.on('close', (code) => {
      if (code === 0) resolve(stdout);
      else reject(new Error(stderr || `Exit code ${code}`));
    });
  });
}

架构是这样的:

主进程(Agent 逻辑)
    ↓ spawn 子进程
子进程(执行 CLI 命令)
    ↓ IPC 进程间通信
主进程(拿到结果,继续循环)

这样 Agent 的主循环不会被 CLI 命令阻塞,可以继续处理其他逻辑。


更深一层:为什么 Agent 用 ReAct 而不是简单的 chain?

很多人会问:为什么不能 prompt → LLM → 结果 一步到位?

因为现实任务不是线性的。举个例子:用户说"帮我创建一个 React 项目并运行起来"。

LLM 的推理过程是:

1. 需要先创建项目 → 调用 vite create
2. 需要安装依赖 → 调用 pnpm install
3. 需要启动项目 → 调用 pnpm dev
4. 检查启动是否成功 → 可能需要读日志
5. 如果启动失败 → 调整配置,再试一次

每一步都可能失败,每一步的结果都会影响下一步的决策。这种"边做边想"的模式,是线性 chain 做不到的。

ReAct 的 while 循环天然支持这种动态决策——LLM 每次拿到工具结果后,都能重新评估局势,决定下一步做什么。


总结:Agent 真没那么玄乎

拆开来看就这几样东西:

模块核心作用一句话解释
LLM推理引擎负责"想",决定调什么工具、怎么组合结果
Memory上下文管理负责"记",让多轮对话不丢失
Tool能力扩展负责"做",让模型能干实际的事
RAG知识补全负责"查",查私有知识库补训练数据短板
MCP外部接入负责"连",对接第三方服务和数据源

一个能思考、能记住、能干活、能查资料的扩展后的大模型,就是一个 Agent。

你不需要懂什么高深理论,跑一遍代码就全明白了。