用 LangGraph 实现 Agent 暂停确认与断点恢复

作者:袖梨 2026-09-15

当 Agent 能够、下单、删除数据或修改生产配置时,全自动执行并不总是合理选择。面对不可逆操作,系统需要先保存当前状态,在关键节点暂停,等待用户确认后再从原位置继续。LangGraph 提供的检查点、中断函数与恢复命令,正好构成了这套人工介入流程的核心。

上一秒它还在自动帮你处理事务,下一秒它停下来问:「要向张三 $100,确认吗?」

你输入「确认」,它接着往下跑;你说不对,它就改。

这就是 Harness(Agent 运行环境)最重要的一项能力:中断与恢复。本文用两个能跑的 demo 讲清它怎么实现——LangGraph.js + MemorySaver

一、Agent 为什么需要「停下来」

先看一个最朴素的场景:

用户:帮我给张三转 100 块

Agent 内心:识别到意图 → 准备执行 transfer("张三", 100)

问题来了——这一步能直接执行吗?

不能。是不可逆的,模型理解错一个字代价就是真金白银。所以在真正扣款之前,必须有人点头。

类似的场景还有一堆:

场景为什么要中断
、下单、删除数据不可逆操作,要人确认
调用付费 API、跑大规模任务成本高,要授权
修改生产环境配置风险高,要审批
触发了敏感工具合规要求,要人工介入

这些场景有一个共同点:流程不是一口气跑完的,中间必须停下来等一个「外部输入」,而这个输入可能要等很久——几秒、几小时、甚至几天。

这就是「中断与恢复」要解决的问题,也是 Harness 这一层最难做对的部分:

暂停时,当前执行到哪、状态是什么,必须被完整保存下来;恢复时,要能从暂停的那一点继续,而不是从头再来。


二、前置:为什么是多 Agent,为什么是 LangGraph

要讲清中断恢复,得先交代它所在的上下文。

2.1 复杂的 Agent 产品基本都是多 Agent 架构

原因主要有三个。

① 上下文的开销:单 Agent 的 system prompt 越来越臃肿

单 Agent 架构下,所有工具的说明、每个功能的 prompt 全都塞进 system prompt 里。但实际执行某个功能时,只需要其中一部分——剩下的全是陪跑。

陪跑的代价不只是 token 更贵,更要命的是:

无关信息会干扰模型思考,让它效率更低、更容易出错。

拆成多 Agent 之后,每个 Agent 只保留自己需要的那部分 prompt。虽然调用 LLM 的次数变多了,但每次消耗的 token 更少,而且没有冗余信息干扰,准确率更高

用一个公式来理解:

Agent = LLM(大脑) + Harness(tool + mcp + rag + skill + ...)
  • 单 Agent:只有一个大脑,所有事都得一步步想、一步步调工具;
  • 多 Agent:多个大脑,可以并行思考。主 Agent 下发任务,子 Agent 并行处理完再返回结果。

而且每个大脑还能选适合自己的模型——便宜模型干粗活,贵模型干细活;Agent 也可以按需加载、动态加载。

② 并行思考与任务处理

主管把任务拆开分派给子 Agent,子 Agent 同时干活,整体效率自然更高。

③ 多角色互相讨论,纠错能力更强

典型例子:写代码的 Agent 负责实现,测试 Agent 负责写测试用例(TDD),验证 Agent 负责检查代码是否符合预期,最后告诉主 Agent「通过了」。这种互相 review 的结构,纠错能力远强于单个模型自己检查自己。AutoGen 那套「法庭」式辩论也是同一个思路。

2.2 从 LangChain 到 LangGraph

LangChainLangGraph
定位基础模块 + 线性工作流编排网状工作流编排
提供什么LLM API、Document Loaders、Splitter、Embedding、Vector Store、Output Parser、Memory…工作节点 + 节点之间的组织方式
编排形态线性的网状的
能做什么简单 Agent复杂的多 Agent 协作

两者共享底层基础设施(LLM、工具、记忆这些都是一套),区别在于编排能力:LangChain 的链是一条线走到底,LangGraph 的图可以分支、可以循环、可以并行。

2.3 网状工作流编排的四个要素

LangGraph 的图模型非常简洁,就四样东西:

要素是什么在代码里
开始节点入口,携带初始状态START
工作节点一个职责,读写状态addNode("名字", 函数)
连接节点,决定往哪走addEdge / addConditionalEdges
最终状态结束,输出结果END

最基础的形态(src/basic-graph.mjs):

const graph = new StateGraph(StateAnnotation)
  .addNode("step1", step1)
  .addNode("step2", step2)
  .addEdge(START, "step1")
  .addEdge("step1", "step2")
  .addEdge("step2", END)
  .compile();

const result = await graph.invoke({ text: "hello" });

