创建日期: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)。
注:以上为工具返回的数据,通常为常住人口口径的概略值,具体请以官方最新统计公报为准。"
三个值得注意的点:
- 模型并行调了两个工具(杭州、深圳互不依赖)→ 于是出现连续两个
ToolMessage; - 消息序列就是一次完整的 ReAct 循环:
Human → AI(tool_calls) → Tool → Tool → AI(最终回答); - 它主动标注了数据口径免责——这就是「模型 + 真实工具结果」合起来的效果。
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. 三个坑(都和推理模型有关)
tool_choice不能强制:想「务必先调某个工具」时,推理模型会报Thinking mode does not support this tool_choice——让它auto(默认)即可(详见篇 1);maxTokens要给够:推理模型的thinking会先吃掉 token,给太小会出现「工具没调、文本也空」;- 工具描述会被当真:
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、消息拼装、状态保存,做成了标准件与图。
动手
- 给工具集加一个
city_weather,问「杭州天气如何,顺便告诉我人口」,看它怎么组合调用; - 给
createReactAgent传checkpointer: new MemorySaver(),同一个thread_id追问「刚才查的是哪两个城市?」; - 把工具的
description改得含糊(如「查点东西」),观察模型的调用行为变化。
自测
createReactAgent内部对应你手写循环的哪几步?- 为什么会出现连续两个
ToolMessage? thread_id+ checkpointer 的作用是什么?- 什么情况下你会选择手写而不是用框架?
- 推理模型下,
maxTokens与工具调用有什么关系?
参考
- LangChain.js 文档:js.langchain.com
- LangGraph.js 文档:langchain-ai.github.io/langgraphjs
- 手写循环对照:frontend-agent 系列|协议:llm-format、MCP
- 本系列代码位于 /tmp/lg-lab(probe*.mjs),可复跑