[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?");
}

看上去逻辑很自然,但问题严重:

  1. 第一次执行可能不需要问 age
  2. 第二次恢复又需要问 age
  3. 那么 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"];

执行顺序大致是:

  1. 进入 nodeA
  2. 循环到 "A" → 遇到第 1 个 interrupt
    • langgraph暂停
    • 等用户回答 A 是否通过
  3. 恢复后,再次从头执行 nodeA
  4. 再次循环:
    • 遇到第 1 个 interrupt → 用 resumes[0](之前 A 的答案)
    • 继续到 "B" → 遇到第 2 个 interrupt → 暂停
    • 等用户回答 B

然后继续这样反复,直到所有 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. 第 1 个 interrupt 对应 "C" → 却使用了 resumes[0](也就是 answerA)
  2. 第 2 个 interrupt 对应 "A" → 却使用了 resumes[1](answerB)
  3. 第 3 个 interrupt 对应 "B" → 却使用了 resumes[2](answerC)

你会得到一个非常魔幻的结果:系统看起来好像没报错,但所有人都在用错的问题答案。这种bug很隐蔽,调试时会非常痛苦。

3. 不要在interrupt中返回复杂值

1. 为什么要简单值?

当用 interrupt 暂停图的时候,langgraph背后做了两件事:

  1. 把当前节点状态 + interrupt相关信息“存起来”,写到内存 / 文件 / 数据库 / 远程存储等
  2. 未来恢复执行时,再把这些信息“读回来”,重新构造出运行上下文。

这个“存起来 / 读回来”的过程,本质就是序列化 / 反序列化

  • 原始类型(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?" → 字符串 OK
  • 42 → 数字 OK
  • true → 布尔 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. 容易踩坑的点

很多同学一看能传对象,就很容易写成这样:

  1. 想顺手把“校验函数”一块儿塞进去
  2. 把类实例塞进去

顺手把“校验函数”一块儿塞进去

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 };
}

这里有两个关键点:

  1. 副作用(写 audit log)发生在interrupt之后;
  2. 如果 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-

posted @ 2026-03-23 14:50  Zhentiw  阅读(7)  评论(0)    收藏  举报