用 LangChain 写一个最简 Agent80 行代码搞清楚到底发生了什么Agent 不是魔法本质就是LLM 工具 schema while 循环。这篇博文不用 LangGraph不用 ReAct prompt 模板从零拆给你看。写在前面很多人第一次接触 Agent是从一份铺满了 LangGraph、Checkpointer、StateGraph、MessagesAnnotation 的代码开始的——然后被劝退。但其实一个能跑、能调工具、会自己收尾的 Agent只需要三件东西一个会Tool Calling的 ChatModel任何 OpenAI 兼容模型都行一组用 Zod 描述参数的工具一个最多 20 行的while循环这篇文章就是要把这三件事各自讲清楚最后给你一份不到 80 行、复制即可运行的最简 Agent。读完之后再去看 LangGraph你会发现哦原来都是在这个循环上加东西。L0起点是一个 ChatModel什么都不加先让模型能说话import{ChatOpenAI}fromlangchain/openaiconstllmnewChatOpenAI({model:gpt-4o-mini,apiKey:process.env.API_KEY,configuration:{baseURL:process.env.BASE_URL},// 兼容自部署 / 代理网关temperature:0.1,})constreplyawaitllm.invoke(你好)console.log(reply.content)为什么temperature: 0.1后面要让模型按 schema 输出工具调用温度高了它会自由发挥把 JSON 写错。Tool Calling 和 ReAct 推理对格式要求严格低温度 少格式漂移。到这一步它只是一个聊天接口。它不知道现在几点、不知道某个城市的天气问什么都只能凭训练数据胡诌。我们要让它会用工具。L1加上 System Prompt 和历史聊天接口要变 Agent人格设定和上下文连续性是地基。LangChain 提供了ChatPromptTemplateMessagesPlaceholderimport{ChatPromptTemplate,MessagesPlaceholder}fromlangchain/core/promptsconstSYSTEM_PROMPT你是一个生活助手可以根据用户问题调用工具回答。 - 优先复用历史结果不要重复调用同一工具 - 工具返回为空时告诉用户并建议换种问法 - 不要编造工具没返回的字段constpromptChatPromptTemplate.fromMessages([[system,SYSTEM_PROMPT],newMessagesPlaceholder(history),[human,{input}],])MessagesPlaceholder(history)是个洞调用时塞进去constmessagesawaitprompt.formatMessages({input:北京今天多少度,history:previousMessages,// BaseMessage[]})为什么不直接手拼数组因为 prompt 模板把prompt 定义和runtime 注入分开——同一个模板可以在不同上下文复用prompt 改字的时候不用改业务代码。但到这步它还是个会复读的聊天机器人。下一步是关键。L2bindTools——让模型返回函数调用而不是回答这是 Agent 的灵魂。没有bindTools之前模型只能输出字符串。有了bindTools之后模型可以输出我想调用get_weather参数是{city: 北京}——以结构化字段返回不是字符串。定义一个工具LangChain 的tool()helper 把一个函数 一份 Zod schema打包成模型能识别的工具import{tool}fromlangchain/core/toolsimport{z}fromzodconstgetWeatherTooltool(async({city,date}){// 真正的 HTTP 调用这里只是示意constresawaitfetch(https://example.com/weather?city${city}date${date??today})constdataawaitres.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 里理解参数含义。写得越清楚参数抽得越准。绑定工具constllmWithToolsllm.bindTools([getWeatherTool])constaiawaitllmWithTools.invoke(北京今天天气怎么样)console.log(ai.tool_calls)// [{ name: get_weather, args: { city: 北京 }, id: call_abc }]注意这里ai.content通常是空字符串——模型选择了调用工具而不是直接回答所以回答字段空着调用字段填上。bindTools 的本质剥开 LangChain 的封装bindTools做了两件事把工具 schema 转成 OpenAI 的tools字段一份 JSON Schema 描述告诉模型你可以选择回答或调用工具这是 OpenAI Function Calling 协议DeepSeek、通义、Claude 都兼容这套。LangChain 在这之上做了一层 Zod ↔ JSON Schema 的映射——这就是 90% 教程跳过的胶水// LangChain 适配层做的事简化版functionparameterToZod(param){switch(param.type){casenumber:caseinteger:returnz.number()caseboolean:returnz.boolean()casearray:returnz.array(z.unknown())caseobject:returnz.record(z.string(),z.unknown())default:returnz.string()}}到这一步模型会调一次工具了。但只调一次——还不算 Agent。L3用 while 循环让它自己决定调几次这是 Agent 真正诞生的一步。逻辑就一句话模型返回tool_calls→ 执行工具 → 把结果塞回去再问一次 → 直到模型不再返回tool_calls那就是最终答案。代码import{HumanMessage,SystemMessage,ToolMessage,AIMessage}fromlangchain/core/messagesasyncfunctionrunAgent(userInput:string){constllmWithToolsllm.bindTools([getWeatherTool/*, ...其他工具 */])constmessages:any[][newSystemMessage(SYSTEM_PROMPT),newHumanMessage(userInput),]constMAX_ITER5for(leti0;iMAX_ITER;i){constai:AIMessageawaitllmWithTools.invoke(messages)messages.push(ai)// 收敛模型不再调工具 它觉得已经能答了if(!ai.tool_calls||ai.tool_calls.length0){returnai.contentasstring}// 把每个工具调用都执行掉结果作为 ToolMessage 塞回去for(constcallofai.tool_calls){consttool[getWeatherTool].find(tt.namecall.name)if(!tool){messages.push(newToolMessage({content:未知工具:${call.name},tool_call_id:call.id!,}))continue}constresultawaittool.invoke(call.args)messages.push(newToolMessage({content:typeofresultstring?result:JSON.stringify(result),tool_call_id:call.id!,}))}}return达到最大迭代次数未能给出答案}就这么多。这就是一个 Agent。跑一下constanswerawaitrunAgent(北京今天的天气怎么样如果下雨提醒我带伞)// 模型会// 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_CHARS2000functiontruncate(s:string){returns.lengthMAX_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你会发现自己直接看到本质。完整可运行 Demoimport{ChatOpenAI}fromlangchain/openaiimport{tool}fromlangchain/core/toolsimport{HumanMessage,SystemMessage,ToolMessage,typeAIMessage}fromlangchain/core/messagesimport{z}fromzodconstllmnewChatOpenAI({model:gpt-4o-mini,apiKey:process.env.API_KEY,configuration:{baseURL:process.env.BASE_URL},temperature:0.1,})constgetWeatherTooltool(async({city}:{city:string}){// 模拟数据实际项目里换成真实 API 调用constmock:Recordstring,string{北京:晴, 24°C,上海:多云, 22°C,广州:雷阵雨, 28°C,}return${city}今天天气${mock[city]??暂无数据}},{name:get_weather,description:查询某个城市今天的天气,schema:z.object({city:z.string().describe(城市名称如 北京),}),})consttools[getWeatherTool]constllmWithToolsllm.bindTools(tools)constSYSTEM_PROMPT你是生活助手根据用户问题调用工具回答回答简洁自然。asyncfunctionrunAgent(userInput:string):Promisestring{constmessages:any[][newSystemMessage(SYSTEM_PROMPT),newHumanMessage(userInput),]for(leti0;i5;i){constai(awaitllmWithTools.invoke(messages))asAIMessage messages.push(ai)constcallsai.tool_calls??[]if(calls.length0){returntypeofai.contentstring?ai.content:JSON.stringify(ai.content)}for(constcallofcalls){constttools.find((x)x.namecall.name)constresultt?awaitt.invoke(call.argsasany):未知工具:${call.name}messages.push(newToolMessage({content:typeofresultstring?result:JSON.stringify(result),tool_call_id:call.id!,}))}}return达到最大迭代次数}// 跑起来runAgent(北京今天天气怎么样).then(console.log)环境变量API_KEY你的 keyBASE_URLhttps://api.openai.com# 也可以指向任意 OpenAI 兼容代理网关pnpm add langchain/openai langchain/core zod跑起来即可。结语Agent 不是大模型的某种特殊能力是外部代码用 while 循环驱动模型的一种使用模式。模型能 Tool Calling意味着它能输出结构化指令。我们写while循环意味着我们决定何时停。两者结合 Agent。