一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

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

时间:2026-07-11 10:36:06 编辑:袖梨 来源:一聚教程网

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

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

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

先搞清楚一个问题: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-langchainpnpm initpnpm 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 create2. 需要安装依赖 → 调用 pnpm install3. 需要启动项目 → 调用 pnpm dev4. 检查启动是否成功 → 可能需要读日志5. 如果启动失败 → 调整配置,再试一次

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

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

总结:Agent 真没那么玄乎

拆开来看就这几样东西:

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

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

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

热门栏目