做了几年 Java 和 AI,我发现一件挺反直觉的事:身边不少同事 LangChain、AutoGPT、各种 Agent 框架玩得飞起,让他当场从零写一个会自己调工具的智能体,反而愣住了。
不是他们不会写代码,是框架把太多东西包掉了。你只管agent.run("帮我订明天的机票"),至于它怎么决定调哪个工具、怎么把工具结果塞回去、怎么判断该停了,全在黑盒里。用着是爽,真出一个诡异的 bug,你连日志往哪看都不知道。
更尴尬的是现在"Agent"这个词被用得太滥了。加个 if 判断也敢叫智能体,套个提示词模板也敢叫 Agent。听得多了,反而没人说得清它到底比普通聊天机器人多了哪根筋。
所以我开这个系列,叫「Agent 实战」。不堆概念,不抄论文,一期一个能落地的主题,从最底下那层往上拆。今天第 1 期,我们不依赖任何框架,只靠 Node 自带的 fetch,用不到一百行 TypeScript,手搓一个能自己决定"该调哪个工具、调完了再想"的最小智能体。
读完后你能带走三样东西:一句话讲清 Agent 到底是什么、一张完整的一次对话流转图、一份直接能跑的 TS 代码。后面几期再往上叠记忆、规划、多智能体协作。
先把话说清楚:Agent 到底多了哪根筋
先讲个生活里的例子。你让普通聊天机器人"明天北京下雨吗,下的话提醒我带伞",它大概率会给你一段关于北京天气的文字描述,然后停住。它只是把话说顺了,没真的去查、也没真的替你做决定。
你让一个 Agent 做同样的事,它会拆成几步:先调天气工具查北京明天的降水概率,看到概率超过六成,再决定给你发一条带伞提醒。区别在于,Agent 不只是"吐字",它会动手。
它和普通脚本、和 RPA(机器人流程自动化)也不一样。RPA 是按你写死的步骤一步步点按钮,流程变了就报错。Agent 是给模型一组工具和一个目标,让它自己判断每一步怎么走。同样是"查天气再提醒",RPA 你得把判断逻辑亲手写好,Agent 是把判断权交给了模型。
为什么这东西这两年才火?不是因为概念新,ReAct 那篇论文 2022 年底就出了。真正的原因是 2023 年之后大模型推理能力过了某个坎,能比较稳定地输出"我接下来要调工具"这种结构化决策了。模型不够聪明时,你让它规划,它胡说八道;模型够聪明了,这套"边想边干"的循环才转得起来。
一句话总结 Agent 的骨架:一个大模型当脑子,一组工具当手,一段记忆当笔记本,外面套一个循环当节拍器。循环反复问模型同一件事,下一步你打算干嘛,要调工具就说,拿到结果再想,直到能直接回答你为止。这套"边推理边行动"的思路,圈内叫 ReAct,Reasoning 加 Acting,名字挺学术,本质就是边想边干。
拆开看四个零件,每个都有坑
脑子(LLM)不用多说,负责决策和表达。它会犯的错是"自信地乱调工具",比如你问它一个纯知识问题,它偏要调计算器。靠 system 提示词把边界划清楚能缓解,但没法根治,后面踩坑章节会细讲。
手(Tools)是 Agent 真正能产生外部影响的地方,也是它和普通聊天机器人最本质的区别。工具可以是计算器、查数据库、调内部 API、发邮件、跑一段 SQL。模型本身碰不到这些,它只能"说"我要调,真正执行的是你写的函数。这就是 Agent 安全性的命门:模型只有建议权,执行权和边界在你手里。
记忆(Memory)分两种,短期是你传给模型的整段 messages,长期是跨会话存下来的东西,比如用户偏好。本期这个最小版先不接记忆,每次对话都是一张白纸。别急,下期专门讲怎么把记忆接上,那才是 Agent 从"玩具"变"助手"的关键一步。
循环(Loop)是节拍器,负责反复问、反复收、反复判断停没停。它看起来最不起眼,却最容易出事。循环不设上限,模型一旦陷入"调工具、看结果、觉得不对再调"的死结,token 蹭蹭涨,你的账单也跟着跳。本期代码里我给循环套了maxSteps,就是专门防这个。
把四个零件串起来,循环每转一圈其实就三步:思考(模型读上下文,决定下一步)、行动(模型说要调工具,你执行)、观察(把工具结果塞回上下文)。再转下一圈,直到模型觉得能答了,就停。
一整轮对话是怎么转起来的
光讲概念还是虚,我把一次真实对话的 message 流转完整贴出来。看懂这一段,Agent 对你就没有秘密了。假设用户问:"北京今天天气怎么样?顺便帮我算 123 乘 456 等于多少。"
第 1 轮,你发请求。messages 里先放一条 system(你是小关的智能体,手里有计算和查天气两个工具),再放一条 user(上面那个问题)。发给模型。
第 1 轮,模型回。模型读完,决定先查天气。它不会直接吐文字,而是返回一个tool_calls字段,里面写着:我要调get_weather,参数是{"city": "北京"}。注意,这一步模型没有回答问题,它只是"举手说我要干活"。
第 1 轮,你执行。你看到tool_calls,按名字找到本地函数get_weather("北京"),跑出结果,比如"晴,28 度"。
第 1 轮,你回填。把模型那条带tool_calls的消息原样存回 messages,再追加一条 role 为 tool 的消息,内容是"晴,28 度",并带上tool_call_id和刚才那次调用对上号。
第 2 轮,模型再读。现在上下文里多了"北京晴 28 度"这个观察结果。模型想了想,天气回完了,但用户还让算 123 乘 456,于是它又回一个tool_calls:调calculator,参数{"expr": "123*456"}。
第 2 轮,你执行并回填。跑出 56088,再追加一条 tool 消息。
第 3 轮,模型收尾。这次模型不再返回tool_calls,而是直接给出自然语言回答:"北京今天晴,28 度。另外 123 乘 456 等于 56088。"循环结束。
整个过程里,模型从来没有直接算出 56088,它只做了"两次决定调工具"和"一次总结回答"。真正的计算和查天气,都是你写的函数干的。这就是 Agent 和聊天机器人那条分界线,也是它能干实事的原因。
动手:完整可运行版(多工具路由)
下面这个版本在最小版基础上加了一个工具,变成"计算 + 查天气"两个,刚好能演示 Agent 自己选工具的能力。只依赖 Node 18 自带的 fetch,零第三方依赖,保存成agent.ts用ts-node直接跑(或先tsc编译再用node跑),Key 填上就能动。
// agent.ts —— Agent 实战第1期:零框架手搓最小智能体 // 运行:Node 18+,npm i -D typescript ts-node,然后 npx ts-node agent.ts const API_KEY = "你的KEY"; const BASE = "https://api.deepseek.com"; const MODEL = "deepseek-v4-flash"; // 1) 工具清单:模型只能从这里挑。挑错了,先别怪模型,多半是你的描述没写清 const tools = [ { type: "function", function: { name: "calculator", description: "计算数学表达式,比如 23*7+4。只接收合法算术式。", parameters: { type: "object", properties: { expr: { type: "string", description: "算术表达式" } }, required: ["expr"], }, }, }, { type: "function", function: { name: "get_weather", description: "查询指定城市今天的天气,返回温度和天气状况。", parameters: { type: "object", properties: { city: { type: "string", description: "城市名,如 北京" } }, required: ["city"], }, }, }, ]; // 2) 本地工具实现:模型只"说"要调,真正干活的是这些函数 function calculator(args: { expr: string }): string { // 演示用 eval,线上务必换成安全解析器(见踩坑第 3 条) // 注意:Node 的 eval 同样能执行任意代码,这里仅作演示 return String(eval(args.expr)); } function get_weather(args: { city: string }): string { // 这里用假数据,真实场景换成和风天气、高德等开放 API const fake: Record<string, string> = { 北京: "晴,28度", 上海: "阴,25度", 深圳: "雷阵雨,30度", }; return fake[args.city] ?? `${args.city} 未知,默认多云,22度`; } // 3) 工具分发表:名字映射到函数,比一长串 if-else 干净 const toolRegistry: Record<string, (args: any) => string> = { calculator, get_weather, }; async function callLlm(messages: any[]): Promise<any> { const resp = await fetch(`${BASE}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}`, }, body: JSON.stringify({ model: `${MODEL}`, messages, tools, tool_choice: "auto", // 让模型自己决定调不调、调哪个 }), }); const data: any = await resp.json(); return data.choices[0].message; } async function runAgent(question: string, maxSteps = 6): Promise<string> { const messages: any[] = [ { role: "system", content: "你是工具智能体,手里有计算和查天气两个工具。需要时用工具,能直接回答就别硬调。", }, { role: "user", content: question }, ]; for (let step = 1; step <= maxSteps; step++) { console.log(`\n===== 第 ${step} 步 =====`); const msg = await callLlm(messages); if (msg.tool_calls) { // 模型举手说要干活,我们照办 messages.push(msg); for (const tc of msg.tool_calls) { const name = tc.function.name; const args = JSON.parse(tc.function.arguments); console.log(`模型决定调工具:${name},参数:${JSON.stringify(args)}`); const result = toolRegistry[name](args); console.log(`工具返回:${result}`); messages.push({ role: "tool", content: String(result), tool_call_id: tc.id, }); } } else { // 模型不再调工具,说明它觉得能答了 console.log("模型给出最终回答:"); return msg.content; } } return "步数到上限,强制收工,避免无限循环烧钱。"; } runAgent("北京今天天气怎么样?顺便帮我算一下 123 乘 456 等于多少").then(console.log);跑起来控制台如图:
你看,第 1 步它自己选了查天气,第 2 步自己选了算乘法,全程没有你写死任何"先查后算"的 if。这就是 Agent 的妙处:路径是模型临场选的,不是你预设的。
我踩过的坑,你直接拿去避
这些问题我当时一个一个撞过,按出现频率排:
- 循环不设上限。最早我没加
maxSteps,有次模型对一个含糊问题反复调工具十几次,一条请求烧掉快一块钱。现在默认 6 步,复杂任务最多放 10 步。 - 工具返回直接整段塞回上下文。某个工具返回了几千字的 JSON,结果前面的对话全被挤到 token 上限外,模型"失忆"开始胡说。现在关键结果一律截断,只留模型真要用的那几行。
- calculator 用了 eval。这是送上门的远程执行漏洞,Node 的 eval 同样能执行任意代码。线上我改用独立表达式解析库(如 expr-eval)配合运算符白名单,或者自己写个只认加减乘除的解析器,绝不直接 eval 用户串。
- 工具描述写得太省。模型是靠
description判断"什么时候该调这个工具"的。你把描述写成"计算",它就只认字面"计算"两个字;写成"计算数学表达式,如 23*7+4",命中率明显高。描述是给模型看的说明书,不是给你自己看的注释。 - 一次让模型返回多个 tool_calls 时处理不全。有些问题模型会一口气甩出两三个
tool_calls,只处理第一个就会漏活。上面代码用 for 循环把msg.tool_calls全兜住了,别偷懒只取[0]。 - tool 消息忘了带
tool_call_id。模型回你的tool_calls里每个都有 id,回填的 tool 消息必须带上同一个 id 对应上,否则接口直接报错。这个错非常隐蔽,第一次写基本都会踩。 - 把模型当真理。模型会"自信地乱调",比如你问它一个纯知识问题它偏要调计算器。靠 system 提示词把边界划清能缓解,但别指望它 100% 听话。关键动作(发邮件、删数据)一定要在你这层加二次确认,别让模型一句话就把生产库清空了。
- 选太重的模型做简单路由。早期我所有步骤都上 deepseek-v4-pro,后来发现"决定调不调工具"这种活 4o-mini或者flash 就够,换小模型后单请求成本降了七八成,准确率几乎没掉。
什么时候该上 Agent,什么时候别碰
这是我最想强调的一节,因为太多人为了用而用。
适合上 Agent 的场景:任务路径不固定、需要跟外部系统交互、且中间要靠"判断"决定下一步。比如"从用户问题里抽关键词,查内部知识库,拼成答案回复";比如"监控告警,判断严重级别,严重就拉群通知人"。这类活规则写不死,Agent 正好补上。
不该上 Agent 的场景:流程固定、输入输出可枚举。比如"把 CSV 转成 Excel",一个脚本够了,别套 Agent,纯属加延迟加成本。还有对准确率要求极高、错了代价很大的环节,比如直接操作资金转账,宁可多写几个 if,也别把决策权交给模型。
Agent 是给"不确定性"用的。确定性高的活,老老实实写代码,又快又稳又便宜。
进阶路线图:这个系列后面讲什么
本期是地基。往上盖楼,按顺序大概是这样:
- 第 2 期 记忆系统:给 Agent 接上短期和长期记忆,让它跨多轮对话不健忘,能记住"我上次说我喜欢用 Java 不用 Go"。
- 第 3 期 规划与反思:引入"先列步骤再执行",以及执行失败后的自我纠错,从单步反应进化成有策略的多步任务。
- 第 4 期 多智能体协作:一个负责拆解任务,几个分别干检索、计算、校对的活,最后汇总。讲清楚什么时候该拆、怎么避免它们互相甩锅。
- 第 5 期 工具编排与评估:怎么管理十几个工具不混乱,怎么给 Agent 打分看它到底靠不靠谱。
每一期都会延续今天这个"零框架、能跑、有踩坑"的写法,代码同样会传 Gitee。
下期预告与互动
下期我们给这个健忘的智能体接上记忆,让它第一次能"接着上次的话聊",这一步做完,它才真正像个助手而不是一次性计算器。
完整可运行代码已上传 GitHub,评论区回复666即可获取。