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

"人机协同"(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}
}

两个机制值得记:

  1. NoNeedToEditExitAction。LoopAgent 默认会一直转,人的"满意"就是循环的终止条件------退出权在人手里,不在模型手里。
  2. 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/supervisorplanexecutedeep):

  • 5_supervisor = 模式 1 + Supervisor。金融顾问 supervisor 管两个子 agent,transaction_agenttransfer_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 ← 中断发生在这里

答案是开头那个 IDpm_supervisor>project_execution_agent>Executor>allocate_budget。中断信号沿着调用链逐级 AppendAddressSegment,形成完整地址;恢复时 ResumeWithParamsTargets 参数按 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:中断了,状态存哪

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/

几个贯穿的设计判断:

  • 拒绝不是 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.goreview_edit_wrapper.gofollow_up_tool.go;模式 3 自定义 agent:3_feedback-loop/writer_agent.goreviewer_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 没动。

相关推荐
武子康2 小时前
Pi 怎样决定模型看见什么:AGENTS.md、SYSTEM.md 与 Skills 的加载边界
人工智能·llm·agent
也非2 小时前
制作一键安装DeepSeek Harness安装包,以及创造模式实测
agent·deepseek
程序员鱼皮2 小时前
DeepSeek Harness 最新邪修曝光!被吹成核弹的极简模式,真的夯吗?
前端·后端·ai编程
刘立军2 小时前
异常与统一封装架构:规范统一返回值、全局异常与参数校验
架构·ai编程
宋哥转AI2 小时前
深入理解 AI Agent · MEMORY #01:从认知科学到记忆分层,建立完整的记忆认知框架
人工智能·agent·ai编程
卡卡罗特AI2 小时前
AI总乱改代码?一个规则文件帮你搞定!99%的人都没设置!附万能模板!
openai·ai编程
TonyLee0172 小时前
关于大模型LLM的应用技术栈简记
大模型·agent·提示词工程
晚安code2 小时前
Function Calling 会被 MCP 取代吗?理清两者关系与使用细节
ai编程