ByteNoteByteNote
HITL 审批:从请求到放行的端到端实现
字

字节笔记本

2026年10月24日 · 约 9 分钟读完

HITL 审批:从请求到放行的端到端实现

API中转
¥120

Agent SDK 概览

本文是《从 0 写 Agent》系列第 37 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「HITL 审批的端到端实现」。

第 12-13 章我们讲了 HITL 的原理和序列化。这一章看它在真实平台(agents-server)里怎么端到端实现,从前端弹审批框,到后端 pending 状态机,到 ResumeStreamed 恢复执行。


完整流程

agents-server 的 HITL 跨越三层,完整流程:

text
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 继续

Claude Code

pending map,暂存中断状态

go
// 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,防泄漏

go
// 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:

go
// 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

go
// 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 端到端的核心:

  1. pending map,内存暂存中断的 RunState
  2. cleanupLoop,10 分钟过期清理,防泄漏
  3. 审批恢复,Approve/Reject → ResumeStreamed
  4. 内存 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 移植版)。全系列目录见总览。

相关文章

分享: