HITL 源码:8 种人机协同模式的设计(第85篇-E71)

作者:袖梨 2026-08-17

Eino 的官方示例仓库里有一个目录 adk/human-in-the-loop/,下面整整 8 个子目录,每个是一种模式。这是把"人机协同"拆得最细的一份源码教材:

HITL 源码:8 种人机协同模式的设计(第85篇-E71)

#目录人做的事人在循环里的位置
11_approval批准 / 拒绝工具执行把关
22_review-and-edit审查并修改参数工具执行前把关 + 修正
33_feedback-loop对产出提意见产出迭代循环
44_follow-up回答澄清问题Agent 主动求助
55_supervisor审批(多 agent 层级中)模式 1 + Supervisor 编排
66_plan-execute-replan改计划参数模式 2 + 计划-执行-重规划
77_deep-agents答疑(深度研究 agent 中)模式 4 + Deep Agents
88_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 的形态返回的

funcStatefulInterrupt(ctx context.Context, info any, state any)error

工具调用到一半要暂停,不是 panic,不是 os.Exit,而是返回一个实现了 error 接口的 InterruptSignal。上层 Runner 捕获这个特殊 error,识别出"这不是故障,是要人介入",于是把整个执行状态存进 checkpoint,把 info(给人看的信息)推给前端,然后优雅地结束这一轮。

一个结构分两半:

  1. info:给人看的——"工具 transfer_funds 想转 500 块,等你批准"。它会被渲染成界面上的审批卡片。
  2. state:给机器看的——中断时工具的内部状态(比如还没执行的参数 JSON)。恢复时框架原样回填,工具从断点继续,就像什么都没发生过。

恢复靠两个泛型判定函数,每个可中断工具的代码都是同一个"三段式"骨架:

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(复刻三段式判定,纯标准库)看三种走向:

场景 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——一个包装器,把任意工具包成"需审批":

type InvokableApprovableTool struct {    tool.InvokableTool  // 包装任意已有工具}

包装器的 InvokableRun 就是上面的三段式。人的决策只有两个字段:

type ApprovalResult struct {    Approved         bool    DisapproveReason *string}

值得学的细节在拒绝分支。人拒绝后,工具不是返回 error,而是返回一段普通字符串:

if data.DisapproveReason != nil {    return fmt.Sprintf("tool '%s' disapproved, reason: %s", ...), nil}

这段字符串会作为工具结果进入 LLM 上下文。也就是说,拒绝理由是喂给 Agent 的信息:Agent 看到"被拒了,理由是超出单笔限额",下一轮会自己调整方案(比如拆成两笔)。如果把拒绝做成 error,Agent 的循环就断了——用户在界面上看到的是一个红色报错,而不是一个会改方案的助手。

场景 B(模式1):人拒绝并给理由 → 拒绝也是信息,回填 LLM  工具结果(进 LLM 上下文): "disapproved, reason: 超出单笔限额"场景 C(模式1 第三段):非恢复目标的重跑 → 再中断(防重复执行)  再中断: true

(二)模式 2 Review-and-Edit:把"同意/拒绝"升级为"三态"

审批只能点头摇头,Review-and-Edit(common/tool/review_edit_wrapper.go)给人的是编辑权。结果结构从二态变三态:

type ReviewEditResult struct {    EditedArgumentsInJSON *string// 改参数后执行    NoNeedToEdit          bool// 不改,按原参数执行    Disapproved           bool// 拒绝    DisapproveReason      *string}

判定顺序有讲究:先看 Disapproved(拒了就什么都不做),再看 NoNeedToEdit(原样执行),最后才处理改参。改参分支有个容易被忽略的细节——工具结果里明确告诉 LLM 参数被人改过

return fmt.Sprintf("... the user explicitly changed tool call arguments to %s. Tool called, final result: %s", ...)

为什么要说这一嘴?因为 LLM 下一轮会记得自己当初申请的参数。如果执行结果和它申请的对不上又不解释,模型可能困惑甚至"纠正"回去。明确告知"用户改了参数",Agent 才能基于新事实继续。

场景 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 两个方法

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}}

两个机制值得记:

  1. NoNeedToEditExitAction。LoopAgent 默认会一直转,人的"满意"就是循环的终止条件——退出权在人手里,不在模型手里。
  2. feedback 不走工具返回值,而是作为 ReviewAgent 的 assistant 消息进入对话。因为这里是 agent 对 agent 的循环,意见是"评审员的发言",写手在下一轮自然读到。
场景 E(模式3 Feedback Loop):写手-评审循环,NoNeedToEdit 退出  轮数=2  诗=v1: 静夜思 -> 按反馈修改(加一句望月)  [ExitAction: 评审通过,循环结束]

(四)模式 4 Follow-up:Agent 主动举手

前三种都是"Agent 到关口等人",Follow-up 反过来:Agent 发现信息不够,主动发起中断问人

源码是 common/tool/follow_up_tool.go——一个叫 FollowUpTool 的普通工具,输入是要问的问题列表:

