Day 14 / 共 20 天 · 第 3 周 ADK 智能体开发

中断与恢复 Interrupt / Resume

Agent 跑到一半停下来等人类(比如审批一笔转账),批准后从断点继续——这就是 Human-in-the-loop。今天看它怎么和图执行结合,现场怎么存、怎么恢复。

📍 你在整门课的位置 · 第 3 周 Agent 套件(共 4 周 · 20 天)
D11 ADK 抽象 D12 ChatModelAgent D13 多智能体 D14 中断恢复 D15 ReAct 精读
L01

为什么需要中断

很多真实场景里,Agent 不能自作主张,必须停下来等人:转账前要人工确认、删数据前要审批、拿不准时要向用户追问。这叫 Human-in-the-loop(人在环中)

🧯 没有中断/恢复机制,会出什么事故? 场景:Agent 帮用户操作账户,模型一冲动就调了"清空购物车""删除全部邮件""转账 5 万"。没有"停下等人批"这一环,等你发现时钱已经出去、数据已经没了——大模型有概率犯错,把不可逆操作全权交给它就是定时炸弹。中断/恢复就是给危险动作装一道"人工闸门"。
💡 本质:Human-in-the-loop = 跑一半存 checkpoint、等人输入、从断点续跑 这就像生活中打游戏存档:打到 Boss 前先存盘(checkpoint),去吃个饭(等人),回来读档从原地继续(resume),而不是从头再打一遍。Eino 把这套做成三件套:中断触发(compose/interrupt.go)→ 存档 WithCheckPointStorecompose/checkpoint.go:60)+ WithCheckPointIDcheckpoint.go:74)→ 恢复 compose.Resumecompose/resume.go:94)。ADK 层则封装成 ChatModelAgentInterruptInfoadk/chatmodel.go:724)和 ChatModelAgentResumeDatachatmodel.go:773)。
难点在哪? Agent 一次运行是个连续过程(模型→工具→模型……)。要"停下来等人",意味着:① 得能在某一步暂停;② 把当前"跑到哪了、上下文是什么"这个现场完整存下来(可能人几小时后才来批);③ 人给了答复后能从那个断点继续,而不是从头重跑(重跑会重复扣费、重复副作用)。Eino 的中断/恢复机制解决这三点。
L02

一次中断恢复全流程

1. 正常跑 — Agent 推理,决定要执行"转账"工具 模型输出 tool_call: transfer(1000元)
2. 触发中断 — 该工具/节点标记为"需人工",抛出中断 InterruptError
3. 存现场 — 把"跑到哪、消息历史、State"存进 CheckPoint 存到 Store(内存/Redis/DB)
4. 返回中断事件 — 迭代器给你一个 Interrupt 事件,运行暂停 你的程序去通知人类审批
5. 人类批准 — 几分钟/几小时后,人点了"同意" 你调用 Resume,带上批准结果
6. 恢复 — 从 CheckPoint 读回现场,从断点继续执行转账,Agent 接着往下跑 不重跑前面的步骤
Agent 引擎 CheckPoint Store 人类审批 ① 正常跑到"转账"这步 ② 触发中断(InterruptError) ③ 存现场(跑到哪+消息+State) ④ 返回中断事件 → 通知人去批 ⑤(几小时后)点"同意" ⏳ ⑥ Resume(带审批结果) ⑦ 读回存档,从断点续跑
中断恢复时序:引擎跑到危险步 → 存档到 Store → 暂停等人 → 人批准后 Resume → 读档从断点继续(前面的步骤不重跑)。
🧑‍💻 第一人称·你就是那笔"转账 5 万"的请求,走一遍 「我」被用户发起:给张三转 5 万。
→ 我进了模型节点,模型说"该调 transfer 工具了",我带着 tool_call 往下走。
→ 到了转账工具,它一看 5万 > 阈值,"啪"地把我按下暂停,抛出中断。
→ 引擎把"我现在在哪、消息历史、State"打包塞进 Store,给我贴了张 checkPointID 便签,然后把外面的迭代器停在一个 Interrupt 事件上。
→ 我在存档里睡了两小时。审批人点了"同意"。
→ 程序拿着我的 checkPointIDResume,把我从 Store 里捞出来,"同意"作为转账那步的结果注入,我接着上次的地方往下跑,最后回一句"已转账"。我从没从头重来过——这就是不重复扣费、不重复副作用的关键。
L03

中断怎么触发

中断本质是一个特殊的 error——节点执行中返回 InterruptErrorcompose/ 中断相关),图执行引擎识别到它就停止调度、进入"存现场"流程,而不是当普通错误抛出。

// 概念:某个工具/节点里
if 需要人工审批 {
    return nil, compose.NewInterruptError(想让人看的信息)   // 触发中断
}
读法:中断"伪装"成一种特殊 error 往上传,引擎特判它——不当失败处理,而是触发暂停+存档。这样中断机制不用侵入每个节点的正常逻辑,复用了 Go 的 error 传播路径。很巧妙。
在 ADK 层,对应 AgentAction 里的 Interrupt 动作(Day 11 的 AgentEvent.Action)。所以你在事件循环里能收到"这是个中断事件",知道该去找人了。
L04

