[LangGraph] 中断注意事项
1. 不要用try/catch包裹interrupt调用
我们在写带 interrupt 的节点函数时,有一个很隐蔽但非常关键的陷阱:
interrupt的暂停机制,其实是通过“抛出一个专门的异常”来实现的,也就是说,langgraph在内部让代码“假装抛错”,从而中止执行,把控制权交还给运行时的图引擎。
换句话说,interrupt并不是普通函数返回,它本质上是throw出去的。
因此,如果你在代码里不小心用 try/catch 把这个异常兜住了,那图引擎本来应该接收到的暂停信号,就被你“截胡”了,结果就是:
- 图不会暂停
- 人类交互不会触发
- interrupt直接失效
- 你调试半天不知道为什么
1. 正确的使用方式
最佳实践很简单,就是:interrupt要放在一个不被catch包裹的位置,让它能够顺利往外“抛”。
例如:
async function nodeA(state: State) {
// interrupt 在这里直接执行
const name = interrupt("请输入你的名字?");
// 后面的逻辑可以继续写
// 即便这里 try/catch 也不会影响 interrupt
try {
await fetchData();
} catch (err) {
console.error(err);
}
return state;
}
这样写就能保证 interrupt() 抛出的异常,会被图执行引擎正确接收到,然后暂停执行,等待外部输入。
2. 如果真的需要try/catch怎么办
有时候节点里确实需要异常处理,比如访问外部接口失败、调用三方API超时等等,那怎么办?
做法是:只处理你关心的错误,其余的原样抛回去。
async function nodeA(state: State) {
try {
const name = interrupt("请输入名字?");
// 下面运行失败你可以捕捉
await fetchData();
} catch (err) {
if (err instanceof NetworkError) {
console.error(err);
}
// 必须重新抛出,不然会吃掉interrupt
throw err;
}
return state;
}
3. 错误示范
下面这个写法看似没问题,其实是最容易出bug的:
async function nodeA(state: State) {
try {
const name = interrupt("请输入名字?");
} catch (err) {
console.error(err);
}
return state;
}
这一段会导致:
- interrupt 无法暂停图执行
- 用户输入环节不会出现
- 程序继续往下走
- 最终你会疑惑“为什么 interrupt 不工作?”
2. 保证interrupt的调用顺序
当一个节点里出现不止一个 interrupt 时,LangGraph 会在内部维护一个“resume 的值列表”。
简单讲就是:每次用户回答一个问题,LangGraph 会把这个回答按顺序保存起来,例如
resumes = [name, age, city]
下一次恢复执行的时候,节点会重新从头运行,然后每遇到一个 interrupt,就从 list 里取一个值,而取值是基于“index 匹配”。
也就是说:
- 第一个 interrupt →
resumes[0] - 第二个 interrupt →
resumes[1] - 第三个 interrupt →
resumes[2]
如果节点逻辑中让 interrupt 的执行顺序发生变化,那 resumes 的对应关系就直接混乱了。结果可能是:
- 问用户的内容不一致
- 恢复时取错答案
- 甚至业务逻辑直接炸掉
1. 正确方式
像这样:
async function nodeA(state: State) {
// 这个节点,无论执行多少次,中断的顺序是不会发生变化的
const name = interrupt("What's your name?");
const age = interrupt("What's your age?");
const city = interrupt("What's your city?");
return { name, age, city };
}
无论运行多少次,恢复多少次,只要顺序固定,resume 匹配就不会乱。
2. 错误做法
下面这种情况是初学者最常写的,在条件分支中做中断:
async function nodeA(state: State) {
const name = interrupt("What's your name?");
if (state.needsAge) {
const age = interrupt("What's your age?");
}
const city = interrupt("What's your city?");
}
看上去逻辑很自然,但问题严重:
- 第一次执行可能不需要问 age
- 第二次恢复又需要问 age
- 那么 resume 的顺序就会错位,导致系统“以为”第 2 个答案其实是 city,而不是 age
最终会出现非常诡异的运行效果,调试难度极大。
另外,同样不要使用非确定性逻辑动态控制interrupt调用次数或顺序。例如下面是一个循环里的动态interrupt的场景:
async function nodeA(state: State) {
const results = [];
for (const item of state.dynamicList || []) {
const approved = interrupt(`Approve ${item}?`);
results.push({ item, approved });
}
return { results };
}
看起来逻辑很自然:
- 有多少个 item,就问多少次;
- 每次问用户“是否通过这个 item”;
- 把结果 push 到数组里。
问题就出在:state.dynamicList 是“动态”的。
1. 第一次执行时的流程
假设第一次执行时:
state.dynamicList = ["A", "B", "C"];
执行顺序大致是:
- 进入
nodeA - 循环到
"A"→ 遇到第 1 个 interrupt- langgraph暂停
- 等用户回答 A 是否通过
- 恢复后,再次从头执行
nodeA - 再次循环:
- 遇到第 1 个 interrupt → 用
resumes[0](之前 A 的答案) - 继续到
"B"→ 遇到第 2 个 interrupt → 暂停 - 等用户回答 B
- 遇到第 1 个 interrupt → 用
然后继续这样反复,直到所有 item 问完。
注意: 这一切可以正常工作的前提是每次恢复时,state.dynamicList 的内容和顺序都没有变化。
2. 第二次执行,一旦列表变了,所有映射就乱了
现在看两个典型翻车场景:
场景 1:列表长度变了
第一次执行:
state.dynamicList = ["A", "B", "C"]; // 3 条
某次恢复时(比如上游节点做了更新):
state.dynamicList = ["A", "B"]; // 变成 2 条
会发生什么?
- 恢复时仍然有
resumes = [answerA, answerB, answerC] - 再次从头执行节点:
- 遍历
"A"→ 第 1 个 interrupt,用resumes[0]→ OK - 遍历
"B"→ 第 2 个 interrupt,用resumes[1]→ OK - 循环结束,没有第三个 interrupt 了
- 遍历
- 结果:
resumes[2](原来 C 的答案)永远没人用,状态与真实业务不一致,逻辑变得奇怪。甚至下一轮再执行时,这种错位会进一步放大
更糟糕的是:你在代码里完全看不出“哪里错了”,因为都是合法的 TypeScript 代码,只是映射关系悄悄错了。
场景 2:列表顺序变了(长度没变)
第一次执行:
state.dynamicList = ["A", "B", "C"];
// 用户依次回答了 A、B、C
resumes = [answerA, answerB, answerC];
某次恢复时:
state.dynamicList = ["C", "A", "B"]; // 同样 3 条,但顺序改变
恢复执行时:
- 第 1 个 interrupt 对应
"C"→ 却使用了resumes[0](也就是 answerA) - 第 2 个 interrupt 对应
"A"→ 却使用了resumes[1](answerB) - 第 3 个 interrupt 对应
"B"→ 却使用了resumes[2](answerC)
你会得到一个非常魔幻的结果:系统看起来好像没报错,但所有人都在用错的问题答案。这种bug很隐蔽,调试时会非常痛苦。
3. 不要在interrupt中返回复杂值
1. 为什么要简单值?
当用 interrupt 暂停图的时候,langgraph背后做了两件事:
- 把当前节点状态 + interrupt相关信息“存起来”,写到内存 / 文件 / 数据库 / 远程存储等
- 未来恢复执行时,再把这些信息“读回来”,重新构造出运行上下文。
这个“存起来 / 读回来”的过程,本质就是序列化 / 反序列化。
- 原始类型(string / number / boolean / null)
- 数组、普通对象(key 是字符串,value 是简单类型或嵌套对象)
这些都很容易被序列化,一般用 JSON.stringify / JSON.parse 就解决了。
但像下面这些,就很麻烦甚至完全不行:
- 函数(function)
- class 实例(new 出来的对象)
- 各种带原型链、闭包、内部状态的复杂对象
- 各种数据库连接、流对象、Response/Request 之类的东西
不同的checkpointer实现(内存、文件、Redis、云端存储)并不保证支持这些复杂类型。
为了让你的图在任何环境都稳定运行,最保险的就是:interrupt 里只用“普通的、朴素的、能JSON化”的东西。
2. 推荐的用法
最简单的例子就是直接传 string、number或boolean:
async function nodeA(state: State) {
const name = interrupt("What's your name?");
const count = interrupt(42);
const approved = interrupt(true);
return { name, count, approved };
}
从序列化角度看:
"What's your name?"→ 字符串 OK42→ 数字 OKtrue→ 布尔 OK
恢复的时候,照样可以还原成一模一样的值。
再复杂一点,你可以传“结构化”的对象,只要内部字段也是可序列化的简单值就行:
async function nodeA(state: State) {
const response = interrupt({
question: "Enter user details",
fields: ["name", "email", "age"],
currentValues: state.user || {},
});
return { user: response };
}
这里也没问题:
question是字符串fields是字符串数组currentValues是一个普通对象(只要你里面别塞奇怪的东西,比如函数、Date也最好转字符串)
这个模式在实际业务里非常好用:你可以把interrupt当成一种“前端表单配置”,把问题和初始值结构化地传出去。
3. 容易踩坑的点
很多同学一看能传对象,就很容易写成这样:
- 想顺手把“校验函数”一块儿塞进去
- 把类实例塞进去
顺手把“校验函数”一块儿塞进去
function validateInput(value: string): boolean {
return value.length > 0;
}
async function nodeA(state: State) {
const response = interrupt({
question: "What's your name?",
validator: validateInput, // ❌ 这里就踩坑了
});
return { name: response };
}
问题在于:
- 函数在序列化时,只能变成一段字符串(甚至直接被丢掉);
- 反序列化的时候,没法恢复成一个可执行的 function;
- 换个 checkpointer 或换个环境,这段代码就直接挂掉。
正确做法:把“怎么校验”这件事留在两端各自实现,而不是通过 interrupt 传函数,比如:
- 只传一个
validatorType: "nonEmpty",前端自己根据类型做校验; - 或者传一个简单的
minLength: 1,让 UI 自己决定怎么控件级校验; - 或者干脆交给 LLM 或前端逻辑来判断。
把类实例塞进去
class DataProcessor {
constructor(private config: any) {}
}
async function nodeA(state: State) {
const processor = new DataProcessor({ mode: "strict" });
const response = interrupt({
question: "Enter data to process",
processor, // ❌ 直接塞实例
});
return { result: response };
}
这个问题更直观:
processor包含原型链、私有字段等信息;- 很多checkpointer根本不知道怎么“合法地存储和恢复”这种对象;
- 即便你强行 JSON.stringify,它也只会丢掉大部分行为信息,恢复回来也不是原对象。
正确做法:
-
只传必要的配置:
const response = interrupt({ question: "Enter data to process", processorConfig: { mode: "strict" }, }); -
真正的
DataProcessor实例,在恢复执行后重新new一次:const processor = new DataProcessor(response.processorConfig);
4. interrupt前的副作用操作需具备幂等性
只要用了interrupt,节点前半段的代码,随时都可能被“重复播放”好几遍。所以在interrupt之前做的任何“会改变外部世界”的操作,都必须经得住“反复重放”。
1. 为什么会重复执行
中断机制的运行方式是这样的:
- 节点里一旦遇到
interrupt(...),langgraph就会:- 记录当前状态
- 把中断信息存入checkpointer
- 暂停执行,等人类/外部系统给答案
- 当有了答案后,再次“恢复”时:
- 不是从中断那一行继续
- 而是从节点函数的开头重新跑一遍
- 跑到第一个 interrupt,用之前存的 resume 值
- 再跑到第二个 interrupt,用下一个 resume 值
- 以此类推
所以:在第一个 interrupt 之前的所有代码,很可能会执行多次。
如果你在这段区域做了“插入数据库、记录日志、发通知”之类的操作,就意味着每次恢复都可能再来一遍。
这就是为什么文档强调:interrupt前面的“副作用操作”必须是幂等的。
[!note]
幂等性:指某操作无论执行多少次,最终效果都相同,不会因为重复执行而造成副作用。
典型例子:
- 设置某个状态:
status = "pending":无论设置多少次,最终状态都是"pending"- 覆盖式写入:
UPDATE ... SET value = 1:重复执行不会越写越多- upsert:“有就更新,没有就创建”,重复执行不会多出来一堆重复记录
反例则是:
- 每次都新建一条记录:
INSERT INTO audit_logs (...)- 每次都往数组/历史里 append:
history.push("xxx")- 每次都发一条消息/通知/邮件:
sendEmail(...)这些操作一旦被重复执行,结果就是:多条重复记录、多条垃圾日志、多次通知,甚至业务状态紊乱。
2. 推荐模式
interrupt前只做“可安全重放”的操作
比如官方给的这种写法其实就很好:
async function nodeA(state: State) {
// 使用 upsert,确保幂等
await db.upsertUser({
userId: state.userId,
status: "pending_approval",
});
const approved = interrupt("Approve this change?");
return { approved };
}
这里为什么是安全的?
upsertUser的语义一般是:- 如果用户不存在 → 创建一条
- 如果用户已存在 → 更新这条
- 无论 nodeA 因为 interrupt 被重跑多少次,最终 user 的状态都是:
- 存在
- 且
status = "pending_approval"
执行 1 次和执行 5 次,效果一样,这就是幂等。
副作用尽量放到interrupt之后
这是最稳妥的写法:
async function nodeA(state: State) {
const approved = interrupt("Approve this change?");
if (approved) {
await db.createAuditLog({
userId: state.userId,
action: "approved",
});
}
return { approved };
}
这里有两个关键点:
- 副作用(写 audit log)发生在interrupt之后;
- 如果
approved已经有值了,恢复时这个节点一般只会执行一次完整流程(不会在“中断点”反复卡住)。
也就是说:
- “多次重跑”的那段,只有interrupt前面的逻辑;
- 真正不可重复的操作(write log)放在中断之后,就不会被多次执行。
把“中断”和“副作用”拆成两个节点
这个模式在真实项目里非常常见,也更符合“单一职责”的设计思路。
async function approvalNode(state: State) {
const approved = interrupt("Approve this change?");
return { approved };
}
async function notificationNode(state: State) {
if (state.approved) {
await sendNotification({
userId: state.userId,
status: "approved",
});
}
return state;
}
好处很明显:
approvalNode只负责和人类打交道,中断逻辑更清晰;notificationNode只负责通知,且一般只执行一次,不用担心重放;- graph级别更好编排:
- 先跑审批节点
- 审批通过后再跑通知节点
3. 错误示范
在interrupt前创建记录
async function nodeA(state: State) {
const auditId = await db.createAuditLog({
userId: state.userId,
action: "pending_approval",
timestamp: new Date(),
});
const approved = interrupt("Approve this change?");
return { approved, auditId };
}
问题出在哪?
- 每次节点从头重跑,就会再
createAuditLog一次; - 于是数据库里会有多条“pending_approval”的记录;
- 你还把第一次的
auditId存在返回值里,后面再恢复时,id 与实际记录数量也对不上。
实际效果就是:日志倍增、状态难以追踪,而且非常难排查,因为代码逻辑看上去“很正常”。
在interrupt前往数组/历史里追加
async function nodeA(state: State) {
await db.appendToHistory(state.userId, "approval_requested");
const approved = interrupt("Approve this change?");
return { approved };
}
这里更直观:
- 每恢复一次,就再追加
"approval_requested"一次; - 最后历史记录变成
"approval_requested"* N; - 你自己都看不懂到底请求了几次审批。
更好的写法可以是:
- 只在审批真正发起的那一刻(前一节点)写一次历史;
- 或把“请求审批”的信息放到 interrupt 之后;
- 或者改成写幂等记录(例如按
requestId做去重)。
-EOF-

浙公网安备 33010602011771号