"人机协同"(HITL,Human-In-The-Loop)这个词,很多人理解为"弹个审批框"。第 84 篇讲工具安全时,HITL 是 L2 那道"看得见但调不动"的审批闸;第 4 篇讲过 DeepFlux 的中断恢复链路。但 HITL 远不止审批------人在循环里的位置不同,协作的形态就完全不同。
Eino 的官方示例仓库里有一个目录 adk/human-in-the-loop/,下面整整 8 个子目录,每个是一种模式。这是把"人机协同"拆得最细的一份源码教材:
| # | 目录 | 人做的事 | 人在循环里的位置 |
|---|---|---|---|
| 1 | 1_approval |
批准 / 拒绝 | 工具执行前把关 |
| 2 | 2_review-and-edit |
审查并修改参数 | 工具执行前把关 + 修正 |
| 3 | 3_feedback-loop |
对产出提意见 | 产出后迭代循环 |
| 4 | 4_follow-up |
回答澄清问题 | Agent 主动求助 |
| 5 | 5_supervisor |
审批(多 agent 层级中) | 模式 1 + Supervisor 编排 |
| 6 | 6_plan-execute-replan |
改计划参数 | 模式 2 + 计划-执行-重规划 |
| 7 | 7_deep-agents |
答疑(深度研究 agent 中) | 模式 4 + Deep Agents |
| 8 | 8_supervisor-plan-execute |
审批(嵌套层级深处) | 模式 1 + 嵌套多 agent 寻址 |
看清楚这张表会发现:1-4 是原子模式,5-8 是它们与多 agent 编排的组合。所以这篇的重点是先把 4 个原子模式 + 底层中断原语讲透,组合模式自然就懂了。
(顺便纠正一个常见讹传:网上有文章把 Eino 的 HITL 说成 Approval/Review/Escalation/Collaborative/Lazy Review 之类------源码里没有这些目录名。以下全部以 eino-examples/adk/human-in-the-loop/ 真实代码为准。)
(零)底层原语:中断是"事件",不是"崩溃"
8 种模式全部建在同一套原语上(eino/components/tool/interrupt.go)。理解了原语,8 种模式只是同一块砖的 8 种砌法。
最反直觉的一点:中断是以 error 的形态返回的。
go
func StatefulInterrupt(ctx context.Context, info any, state any) error
工具调用到一半要暂停,不是 panic,不是 os.Exit,而是返回一个实现了 error 接口的 InterruptSignal。上层 Runner 捕获这个特殊 error,识别出"这不是故障,是要人介入",于是把整个执行状态存进 checkpoint,把 info(给人看的信息)推给前端,然后优雅地结束这一轮。
一个结构分两半:
info:给人看的 ------"工具transfer_funds想转 500 块,等你批准"。它会被渲染成界面上的审批卡片。state:给机器看的------中断时工具的内部状态(比如还没执行的参数 JSON)。恢复时框架原样回填,工具从断点继续,就像什么都没发生过。
恢复靠两个泛型判定函数,每个可中断工具的代码都是同一个"三段式"骨架:
go
wasInterrupted, _, stored := tool.GetInterruptState[string](ctx)
if !wasInterrupted {
// ① 首次执行:挂起。把参数存进 state,返回中断信号
return "", tool.StatefulInterrupt(ctx, &ApprovalInfo{...}, argumentsInJSON)
}
isResumeTarget, hasData, data := tool.GetResumeContext[*ApprovalResult](ctx)
if isResumeTarget && hasData {
// ② 我是这次恢复的目标:读人的决策,继续执行
...
}
// ③ 不是目标却重跑(兄弟组件被恢复捎带我):再中断,防止重复执行
return "", tool.StatefulInterrupt(ctx, &ApprovalInfo{...}, storedArguments)
第 ③ 段是最微妙、也最容易被漏写的一段:恢复一次执行时,不只有断点处那个工具会重跑 ------同层的兄弟工具也可能被框架重新执行。这时 isResumeTarget=false,如果误当成"正常执行"跑下去,工具就会被凭空执行两次。所以第三段的职责是:不是我的恢复,我就再中断一次,等真正轮到我。
用 demo(复刻三段式判定,纯标准库)看三种走向:
css
场景 A(模式1 Approval):transfer_funds 首次调用 → 中断等待批准
中断信号: ID=financial_supervisor>transaction_agent>transfer_funds
给人看: {transfer_funds {"amount":500}}
存 state: {"amount":500}(恢复时回填)
注意中断信号里的 ID------financial_supervisor>transaction_agent>transfer_funds,这是地址:从根 agent 一路到具体工具的层级路径。人批准之后,恢复请求要凭这个 ID 才能直达断点(模式 8 详讲)。
(一)模式 1 Approval:拒绝也是"信息"
最经典的审批。源码是 common/tool/approval_wrapper.go 里的 InvokableApprovableTool------一个包装器,把任意工具包成"需审批":
go
type InvokableApprovableTool struct {
tool.InvokableTool // 包装任意已有工具
}
包装器的 InvokableRun 就是上面的三段式。人的决策只有两个字段:
go
type ApprovalResult struct {
Approved bool
DisapproveReason *string
}
值得学的细节在拒绝分支。人拒绝后,工具不是返回 error,而是返回一段普通字符串:
go
if data.DisapproveReason != nil {
return fmt.Sprintf("tool '%s' disapproved, reason: %s", ...), nil
}
这段字符串会作为工具结果进入 LLM 上下文。也就是说,拒绝理由是喂给 Agent 的信息:Agent 看到"被拒了,理由是超出单笔限额",下一轮会自己调整方案(比如拆成两笔)。如果把拒绝做成 error,Agent 的循环就断了------用户在界面上看到的是一个红色报错,而不是一个会改方案的助手。
arduino
场景 B(模式1):人拒绝并给理由 → 拒绝也是信息,回填 LLM
工具结果(进 LLM 上下文): "disapproved, reason: 超出单笔限额"
场景 C(模式1 第三段):非恢复目标的重跑 → 再中断(防重复执行)
再中断: true
(二)模式 2 Review-and-Edit:把"同意/拒绝"升级为"三态"
审批只能点头摇头,Review-and-Edit(common/tool/review_edit_wrapper.go)给人的是编辑权。结果结构从二态变三态:
go
type ReviewEditResult struct {
EditedArgumentsInJSON *string // 改参数后执行
NoNeedToEdit bool // 不改,按原参数执行
Disapproved bool // 拒绝
DisapproveReason *string
}
判定顺序有讲究:先看 Disapproved(拒了就什么都不做),再看 NoNeedToEdit(原样执行),最后才处理改参。改参分支有个容易被忽略的细节------工具结果里明确告诉 LLM 参数被人改过:
go
return fmt.Sprintf("... the user explicitly changed tool call arguments to %s. Tool called, final result: %s", ...)
为什么要说这一嘴?因为 LLM 下一轮会记得自己当初申请的参数。如果执行结果和它申请的对不上又不解释,模型可能困惑甚至"纠正"回去。明确告知"用户改了参数",Agent 才能基于新事实继续。
css
场景 D(模式2 Review-Edit):三态------改参 / 不改 / 拒绝
改参: user edited args to {"date":"2025-10-16","class":"business"}. result: booked({...})
不改: booked(...)
典型场景:订机票。Agent 拟好 {"date":"2025-10-15","class":"economy"},人一看把舱位改成商务舱再放行------不批准也不拒绝,而是修正。
(三)模式 3 Feedback Loop:人不在"关口",在"循环"里
前两种模式人站在工具前面把关,Feedback Loop(3_feedback-loop)把人放进了产出迭代循环:写手 Agent 写诗 → 评审中断给人看 → 人提意见 → 写手按意见改 → 再评审 → 人满意了退出。
结构是一个 LoopAgent 装两个子 agent:普通的 WriterAgent(ChatModelAgent)+ 自定义的 ReviewAgent。后者不再是包装工具,而是自己实现 Agent 接口的 Run/Resume 两个方法:
go
func (r ReviewAgent) Run(ctx context.Context, input *adk.AgentInput, ...) {
// 从 session 拿到写手的产出
contentToReview, _ := adk.GetSessionValue(ctx, "content_to_review")
// 中断,把作品给人看,state 存作品本身
event := adk.StatefulInterrupt(ctx, feInfo, contentToReview.(string))
gen.Send(event)
}
func (r ReviewAgent) Resume(ctx context.Context, info *adk.ResumeInfo, ...) {
if !info.IsResumeTarget { // 没轮到我:再中断
...
}
if feInfo.NoNeedToEdit {
gen.Send(&adk.AgentEvent{Action: adk.NewExitAction()}) // 满意 → 退出循环
return
}
// 意见作为 ReviewAgent 的"发言"进入循环,写手下一轮读到它
...MessageOutput: &schema.Message{Role: schema.Assistant, Content: *feInfo.Feedback}
}
两个机制值得记:
NoNeedToEdit→ExitAction。LoopAgent 默认会一直转,人的"满意"就是循环的终止条件------退出权在人手里,不在模型手里。- feedback 不走工具返回值,而是作为 ReviewAgent 的 assistant 消息进入对话。因为这里是 agent 对 agent 的循环,意见是"评审员的发言",写手在下一轮自然读到。
ini
场景 E(模式3 Feedback Loop):写手-评审循环,NoNeedToEdit 退出
轮数=2 诗=v1: 静夜思 -> 按反馈修改(加一句望月) [ExitAction: 评审通过,循环结束]
(四)模式 4 Follow-up:Agent 主动举手
前三种都是"Agent 到关口等人",Follow-up 反过来:Agent 发现信息不够,主动发起中断问人。
源码是 common/tool/follow_up_tool.go------一个叫 FollowUpTool 的普通工具,输入是要问的问题列表:
go
func FollowUp(ctx context.Context, input *FollowUpToolInput) (string, error) {
// 三段式:首次中断把问题列表给人,state 存问题
// 恢复时把人的回答原样作为工具返回值
return resumeData.UserAnswer, nil
}
精妙之处在于工具的返回值就是人的回答 。对 LLM 来说,FollowUpTool 和一个普通的查库工具没有任何区别------都是"调了、拿到字符串、继续推理"。人机交互被封装成了工具调用,Agent 的指令里只需写一句:"信息不足时,先用 FollowUpTool 问清楚再分析。"
arduino
场景 F(模式4 Follow-up):Agent 主动问人要缺失信息
工具返回(=人的回答): "科技行业; 上季度; 稳健型"
典型场景:用户说"帮我分析市场趋势"------分析什么行业?什么时间段?风险偏好?模糊指令下 Agent 与其瞎猜,不如中断问一轮。
(五)模式 5-8:原子模式 × 多 agent 编排
后 4 个目录没有引入新的中断原语,而是把 1-4 装进三种编排(都在 eino/adk/prebuilt/:supervisor、planexecute、deep):
- 5_supervisor = 模式 1 + Supervisor。金融顾问 supervisor 管两个子 agent,
transaction_agent的transfer_funds工具包了审批包装器。查余额直接跑,转账必须过人。 - 6_plan-execute-replan = 模式 2 + Planner/Executor/Replanner。订机票、订酒店两个工具都包 Review-Edit,人可以在计划执行的每一步改参数,Replanner 根据人的修改调整后续计划。
- 7_deep-agents = 模式 4 + Deep Agents。深度研究 agent 的指令明确要求"先 FollowUp 再分析"------研究型任务信息缺口大,主动澄清比返工便宜。
- 8_supervisor-plan-execute = 模式 1 + 嵌套 。这个示例专门演示最难的情况:中断发生在层级深处。
模式 8 值得单独看,因为它暴露了 HITL 在多 agent 下的真问题------断在三层之下,恢复怎么找到它:
sql
pm_supervisor(监督者)
└─ project_execution_agent(Plan-Execute-Replan)
└─ Executor
└─ allocate_budget ← 中断发生在这里
答案是开头那个 ID:pm_supervisor>project_execution_agent>Executor>allocate_budget。中断信号沿着调用链逐级 AppendAddressSegment,形成完整地址;恢复时 ResumeWithParams 的 Targets 参数按 interruptID 定向投递:
go
runner.ResumeWithParams(ctx, "1", &adk.ResumeParams{
Targets: map[string]any{
interruptID: apResult, // 凭 ID 直达三层之下的断点
},
})
ini
场景 G(模式8 嵌套):supervisor>plan>executor 深层工具寻址
interruptID= pm_supervisor>project_execution_agent>Executor>allocate_budget
恢复直达深层工具: budget allocated
这也解释了三段式第 ③ 段为什么必须存在:恢复嵌套执行时,supervisor、plan agent、executor 都会被重跑一遍,只有地址匹配的那一个 isResumeTarget=true,其余全部走"再中断"分支,各回各的断点。
(六)Checkpoint:中断了,状态存哪
StatefulInterrupt 的 state 要活过进程重启才行------生产里,中断时用户可能几小时后才来点"批准",甚至批准的请求落在另一台机器上。
Eino 的解法是 CheckPointStore:Runner 配置一个存储(示例用内存版,生产换 Redis),中断发生时把 runContext + 所有 interrupt 的 ID/地址/state 打包 gob 序列化存进去;ResumeWithParams 时用同一个 CheckPointID 取出来,反序列化回填 ctx。
两个工程细节,都是真实翻车换来的:
schema.Register:state 是any类型,gob 序列化需要注册具体类型。每个模式的 Info 结构体都有init() { schema.Register[*ApprovalInfo]() }------漏了注册,恢复时反序列化直接报错。- checkpoint 的版本兼容 :
adk/interrupt.go里有一段 100 行的preprocessADKCheckpoint,专门修补 v0.8.0-v0.8.3 存的旧 checkpoint 与新版本 gob 格式不兼容的问题(同一类型名一个走了 GobEncoder 一个没走,wire format 冲突,只能在字节流里原地替换类型名)。checkpoint 里存的序列化格式一旦上线就是长期契约------这是做"可恢复执行"最容易被低估的成本。
小结
8 种模式,一块基石。把这篇压成三句话:
| 层 | 内容 | 关键源码 |
|---|---|---|
| 原语 | 中断=特殊 error(info 给人 / state 给机器),恢复=ctx 回填 | components/tool/interrupt.go |
| 三段式 | ①首次中断 ②目标恢复 ③sibling 重跑再中断 | 每个可中断工具的固定骨架 |
| 8 种模式 | 1-4 原子(审批/改参/反馈/追问),5-8 与 supervisor/plan-execute/deep 组合,嵌套靠 interruptID 地址寻址 | eino-examples/adk/human-in-the-loop/ |
几个贯穿的设计判断:
- 拒绝不是 error:审批被拒回填字符串给 LLM,Agent 能自己调整方案,循环不断。
- 告诉 LLM 人改过参数:执行结果与申请不符时要说明,否则模型会"纠正"回去。
- 退出权在人:Feedback Loop 的终止条件是人的 NoNeedToEdit,不是模型的自我判断。
- 人机交互可以封装成工具调用:FollowUpTool 让"问人"和"查库"在 Agent 眼里同构。
- 嵌套场景靠地址:interruptID 是层级路径,恢复定向投递,sibling 靠三段式第 ③ 段各回各位。
对照第 4 篇的 DeepFlux:那边的 HITL 走 session_interrupts 表 + SSE interrupt 帧 + resume API,是 HTTP 服务世界里的同一套思想------info 推给浏览器,state 存数据库,恢复凭 interrupt 记录定位。库和平台,殊途同归。
下一篇离开安全主题,进入可观测------一条请求穿过 4 个服务,OTel 全链路追踪怎么做。
代码状态(本篇引用与 demo 边界)
源码定位(全部真实存在)
- 原语:
eino/components/tool/interrupt.go(Interrupt/StatefulInterrupt/CompositeInterrupt/GetInterruptState/GetResumeContext,含三段式官方注释);eino/adk/interrupt.go(ResumeInfo/InterruptInfo/Address/InterruptContexts/WithCheckPointID/preprocessADKCheckpoint/bridgeStore/getNextResumeAgent) - 模式 1-4 复用件:
eino-examples/adk/common/tool/approval_wrapper.go、review_edit_wrapper.go、follow_up_tool.go;模式 3 自定义 agent:3_feedback-loop/writer_agent.go、reviewer_agent.go - 8 个示例:
eino-examples/adk/human-in-the-loop/1_approval...8_supervisor-plan-execute(5-8 的 agent 装配在各自agent.go/tools.go) - 编排底座:
eino/adk/prebuilt/{supervisor,planexecute,deep}
demo 复刻(/tmp/e85demo,纯标准库):InterruptSignal(error 形态)、GetInterruptState/GetResumeContext 两段判定、三段式骨架、模式 1/2/4 全量判定逻辑、模式 3 循环、模式 8 地址寻址。自检 8 项全过(输出即正文引用)。
demo 未覆盖(如实标注):gob 序列化与 schema.Register(demo 用内存 map)、CompositeInterrupt 聚合子图中断、流式恢复(EnableStreaming)、CheckPointStore 持久化与跨进程 Resume、SSE/终端渲染、真实 LLM 调用。场景 D"不改"分支 booked() 为空参(demo 首次中断未传参),逻辑等价。
边界 :README 老占位行中"Approval/Review/Feedback/..."的模式名与源码目录名有出入(无 Escalation/Collaborative/Lazy Review),本文一律以 human-in-the-loop/ 真实目录为准。
git 没动。