节点就是函数,返回值就是下一个节点的状态。

2.4 分支与循环

「网状」体现在两个能力上。

分支:用 addConditionalEdges 按条件决定下一步走哪(conditional-routing.mjs):

const router = (state) => {
  const isMath = /[+-*/]/.test(state.query);
  return { route: isMath ? "math" : "ch@t" };
};

.addConditionalEdges("router", (state) => state.route, {
  math: "math",
  ch@t: "ch@t"
})

循环:把条件边指回自己,就形成了重试(loop-retry.mjs):

.addConditionalEdges("attempt", (state) => state.ok ? "done" : "retry", {
  retry: "attempt",     // 指回自己 → 循环
  done: END
})

顺带一个 demo 里的小知识:eval() 会把传入的字符串当作 JS 代码来执行,返回执行结果。所以 eval("1+2") 就是 3。在数学计算节点里用它很省事,但生产环境千万别对用户输入用 eval()——那等于把任意代码执行权交出去了。

图搭好之后还能可视化出来——drawMermaid() 会生成 Mermaid 流程图,能直接看到节点流转关系:

const drawable = await graph.getGraphAsync();
console.log(drawable.drawMermaid({ withStyles: true }));

三、状态与持久化:中断的地基

3.1 状态怎么定义

每个节点读写的「状态」由 Annotation 声明,关键是两个配置(basic-graph.mjs):

const StateAnnotation = Annotation.Root({
  text: Annotation({
    // _prev: 来到当前节点之前的状态;next: 当前节点返回的状态
    // reducer: 决定状态怎么变
    reducer: (_prev, next) => next,
    default: () => "",   // 默认值
  })
});
  • reducer:状态的合并规则。(_prev, next) => next 表示直接用新值覆盖;想累加就写成 (prev, next) => prev + next
  • default:初始值。

3.2 为什么需要持久化

这是全文的转折点。回到第一节那个问题——Agent 暂停等确认,那「暂停」这个动作到底发生了什么?

如果状态只活在内存里,进程一停就全没了,恢复时只能从头再来。所以:

中断的前提是持久化。没有 checkpointer,就没有中断恢复。

LangGraph 的解法是给图配一个 checkpointer(存档器)。最简单的实现是 MemorySaver——把状态存到内存里,下次执行时基于上次的状态继续(checkpointer-memory.mjs):

const checkpointer = new MemorySaver();
const app = graph.compile({ checkpointer });   // 编译时挂上

const user1 = { configurable: { thread_id: "用户-小张" } };
const user2 = { configurable: { thread_id: "用户-小李" } };

console.log(await app.invoke({}, user1));   // 第1次进入
console.log(await app.invoke({}, user1));   // 第2次进入 —— 记得上次的 visitCount
console.log(await app.invoke({}, user1));   // 第3次进入
console.log(await app.invoke({}, user2));   // 小张是独立的一份,从第1次开始

跑出来的效果是:小张连续三次进入,访问计数依次累加;而小李是另一条记录,从 1 开始。

关键在 thread_id——它就是「会话/线程」的身分证。同一个 thread_id 的状态会续上,不同的 thread_id 互相隔离:

const config = { configurable: { thread_id: "会话ID" } };

一句话:checkpointer 决定状态存在哪,thread_id 决定状态属于谁。


四、中断:interrupt()

有了持久化,中断就可以实现了。完整例子在 graph-interrupt.mjs。

4.1 图长什么样

START → showTransfer(展示待办)→ waitConfirm(等待确认)→ END

两个节点各司其职:

// 节点一:准备待确认的动作
const showTransfer = () => ({
  actionSummary: "向张三 $100"
});

// 节点二:在这里中断,等用户输入
const waitConfirm = (state) => {
  const text = interrupt({                          // ← 中断点
    hint: "终端里输入[确认]或者备注后回车,图才会继续",
    actionSummary: state.actionSummary
  });
  return { userInput: String(text) };                // ← 恢复后拿到输入
};

4.2 interrupt() 做了什么

一层层看:

第一,它让图停下来。 调用 interrupt() 的节点会立刻挂起,图不再往下走。注意——这不是报错,是正常的暂停

第二,它把信息带出去。 传给 interrupt() 的参数(这里的 hintactionSummary)会作为中断的载荷返回给调用方:

const config = { configurable: { thread_id: "interrupt-demo" } };

const paused = await graph.invoke({}, config);
console.log("待你确认", paused.__interrupt__?.[0]?.value);

返回结果里会带一个 __interrupt__ 数组,[0].value 就是你传进去的那份数据。前端就是靠它渲染出「确认弹窗 / 卡片」的

第三,它会记住自己停在哪。 这正是为什么 compile() 时必须挂 checkpointer:

.compile({ checkpointer: new MemorySaver() })