funcFollowUp(ctx context.Context, input *FollowUpToolInput) (string, error) {    // 三段式:首次中断把问题列表给人,state 存问题// 恢复时把人的回答原样作为工具返回值return resumeData.UserAnswer, nil}

精妙之处在于工具的返回值就是人的回答。对 LLM 来说,FollowUpTool 和一个普通的查库工具没有任何区别——都是"调了、拿到字符串、继续推理"。人机交互被封装成了工具调用,Agent 的指令里只需写一句:"信息不足时,先用 FollowUpTool 问清楚再分析。"

场景 F(模式4 Follow-up):Agent 主动问人要缺失信息  工具返回(=人的回答): "科技行业; 上季度; 稳健型"

典型场景:用户说"帮我分析市场趋势"——分析什么行业?什么时间段?风险偏好?模糊指令下 Agent 与其瞎猜,不如中断问一轮。

(五)模式 5-8:原子模式 × 多 agent 编排

后 4 个目录没有引入新的中断原语,而是把 1-4 装进三种编排(都在 eino/adk/prebuilt/supervisorplanexecutedeep):

  1. 5_supervisor = 模式 1 + Supervisor。金融顾问 supervisor 管两个子 agent,transaction_agenttransfer_funds 工具包了审批包装器。查余额直接跑,转账必须过人。
  2. 6_plan-execute-replan = 模式 2 + Planner/Executor/Replanner。订机票、订酒店两个工具都包 Review-Edit,人可以在计划执行的每一步改参数,Replanner 根据人的修改调整后续计划。
  3. 7_deep-agents = 模式 4 + Deep Agents。深度研究 agent 的指令明确要求"先 FollowUp 再分析"——研究型任务信息缺口大,主动澄清比返工便宜。
  4. 8_supervisor-plan-execute = 模式 1 + 嵌套。这个示例专门演示最难的情况:中断发生在层级深处

模式 8 值得单独看,因为它暴露了 HITL 在多 agent 下的真问题——断在三层之下,恢复怎么找到它

pm_supervisor(监督者)└─ project_execution_agent(Plan-Execute-Replan)   └─ Executor      └─ allocate_budget ← 中断发生在这里

答案是开头那个 IDpm_supervisor>project_execution_agent>Executor>allocate_budget。中断信号沿着调用链逐级 AppendAddressSegment,形成完整地址;恢复时 ResumeWithParamsTargets 参数按 interruptID 定向投递:

runner.ResumeWithParams(ctx, "1", &adk.ResumeParams{    Targets: map[string]any{        interruptID: apResult,  // 凭 ID 直达三层之下的断点    },})
场景 G(模式8 嵌套):supervisor>plan>executor 深层工具寻址  interruptID= pm_supervisor>project_execution_agent>Executor>allocate_budget  恢复直达深层工具: budget allocated

这也解释了三段式第 ③ 段为什么必须存在:恢复嵌套执行时,supervisor、plan agent、executor 都会被重跑一遍,只有地址匹配的那一个 isResumeTarget=true,其余全部走"再中断"分支,各回各的断点。

(六)Checkpoint:中断了,状态存哪

StatefulInterruptstate 要活过进程重启才行——生产里,中断时用户可能几小时后才来点"批准",甚至批准的请求落在另一台机器上。

Eino 的解法是 CheckPointStore:Runner 配置一个存储(示例用内存版,生产换 Redis),中断发生时把 runContext + 所有 interrupt 的 ID/地址/state 打包 gob 序列化存进去;ResumeWithParams 时用同一个 CheckPointID 取出来,反序列化回填 ctx。

两个工程细节,都是真实翻车换来的:

  1. schema.Register:state 是 any 类型,gob 序列化需要注册具体类型。每个模式的 Info 结构体都有 init() { schema.Register[*ApprovalInfo]() }——漏了注册,恢复时反序列化直接报错。
  2. 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/

几个贯穿的设计判断:

  1. 拒绝不是 error:审批被拒回填字符串给 LLM,Agent 能自己调整方案,循环不断。
  2. 告诉 LLM 人改过参数:执行结果与申请不符时要说明,否则模型会"纠正"回去。
  3. 退出权在人:Feedback Loop 的终止条件是人的 NoNeedToEdit,不是模型的自我判断。
  4. 人机交互可以封装成工具调用:FollowUpTool 让"问人"和"查库"在 Agent 眼里同构。
  5. 嵌套场景靠地址:interruptID 是层级路径,恢复定向投递,sibling 靠三段式第 ③ 段各回各位。

对照第 4 篇的 DeepFlux:那边的 HITL 走 session_interrupts 表 + SSE interrupt 帧 + resume API,是 HTTP 服务世界里的同一套思想——info 推给浏览器,state 存数据库,恢复凭 interrupt 记录定位。库和平台,殊途同归。

下一篇离开安全主题,进入可观测——一条请求穿过 4 个服务,OTel 全链路追踪怎么做。

代码状态(本篇引用与 demo 边界)

源码定位(全部真实存在)

  1. 原语:eino/components/tool/interrupt.go(Interrupt/StatefulInterrupt/CompositeInterrupt/GetInterruptState/GetResumeContext,含三段式官方注释);eino/adk/interrupt.go(ResumeInfo/InterruptInfo/Address/InterruptContexts/WithCheckPointID/preprocessADKCheckpoint/bridgeStore/getNextResumeAgent)
  2. 模式 1-4 复用件:eino-examples/adk/common/tool/approval_wrapper.goreview_edit_wrapper.gofollow_up_tool.go;模式 3 自定义 agent:3_feedback-loop/writer_agent.goreviewer_agent.go
  3. 8 个示例:eino-examples/adk/human-in-the-loop/1_approval8_supervisor-plan-execute(5-8 的 agent 装配在各自 agent.go/tools.go
  4. 编排底座: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 没动。

相关文章

精彩推荐