HITL 八种模式:Eino 的人机协同设计
系列「企业级 AI Agent 实现拆解」E20 篇。上一篇 E19 讲了 Agent Transfer——怎么把任务从一个 AI 交给另一个 AI。这篇解决另一个核心问题AI 和人怎么协同。Eino 在adk/human-in-the-loop/下提供了八种具体模式每种对应一个真实场景底层共享同一套中断/恢复机制。读完这篇你会知道HITL 的底层机制StatefulInterrupt、GetInterruptState、GetResumeContext 三件套八种模式各自的适用场景和实现方式如何用tool.StatefulInterrupt在任意工具里嵌入中断点为什么必须调用schema.Register才能让 Checkpoint 正确序列化一、为什么需要人在环中全自动 Agent 有一个无法回避的问题高风险操作不能事后撤销。银行转账确认错了、删除文件确认错了、发出去的邮件撤不回来——这些操作需要在执行前让人看一眼。HITLHuman-in-the-Loop人在环中的核心思路AI 完成准备工作在关键决策点暂停等人确认后继续。它不是AI 干不了才问人而是AI 能干但这一步必须人决定。二、底层机制三个函数Eino 的所有 HITL 模式都建在同一套机制上只需要三个函数tool.StatefulInterrupt触发中断// 在工具执行期间调用暂停整个 Agent把 info 展示给用户把 state 存入 Checkpointreturn,tool.StatefulInterrupt(ctx,ApprovalInfo{...},argumentsInJSON)// ↑ 展示给用户的信息 ↑ 恢复时需要用的状态区别info是给用户看的可以是任何实现了String()的对象state是工具恢复时需要的内部数据存入 Checkpoint反序列化后拿回来。tool.GetInterruptState检查是否从中断恢复wasInterrupted,_,storedState:tool.GetInterruptState[string](ctx)if!wasInterrupted{// 第一次执行触发中断return,tool.StatefulInterrupt(ctx,info,argumentsInJSON)}// 从中断恢复继续执行tool.GetResumeContext拿到用户传回的数据isResumeTarget,hasData,data:tool.GetResumeContext[*ApprovalResult](ctx)ifisResumeTargethasData{ifdata.Approved{// 用户批准了继续执行真实工具}}runner.ResumeWithParams从应用侧恢复iter,errrunner.ResumeWithParams(ctx,checkpointID,adk.ResumeParams{Targets:map[string]any{interruptID:ApprovalResult{Approved:true},},})Targets是一个 MapinterruptID从lastEvent.Action.Interrupted.InterruptContexts[0].ID拿到→ 用户输入的结果。框架负责找到对应的工具调用并注入这个结果。三、模式一Approval二选一审批场景执行敏感工具前展示参数用户只需回答 Y/N。核心实现是一个工具包装器InvokableApprovableTooladk/common/tool/approval_wrapper.gofunc(i InvokableApprovableTool)InvokableRun(ctx context.Context,argumentsInJSONstring,opts...tool.Option)(string,error){wasInterrupted,_,storedArguments:tool.GetInterruptState[string](ctx)if!wasInterrupted{// 第一次中断把参数展示给用户return,tool.StatefulInterrupt(ctx,ApprovalInfo{ToolName:toolInfo.Name,ArgumentsInJSON:argumentsInJSON,},argumentsInJSON)}isResumeTarget,hasData,data:tool.GetResumeContext[*ApprovalResult](ctx)ifisResumeTargethasData{ifdata.Approved{returni.InvokableTool.InvokableRun(ctx,storedArguments,opts...)// 批准执行}returnfmt.Sprintf(tool %s disapproved, reason: %s,...),nil// 拒绝返回拒绝信息}}把任何工具变成需要审批的工具只需要包一层bookingTool:tool.InvokableApprovableTool{InvokableTool:originalBookingTool}用户侧循环// 收到中断事件拿到 interruptIDinterruptID:lastEvent.Action.Interrupted.InterruptContexts[0].ID// 等用户输入 Y/N生成 ApprovalResultifuserInputY{apResulttool.ApprovalResult{Approved:true}}else{apResulttool.ApprovalResult{Approved:false,DisapproveReason:reason}}// 带结果恢复iter,_runner.ResumeWithParams(ctx,1,adk.ResumeParams{Targets:map[string]any{interruptID:apResult},})四、模式二Review-Edit审阅 修改参数场景工具调用前用户不只是 Y/N还可以修改参数再执行。InvokableReviewEditTooladk/common/tool/review_edit_wrapper.go在 Approval 基础上多了一个ReviewEditResulttypeReviewEditResultstruct{EditedArgumentsInJSON*string// 用户修改后的 JSON 参数NoNeedToEditbool// 直接批准无需修改Disapprovedbool// 拒绝DisapproveReason*string}用户侧有三种选择switchstrings.ToLower(nInput){caseno need to edit:result.NoNeedToEdittrue// 直接用 AI 生成的参数执行casen:result.Disapprovedtrue// 拒绝这次工具调用default:result.EditedArgumentsInJSONnInput// 把用户输入的 JSON 作为新参数}工具恢复时用修改后的参数重新执行ifresult.EditedArgumentsInJSON!nil{res,_:i.InvokableTool.InvokableRun(ctx,*result.EditedArgumentsInJSON,opts...)returnfmt.Sprintf(user changed args to %s. Result: %s,...,res),nil}五、模式三Feedback Loop循环反馈场景AI 生成内容 → 人审阅 → 提供修改意见 → AI 修改 → 循环直到满意为止。实现用adk.NewLoopAgent把 Writer 和 Reviewer 放在一个 Loop 里// writer_agent.gola,_:adk.NewLoopAgent(ctx,adk.LoopAgentConfig{Name:Writer MultiAgent,SubAgents:[]adk.Agent{writerAgent,// 负责写/修改ReviewAgent{},// 负责中断等人反馈},})ReviewAgent是一个 Custom Agent每次 Writer 写完就触发中断拿到人工反馈typeFeedbackInfostruct{NoNeedToEditbool// 满意结束循环Feedback*string// 不满意填修改建议}用户侧输入 “NO NEED TO EDIT” →reInfo.NoNeedToEdit true→ LoopAgent 收到后退出循环输入其他文字 →reInfo.Feedback nInput→ Writer 看到反馈重新写注意使用自定义中断信息类型时必须注册funcinit(){schema.RegisterName[*FeedbackInfo](human_in_the_loop.FeedbackInfo)}不注册会导致 Checkpoint 序列化失败——框架无法知道反序列化时该用哪个具体类型。六、模式四Follow-up追加澄清场景用户请求模糊AI 先问清楚再执行而不是直接猜测。FollowUpTool工具adk/common/tool/follow_up_tool.go可以携带一组问题触发中断funcFollowUp(ctx context.Context,input*FollowUpToolInput)(string,error){wasInterrupted,_,storedState:tool.GetInterruptState[*FollowUpState](ctx)if!wasInterrupted{// 第一次中断展示所有问题return,tool.StatefulInterrupt(ctx,FollowUpInfo{Questions:input.Questions},FollowUpState{Questions:input.Questions})}isResumeTarget,hasData,resumeData:tool.GetResumeContext[*FollowUpInfo](ctx)if!isResumeTarget{// 不是本次恢复的目标工具继续保持中断return,tool.StatefulInterrupt(ctx,FollowUpInfo{Questions:storedState.Questions},storedState)}returnresumeData.UserAnswer,nil// 把用户答案返回给 Agent 作为工具结果}用户侧fuInfo.UserAnsweruserInput iter,_runner.ResumeWithParams(ctx,1,adk.ResumeParams{Targets:map[string]any{interruptCtx.ID:fuInfo},})和 AskForClarification 的区别E18 里的ask_for_clarification工具使用老 APIcompose.NewInterruptAndRerunErr每次只支持单一文本输入。FollowUpTool使用新的tool.StatefulInterruptAPI支持携带任意结构化状态更推荐用新 API。七、模式五八Supervisor 内嵌审批Multi-Agent场景Multi-Agent 系统里子 Agent 执行高风险操作时需要主控层面的审批。模式五5_supervisor是金融转账场景模式八8_supervisor-plan-execute是项目预算分配场景——两者的关键设计相同把审批工具包装进子 Agent中断信号会透传到顶层 Runner。外层处理代码完全相同for{lastEvent,interrupted:processEvents(iter)if!interrupted{break}// 不管中断在哪个子 Agent 发生都在这里统一处理interruptID:lastEvent.Action.Interrupted.InterruptContexts[0].ID// 等用户输入ResumeWithParams 注入结果iter,errrunner.ResumeWithParams(ctx,checkpointID,adk.ResumeParams{Targets:map[string]any{interruptID:apResult},})}框架保证子 Agent 里的Interrupted动作会一路传到最外层的 Iterator应用侧不需要关心它发生在哪一层。八、模式六Plan-Execute-Replan计划阶段审阅场景AI 先制定旅行计划人审阅每一步的工具调用后再执行可以逐步修改参数。这是 Review-Edit 的扩展版——Agent 对每个工具调用都使用InvokableReviewEditTool包装用户逐步审阅整个旅行计划的每个预订操作订机票 → 用户审阅 → 改了出发时间 → 执行 订酒店 → 用户审阅 → 批准 → 执行 推荐景点 → 用户审阅 → 批准 → 执行每次中断和恢复的代码模式与模式二完全一样只是 Agent 完成全程后会经历多次中断循环。九、模式七Deep Agents深层追问场景嵌套多层 Agent 执行昂贵任务前先用 FollowUpTool 追问避免方向跑偏后浪费大量 Token。使用deep.New预制adk/prebuilt/deepreturndeep.New(ctx,deep.Config{Name:DataAnalysisAgent,Instruction:...IMPORTANT: Before starting any analysis, you MUST first use the FollowUpTool to ask the user clarifying questions about: 1. What specific market sectors they are interested in 2. What time period they want to analyze 3. What type of analysis they need 4. Their risk tolerance,ChatModel:m,SubAgents:[]adk.Agent{researchAgent,analysisAgent},ToolsConfig:adk.ToolsConfig{ToolsNodeConfig:compose.ToolsNodeConfig{Tools:[]tool.BaseTool{followUpTool},},},})系统 Prompt 里的 “you MUST first use the FollowUpTool” 是 Prompt 工程不是技术强制——模型可能跳过但这是可接受的折中。deep.New本质上还是带 SubAgent 工具的 ChatModelAgent即 E19 讲的 AgentTool 模式。十、八种模式一张表模式核心机制人的决策适用场景1. ApprovalInvokableApprovableToolY / N敏感操作一键审批2. Review-EditInvokableReviewEditTool批准 / 拒绝 / 改参数参数需要人工核对调整3. Feedback LoopLoopAgent Custom ReviewAgent满意退出 / 给反馈创作类内容迭代打磨4. Follow-upFollowUpTool回答问题需求模糊时先澄清5. Supervisor 审批Supervisor ApprovalToolY / N财务/权限类Multi-Agent 高风险子操作6. Plan-Execute-ReplanReviewEditTool 多步骤逐步审阅 修改复杂多步骤任务全程把控7. Deep Agents 追问FollowUpTool SubAgent回答多个问题嵌套多步骤前的信息收集8. Supervisor-Plan-ExecuteSupervisor 计划 审批Y / N资源分配项目设置类复杂工作流十一、实现要点别忘了 Register任何自定义的中断信息类型都必须在init()里注册funcinit(){schema.Register[*ApprovalInfo]()schema.Register[*ReviewEditInfo]()schema.RegisterName[*FeedbackInfo](human_in_the_loop.FeedbackInfo)}原因Checkpoint 序列化时框架需要把info和state存入持久化存储比如 Redis反序列化时要知道用哪个具体类型重建对象。不注册会在恢复时报类型找不到的错误且这个错误只在真正尝试恢复时才出现——如果只在本进程内测试不会踩到这个坑。小结Eino 的八种 HITL 模式都建在同一套机制上StatefulInterrupt触发中断GetInterruptState/GetResumeContext在工具侧检查状态runner.ResumeWithParams从应用侧注入用户输入。区别在于中断时展示什么、用户输入什么、恢复后做什么。三个官方工具包装器ApprovalWrapper、ReviewEditWrapper、FollowUpTool覆盖了大多数场景复杂场景自己组合即可。下篇继续。代码来源cloudwego/eino · cloudwego/eino-examples