没有它,图停下来之后就真的「没了」,谈不上恢复。

4.3 interrupt() 的返回值

这是最容易忽略的一点。看 waitConfirm 这行:

const text = interrupt({ ... });
return { userInput: String(text) };

interrupt() 有返回值——它的返回值就是恢复时传进来的那份数据

也就是说,同一个节点会经历两个阶段:第一次执行到 interrupt() 停下来;恢复后这个节点会被重新执行,这次 interrupt() 直接返回你给的输入,代码继续往下走,把 userInput 写进状态。

这个设计很巧妙:你不需要额外写「恢复之后该做什么」的逻辑,把恢复后的处理直接写在 interrupt() 后面就行。


五、恢复:Command

用户输入完之后,怎么让它接着跑?

const rl = createInterface({ input: process.stdin, output: process.stdout });
const line = (await rl.question("> ")).trim();
await rl.close();

const done = await graph.invoke(new Command({ resume: line }), config);
console.log("done:", done);

关键就是 new Command({ resume: line })

说明
Command命令对象,告诉图「这次不是新的一轮,而是带着指令回来」
resume恢复时携带的数据,会成为 interrupt() 的返回值
config必须是同一个 thread_id

thread_id 这一条是最容易踩的坑:恢复时如果 thread_id 和中断时不一致,图会认为这是一个全新的会话,于是从头开始跑——你会看到 showTransfer 又执行了一遍,而不是从中断点继续。

所以实践中通常这么写:

const config = { configurable: { thread_id: sessionId } };   // 抽成变量,两处共用

六、再往前一步:把状态存到数据库

MemorySaver 有个明显的局限:它存在进程内存里。进程一重启,所有会话状态就全丢了——对「等用户明天来确认」这种场景是不可接受的。

所以生产环境要换成持久化的 checkpointer:

存储适用
MemorySaver本地开发、demo、单元测试
SQLite单机部署、中小规模,文件型数据库
Redis多实例部署、需要高性能读写和过期策略

换起来很简单——中断恢复的业务代码完全不用改,只换 compile() 里那个 checkpointer 实例

这也再次印证了那个分层:

图描述「做什么」,checkpointer 决定「状态存在哪」。 两者解耦,所以可以从内存一路平滑升级到数据库。


七、几个必须记住的点

7.1 中断不是异常

interrupt()正常的流程控制,不是抛错。别用 try/catch 去接它,也别把它当成失败处理。

7.2 没有 checkpointer 就没有中断恢复

.compile()                                  // ❌ 中断后无法恢复
.compile({ checkpointer: new MemorySaver() })  // ✅

中断的本质是「把执行现场存下来」,没有存档器就无处可存。

7.3 恢复时 thread_id 必须一致

这是最高频的错误来源。不一致 = 新会话 = 从头执行。

7.4 恢复后节点会重新执行

被中断的那个节点会从头再跑一遍,interrupt() 这次直接返回 resume 的数据。所以 interrupt() 之前的代码要保证可以安全地重复执行——别在里面写扣款、发消息这类有副作用的操作。

7.5 中间状态会推给前端

paused.__interrupt__[0].value 就是你要发给前端渲染「确认卡片」的数据。中断之所以对用户可见,靠的就是它。


八、总结

把这篇串成一条线:

  1. 复杂 Agent 走向多 Agent,因为单 Agent 的 system prompt 里塞了太多用不上的信息,既费 token 又干扰判断;拆开之后每个 Agent 只带必要的 prompt,更准也更省。而多 Agent 的编排需要「网状」能力,所以从 LangChain 走向了 LangGraph。
  2. LangGraph 的图就四要素:开始节点、工作节点、边、最终状态。加上条件边就有了分支和循环。
  3. 状态靠 Annotation 声明reducer 决定怎么合并,default 给初始值。
  4. 中断的前提是持久化checkpointer 决定状态存在哪,thread_id 决定状态属于谁。
  5. 中断用 interrupt():图挂起,载荷通过 __interrupt__ 带出去;恢复用 new Command({ resume }),同一个 thread_id,图就从停下的地方接着跑。
  6. interrupt() 的返回值就是 resume 传进来的数据——恢复后的处理逻辑,直接写在它后面即可。
  7. MemorySaver 只适合开发,生产要换 SQLite / Redis;好在业务代码不用动,只换 checkpointer 实例。

最后记一句

一个能自动跑的 Agent 已经不容易了,但真正让它能用于生产的是「该停的时候停得住,该继续的时候接得上」。 中断与恢复不是锦上添花的功能,而是 Agent 敢碰真实业务的入场券。

如果你也在做 Agent 落地,希望这篇帮你把「暂停 / 恢复」这条链路理清楚。有问题欢迎评论区交流 ?

相关文章

精彩推荐