当 Agent 能够、下单、删除数据或修改生产配置时,全自动执行并不总是合理选择。面对不可逆操作,系统需要先保存当前状态,在关键节点暂停,等待用户确认后再从原位置继续。LangGraph 提供的检查点、中断函数与恢复命令,正好构成了这套人工介入流程的核心。
上一秒它还在自动帮你处理事务,下一秒它停下来问:「要向张三 $100,确认吗?」
你输入「确认」,它接着往下跑;你说不对,它就改。
这就是 Harness(Agent 运行环境)最重要的一项能力:中断与恢复。本文用两个能跑的 demo 讲清它怎么实现——LangGraph.js + MemorySaver。
先看一个最朴素的场景:
用户:帮我给张三转 100 块
Agent 内心:识别到意图 → 准备执行 transfer("张三", 100)
问题来了——这一步能直接执行吗?
不能。是不可逆的,模型理解错一个字代价就是真金白银。所以在真正扣款之前,必须有人点头。
类似的场景还有一堆:
| 场景 | 为什么要中断 |
|---|---|
| 、下单、删除数据 | 不可逆操作,要人确认 |
| 调用付费 API、跑大规模任务 | 成本高,要授权 |
| 修改生产环境配置 | 风险高,要审批 |
| 触发了敏感工具 | 合规要求,要人工介入 |
这些场景有一个共同点:流程不是一口气跑完的,中间必须停下来等一个「外部输入」,而这个输入可能要等很久——几秒、几小时、甚至几天。
这就是「中断与恢复」要解决的问题,也是 Harness 这一层最难做对的部分:
暂停时,当前执行到哪、状态是什么,必须被完整保存下来;恢复时,要能从暂停的那一点继续,而不是从头再来。
要讲清中断恢复,得先交代它所在的上下文。
原因主要有三个。
① 上下文的开销:单 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 负责写测试用例(TDD),验证 Agent 负责检查代码是否符合预期,最后告诉主 Agent「通过了」。这种互相 review 的结构,纠错能力远强于单个模型自己检查自己。AutoGen 那套「法庭」式辩论也是同一个思路。
| LangChain | LangGraph | |
|---|---|---|
| 定位 | 基础模块 + 线性工作流编排 | 网状工作流编排 |
| 提供什么 | LLM API、Document Loaders、Splitter、Embedding、Vector Store、Output Parser、Memory… | 工作节点 + 节点之间的组织方式 |
| 编排形态 | 线性的 | 网状的 |
| 能做什么 | 简单 Agent | 复杂的多 Agent 协作 |
两者共享底层基础设施(LLM、工具、记忆这些都是一套),区别在于编排能力:LangChain 的链是一条线走到底,LangGraph 的图可以分支、可以循环、可以并行。
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" });
节点就是函数,返回值就是下一个节点的状态。
「网状」体现在两个能力上。
分支:用 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 }));
每个节点读写的「状态」由 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:初始值。这是全文的转折点。回到第一节那个问题——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决定状态属于谁。
有了持久化,中断就可以实现了。完整例子在 graph-interrupt.mjs。
START → showTransfer(展示待办)→ waitConfirm(等待确认)→ END
两个节点各司其职:
// 节点一:准备待确认的动作
const showTransfer = () => ({
actionSummary: "向张三 $100"
});
// 节点二:在这里中断,等用户输入
const waitConfirm = (state) => {
const text = interrupt({ // ← 中断点
hint: "终端里输入[确认]或者备注后回车,图才会继续",
actionSummary: state.actionSummary
});
return { userInput: String(text) }; // ← 恢复后拿到输入
};
interrupt() 做了什么一层层看:
第一,它让图停下来。 调用 interrupt() 的节点会立刻挂起,图不再往下走。注意——这不是报错,是正常的暂停。
第二,它把信息带出去。 传给 interrupt() 的参数(这里的 hint 和 actionSummary)会作为中断的载荷返回给调用方:
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() })
没有它,图停下来之后就真的「没了」,谈不上恢复。
interrupt() 的返回值这是最容易忽略的一点。看 waitConfirm 这行:
const text = interrupt({ ... });
return { userInput: String(text) };
interrupt() 有返回值——它的返回值就是恢复时传进来的那份数据。
也就是说,同一个节点会经历两个阶段:第一次执行到 interrupt() 停下来;恢复后这个节点会被重新执行,这次 interrupt() 直接返回你给的输入,代码继续往下走,把 userInput 写进状态。
这个设计很巧妙:你不需要额外写「恢复之后该做什么」的逻辑,把恢复后的处理直接写在 interrupt() 后面就行。
用户输入完之后,怎么让它接着跑?
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 决定「状态存在哪」。 两者解耦,所以可以从内存一路平滑升级到数据库。
interrupt() 是正常的流程控制,不是抛错。别用 try/catch 去接它,也别把它当成失败处理。
.compile() // ❌ 中断后无法恢复
.compile({ checkpointer: new MemorySaver() }) // ✅
中断的本质是「把执行现场存下来」,没有存档器就无处可存。
thread_id 必须一致这是最高频的错误来源。不一致 = 新会话 = 从头执行。
被中断的那个节点会从头再跑一遍,interrupt() 这次直接返回 resume 的数据。所以 interrupt() 之前的代码要保证可以安全地重复执行——别在里面写扣款、发消息这类有副作用的操作。
paused.__interrupt__[0].value 就是你要发给前端渲染「确认卡片」的数据。中断之所以对用户可见,靠的就是它。
把这篇串成一条线:
Annotation 声明,reducer 决定怎么合并,default 给初始值。checkpointer 决定状态存在哪,thread_id 决定状态属于谁。interrupt():图挂起,载荷通过 __interrupt__ 带出去;恢复用 new Command({ resume }),同一个 thread_id,图就从停下的地方接着跑。interrupt() 的返回值就是 resume 传进来的数据——恢复后的处理逻辑,直接写在它后面即可。MemorySaver 只适合开发,生产要换 SQLite / Redis;好在业务代码不用动,只换 checkpointer 实例。最后记一句:
一个能自动跑的 Agent 已经不容易了,但真正让它能用于生产的是「该停的时候停得住,该继续的时候接得上」。 中断与恢复不是锦上添花的功能,而是 Agent 敢碰真实业务的入场券。
如果你也在做 Agent 落地,希望这篇帮你把「暂停 / 恢复」这条链路理清楚。有问题欢迎评论区交流 ?