- 人工智能
- 大模型
- AI Agent
- Agent 框架
- 多智能体
- 工具调用
- MCP Clients
- Agent 记忆
【免费下载链接】adk-go
An open-source, code-first Go toolkit for building, evaluating, and deploying sophisticated AI agents with flexibility and control.
导读
本文讲解 adk-go 开源 Go Agent 开发套件中最小化的 Human-in-the-Loop(HITL,人工在环)工作流实现。以 examples/workflow/hitl_simple/README.md 为骨架,本文将带你理解工作流节点如何通过RequestInput事件暂停运行、等待人工输入,以及控制台启动器如何完成暂停/恢复(pause/resume)闭环——整个过程不需要 LLM、不需要 API Key、也不需要流式传输,仅仅依赖两个普通的工作流节点即可跑通。
读完本文,你将掌握:RequestInput事件与ErrNodeInterrupted的暂停协议、InterruptID的关联机制、NodeConfig.RerunOnResume的两种恢复模式(handoff 与 re-entry),并能直接运行该示例观察完整会话。
什么是 HITL 最小工作流?
概念与定位
hitl_simple是 adk-go 中"最小的端到端 HITL 工作流"示例:两个节点构成一条链路——第一个节点暂停并请求人工输入,第二个节点消费并响应人工的回复。它的定位极其纯粹:
- 概念:两节点 HITL 交接(handoff)——用
RequestInput暂停,在下一个节点恢复执行; - 需要 LLM 吗?不需要。
这是验证控制台启动器(console launcher)暂停/恢复能力的最小样本,也是理解 adk-go 工作流 HITL 机制的最佳入门点。它的孪生变体是 examples/workflow/hitl_rerun,把同样的场景压缩成单个重入节点,本文末尾会做对照说明。
目标
示例展示了最简单的 HITL 模式:
ask_name节点发射一个RequestInput事件,然后返回ErrNodeInterrupted——这会让工作流暂停;- 控制台启动器渲染出提示语(prompt);
- 用户的回复以类型化输入(typed input)的形式投递给下一个节点
greet。
这里没有任何模型调用,暂停与恢复完全由工作流引擎的调度器承担。
工作流结构与执行时序
节点图
示例的工作流拓扑如下:
四个执行步骤
上图中编号的连线代表用户与应用之间按顺序发生的四次交互:
- ask_name:一个
EmittingFunctionNode(发射型函数节点),它产出RequestInput事件(携带每次请求全新的InterruptID)并中断本次运行; - greet:一个普通的
FunctionNode,接收回复字符串并返回问候语。
对应到代码里,工作流通过 workflow.Chain 连接三个端点:
Edges: workflow.Chain(workflow.Start, ask, greet),即Start → ask_name → greet的严格串行链路。关于Chain、Start端点等边构建细节,可参考 workflow/edgebuilder.go 与 workflow/graph.go。
运行示例
启动命令
示例通过 launcher 的 console 子命令运行:
go run ./examples/workflow/hitl_simple/ consolego run会在仓库根目录 go.mod 声明的模块google.golang.org/adk/v2下编译并执行该示例;console子命令由 cmd/launcher/full 提供的完整启动器解析,该启动器封装了 cmd/launcher 下的通用启动逻辑。示例本身通过 agent.NewSingleLoader 注册单 Agent,再交给full.NewLauncher().Execute(...)启动,见 examples/workflow/hitl_simple/main.go。
一次完整的示例会话
控制台中会出现如下往返:
User -> hello Agent -> What's your name? User -> Alice Agent -> Hello, Alice!- 输入任意内容(如
hello)启动运行,工作流从Start走到ask_name; ask_name发出RequestInput事件并暂停,控制台渲染出What's your name?;- 用户输入
Alice,引擎把该回复按InterruptID路由回工作流; greet收到回复并输出Hello, Alice!,运行结束。
代码拆解:暂停与恢复的两大核心机制
1. 暂停协议:NewRequestInputEvent+ErrNodeInterrupted
ask_name节点是暂停的发起者,其核心实现(examples/workflow/hitl_simple/main.go):
ask := workflow.NewEmittingFunctionNodeany, any error) (any, error) { // 每个请求一个全新 InterruptID if err := emit(workflow.NewRequestInputEvent(ctx, session.RequestInput{ InterruptID: "ask_name-" + uuid.NewString(), Message: "What's your name?", })); err != nil { return nil, err } return nil, workflow.ErrNodeInterrupted }, workflow.NodeConfig{}, )两处关键点:
- workflow.NewRequestInputEvent构造一个带
RequestedInput字段的session.Event。该事件还会合成一个名为adk_request_input的 FunctionCall 部件(其ID等于req.InterruptID),并把InterruptID写入LongRunningToolIDs。这一形态有双重作用:其一,让Event.IsFinalResponse()返回 true,从而在请求产出后终止外层 Agent 循环;其二,让通用的"按 FunctionCall ID 分发 FunctionResponse"机制能够把人工回复路由回发出请求的工作流 Agent。常量定义见 workflow/request_input.go 中的WorkflowInputFunctionCallName。 workflow.ErrNodeInterrupted是约定的中断哨兵错误。节点在发射完请求事件后返回它,调度器观察到Event.RequestedInput就会把节点置为NodeWaiting(等待状态),并停止调度其后继节点——详见 workflow/hitl_test.go 中TestScheduler_HitlNode_PausesAndForwardsRequest的断言:等待暂停时downstream绝不运行。
2. 唯一性要求:为什么InterruptID必须"每次全新"
代码注释明确强调:复用的 ID 会让 Dev UI 把一次稍后运行的提示误判为已经回答。这与 Python 版 adk 的RequestInput.interrupt_id默认生成全新 UUID 的行为保持一致。
从 session/session.go 中RequestInput结构体的定义可以看到,InterruptID的职责是"将本次请求与恢复它的响应关联起来",回复正是靠匹配这个 ID 被路由回去的。引擎对它的约定是:
- 留空时,引擎自动填充一个全新 UUID(推荐默认);
- 也可以用"可读前缀 + UUID"自行构造;
- 不要在同一个 session 的多次运行之间复用同一个固定字面量——客户端(尤其 Dev UI)会按此 ID 记录"已回答的请求",复用会导致后续运行不再弹出输入框。
RequestInput还支持Message(UI 上展示的提示文本)、ResponseSchema(人工回复必须满足的 JSON Schema)和Payload(随提示透传给 UI 的上下文,引擎不做解释)。Payload必须是 JSON 可编码的;二进制数据建议通过agent.Artifacts存放,在Payload里只放 URI 字符串。
3. 恢复协议:Workflow.Resume
暂停之后如何恢复?答案是 workflow/resume.go 中的(*Workflow).Resume,其签名是:
func (w *Workflow) Resume( ctx agent.Context, state *RunState, responses map[string]any, ) iter.Seq2[*session.Event, error]responses把RequestInput.InterruptID映射到用户提供的回复载荷。Resume对每个处于NodeWaiting且InterruptID命中的节点执行:
- 校验:若
PendingRequest.ResponseSchema非空,先校验载荷,不匹配则以ErrInvalidResumeResponse报错,节点保持NodeWaiting、PendingRequest不变,便于调用方修正后重试; - 消费:先清空
PendingRequest、把状态置为NodePending,再重新调度——这样对同一InterruptID的重复Resume调用会变成无害的空操作; - 路由:把回复像"提问者自己的输出"一样投递给提问者的后继节点(handoff 模式),提问者自身不会重新执行。
Resume还定义了ErrNothingToResume:当responses非空但没有等待节点匹配时返回,用于区分"成功恢复"与"回复投递没有生效"(通常是目标已过期/已消费,或工作流图已演进出该等待节点)。空responses(或 nil 状态)则视为干净的空操作,不报错。
NodeConfig:控制 HITL 恢复行为的开关
两种 HITL 变体的差异本质上是 workflow/config.go 中NodeConfig.RerunOnResume字段的控制:
| 取值 | 恢复模式 | 行为 |
|---|---|---|
&true | re-entry(重入) | 恢复时从头重跑被中断的节点,回复经ctx.ResumedInput交付给该节点本体 |
&false | handoff(交接) | 把回复作为该节点的输出,路由给后继节点作为输入,提问者自身不重跑 |
nil | 跟随引擎默认 | 当前引擎把 nil 视为 handoff |
hitl_simple使用的是默认的 handoff 语义(ask_name提问、greet消费回复,提问者不重跑);hitl_rerun则显式设置NodeConfig{RerunOnResume: &rerun}(其中rerun := true)走 re-entry 模式。
re-entry 模式的一站式封装:ResumeOrRequestInput
单节点重入变体(examples/workflow/hitl_rerun/main.go)把"提问—暂停—恢复后消费回复"压缩成一个调用:
reply, err := workflow.ResumeOrRequestInput(nc, emit, session.RequestInput{ InterruptID: "ask_name-" + nc.InvocationID(), Message: "What's your name?", })workflow.ResumeOrRequestInput 的实现逻辑(workflow/request_input.go):
- 第一次执行:
ctx.ResumedInput(req.InterruptID)查不到已恢复输入,于是发射NewRequestInputEvent并返回ErrNodeInterrupted(暂停、无输出); - 恢复后重跑:同一调用通过
ctx.ResumedInput拿到人工回复并返回它,函数体随即把它转成最终输出。
注意此处InterruptID内嵌了调用 ID(nc.InvocationID()):在单次运行的多次重入之间保持稳定,使回复仍能正确关联;同时对不同运行又是唯一的,保证 Dev UI 在后续运行中会重新弹出提示。
机制层面的佐证:调度器测试
workflow/hitl_test.go 用一组测试精确刻画了调度器对 HITL 事件的语义,可作为阅读源码时的对照:
TestScheduler_HitlNode_PausesAndForwardsRequest:节点产出一个RequestInput事件后退出,引擎把事件向下游转发、不调度后继节点,工作流以提问者停驻(parked)状态干净收尾;TestScheduler_HitlNode_AutoGeneratesInterruptID:节点作者未提供InterruptID时,引擎会补一个非空 UUID;TestScheduler_HitlNode_PreservesExplicitInterruptID:显式提供的 ID 原样穿透到消费方;TestScheduler_HitlNode_MultipleRequestsPark:一个节点可在单次激活中抛出多个中断,全部记录在NodeState.Interrupts,节点停驻在NodeWaiting;TestScheduler_HitlNode_ErrorAfterRequestFails:先记录请求又返回错误的节点按失败处理,而非等待——失败优先于已记录的请求;TestScheduler_HitlNode_ConcurrentBranches_PausesOnlyWhenAllNonRunning:并行分支中,非 HITL 分支照常完成,HITL 分支的后继不调度,全部活节点完成或进入NodeWaiting后才终止。
两种 HITL 模式怎么选?
把hitl_simple(handoff)与hitl_rerun(re-entry)放在一起对比,选择依据一目了然:
| 维度 | hitl_simple(handoff) | hitl_rerun(re-entry) |
|---|---|---|
| 节点数量 | 两个(提问 + 消费) | 一个(提问 + 消费合一) |
| 恢复路径 | 回复流向后继节点 | 回复流回同节点并从头重跑 |
| 关键 API | NewRequestInputEvent+ErrNodeInterrupted | ResumeOrRequestInput |
| 关键配置 | 默认(nil 即 handoff) | RerunOnResume: &true |
| 适用场景 | 提问与处理解耦、处理逻辑较长或需要独立命名 | 提问与最终输出强耦合、节点体量小的场景 |
进一步探索
- 运行孪生变体:
go run ./examples/workflow/hitl_rerun/ console,对比单节点重入与双节点交接的会话差异; - 在真实会话中体验暂停/恢复的持久化语义,可阅读 session/session.go 中
RequestInput的 JSON 序列化注释(它会跨暂停/恢复回合持久化到会话状态中); - 若你的 HITL 节点需要合法化回复格式,为
RequestInput设置ResponseSchema,结合ErrInvalidResumeResponse实现"回复格式错误可重试"的交互; - 若需要在多步审批等真实业务中落地,可参考更复杂的 examples/workflow/complex 与 examples/workflow/hitl_rerun/README.md,它们展示了 HITL 与更丰富工作流图组合的用法。
- 人工智能
- 大模型
- AI Agent
- Agent 框架
- 多智能体
- 工具调用
- MCP Clients
- Agent 记忆
【免费下载链接】adk-go
An open-source, code-first Go toolkit for building, evaluating, and deploying sophisticated AI agents with flexibility and control.
相关推荐
Ryujinx模拟器:4步跑通Switch游戏
Ryujinx模拟器:4步跑通Switch游戏 把游戏文件放进目录、点下启动,窗口却瞬间闪退。Ryujinx模拟器这款开源的C 项目并不"装了就能玩",本文带你
硬件仿真图形学Apache Airflow HITL(Human-in-the-loop)实战指南:用人工审批与决策操作符为工作流注入人类判断
Apache Airflow HITL(Human in the loop)实战指南:用人工审批与决策操作符为工作流注入人类判断 Human in the Lo
后端任务调度工作流自动化数据编排批处理数据工程流程编排CopilotKit 与 LangGraph 实战:构建带人工审批中断(HITL Interrupt)的 AI Agent
CopilotKit 与 LangGraph 实战:构建带人工审批中断(HITL Interrupt)的 AI Agent 本文基于 CopilotKit 仓库
人工智能AI AgentAgent 框架前端后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考