




















我们在写带 interrupt 的节点函数时,有一个很隐蔽但非常关键的陷阱:
interrupt的暂停机制,其实是通过“抛出一个专门的异常”来实现的,也就是说,langgraph在内部让代码“假装抛错”,从而中止执行,把控制权交还给运行时的图引擎。
换句话说,interrupt并不是普通函数返回,它本质上是throw出去的。
因此,如果你在代码里不小心用 try/catch 把这个异常兜住了,那图引擎本来应该接收到的暂停信号,就被你“截胡”了,结果就是:
最佳实践很简单,就是: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() 抛出的异常,会被图执行引擎正确接收到,然后暂停执行,等待外部输入。
有时候节点里确实需要异常处理,比如访问外部接口失败、调用三方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;
}
下面这个写法看似没问题,其实是最容易出bug的:
async function nodeA(state: State) {
try {
const name = interrupt("请输入名字?");
} catch (err) {
console.error(err);
}
return state;
}
这一段会导致:
当一个节点里出现不止一个 interrupt 时,LangGraph 会在内部维护一个“resume 的值列表”。
简单讲就是:每次用户回答一个问题,LangGraph 会把这个回答按顺序保存起来,例如
resumes = [name, age, city]
下一次恢复执行的时候,节点会重新从头运行,然后每遇到一个 interrupt,就从 list 里取一个值,而取值是基于“index 匹配”。
也就是说:
resumes[0]resumes[1]resumes[2]如果节点逻辑中让 interrupt 的执行顺序发生变化,那 resumes 的对应关系就直接混乱了。结果可能是:
像这样:
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 匹配就不会乱。
下面这种情况是初学者最常写的,在条件分支中做中断:
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?");
}
看上去逻辑很自然,但问题严重:
最终会出现非常诡异的运行效果,调试难度极大。
另外,同样不要使用非确定性逻辑动态控制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 };
}
看起来逻辑很自然:
问题就出在:state.dynamicList 是“动态”的。
1. 第一次执行时的流程
假设第一次执行时:
state.dynamicList = ["A", "B", "C"];
执行顺序大致是:
nodeA"A" → 遇到第 1 个 interrupt
nodeAresumes[0](之前 A 的答案)"B" → 遇到第 2 个 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] → OKresumes[2](原来 C 的答案)永远没人用,状态与真实业务不一致,逻辑变得奇怪。甚至下一轮再执行时,这种错位会进一步放大更糟糕的是:你在代码里完全看不出“哪里错了”,因为都是合法的 TypeScript 代码,只是映射关系悄悄错了。
场景 2:列表顺序变了(长度没变)
第一次执行:
state.dynamicList = ["A", "B", "C"];
// 用户依次回答了 A、B、C
resumes = [answerA, answerB, answerC];
某次恢复时:
state.dynamicList = ["C", "A", "B"]; // 同样 3 条,但顺序改变
恢复执行时:
"C" → 却使用了 resumes[0](也就是 answerA)"A" → 却使用了 resumes[1](answerB)"B" → 却使用了 resumes[2](answerC)你会得到一个非常魔幻的结果:系统看起来好像没报错,但所有人都在用错的问题答案。这种bug很隐蔽,调试时会非常痛苦。
当用 interrupt 暂停图的时候,langgraph背后做了两件事:
这个“存起来 / 读回来”的过程,本质就是序列化 / 反序列化。
这些都很容易被序列化,一般用 JSON.stringify / JSON.parse 就解决了。
但像下面这些,就很麻烦甚至完全不行:
不同的checkpointer实现(内存、文件、Redis、云端存储)并不保证支持这些复杂类型。
为了让你的图在任何环境都稳定运行,最保险的就是:interrupt 里只用“普通的、朴素的、能JSON化”的东西。
最简单的例子就是直接传 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当成一种“前端表单配置”,把问题和初始值结构化地传出去。
很多同学一看能传对象,就很容易写成这样:
顺手把“校验函数”一块儿塞进去
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 };
}
问题在于:
正确做法:把“怎么校验”这件事留在两端各自实现,而不是通过 interrupt 传函数,比如:
validatorType: "nonEmpty",前端自己根据类型做校验;minLength: 1,让 UI 自己决定怎么控件级校验;把类实例塞进去
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 包含原型链、私有字段等信息;正确做法:
只传必要的配置:
const response = interrupt({
question: "Enter data to process",
processorConfig: { mode: "strict" },
});
真正的 DataProcessor 实例,在恢复执行后重新new一次:
const processor = new DataProcessor(response.processorConfig);
只要用了interrupt,节点前半段的代码,随时都可能被“重复播放”好几遍。所以在interrupt之前做的任何“会改变外部世界”的操作,都必须经得住“反复重放”。
中断机制的运行方式是这样的:
interrupt(...),langgraph就会:
所以:在第一个 interrupt 之前的所有代码,很可能会执行多次。
如果你在这段区域做了“插入数据库、记录日志、发通知”之类的操作,就意味着每次恢复都可能再来一遍。
这就是为什么文档强调:interrupt前面的“副作用操作”必须是幂等的。
[!note]
幂等性:指某操作无论执行多少次,最终效果都相同,不会因为重复执行而造成副作用。
典型例子:
- 设置某个状态:
status = "pending":无论设置多少次,最终状态都是"pending"- 覆盖式写入:
UPDATE ... SET value = 1:重复执行不会越写越多- upsert:“有就更新,没有就创建”,重复执行不会多出来一堆重复记录
反例则是:
- 每次都新建一条记录:
INSERT INTO audit_logs (...)- 每次都往数组/历史里 append:
history.push("xxx")- 每次都发一条消息/通知/邮件:
sendEmail(...)这些操作一旦被重复执行,结果就是:多条重复记录、多条垃圾日志、多次通知,甚至业务状态紊乱。
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 的语义一般是:
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 };
}
这里有两个关键点:
approved 已经有值了,恢复时这个节点一般只会执行一次完整流程(不会在“中断点”反复卡住)。也就是说:
把“中断”和“副作用”拆成两个节点
这个模式在真实项目里非常常见,也更符合“单一职责”的设计思路。
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 只负责通知,且一般只执行一次,不用担心重放;在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 一次;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;更好的写法可以是:
requestId 做去重)。-EOF-
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。