跳到主要内容

创建日期:2026-09-14 | 最近更新:2026-09-14 本机真实运行(@langchain/langgraph 1.4.15 + @langchain/core 1.2.11);下方工具调用与消息序列均为实测。

实战:用 LangGraph 写一个 ReAct Agent(并和手写循环对照)

前两篇有了零件:LangChain 给你模型/工具/结构化输出,LangGraph 给你状态/循环/记忆。这篇把它们拼成一个会自己调工具、多轮完成任务的 Agent——createReactAgent 一行搞定。最后对照你手写的那个 while,说清「什么时候用框架、什么时候该自己写」。

1. 定义工具:tool() + zod schema

LangChain 用一个 helper 把「函数 + 名称 + 描述 + 参数 schema」打包成工具:

import { tool } from '@langchain/core/tools';
import { z } from 'zod';

const CITY = { 北京: 2189, 上海: 2487, 杭州: 1262, 深圳: 1768 };

const cityPopulation = tool(async ({ city }) => {
const v = CITY[city];
console.log(` ▶ 工具被调用: city_population(${city}) -> ${v ?? '无数据'}`);
return v ? `${city} 人口 ${v}` : `没有 ${city} 数据`;
}, {
name: 'city_population',
description: '查询城市人口(万人)', // ← 描述会进模型提示,直接影响它要不要调、调哪个
schema: z.object({ city: z.string() }), // ← zod schema,模型看的参数声明
});

对照 frontend-agent 篇 1:你写的是 {name, description, input_schema, run} 四件套;LangChain 只是把「JSON Schema 声明」换成了 zod(省得手写 JSON),并统一了执行与回填。

2. 一行创建 Agent

import { createReactAgent } from '@langchain/langgraph/prebuilt';

const agent = createReactAgent({ llm: model, tools: [cityPopulation] });

const res = await agent.invoke(
{ messages: [{ role: 'user', content: '查一下杭州和深圳的人口,然后告诉我哪个多。' }] },
{ configurable: { thread_id: 't1' } }, // ← 传 thread_id 就有记忆
);
console.log(res.messages.at(-1).text);

createReactAgent 做的,正是你手写过的那套:「模型节点」→ 有 tool_calls 就去「工具节点」执行 → 结果作为消息回填 → 回到模型 → 直到不再调工具。区别只是它被封装成了一张 LangGraph 图。

3. 真实运行输出(本机实测)

▶ 工具被调用: city_population(杭州) -> 1262
▶ 工具被调用: city_population(深圳) -> 1768
消息角色序列: HumanMessage -> AIMessage -> ToolMessage -> ToolMessage -> AIMessage
最终回答: "查询结果如下:

| 城市 | 人口 |
|------|------|
| 杭州 | 1262 万 |
| 深圳 | 1768 万 |

**深圳人口更多**,比杭州多约 506 万人(1768 − 1262 = 506)。

注:以上为工具返回的数据,通常为常住人口口径的概略值,具体请以官方最新统计公报为准。"

三个值得注意的点:

  1. 模型并行调了两个工具(杭州、深圳互不依赖)→ 于是出现连续两个 ToolMessage
  2. 消息序列就是一次完整的 ReAct 循环Human → AI(tool_calls) → Tool → Tool → AI(最终回答)
  3. 主动标注了数据口径免责——这就是「模型 + 真实工具结果」合起来的效果。

4. 和手写循环对照:框架替你做了什么

把这篇和 frontend-agent 复杂 agent 放一起看:

环节手写(frontend-agent)框架(LangGraph)
循环while (有 tool_use)预置的图 + 条件回边
消息回填手拼 tool_result 块(还要合并成一条)ToolMessage 自动处理
多工具并行自己遍历 tool_uses自动按 tool_calls 执行
记忆自己落文件/内存变量checkpointer + thread_id
可观测自己 console.log事件流 / 回调 / 追踪
控制力完全在你手里(白名单、落盘校验、确认闸门)约定大于配置,想改要读源码

结论(选型建议)

  • 想快速搭、要记忆/事件流/生态 → 用 createReactAgent
  • 要精细控制(文件沙箱、重动作确认、以落盘为准的完成判定)→ 像 InkOS 那样在图之上再叠约束,或者干脆手写;
  • 现实项目里常见组合:框架搭骨架,关键节点自己写

5. 三个坑(都和推理模型有关)

  1. tool_choice 不能强制:想「务必先调某个工具」时,推理模型会报 Thinking mode does not support this tool_choice——让它 auto(默认)即可(详见篇 1);
  2. maxTokens 要给够:推理模型的 thinking 会先吃掉 token,给太小会出现「工具没调、文本也空」;
  3. 工具描述会被当真description 写「查询城市人口(万人)」,模型就按这个理解调用;描述含糊会导致调错工具或该调不调。

6. 接下来能做什么

  • 流式agent.stream(..., { streamMode: 'values' | 'updates' }) 拿到逐步事件(做打字机/进度条);
  • 人工介入(HITL):用 LangGraph 的 interrupt 在关键节点暂停,等人确认——这就是「重动作确认闸门」的框架实现;
  • 多 Agent:把子图当节点(Supervisor / Swarm 模式);
  • 部署:LangGraph 有配套的 langgraph-sdk/Platform,把图当服务跑。

系列小结

LangChain.js → 模型 / 提示 / 解析 / 工具(组件)
LangGraph.js → 状态 / 节点 / 边 / 记忆(编排)
createReactAgent → 把上面两者拼成 ReAct Agent(一行起步)

你手写过循环之后再看这套,会发现:框架没有魔法,它只是把你写过的 while、消息拼装、状态保存,做成了标准件与图。

动手

  1. 给工具集加一个 city_weather,问「杭州天气如何,顺便告诉我人口」,看它怎么组合调用;
  2. createReactAgentcheckpointer: new MemorySaver(),同一个 thread_id 追问「刚才查的是哪两个城市?」;
  3. 把工具的 description 改得含糊(如「查点东西」),观察模型的调用行为变化。

自测

  1. createReactAgent 内部对应你手写循环的哪几步?
  2. 为什么会出现连续两个 ToolMessage
  3. thread_id + checkpointer 的作用是什么?
  4. 什么情况下你会选择手写而不是用框架?
  5. 推理模型下,maxTokens 与工具调用有什么关系?

参考