为什么你调的 LLM 接口不值钱?——从零理解 Agent 开发的正确姿势
为什么你调的 LLM 接口不值钱?——从零理解 Agent 开发的正确姿势
2026年7月6日 · 学习总结 · 实战教学向
一、开篇:一个让你重新思考的问题
来,先看一段代码:
constmodel=newChatOpenAI({modelName:'deepseek-v4-flash',/* ... */});constresponse=awaitmodel.invoke('你好,推荐一款蛋白粉');console.log(response.content);三行代码,一个 AI 对话就跑起来了。你看着终端里流淌出来的文字,心里暗爽——“就这?AI 开发也太简单了吧。”
然后老板找到你:“小王,做个智能客服,能查我们内部的订单系统、读公司的产品文档、自动帮用户退换货。下班前出个方案。”
你盯着那三行代码,陷入了沉默。
问题出在哪?
那三行代码调的是裸 LLM——一个被困在 API 后面的"大脑"。它智商很高,但它:
- 记不住你上一条消息说了什么(无状态)
- 没法真的去查你的订单数据库(不会执行)
- 完全不知道你们公司有什么产品文档(无私有知识)
- 回答不了"今天股价多少"(训练数据过时)
- 处理不了"帮我做一份 Q3 分析 PPT"这种多步骤任务(不会编排)
你花十分钟学会的那三行代码,只解决了"让模型说话"。而值钱的部分——让模型干活——一行都还没开始写。
今天这篇文章,就是要把"说话"到"干活"中间缺的每一块拼图,掰开揉碎了讲清楚。读完你会得到一个公式,和把这个公式落地的全部知识:
Agent = LLM + Memory + Tool + RAG + MCP + Skills
二、裸 LLM 的"五宗罪"及解药
咱们一个一个来。每一点都遵循同一个模式:先看清问题,再给出解法,最后落到代码层面的关键动作。
第一宗:健忘症(Stateless)
问题场景:你跟 LLM 聊了两个小时,把项目背景、技术选型、团队分工全交代清楚了。第二天打开新对话——“你好,请问有什么可以帮您?” 它全忘了。不是它不想记,是它压根没有"记忆"这个能力。LLM 每一次调用都是无状态的,上一轮的输出不会自动变成下一轮的输入。
解法:Memory(记忆模块)
最简单的记忆就是你手动把历史消息拼回去:
constmessages=[newSystemMessage('你是一个代码助手'),newHumanMessage('帮我读 tool.mjs'),];constresponse=awaitmodel.invoke(messages);messages.push(response);// ← 这一行,就是最朴素的 Memory进阶一点,把关键信息存到 Redis、数据库、或者前端 localStorage,在需要的时候检索出来注入上下文。核心思想就一句话:该记住的东西持久化,下次对话塞回去。
第二宗:手残(No Tool Use)
问题场景:你问 LLM “帮我看看这个项目的代码结构”,它能给你写五百字方法论——用什么命令、看哪些文件、怎么分析——但它永远不会真的执行ls或者readFile。它能"告诉你怎么做",不能"替你做"。
解法:Tool Use(工具调用)
给它装上"手"。在 LangChain 里,一个 Tool 长这样:
constreadFileTool=newTool(async({filePath})=>{// ① 干活的函数constcontent=awaitfs.readFile(filePath,'utf-8');returncontent;},{// ② 描述对象(给 LLM 看的说明书)name:'read_file',description:'读取文件内容。当用户要求查看代码、分析文件时调用此工具。',schema:z.object({filePath:z.string().describe('要读取的文件路径'),}),});constmodelWithTools=model.bindTools([readFileTool]);// 注册工具注意这个设计:Tool 有两个部分——函数负责执行,描述对象负责让 LLM 理解"什么时候该用我"。两者职责分离,谁也别越界。
第三宗:知识盲区(No Private Knowledge)
问题场景:你们公司有 300 篇内部 Wiki、500 个 Confluence 页面。随便一个新人都能翻到的信息,LLM 却一无所知——这些私有数据从来没进过训练集。你让它"按内部规范写接口",它只能瞎编一个。
解法:RAG(Retrieval-Augmented Generation,检索增强生成)
流程三步走:
- 索引:把内部文档切片 → 向量化 → 存入向量数据库
- 检索:用户提问时,从向量库检索最相关的文档片段
- 生成:把检索结果拼进 Prompt,交给 LLM 生成回答
本质上是给 LLM 配了一位"内部档案馆馆长"——你问问题,馆长先去翻档案,把找到的资料连同问题一起递给 LLM。
第四宗:信息滞后(No Real-time Data)
问题场景:“今天深圳天气怎么样?”“比特币最新价格?”“刚结束的欧洲杯谁夺冠了?” LLM 的训练数据有截止日期,在此之后的事,它一问三不知。
解法:MCP(Model Context Protocol,模型上下文协议)
你可以把 MCP 理解为"Tool 的 USB 接口标准"——不管你接入的是天气 API、股票数据源、还是新闻 RSS,只要遵循 MCP 协议,LLM 就能即插即用。它解决了 Tool 的标准化问题:不用每次接入新数据源都写一套胶水代码。
第五宗:不会编排(No Task Orchestration)
问题场景:“帮我做一份 Q3 业务分析 PPT——从数据库拉数据,用 ECharts 画图,导出 PDF”——这是一个多步骤任务,涉及数据查询、可视化、文档生成三个环节。裸 LLM 只能一段段给你文字建议,它不会自动拆解任务、按顺序执行、处理中间异常、交付最终成品。
解法:Skills(技能蒸馏)
把经过验证的复杂任务流程固化,变成可复用的"技能包"。一次打磨,反复调用。这背后是 LangGraph 这类多智能体编排框架的用武之地——每个子任务分给一个专门的 Agent,由一个"调度者"统一协调。
一张图收束
| 缺陷 | 症状 | 解药 | 一句话 |
|---|---|---|---|
| 无状态 | 记不住对话 | Memory | 持久化 + 塞回上下文 |
| 不会执行 | 只能给思路 | Tool Use | 函数干活,描述对象给 LLM 看 |
| 不知内档 | 不懂私有文档 | RAG | 检索 → 注入 Prompt → 生成 |
| 信息滞后 | 没有实时数据 | MCP | Tool 的标准化协议 |
| 不会编排 | 处理不了复杂任务 | Skills | 任务流程固化复用 |
五个问题,五个解药。把它们装到一起——一个知道内部知识、能动手干活、有记忆、能联网、能编排复杂任务的大模型——这就是 Agent。
三、解剖一个 Tool:LLM 是怎么"长出手"的?
理论讲完,上硬菜。我们逐行拆解今天写的tool.mjs,这是理解 Agent 工作机制最核心的代码。
3.1 Tool 的定义:两段式结构
constreadFileTool=newTool(// ========== 第一部分:执行函数 ==========async({filePath})=>{constcontent=awaitfs.readFile(filePath,'utf-8');console.log(`[工具调用] read_file (${filePath}) 成功读取${content.length}字节`);returncontent;},// ========== 第二部分:描述对象 =========={name:'read_file',description:'用此工具读取文件内容,当用户要求读取文件、查看代码、分析文件内容时,调用此工具。输入文件路径(可以是相对路径或绝对路径)',schema:z.object({filePath:z.string().describe('要读取的文件路径'),}),});这里有三个值得停下来想一想的细节:
细节一:description是写给 LLM 看的,不是写给人看的。你写的description会直接影响到 LLM 的 tool selection 准确率。写得敷衍——“读取文件”——LLM 可能不知道该什么时候用。写得详细——“当用户要求读取文件、查看代码、分析文件内容时调用”——LLM 就能精准命中场景。把 description 当成 API 文档来写,消费者是 LLM。
细节二:schema用 Zod 做运行时类型校验。z.object({ filePath: z.string().describe('...') })这行代码的价值在于——LLM 虽然强大,但它生成的参数格式偶尔也会出错。Zod 在你的函数执行之前做了一层验证,参数不对直接报错,不会让错误的数据流进业务逻辑。永远不要信任 LLM 生成的参数格式。
细节三:工具函数里的console.log。这不是调试代码,是用户体验设计——Agent 的任务可能很耗时(读大文件、调慢接口),如果界面长时间没有任何反馈,用户会焦虑、会怀疑是不是卡死了、会直接关掉页面。每隔几秒汇报一下进度,是 Agent 开发的基本素养。
3.2 tool_calls 的流转:LLM 怎么告诉你"我要用工具了"
当你把 Tool 通过bindTools()注册给模型后,对话流程变成了这样:
用户: "读取 tool.mjs 的内容并解释" ↓ LLM 推理: "我需要读文件,用 read_file 工具" ↓ LLM 停止生成文字,返回 tool_calls: [{ id: "call_abc123", // 唯一标识,关联后续结果 name: "read_file", // 工具名 arguments: { // 参数(由 schema 约束) filePath: "tool.mjs" } }] ↓ 你的代码执行 readFileTool("tool.mjs") ↓ 返回 ToolMessage: { tool_call_id: "call_abc123", // ← 靠这个 id 关联! content: "<文件内容>" } ↓ 把 ToolMessage 追加到 messages 数组 ↓ LLM 拿到文件内容,继续推理 → 生成代码解释关键设计:id字段的作用。Agent 可能同时调用多个工具——比如读 3 个文件——每个工具的执行耗时不同,返回顺序可能和调用顺序不一致。id就是用来把"哪个返回结果对应哪个调用请求"关联起来的,保证 LLM 拿到的是正确的上下文。就像快递单号——你同时下了三单,到货时间不一样,但每个包裹上都有单号,你不会搞混。
3.3 消息历史:Agent 的"短期记忆"
constmessages=[newSystemMessage('你是一个代码分析助手...'),newHumanMessage('请读取 tool.mjs 文件的内容并解释代码'),];letresponse=awaitmodelWithTools.invoke(messages);messages.push(response);// ← 把 LLM 的响应(可能包含 tool_calls)推进去// 如果 response 里有 tool_calls,执行工具后:// messages.push(new ToolMessage(result, tool_call_id));// 然后再次 invoke(messages),LLM 就能看到工具执行结果messages数组就是对话的"记忆带"。每一次invoke()都带着完整的消息历史,LLM 才能理解上下文。这里有一个工程上的trade-off:消息历史越长,token 消耗越大、响应越慢。生产环境中你需要在"保留足够上下文"和"控制 token 成本"之间找平衡——裁剪旧消息、摘要压缩、只保留关键轮次,都是常用手段。
四、Promise.all:决定 Agent 是"能用"还是"好用"的分水岭
4.1 先复习 Promise 的三种状态
这是今天 1.html 里讲的基础,但它是理解 Agent 性能优化的前提:
Promise 状态机: pending ──→ fulfilled (resolved) └──────→ rejected 规则:状态只能从 pending 出发,一旦到达 fulfilled 或 rejected,永久定型,不可逆转。类比:你下单了一个快递。下单那一刻是pending,快递到了是fulfilled,丢件了是rejected。不管是到了还是丢了,这单的状态就此锁定,不会今天到了明天又变回"运输中"。
4.2 await 的陷阱:你以为在并行,其实在排队
// ❌ 反模式:串行等待console.time('serial');constweather=awaitgetWeather();// 等 2000msconsttweets=awaitgetTweets();// 再等 500msconsole.timeEnd('serial');// → 约 2500ms这段代码的问题在于:getWeather()和getTweets()之间没有任何依赖关系——天气数据不需要推文作为输入,推文也不需要等天气结果。但你用await把它们写成了串行——第二个操作干等第一个操作完成,白白浪费了 500ms。
4.3 Promise.all:让独立任务一起跑
// ✅ 正解:并行执行console.time('parallel');const[weather,tweets]=awaitPromise.all([getWeather(),// 2000msgetTweets(),// 500ms —— 和上面同时跑!]);console.timeEnd('parallel');// → 约 2000ms(以最慢的为准)// 节省 500ms,性能提升 20%Promise.all的语义:
- 接收一个 Promise 数组
- 同时启动所有 Promise(不是排队,是一起发车)
- 等所有Promise 都完成才返回
- 返回的结果顺序与传入的数组顺序一致(不是按完成先后)
4.4 推广到 Agent:这才是真正的性能优化
回到 Agent 场景。假设你需要同时读取 5 个代码文件来分析项目结构:
// ❌ 串行:5 × 200ms = 1000msfor(constfileoffiles){constcontent=awaitreadFileTool.invoke({filePath:file});}// ✅ 并行:max(200, 200, 200, 200, 200) = 200msconstresults=awaitPromise.all(files.map(f=>readFileTool.invoke({filePath:f})));800ms 的差距。而这还是一个保守的估算。实际场景中,Tool 可能包括数据库查询、HTTP 请求、文件 I/O——这些操作的耗时差异更大,并行的收益也就更大。
Agent 性能优化的第一原则:凡是彼此间没有数据依赖的 Tool 调用,一律用 Promise.all 并行执行。不需要更快的硬件,不需要更复杂的算法,只需要用对并发模式。这就是"工程素养"——同样的工具,不同的人用出来,性能天差地别。
五、两个 Demo 串起来的完整学习链路
今天写了两个 Demo,它们合在一起构成了从"调接口"到"做 Agent"的完整学习路径:
Demo 1:index.mjs — 统一接口的价值
import{ChatOpenAI}from'@langchain/openai';constmodel=newChatOpenAI({modelName:'deepseek-v4-flash',apiKey:process.env.DEEPSEEK_API_KEY,configuration:{baseURL:'https://api.deepseek.com/v1'},});constresponse=awaitmodel.invoke('你好,推荐一款蛋白粉');console.log(response.content);虽然调的是 DeepSeek,用的是@langchain/openai的类。为什么能这样?因为 DeepSeek 的 API 兼容 OpenAI 规范。而 LangChain 的核心价值之一就是统一抽象——换模型厂商就像换数据库驱动,上层的 Agent 逻辑一行不用改。这种"面向接口编程"的思路,在微服务和 AI 开发中同样适用。
Demo 2:tool.mjs — 从"说话"到"干活"的跃迁
这是今天的重头戏。完整流程:
- 定义 Tool(
new Tool(fn, { name, description, schema })) - 注册 Tool(
model.bindTools([readFileTool])) - 构建消息(
SystemMessage+HumanMessage) - 调用模型(
modelWithTools.invoke(messages)) - 处理 tool_calls(执行工具函数,返回
ToolMessage) - 管理历史(
messages.push(response)) - 继续推理(再次
invoke,LLM 拿到工具结果后生成最终答案)
这七个步骤,就是 Agent 运行的最小闭环。掌握了它,后面学 LangGraph 多智能体编排、NestJS 工程化封装、生产环境部署,都是在这个闭环上做加法。
六、结语:今天迈出的这一步,是最值钱的一步
回头再看开篇那个公式:
Agent = LLM + Memory + Tool + RAG + MCP + Skills
今天你学到的不只是一个公式,而是公式里每一项落到代码层面长什么样:
- Memory=
messages.push(response)+ 数据库持久化 - Tool=
new Tool(async fn, { name, description, schema: z.object({...}) })+model.bindTools() - RAG= 向量检索 + Prompt 注入(下一步深入)
- MCP= 标准化 Tool 协议(下一步深入)
- Skills= LangGraph 多智能体任务编排(下一步深入)
而贯穿始终的性能基石——Promise.all——是你今天掌握的最具性价比的优化手段。
接下来的学习路线也很清晰:
LangChain(单 Agent → 今天已完成) ↓ NestJS(把 Agent 封装成 API 服务) ↓ LangGraph(多 Agent 协作编排) ↓ MCP / RAG / Skills(能力拼图补齐) ↓ 生产环境部署(性能、监控、容错)从model.invoke("你好")到打造一个能自主思考、规划、执行的生产级 Agent——这中间隔的不是代码量,是思维层次。而今天,你已经跨过了第一道门槛。
