
字节笔记本
2026年10月24日 · 约 9 分钟读完
HITL 审批:从请求到放行的端到端实现

本文是《从 0 写 Agent》系列第 37 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「HITL 审批的端到端实现」。
第 12-13 章我们讲了 HITL 的原理和序列化。这一章看它在真实平台(agents-server)里怎么端到端实现,从前端弹审批框,到后端 pending 状态机,到 ResumeStreamed 恢复执行。
完整流程
agents-server 的 HITL 跨越三层,完整流程:
1. Agent 跑到危险工具,SDK 返回 Interruption
2. 运行层把 Interruption 翻译成 protocol 事件,推给前端
3. 运行层把 SDK RunState 存进 pending map
4. 前端弹"要执行 X,同意?"
5. 用户点同意/拒绝
6. 前端发 tool.approve/tool.reject
7. 分发层从 pending 取出 RunState
8. 记录审批决策(Approve/Reject)
9. ResumeStreamed 继续
pending map,暂存中断状态
// ws_handler.go:31
type pendingRun struct {
state *agents.RunState // SDK 的可恢复状态
result *agents.StreamedResult
sink EventSink
agentConfig *store.AgentConfig
}
pending map[string]*pendingRun // runID → 中断的 run当 SDK 的 RunStreamed 返回带 Interruptions 的结果,运行层不丢弃状态,存进 pending map。 等用户审批后取出恢复。
为什么用 map 而不是数据库
pending map 是内存中的,不持久化。为什么?
- HITL 是短时的,用户通常几秒到几分钟就审批,不需要持久化
- RunState 带运行时状态(StreamedResult、sink),不好序列化
- 服务重启 = 审批丢失,可接受(用户重新发起 run)
如果需要持久化审批(跨重启),可以把 RunState 序列化存数据库(第 13 章的 MarshalJSON),但 agents-server 选了简化的内存方案。
cleanupLoop,防泄漏
// ws_handler.go:44
func (h *WSHandler) cleanupLoop() {
ticker := time.NewTicker(time.Minute)
for range ticker.C {
for runID, p := range h.pending {
if time.Since(p.createdAt) > 10*time.Minute {
delete(h.pending, runID) // 清理 10 分钟过期的
}
}
}
}每分钟清理超过 10 分钟没审批的 pending run。 防止用户发起 run 后关浏览器,pending 永远占内存。
这是"防内存泄漏"的标准手段,对任何"暂存"数据结构,都要有过期清理。
审批处理
用户点"同意",前端发 tool.approve:
// ws_handler.go(概念)
case "tool.approve":
p := h.pending[msg.RunID]
if p == nil { return } // 过期了或不存在
// 记录审批
p.state.Approve(findInterruption(p.state, msg.CallID), msg.Always)
// 恢复执行
go r.ResumeStreamed(p.state, p.result, p.sink)
delete(h.pending, msg.RunID) // 清理 pending三步,取 pending、Approve、ResumeStreamed。 恢复后,事件继续通过 sink 推给前端。
拒绝类似,但调 Reject(带原因),原因喂回模型让它调整。
运行层的 finishResult
// runner.go:249(概念)
func (r *Runner) finishResult(sr, result, sink) {
if len(result.Interruptions) > 0 {
// 有审批 → 不结束,存 pending
sink(&protocol.Envelope{
Type: "run.tool_call",
ToolCall: {NeedsApproval: true, ...},
})
// RunState 存进 pending(在 ws_handler 层做)
} else {
// 正常结束
sink(&protocol.Envelope{Type: "run.output", Output: result.FinalOutput})
}
}关键判断,result 有 Interruptions 就不结束 run,而是暂停等审批。 前端收到 NeedsApproval: true 的 tool_call 事件,弹审批框。
设计权衡:内存 pending vs 持久化
| 方式 | 优点 | 缺点 |
|---|---|---|
| 内存 pending(agents-server 选的) | 简单、快、不碰数据库 | 服务重启丢失 |
| 持久化 pending | 重启不丢 | 复杂、要序列化 RunState |
agents-server 选内存方案,因为 HITL 是"交互式"的,用户在线时才需要。服务重启时,用户会重新发起对话,不需要恢复旧的 pending。
但如果是"异步审批"场景(审批要等几小时、跨班次),就要持久化。agents-server 的设计假设是"即时交互",够用。
小结
HITL 端到端的核心:
- pending map,内存暂存中断的 RunState
- cleanupLoop,10 分钟过期清理,防泄漏
- 审批恢复,Approve/Reject → ResumeStreamed
- 内存 vs 持久化,agents-server 选内存(即时交互),异步场景要持久化
HITL 是"SDK 能力"变"用户可用功能"的最后一公里。 SDK 提供 RunState 序列化(第 13 章),平台用它做端到端审批流。
下一篇
第 38 章,go:embed + 零构建前端。agents-server 怎么把 React 前端打进 Go 二进制?不用 webpack/vite,用 importmap + ESM CDN 直接跑 JSX。这是"单二进制部署"的秘诀。
本文是《从 0 写 Agent》系列第 37 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。

