【无标题】

用 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。

相关推荐
段ヤシ.1 小时前
回顾Java知识点,面试题汇总Day1(持续更新)
java·开发语言
小娄~~1 小时前
多线程函数
c语言·开发语言
Hello.Reader1 小时前
算法基础(九)——循环不变式如何证明一个算法是正确的
java·开发语言·算法
寻道模式1 小时前
【开发心得】给私有部署OpenClaw添加PDF阅读技能
开发语言·python·pdf
逐梦苍穹1 小时前
Claude Code调用Codex失败复盘:从10个Agent、0次codex exec到Bash-only Worker + Hook强制委托
开发语言·chrome·bash
GISer_Jing1 小时前
全栈实战:分支管理到CI/CD全流程
运维·前端·ci/cd·github·devops
赏金术士1 小时前
Kotlin 从入门到进阶 之泛型 模块(七)
android·开发语言·kotlin
代码中介商1 小时前
C++ 异常处理完全指南
开发语言·c++
MATLAB代码顾问1 小时前
粒子群优化算法(PSO)原理与Python高级实现
开发语言·python·算法