LangGraph 实战入门:用状态图写出分支、重试与人工确认
我第一次把一段 Agent 逻辑写长以后,代码很快变成了这种形态:先判断问题类型,再调用不同工具;失败就重试;碰到转账一类高风险操作,还要暂停等用户确认。继续用一串 if/else 和函数调用当然能写,但流程藏在代码里,状态散落在局部变量中,出错后也很难从中间恢复。
这正是我学习 LangGraph 的原因。LangGraph 的核心不是“再封装一次大模型调用”,而是把流程显式建模为状态图(State Graph):节点负责工作,边负责决定下一步,状态负责在节点间传递数据。
我用五个小例子按“直线流程 → 条件分支 → 循环重试 → 状态持久化 → 人工确认”的顺序逐步实现。前四个非交互示例已经在本地运行;人工确认示例需要终端输入,这里重点解释控制流程。
1. 先把最小状态图跑通
一个最小 LangGraph 包含三类东西:
- State:整个流程共享的数据;
- Node:接收旧状态、返回局部新状态的函数;
- Edge:节点之间的流转关系。
先从一个只有 text 字段的基础示例开始,两个节点依次向它追加文本:
import {
Annotation,
START,
END,
StateGraph,
} from "@langchain/langgraph";
const State = Annotation.Root({
text: Annotation({
reducer: (_previous, next) => next,
default: () => "",
}),
});
const step1 = (state) => ({ text: `${state.text} -> step1` });
const step2 = (state) => ({ text: `${state.text} -> step2` });
const graph = new StateGraph(State)
.addNode("step1", step1)
.addNode("step2", step2)
.addEdge(START, "step1")
.addEdge("step1", "step2")
.addEdge("step2", END)
.compile();
console.log(await graph.invoke({ text: "hello" }));
本地输出为:
{ text: 'hello -> step1 -> step2' }
Annotation.Root 定义状态结构。每个字段的 reducer 决定“节点返回的新值怎样合并进旧状态”。这里直接采用 next,相当于覆盖;如果状态是消息数组,也可以让 reducer 做追加。
我一开始容易把节点理解成“下一步命令”。更准确的理解是:节点只描述一次状态转换,图才负责调度。节点知道输入和输出,不需要知道自己前后连接了谁。
2. 条件边:把路由决策从节点里拿出来
真实流程不会永远直线前进。比如数学表达式走计算节点,普通文本走聊天节点。路由节点只写入决策结果,条件边再把结果映射到目标节点:
const State = Annotation.Root({
query: Annotation({ reducer: (_p, n) => n, default: () => "" }),
route: Annotation({ reducer: (_p, n) => n, default: () => "chat" }),
answer: Annotation({ reducer: (_p, n) => n, default: () => "" }),
});
const router = (state) => ({
route: /[+\-*/]/.test(state.query) ? "math" : "chat",
});
const mathNode = (state) => ({
answer: String(eval(state.query)),
});
const chatNode = (state) => ({
answer: `你说的是:${state.query}`,
});
const graph = new StateGraph(State)
.addNode("router", router)
.addNode("math", mathNode)
.addNode("chat", chatNode)
.addEdge(START, "router")
.addConditionalEdges("router", (state) => state.route, {
math: "math",
chat: "chat",
})
.addEdge("math", END)
.addEdge("chat", END)
.compile();
这个示例本地分别得到 3 和“你说的是:你好”。但其中的 eval() 只能用来演示分支,不能接收真实用户输入:它会执行任意 JavaScript。生产代码应该换成受限表达式解析器,或者把允许的运算明确实现出来。
这里也体现了方案 A 与方案 B 的区别:
- 方案 A:在 router 内部直接调用 math/chat。代码短,但路由、执行和错误处理耦合在一起;
- 方案 B:router 只返回结构化决策,条件边负责跳转。节点更容易测试,流程也能画成图。
当流程只有两个函数时,两者差别不大;一旦增加评估、重试和人工审批,方案 B 更容易维护。
3. 循环不是 while,而是“回到哪个节点”
Agent 常见逻辑是“尝试一次—检查结果—失败再试”。LangGraph 可以用条件边连回当前节点:
const RetryState = Annotation.Root({
tries: Annotation({ reducer: (_p, n) => n, default: () => 0 }),
ok: Annotation({ reducer: (_p, n) => n, default: () => false }),
message: Annotation({ reducer: (_p, n) => n, default: () => "" }),
});
const attempt = (state) => {
const tries = state.tries + 1;
const ok = tries >= 3;
return {
tries,
ok,
message: ok ? `第${tries}次成功` : `第${tries}次失败`,
};
};
const retryGraph = new StateGraph(RetryState)
.addNode("attempt", attempt)
.addEdge(START, "attempt")
.addConditionalEdges(
"attempt",
(state) => (state.ok ? "done" : "retry"),
{ done: END, retry: "attempt" },
)
.compile();
实测最终状态是:
{ tries: 3, ok: true, message: '第3次成功' }
这个写法比把 while 藏在节点里多了一点配置,却换来了两个好处:每次尝试都是可观察的状态转换;以后要在重试之间插入日志、退避等待、换模型或人工介入,不需要拆开一个大循环。
生产环境还必须增加最大步数或失败出口。示例用“第三次必定成功”保证终止,真实的外部调用没有这个保证。
4. Checkpointer:同一个图,不同会话各自续跑
大模型本身是无状态的,图执行也不会自动记住上一次结果。Checkpointer(检查点保存器)负责把某个线程的状态保存下来。
import { MemorySaver } from "@langchain/langgraph";
const graph = new StateGraph(VisitState)
.addNode("recordVisit", (state) => {
const visitCount = state.visitCount + 1;
return {
visitCount,
message: `这是你在本会话里第${visitCount}次进入。`,
};
})
.addEdge(START, "recordVisit")
.addEdge("recordVisit", END)
.compile({ checkpointer: new MemorySaver() });
const userA = { configurable: { thread_id: "用户-A" } };
const userB = { configurable: { thread_id: "用户-B" } };
await graph.invoke({}, userA);
await graph.invoke({}, userB);
console.log(await graph.invoke({}, userA));
console.log(await graph.invoke({}, userB));
本地运行时,A 和 B 的第二次结果都为 2,彼此没有串线。关键不是用户名,而是 thread_id:它是状态分区键。
MemorySaver 适合学习和单进程演示,进程退出后数据不会可靠保留。真正的服务要换成数据库或 Redis 一类持久化后端,并考虑线程 ID 的鉴权,不能让用户随便猜到另一个会话的 ID。
5. interrupt:高风险动作前暂停,而不是“问完继续猜”
自动化流程最危险的情况之一,是模型把“应该询问用户”误当成“用户已经同意”。LangGraph 的 interrupt 会真正暂停图,保存状态,直到外部传入恢复命令。
import { interrupt, Command, MemorySaver } from "@langchain/langgraph";
const waitConfirm = (state) => {
const decision = interrupt({
hint: "请输入确认或取消",
actionSummary: state.actionSummary,
});
return { userInput: String(decision) };
};
const graph = new StateGraph(TransferState)
.addNode("showTransfer", () => ({
actionSummary: "向指定收款人转账 100 元",
}))
.addNode("waitConfirm", waitConfirm)
.addEdge(START, "showTransfer")
.addEdge("showTransfer", "waitConfirm")
.addEdge("waitConfirm", END)
.compile({ checkpointer: new MemorySaver() });
const config = { configurable: { thread_id: "transfer-demo" } };
const paused = await graph.invoke({}, config);
// 用户确认后,再显式恢复
const done = await graph.invoke(
new Command({ resume: "确认" }),
config,
);
我最初尝试直接传入普通状态对象恢复流程。按当前 LangGraph API,更清晰的做法是使用 Command({ resume })。这里的转账仅用于说明控制流,不会触发真实操作。
对比两种方案:
- 普通聊天确认:模型说“请确认”后仍可能在同一轮继续执行,确认状态也不一定持久化;
- 图中断:执行引擎停止,检查点保存,只有明确的恢复输入才能继续。
涉及删除、付款、发布、发消息等外部副作用时,我会优先选择后者。
6. 我会怎样组织一个真实 Agent 图
把这些积木放在一起,一个可维护的 Agent 流程通常是:
- 路由节点产生结构化决策;
- 条件边选择工具或直接回答;
- 执行节点写入结果;
- 评估节点判断是否重试;
- 达到次数上限后转人工处理;
- 高风险动作前用 interrupt 暂停;
- 全程由 Checkpointer 按 thread 保存状态。
节点应该“小而纯”:输入状态,返回状态。数据库写入、HTTP 请求等副作用要集中在少数明确节点中,这样回放或重试时才知道哪些动作不能重复执行。
结尾
我对 LangGraph 最有用的认识,不是记住 addNode 或 addEdge,而是把 Agent 当作一个有状态、可暂停、可恢复的业务流程:
- 直线步骤用普通边;
- 动态选择用条件边;
- 失败重试用回边,并设置终止护栏;
- 多轮续跑用 Checkpointer;
- 高风险操作用 interrupt 做真正的人工确认。
下一步可以在这个骨架上加入结构化输出,让路由节点不再靠正则;再把向量检索、评估和网络兜底接进图里,形成 Agentic RAG。后续三篇文章会沿着这条路线继续。