用 LangChain 写一个最简 Agent:80 行代码搞清楚到底发生了什么
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
什么都不加,先让模型能说话:
ts
import { ChatOpenAI } from '@langchain/openai'
const llm = new ChatOpenAI({
model: 'gpt-4o-mini',
apiKey: process.env.API_KEY,
configuration: { baseURL: process.env.BASE_URL }, // 兼容自部署 / 代理网关
temperature: 0.1,
})
const reply = await llm.invoke('你好')
console.log(reply.content)
为什么
temperature: 0.1?后面要让模型按 schema 输出工具调用,温度高了它会"自由发挥"把 JSON 写错。Tool Calling 和 ReAct 推理对格式要求严格,低温度 = 少格式漂移。
到这一步它只是一个聊天接口。它不知道现在几点、不知道某个城市的天气,问什么都只能凭训练数据胡诌。
我们要让它会用工具。
L1:加上 System Prompt 和历史
聊天接口要变 Agent,人格设定 和上下文连续性 是地基。LangChain 提供了 ChatPromptTemplate + MessagesPlaceholder:
ts
import { ChatPromptTemplate, MessagesPlaceholder } from '@langchain/core/prompts'
const SYSTEM_PROMPT = `你是一个生活助手,可以根据用户问题调用工具回答。
- 优先复用历史结果,不要重复调用同一工具
- 工具返回为空时,告诉用户并建议换种问法
- 不要编造工具没返回的字段`
const prompt = ChatPromptTemplate.fromMessages([
['system', SYSTEM_PROMPT],
new MessagesPlaceholder('history'),
['human', '{input}'],
])
MessagesPlaceholder('history') 是个洞,调用时塞进去:
ts
const messages = await prompt.formatMessages({
input: '北京今天多少度?',
history: previousMessages, // BaseMessage[]
})
为什么不直接手拼数组?因为 prompt 模板把 prompt 定义 和 runtime 注入分开------同一个模板可以在不同上下文复用,prompt 改字的时候不用改业务代码。
但到这步它还是个会复读的聊天机器人。下一步是关键。
L2:bindTools------让模型"返回函数调用"而不是回答
这是 Agent 的灵魂。
没有 bindTools 之前 :模型只能输出字符串。
有了 bindTools 之后 :模型可以输出"我想调用 get_weather,参数是 {city: '北京'}"------以结构化字段返回,不是字符串。
定义一个工具
LangChain 的 tool() helper 把"一个函数 + 一份 Zod schema"打包成模型能识别的工具:
ts
import { tool } from '@langchain/core/tools'
import { z } from 'zod'
const getWeatherTool = tool(
async ({ city, date }) => {
// 真正的 HTTP 调用(这里只是示意)
const res = await fetch(`https://example.com/weather?city=${city}&date=${date ?? 'today'}`)
const data = await res.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 里理解参数含义。写得越清楚,参数抽得越准。
绑定工具
ts
const llmWithTools = llm.bindTools([getWeatherTool])
const ai = await llmWithTools.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% 教程跳过的胶水:
ts
// LangChain 适配层做的事(简化版)
function parameterToZod(param) {
switch (param.type) {
case 'number': case 'integer': return z.number()
case 'boolean': return z.boolean()
case 'array': return z.array(z.unknown())
case 'object': return z.record(z.string(), z.unknown())
default: return z.string()
}
}
到这一步模型会调一次工具了。但只调一次------还不算 Agent。
L3:用 while 循环让它"自己决定调几次"
这是 Agent 真正诞生的一步。逻辑就一句话:
模型返回
tool_calls→ 执行工具 → 把结果塞回去再问一次 → 直到模型不再返回tool_calls,那就是最终答案。
代码:
ts
import { HumanMessage, SystemMessage, ToolMessage, AIMessage } from '@langchain/core/messages'
async function runAgent(userInput: string) {
const llmWithTools = llm.bindTools([getWeatherTool /*, ...其他工具 */])
const messages: any[] = [
new SystemMessage(SYSTEM_PROMPT),
new HumanMessage(userInput),
]
const MAX_ITER = 5
for (let i = 0; i < MAX_ITER; i++) {
const ai: AIMessage = await llmWithTools.invoke(messages)
messages.push(ai)
// 收敛:模型不再调工具 = 它觉得已经能答了
if (!ai.tool_calls || ai.tool_calls.length === 0) {
return ai.content as string
}
// 把每个工具调用都执行掉,结果作为 ToolMessage 塞回去
for (const call of ai.tool_calls) {
const tool = [getWeatherTool].find(t => t.name === call.name)
if (!tool) {
messages.push(new ToolMessage({
content: `未知工具: ${call.name}`,
tool_call_id: call.id!,
}))
continue
}
const result = await tool.invoke(call.args)
messages.push(new ToolMessage({
content: typeof result === 'string' ? result : JSON.stringify(result),
tool_call_id: call.id!,
}))
}
}
return '达到最大迭代次数,未能给出答案'
}
就这么多。这就是一个 Agent。
跑一下:
ts
const answer = await runAgent('北京今天的天气怎么样?如果下雨提醒我带伞')
// 模型会:
// 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 调用)。生产环境一般会按任务复杂度动态调:
ts
// 简单查询:3 轮
// 多步操作:5 轮
// 汇总分析:8 轮
2. ToolMessage 内容要截断
如果工具返回 5KB JSON,下一轮整段塞回 prompt------再下一轮再塞------token 是平方级膨胀。
实践经验:单条 ToolMessage 不超过 2000 字符。超了就截,让模型基于摘要决策,需要细节再发起新查询。
ts
const MAX_TOOL_RESULT_CHARS = 2000
function truncate(s: string) {
return s.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
ts
import { ChatOpenAI } from '@langchain/openai'
import { tool } from '@langchain/core/tools'
import { HumanMessage, SystemMessage, ToolMessage, type AIMessage } from '@langchain/core/messages'
import { z } from 'zod'
const llm = new ChatOpenAI({
model: 'gpt-4o-mini',
apiKey: process.env.API_KEY,
configuration: { baseURL: process.env.BASE_URL },
temperature: 0.1,
})
const getWeatherTool = tool(
async ({ city }: { city: string }) => {
// 模拟数据:实际项目里换成真实 API 调用
const mock: Record<string, string> = {
北京: '晴, 24°C',
上海: '多云, 22°C',
广州: '雷阵雨, 28°C',
}
return `${city} 今天天气:${mock[city] ?? '暂无数据'}`
},
{
name: 'get_weather',
description: '查询某个城市今天的天气',
schema: z.object({
city: z.string().describe('城市名称,如 "北京"'),
}),
}
)
const tools = [getWeatherTool]
const llmWithTools = llm.bindTools(tools)
const SYSTEM_PROMPT = '你是生活助手,根据用户问题调用工具回答,回答简洁自然。'
async function runAgent(userInput: string): Promise<string> {
const messages: any[] = [
new SystemMessage(SYSTEM_PROMPT),
new HumanMessage(userInput),
]
for (let i = 0; i < 5; i++) {
const ai = (await llmWithTools.invoke(messages)) as AIMessage
messages.push(ai)
const calls = ai.tool_calls ?? []
if (calls.length === 0) {
return typeof ai.content === 'string' ? ai.content : JSON.stringify(ai.content)
}
for (const call of calls) {
const t = tools.find((x) => x.name === call.name)
const result = t
? await t.invoke(call.args as any)
: `未知工具: ${call.name}`
messages.push(
new ToolMessage({
content: typeof result === 'string' ? result : JSON.stringify(result),
tool_call_id: call.id!,
})
)
}
}
return '达到最大迭代次数'
}
// 跑起来
runAgent('北京今天天气怎么样?').then(console.log)
环境变量:
bash
API_KEY=你的 key
BASE_URL=https://api.openai.com # 也可以指向任意 OpenAI 兼容代理网关
pnpm add @langchain/openai @langchain/core zod,跑起来即可。
结语
Agent 不是大模型的某种特殊能力,是外部代码用 while 循环驱动模型的一种使用模式。
模型能 Tool Calling,意味着它能输出结构化指令。
我们写 while 循环,意味着我们决定何时停。
两者结合 = Agent。