当前位置: 首页 > news >正文

用 LangChain 写一个最简 Agent:80 行代码搞清楚到底发生了什么

用 LangChain 写一个最简 Agent:80 行代码搞清楚到底发生了什么

Agent 不是魔法,本质就是LLM + 工具 schema + while 循环
这篇博文不用 LangGraph,不用 ReAct prompt 模板,从零拆给你看。

写在前面

很多人第一次接触 Agent,是从一份铺满了 LangGraph、Checkpointer、StateGraph、MessagesAnnotation 的代码开始的——然后被劝退。

但其实一个能跑、能调工具、会自己收尾的 Agent,只需要三件东西:

  1. 一个会Tool Calling的 ChatModel(任何 OpenAI 兼容模型都行)
  2. 一组用 Zod 描述参数的工具
  3. 一个最多 20 行的while循环

这篇文章就是要把这三件事各自讲清楚,最后给你一份不到 80 行、复制即可运行的最简 Agent。读完之后再去看 LangGraph,你会发现"哦原来都是在这个循环上加东西"。


L0:起点是一个 ChatModel

什么都不加,先让模型能说话:

import{ChatOpenAI}from'@langchain/openai'constllm=newChatOpenAI({model:'gpt-4o-mini',apiKey:process.env.API_KEY,configuration:{baseURL:process.env.BASE_URL},// 兼容自部署 / 代理网关temperature:0.1,})constreply=awaitllm.invoke('你好')console.log(reply.content)

为什么temperature: 0.1?后面要让模型按 schema 输出工具调用,温度高了它会"自由发挥"把 JSON 写错。Tool Calling 和 ReAct 推理对格式要求严格,低温度 = 少格式漂移

到这一步它只是一个聊天接口。它不知道现在几点、不知道某个城市的天气,问什么都只能凭训练数据胡诌。

我们要让它会用工具


L1:加上 System Prompt 和历史

聊天接口要变 Agent,人格设定上下文连续性是地基。LangChain 提供了ChatPromptTemplate+MessagesPlaceholder

import{ChatPromptTemplate,MessagesPlaceholder}from'@langchain/core/prompts'constSYSTEM_PROMPT=`你是一个生活助手,可以根据用户问题调用工具回答。 - 优先复用历史结果,不要重复调用同一工具 - 工具返回为空时,告诉用户并建议换种问法 - 不要编造工具没返回的字段`constprompt=ChatPromptTemplate.fromMessages([['system',SYSTEM_PROMPT],newMessagesPlaceholder('history'),['human','{input}'],])

MessagesPlaceholder('history')是个,调用时塞进去:

constmessages=awaitprompt.formatMessages({input:'北京今天多少度?',history:previousMessages,// BaseMessage[]})

为什么不直接手拼数组?因为 prompt 模板把prompt 定义runtime 注入分开——同一个模板可以在不同上下文复用,prompt 改字的时候不用改业务代码。

但到这步它还是个会复读的聊天机器人。下一步是关键。


L2:bindTools——让模型"返回函数调用"而不是回答

这是 Agent 的灵魂。

没有bindTools之前:模型只能输出字符串。
有了bindTools之后:模型可以输出"我想调用get_weather,参数是{city: '北京'}"——以结构化字段返回,不是字符串。

定义一个工具

LangChain 的tool()helper 把"一个函数 + 一份 Zod schema"打包成模型能识别的工具:

import{tool}from'@langchain/core/tools'import{z}from'zod'constgetWeatherTool=tool(async({city,date})=>{// 真正的 HTTP 调用(这里只是示意)constres=awaitfetch(`https://example.com/weather?city=${city}&date=${date??'today'}`)constdata=awaitres.json()return`${city}${date??'今天'}${data.condition}, 温度${data.temp}°C`},{name:'get_weather',description:'查询某个城市的天气情况,支持指定日期',schema:z.object({city:z.string().describe('城市名称,如 "北京"、"上海"'),date:z.string().optional().describe('日期,格式 YYYY-MM-DD,缺省时为今天'),}),})

关键点describe()不是装饰,是给 LLM 看的。模型看不到代码注释,只能从 description 里理解参数含义。写得越清楚,参数抽得越准

绑定工具

constllmWithTools=llm.bindTools([getWeatherTool])constai=awaitllmWithTools.invoke('北京今天天气怎么样?')console.log(ai.tool_calls)// [{ name: 'get_weather', args: { city: '北京' }, id: 'call_abc' }]

注意这里ai.content通常是空字符串——模型选择了"调用工具"而不是"直接回答",所以回答字段空着,调用字段填上

bindTools 的本质

剥开 LangChain 的封装,bindTools做了两件事:

  1. 把工具 schema 转成 OpenAI 的tools字段(一份 JSON Schema 描述)
  2. 告诉模型"你可以选择回答或调用工具"

这是 OpenAI Function Calling 协议(DeepSeek、通义、Claude 都兼容这套)。LangChain 在这之上做了一层 Zod ↔ JSON Schema 的映射——这就是 90% 教程跳过的胶水:

// LangChain 适配层做的事(简化版)functionparameterToZod(param){switch(param.type){case'number':case'integer':returnz.number()case'boolean':returnz.boolean()case'array':returnz.array(z.unknown())case'object':returnz.record(z.string(),z.unknown())default:returnz.string()}}

到这一步模型会调一次工具了。但只调一次——还不算 Agent。


L3:用 while 循环让它"自己决定调几次"

这是 Agent 真正诞生的一步。逻辑就一句话:

模型返回tool_calls→ 执行工具 → 把结果塞回去再问一次 → 直到模型不再返回tool_calls,那就是最终答案。

代码:

import{HumanMessage,SystemMessage,ToolMessage,AIMessage}from'@langchain/core/messages'asyncfunctionrunAgent(userInput:string){constllmWithTools=llm.bindTools([getWeatherTool/*, ...其他工具 */])constmessages:any[]=[newSystemMessage(SYSTEM_PROMPT),newHumanMessage(userInput),]constMAX_ITER=5for(leti=0;i<MAX_ITER;i++){constai:AIMessage=awaitllmWithTools.invoke(messages)messages.push(ai)// 收敛:模型不再调工具 = 它觉得已经能答了if(!ai.tool_calls||ai.tool_calls.length===0){returnai.contentasstring}// 把每个工具调用都执行掉,结果作为 ToolMessage 塞回去for(constcallofai.tool_calls){consttool=[getWeatherTool].find(t=>t.name===call.name)if(!tool){messages.push(newToolMessage({content:`未知工具:${call.name}`,tool_call_id:call.id!,}))continue}constresult=awaittool.invoke(call.args)messages.push(newToolMessage({content:typeofresult==='string'?result:JSON.stringify(result),tool_call_id:call.id!,}))}}return'达到最大迭代次数,未能给出答案'}

就这么多。这就是一个 Agent

跑一下:

constanswer=awaitrunAgent('北京今天的天气怎么样?如果下雨提醒我带伞')// 模型会:// iter 1: 调 get_weather({city:'北京'})// iter 2: 看到结果,决定不再调工具,直接生成"今天有雨,记得带伞"

为什么这个循环已经够用了

回头看这 80 行(含工具定义),你会发现它具备了所有"教科书 Agent"的特征:

特征在哪一行体现
自主决策模型自己决定调哪个工具、调几次
工具使用bindTools+ tool_calls
多步推理for循环让它"看了结果再决定下一步"
自然语言收尾tool_calls.length === 0时退出

它能查信息、调多个工具、根据中间结果换方向。没用 LangGraph,没用 ReAct prompt 模板,没用 Agent Executor。


三个一定要懂的细节(别人不讲的)

1.MAX_ITER不是装饰,是救命的

模型会"卡住"。常见情形:

  • 同一工具反复调:参数差一点点,结果差一点点,永远收敛不了
  • 互相打架:模型先调 A,看到结果调 B,又用 B 的结果回去调 A
  • 工具不存在但模型不死心:返回错误,模型换个名字再调

MAX_ITER = 5是经验值,太低会截断真实多步任务,太高会浪费钱(每轮一次 LLM 调用)。生产环境一般会按任务复杂度动态调:

// 简单查询:3 轮// 多步操作:5 轮// 汇总分析:8 轮

2. ToolMessage 内容要截断

如果工具返回 5KB JSON,下一轮整段塞回 prompt——再下一轮再塞——token 是平方级膨胀

实践经验:单条 ToolMessage 不超过 2000 字符。超了就截,让模型基于摘要决策,需要细节再发起新查询。

constMAX_TOOL_RESULT_CHARS=2000functiontruncate(s:string){returns.length<=MAX_TOOL_RESULT_CHARS?s:s.slice(0,MAX_TOOL_RESULT_CHARS)+'\n…(已截断)'}

3. 模型不一定会"承认"自己调用了工具

有些模型在 tool_calls 之外还会在 content 里写"我去帮你查一下"。这种"双轨输出"如果你没处理,最终答案里会出现奇怪的旁白。

简单做法:只看 tool_calls。有 tool_calls 就执行工具忽略 content;没 tool_calls 才把 content 当最终答案。


真实生产里你还需要什么

这个 80 行的 Agent 是起点,不是终点。再往上每加一件事都是另一篇博文:

需求加什么
用户中途要点确认按钮中断协议:检测到特殊"交互工具"时暂停,把决定权交给前端
刷新页面要能续上状态持久化:把 messages 存进数据库 / Checkpointer
多任务并行执行任务规划:先让 LLM 出 plan,再按依赖图并行
可观测性链路追踪:每一轮 LLM 调用都能追溯耗时 / token / 工具结果
防 prompt 注入意图识别前置:闯入工具调用之前先做意图判断

核心循环不会变——这个while永远在那里,只是被裹了一层又一层的工程外壳。

看懂这 80 行,再去看任何 Agent 框架的源码(包括 LangGraph 的createReactAgent),你会发现自己直接看到本质。


完整可运行 Demo

import{ChatOpenAI}from'@langchain/openai'import{tool}from'@langchain/core/tools'import{HumanMessage,SystemMessage,ToolMessage,typeAIMessage}from'@langchain/core/messages'import{z}from'zod'constllm=newChatOpenAI({model:'gpt-4o-mini',apiKey:process.env.API_KEY,configuration:{baseURL:process.env.BASE_URL},temperature:0.1,})constgetWeatherTool=tool(async({city}:{city:string})=>{// 模拟数据:实际项目里换成真实 API 调用constmock:Record<string,string>={北京:'晴, 24°C',上海:'多云, 22°C',广州:'雷阵雨, 28°C',}return`${city}今天天气:${mock[city]??'暂无数据'}`},{name:'get_weather',description:'查询某个城市今天的天气',schema:z.object({city:z.string().describe('城市名称,如 "北京"'),}),})consttools=[getWeatherTool]constllmWithTools=llm.bindTools(tools)constSYSTEM_PROMPT='你是生活助手,根据用户问题调用工具回答,回答简洁自然。'asyncfunctionrunAgent(userInput:string):Promise<string>{constmessages:any[]=[newSystemMessage(SYSTEM_PROMPT),newHumanMessage(userInput),]for(leti=0;i<5;i++){constai=(awaitllmWithTools.invoke(messages))asAIMessage messages.push(ai)constcalls=ai.tool_calls??[]if(calls.length===0){returntypeofai.content==='string'?ai.content:JSON.stringify(ai.content)}for(constcallofcalls){constt=tools.find((x)=>x.name===call.name)constresult=t?awaitt.invoke(call.argsasany):`未知工具:${call.name}`messages.push(newToolMessage({content:typeofresult==='string'?result:JSON.stringify(result),tool_call_id:call.id!,}))}}return'达到最大迭代次数'}// 跑起来runAgent('北京今天天气怎么样?').then(console.log)

环境变量:

API_KEY=你的 keyBASE_URL=https://api.openai.com# 也可以指向任意 OpenAI 兼容代理网关

pnpm add @langchain/openai @langchain/core zod,跑起来即可。


结语

Agent 不是大模型的某种特殊能力,是外部代码用 while 循环驱动模型的一种使用模式

模型能 Tool Calling,意味着它能输出结构化指令。
我们写while循环,意味着我们决定何时停。
两者结合 = Agent。

http://www.jsqmd.com/news/806401/

相关文章:

  • Linux开发工具
  • AI辅助Android开发:新时代的工程师技能要求与面试指南
  • 从富士康辩论看电子制造业:效率、成本与人性的技术平衡
  • Bunge组织架构重组:农业巨头的战略转型解析
  • 蓝桥杯EDA国赛备赛
  • AI 赋能的 Android 开发:迈向智能化、高效化的新范式
  • 特征提取:从手工特征到深度学习
  • Linux Deadline 调度器的应用场景:4K 视频解码与自动驾驶控制
  • 火山引擎 Agent Plan 初体验实测
  • ARM ETE Trace ID寄存器详解与应用
  • 如何解决多平台加密音乐格式不兼容问题?Unlock Music浏览器端解密技术深度解析
  • MISRA C与CERT C编码标准在汽车电子安全中的协同应用
  • Arm CoreSight TRCPIDR寄存器组解析与应用
  • Gemini3.1Pro数学代码推理能力再突破
  • 锂离子电池安全防护与加密电量计技术解析
  • AI辅助Android开发:从传统到智能化的技术演进
  • 开源状态监控工具openclaw-status:从原理到部署的完整实践指南
  • AI辅助下的ROS2开发:人形机器人在巡检场景中的应用实践
  • 罗技PUBG鼠标宏完整配置教程:告别压枪烦恼,轻松提升射击稳定性
  • 镜像视界视觉重构技术|跨镜轨迹全域贯穿,无感定位精细化管控白皮书
  • 常见404 500错误解析
  • 2026年4月食品输送带供应商口碑推荐,pvc输送带/食品输送带/输送带/工业皮带,食品输送带供应链有哪些 - 品牌推荐师
  • 大模型赋能能源转型:小白程序员必收藏的入门与进阶指南
  • 轻量级实时数据流异常检测:Entropy库原理与工程实践
  • InputTip:提升表单体验的动态输入引导组件设计与实战
  • 指针 引用区别
  • ARM AMU与PMU架构详解及性能监控实践
  • 3步掌握透明悬浮浏览器:终极多任务效率提升指南
  • OpenClaw/GenPark可视化设计器:图形化构建自动化流程
  • AI辅助开发在嵌入式软件工程(机器人方向)中的应用:技术深度解析与实践指南