05中断与人在回路(先会用)
工具触发中断 → 存 checkpoint → Resume 续跑;审批、改参、追问三类场景。
adk/runner.goadk/interrupt.go当 Agent 需要「等一个人」
有些动作不能让 Agent 自作主张:退款、发邮件、删数据、下订单。理想的流程是——Agent 跑到这一步,暂停,把「我想执行 approve(退款 ¥500)」交给人类审批,人点了同意,再从暂停处继续,仿佛从未停过。
这就是人在回路(HITL)。它对框架的要求很苛刻:暂停时要能把「整轮运行状态」完整保存下来(可能进程都退出了),恢复时要能精确还原到那个节点。Eino 把这套能力做成了一等公民。先看它的完整闭环:
对照演示,一次 HITL 走过七个阶段:正常运行 → 中断 → 持久化 → 等待 → 恢复 → 继续 → 完成。注意图里的两条流:横向是控制流沿节点前进,纵向是同一份状态被序列化到 checkpoint、再反序列化还原。它们在中断的那个节点(ApprovalTool)交汇——这正是理解 HITL 的钥匙。
中断不是错误,是「合法的暂停」
新手的第一反应是用 error 表达「需要人工介入」。Eino 明确反对:中断是一种正常的控制流,不是异常。
工具在需要人介入时,调用 Interrupt 或 StatefulInterrupt(adk/interrupt.go):
/* * Copyright 2025 CloudWeGo Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */
package adk
import ( "bytes" "context" "encoding/gob" "errors" "fmt" "sync"
"github.com/cloudwego/eino/internal/core" "github.com/cloudwego/eino/schema")
// ResumeInfo holds all the information necessary to resume an interrupted agent execution.// It is created by the framework and passed to an agent's Resume method.type ResumeInfo struct { // EnableStreaming indicates whether the original execution was in streaming mode. EnableStreaming bool
// Deprecated: use InterruptContexts from the embedded InterruptInfo for user-facing details, // and GetInterruptState for internal state retrieval. *InterruptInfo// … 这只是文件开头 39 行,并非完整声明;点击上方「浏览完整文件」// 无内部状态需要保存func Interrupt(ctx context.Context, info any) *AgentEvent
// 同时保存 agent 的内部 state,恢复时还原func StatefulInterrupt(ctx context.Context, info any, state any) *AgentEventinfo 是给人看的:「我要退款 ¥500,请审批」。区别在于:StatefulInterrupt 还会把 state 序列化保存,恢复时原样还给你——当你的工具在暂停前算了一半、需要接着算时用它。
它们返回的不是 error,而是一个带 Action.Interrupted 的 AgentEvent(adk/interrupt.go:59)。回忆第 1 章:AgentAction 把控制流建模成数据,Interrupted 就是其中一个动作。因为它是数据而非异常,才能被 Runner 捕获、序列化、跨进程存活。
// TypedInterrupt creates a typed interrupt event that pauses execution to request external input.// It is the generic counterpart of Interrupt; see Interrupt for full documentation.func TypedInterrupt[M MessageType](ctx context.Context, info any) *TypedAgentEvent[M] { var rp []RunStep rCtx := getRunCtx(ctx) if rCtx != nil { rp = rCtx.RunPath }
is, err := core.Interrupt(ctx, info, nil, nil, core.WithLayerPayload(rp)) if err != nil { return &TypedAgentEvent[M]{Err: err} }
contexts := core.ToInterruptContexts(is, allowedAddressSegmentTypes)
return &TypedAgentEvent[M]{ Action: &AgentAction{ Interrupted: &InterruptInfo{ InterruptContexts: contexts, }, internalInterrupted: is, }, }}🔑 本章的设计钥匙
把「暂停」建模成一个 Action(数据) 而不是一个 error(异常),是整个 HITL 机制的地基。异常只能被
catch,而数据可以被序列化。正因为中断是数据,Runner 才能把它连同整轮状态gob编码进 checkpoint,让运行真正「冻结」下来——哪怕进程退出。
Checkpoint:把整轮状态冻起来
中断发生后,Runner 会把整轮运行状态——channel 值、各节点 state、以及定位用的 Address——用 gob 序列化,写进 CheckPointStore。
Address 是关键:它记录「运行是在哪个节点暂停的」。恢复时靠它精确找回中断点,而不是从头重跑。这套地址机制底层来自 compose 引擎(第 23 章详解),ADK 通过一个 bridgeStore(adk/interrupt.go:315)把两层的 checkpoint 桥接起来。
func newBridgeStore() *bridgeStore { return &bridgeStore{data: make(map[string][]byte)}}要开启持久化,只需给 Runner 配一个 store,并在运行时带上 checkpoint id:
runner := adk.NewRunner(ctx, adk.RunnerConfig{ Agent: myAgent, CheckPointStore: myStore, // 你的实现,或内存版})
iter := runner.Query(ctx, "帮我给这笔订单退款", adk.WithCheckPointID("session-42"))状态落盘后,进程可以安全退出。用户去审批、隔天再回来都行——checkpoint 静静躺在 store 里,不占任何运行资源。
Resume:从断点无缝继续
人审批通过后,用同一个 checkpoint id 调 Resume 系列方法(adk/runner.go):
/* * Copyright 2025 CloudWeGo Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */
package adk
import ( "context" "errors" "fmt" "runtime/debug" "sync"
"github.com/cloudwego/eino/internal/core" "github.com/cloudwego/eino/internal/safe" "github.com/cloudwego/eino/schema")
func errorIterator[M MessageType](err error) *AsyncIterator[*TypedAgentEvent[M]] { iter, gen := NewAsyncIteratorPair[*TypedAgentEvent[M]]() gen.Send(&TypedAgentEvent[M]{Err: err}) gen.Close() return iter}
func newUserMessage[M MessageType](query string) (M, error) { var zero M switch any(zero).(type) {// … 这只是文件开头 40 行,并非完整声明;点击上方「浏览完整文件」// 策略一:恢复全部中断点(最常用)iter, err := runner.Resume(ctx, "session-42")
// 策略二:精确指定要恢复哪些、喂什么数据iter, err := runner.ResumeWithParams(ctx, "session-42", &adk.ResumeParams{ Targets: map[string]any{ "ApprovalTool": "approved", // 按 Address 定位,注入审批结果 },})Resume(adk/runner.go:124)是「隐式恢复全部」;ResumeWithParams(adk/runner.go:147)让你按 Address 精确控制:Targets 里的地址会收到 isResumeFlow = true 并拿到你注入的数据,不在 Targets 里的中断点则自行决定如何处理。
// Resume continues an interrupted execution from a checkpoint, using an "Implicit Resume All" strategy.// This method is best for simpler use cases where the act of resuming implies that all previously// interrupted points should proceed without specific data.//// When using this method, all interrupted agents will receive `isResumeFlow = false` when they// call `GetResumeContext`, as no specific agent was targeted. This is suitable for the "Simple Confirmation"// pattern where an agent only needs to know `wasInterrupted` is true to continue.func (r *TypedRunner[M]) Resume(ctx context.Context, checkPointID string, opts ...AgentRunOption) ( *AsyncIterator[*TypedAgentEvent[M]], error) { return r.resumeInternal(ctx, checkPointID, nil, opts...)}// ResumeWithParams continues an interrupted execution from a checkpoint with specific parameters.// This is the most common and powerful way to resume, allowing you to target specific interrupt points// (identified by their address/ID) and provide them with data.//// The params.Targets map should contain the addresses of the components to be resumed as keys. These addresses// can point to any interruptible component in the entire execution graph, including ADK agents, compose// graph nodes, or tools. The value can be the resume data for that component, or `nil` if no data is needed.//// When using this method:// - Components whose addresses are in the params.Targets map will receive `isResumeFlow = true` when they// call `GetResumeContext`.// - Interrupted components whose addresses are NOT in the params.Targets map must decide how to proceed:// -- "Leaf" components (the actual root causes of the original interrupt) MUST re-interrupt themselves// to preserve their state.// -- "Composite" agents (like SequentialAgent or ChatModelAgent) should generally proceed with their// execution. They act as conduits, allowing the resume signal to flow to their children. They will// naturally re-interrupt if one of their interrupted children re-interrupts, as they receive the// new `CompositeInterrupt` signal from them.func (r *TypedRunner[M]) ResumeWithParams(ctx context.Context, checkPointID string, params *ResumeParams, opts ...AgentRunOption) (*AsyncIterator[*TypedAgentEvent[M]], error) { return r.resumeInternal(ctx, checkPointID, params.Targets, opts...)}底层做的是:按 checkpoint id 反序列化出整轮状态,按 Address 回到当初暂停的节点,把你注入的数据交给它,然后横向的 run flow 从断点继续——就像演示里第 6 步那样,纵向的 checkpoint 流与横向的 run 流在同一个节点「接上了」。
📝 三类典型 HITL 场景
同一套机制覆盖三种常见需求:审批(注入 approved/rejected)、改参(恢复时用
ResumeParams替换工具参数)、追问(注入用户补充的信息)。它们的差别只是「Resume 时注入什么数据」,机制完全一致。
一个容易忽略的对称性
回忆第 2 章的结论:续跑时的流式模式来自 checkpoint,而不是调用方。这不是孤立的细节,而是 HITL 的一条纪律:恢复必须忠实还原中断时的全部上下文——流式与否、历史消息、节点状态,一个都不能变。否则「仿佛从未暂停」就成了空话。
这也解释了为什么 checkpoint 要存「整轮状态」而不只是「几个变量」:任何被遗漏的上下文,都会让恢复后的行为与暂停前产生偏差。
本章小结
- HITL 让 Agent 在关键动作前暂停、等人、再无缝继续。
- 中断是数据(Action)不是异常(error):
Interrupt/StatefulInterrupt返回带Interrupted的事件,可被序列化。 - Checkpoint 用
gob把整轮状态 +Address冻结进 store,进程可安全退出。 Resume恢复全部,ResumeWithParams按Address精确注入数据;覆盖审批/改参/追问三类场景。- 恢复必须忠实还原全部上下文(含流式模式),这是「仿佛从未暂停」的前提。
到这里,Part I 的单 Agent 主线就通了。下一章进入中间件:如何在不改 Agent 核心的前提下,像叠罗汉一样给它叠加文件系统、任务规划、摘要压缩等能力。