【无标题】

用 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

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

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 做了两件事:

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

这是 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。

相关推荐
Pedantic1 小时前
SwiftUI 手势层级(Gesture Hierarchy)详解
前端
飘尘1 小时前
前端转型全栈(Java后端)的快速上手指引
前端·后端·全栈
一颗烂土豆1 小时前
Meshopt 压缩深度解析,为什么它比 Draco 更快
前端·javascript·webgl
浏览器工程师2 小时前
AI Agent 接浏览器任务,先别让它一路点到底
前端·后端
雨季mo浅忆2 小时前
VSCode自动格式化三要素
前端
爱勇宝3 小时前
深扒 Anthropic 1680 位工程师简历:应届生几乎没机会,AI 公司最缺的不是博士
前端·后端·程序员
kyriewen4 小时前
同事每天催我 Code Review,我写了个脚本让 AI 替我 review PR——现在他反过来催 AI 了
前端·javascript·ai编程
user20585561518136 小时前
Windows 项目安装时报 `node-sass` 错误,如何快速处理
前端
LiaCode6 小时前
Redis 在生产项目的使用
前端·后端