CheckPoint:存什么现场

CheckPoint(检查点)保存"恢复所需的一切":跑到哪个节点了、各 channel 里的值、State(Day 09)、消息历史等——足够之后原样重建执行状态。

类比:就像生活中的游戏存档 CheckPoint 就像生活中的游戏存档:存下你的位置、血量、背包、任务进度。读档时不用重新打前面的关,直接从存档点继续。它就是 Agent 运行的"存档"。关键是存得够全——少存一样,恢复时就会出错或行为不一致。
⚠️ 小白常误以为:中断就是"把 Agent 对象一直挂在内存里等人回来"。其实:那样服务一重启就全没了、还占着内存。真实做法是把现场序列化存进 Store(可以是 Redis/DB),Agent 进程该干嘛干嘛甚至可以下线;人回来时再从 Store 里重建——所以人隔几小时、换台机器都能续上。
编译图时要开启 checkpoint 支持并提供一个 CheckPointStorecompose/checkpoint.go:52,用 WithCheckPointStore 注入,L05),运行时用一个 checkPointIDWithCheckPointID)标识这次会话的存档。恢复时用同一个 ID 找回存档。
L05

Store:现场存哪里

CheckPoint 存到一个 CheckPointStore(接口)。你可以用:

  • 内存 Store:进程内 map,重启就没了——适合开发/测试。
  • Redis / 数据库 Store:持久化,跨进程、跨重启都在——生产环境用。你自己实现 Store 接口对接。
为什么要可插拔的 Store? 因为"人几小时后才审批"意味着你的服务可能已经重启、甚至换了台机器。存内存肯定丢。做成接口,让你按需接 Redis/MySQL/对象存储——又是 Day 01 的"依赖注入/面向接口"哲学:Eino 定义 Store 接口,你提供实现。生产环境务必用持久化 Store。
L06

Resume:从断点继续

人类给了答复后,你带着 checkPointID 和"人给的输入"调用恢复。引擎从 Store 读回 CheckPoint,重建执行状态,把"人给的输入"作为那个中断节点的结果,继续往下跑:

// 概念:恢复运行
iter := runner.Resume(ctx, checkPointID,
    adk.WithResumeInput("审批通过"))   // 把人的决定喂给中断点
for { event, ok := iter.Next(); if !ok {break}; ... }   // 接着消费后续事件
读法:Resume 用 checkPointID 找回存档,把人的输入注入中断点,从那里继续。前面已经跑过的步骤不会重跑——所以不会重复扣 token、不会重复执行副作用。这正是 L01 说的第三个难点的解法。
L07

典型场景:人工审批转账

// 转账工具:金额大就中断等审批
func transferTool(ctx, args) (string, error) {
    if args.Amount > 10000 {
        return "", compose.NewInterruptError("大额转账需人工审批: " + args.描述)
    }
    return 执行转账(args), nil
}
// 主流程
iter := runner.Query(ctx, "给张三转 5 万")
for { e,ok:=iter.Next(); if !ok{break}
    if e.Action != nil && e.Action.Interrupt != nil {
        存 checkPointID; 通知审批人; break   // 暂停,去走审批
    }
}
// ……审批人批准后……
iter2 := runner.Resume(ctx, checkPointID, adk.WithResumeInput("批准"))
📝 举例:小额直接过、大额才拦——同一段代码两种命运 同样这个工具:用户说"转 5000 元"时,5000 < 10000,工具直接执行、Agent 秒回"已转账",人根本不用出现;用户说"转 50000 元"时,50000 > 10000,同一行 if 命中,触发中断、存档、等审批。阈值一个数字,就把"顺滑体验"和"安全闸门"两种需求同时满足了——这正是把控制逻辑写进工具的好处。
串起来看(一句话复述) 模型决定调转账工具 → 工具发现 5 万 > 阈值,抛中断 → 引擎存档、返回中断事件 → 你的程序发审批通知、暂停 → 审批人批准 → 你 Resume → 转账执行、Agent 告诉用户"已转账"。一句话:危险动作先存档暂停,人点头后从原地续跑——思路没断,人只在关键一步插了个手。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 为什么需要中断/恢复?三个难点是什么?
  • 中断怎么触发?(InterruptError 特殊 error)
  • CheckPoint 存什么?为什么要可插拔 Store?
  • Resume 怎么保证不重跑?
  • 人工审批场景怎么落地?

✋ 动手

grep -rn 'Interrupt\|InterruptError' compose/ | head
grep -rn 'CheckPoint\|checkpoint\|Resume' compose/ adk/ | head
grep -rn 'CheckPointStore\|Store' compose/ | head
明天预告 · Day 15:第 3 周收官——ReAct Agent 精读。把 Day 11-14 的知识串起来,逐段读 flow/agent/react 的真实源码,看一个完整 ReAct Agent 从建到跑的全貌。
← Day 13 多智能体 Day 15 · ReAct 精读 →