ByteNoteByteNote
让人来审批工具调用:HITL 怎么做
字

字节笔记本

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

让人来审批工具调用:HITL 怎么做

API中转
¥120

CLAUDE.md 与自动记忆:Agent 的项目记忆

本文是《从 0 写 Agent》系列第 12 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「Human-in-the-Loop:让人审批工具调用」。

Agent 能调工具是好事,但也是危险的事。一个能"删用户"、"发邮件"、"付款"的 Agent,如果不加管控,它可能因为理解错用户意图,造成不可逆的损失。

Human-in-the-Loop(HITL,人在回路中)就是解,Agent 在执行危险工具前暂停,等人审批后再继续。这一章拆 agents-go 的 HITL 状态机:NeedsApproval 怎么标记危险工具,"中断→审批→恢复"怎么流转。


引子:Agent 的危险操作

想象这个场景:

go
agent := &agents.Agent{
    Name: "运维助手",
    Tools: []agents.Tool{
        agents.NewFunctionTool("delete_user", "删除用户", deleteUser),
        agents.NewFunctionTool("send_email", "发邮件", sendEmail),
        agents.NewFunctionTool("refund", "退款", refund),
    },
}

agents.Run(ctx, agent, "把昨天下单的那几个用户处理一下", opts)
// Agent 可能理解成"删除这几个用户",直接删了!

用户说"处理一下",可能意思是"退款"、"发确认邮件"、或者真"删除"。Agent 理解错了,后果可能是删除了不该删的用户。

这不是假设,这是真实企业 Agent 落地的核心风险。Anthropic 的 Claude Code 在执行 rm -rf 这类命令前会问用户,OpenAI 的 Codex 在改代码前要确认,所有生产级 Agent 都有 HITL。


Hooks:运行时事件钩子参考

原理:暂停是原子的

第 4 章我们讲过 HITL 的核心语义,只要一个工具调用需要审批,整个 turn 的所有工具都不执行,立即暂停。

为什么这么严格?因为 resume 时不能重跑。想象非原子的暂停:

text
模型这轮要调 3 个工具:[delete_user, send_email, refund]
  → delete_user 需要审批,暂停
  → send_email 不需要审批,先执行了
  → refund 不需要审批,先执行了
  → 人审批 delete_user:同意
  → resume:重跑这一轮?那 send_email 和 refund 要再跑一次!

重复执行 send_email(发两封邮件)、重复 refund(退两次款),都是灾难。

所以 agents-go 的设计是,有审批就全部暂停,resume 时统一处理。这样保证每个工具调用只执行一次。


源码对照:标记危险工具

HITL 的入口在工具定义上。FunctionTool 有两个相关字段(第 3 章的 tool.go:68):

go
// agents/tool.go:68
type FunctionTool struct {
    // ...
    NeedsApproval bool  // 这个工具永远需要审批
    NeedsApprovalFunc func(ctx, rc, argsJSON) (bool, error)  // 按调用动态决定
}

两种方式标记:

静态标记

go
deleteTool := agents.NewFunctionTool("delete_user", "删用户", deleteUser)
deleteTool.NeedsApproval = true  // 永远需要审批

简单粗暴,这个工具的任何调用都要审批。适合 delete_user、refund 这种"不管参数是什么都危险"的工具。

动态标记

go
emailTool := agents.NewFunctionTool("send_email", "发邮件", sendEmail)
emailTool.NeedsApprovalFunc = func(ctx, rc, argsJSON string) (bool, error) {
    var args struct{ To, Subject, Body string }
    json.Unmarshal([]byte(argsJSON), &args)
    // 发给外部域名要审批,内部不用
    if !strings.Contains(args.To, "@company.com") {
        return true, nil  // 外部邮件,审批
    }
    return false, nil  // 内部邮件,直接发
}

根据参数动态决定,发内部邮件不用审批,发外部要审批。这比静态标记灵活得多,适合"看情况危险"的工具。

requiresApproval(tool.go:89)的优先级是,NeedsApprovalFunc 优先于 NeedsApproval:

go
// agents/tool.go:89
func (t *FunctionTool) requiresApproval(ctx, rc, argsJSON) (bool, error) {
    if t.NeedsApprovalFunc != nil {
        return t.NeedsApprovalFunc(ctx, rc, argsJSON)
    }
    return t.NeedsApproval, nil
}

Agent 级别的审批配置

除了工具自己标记,Agent 也能配置"这个 Agent 的所有工具都要审批"(ApproveTools 字段,run_step.go:402)。比如一个"财务 Agent",它的所有操作都敏感,可以 ApproveTools: []string{"*"} 全审。


源码对照:中断是怎么发生的

HITL 的中断发生在 executeToolsAndSideEffects(第 4 章)。回顾 partitionByApproval(run_step.go:179):

go
// agents/run_step.go:179(简化)
toRun, interruptions, rejected := partitionByApproval(processed.Functions, ...)

if len(interruptions) > 0 {
    // 有待审批的 → 构造 RunState,返回 interruption
    return &singleStepResult{
        NextStep: stepInterruption,
        Interruptions: interruptions,
    }
}

当有 interruptions 时,整个函数立即返回 stepInterruption,不执行任何工具。

回到 runner.loop(run.go:456),这个分支构造完整的 RunState 并返回:

go
// agents/run.go:456(简化)
case stepInterruption:
    state := &RunState{
        CurrentAgent:        agent,
        OriginalInput:       r.originalInput,
        GeneratedItems:      r.generatedItems,
        InterruptedResponse: resp,  // 被中断的那次模型响应
        Interruptions:       step.Interruptions,
        Approvals:           r.rc.Approvals,
        Usage:               r.rc.Usage,
        CurrentTurn:         turn,  // 记住中断在第几轮
    }
    return &RunResult{State: state}

RunResult.State 不为 nil,意味着 Run 暂停了,调用方拿到 State 后处理审批,再 ResumeRun。


源码对照:审批决策

调用方拿到 RunState 后,对每个待审批的 item 做 Approve 或 Reject(run_state.go:45):

go
// agents/run_state.go:45
func (s *RunState) Approve(item *ToolApprovalItem, always bool) {
    s.Approvals.Approve(item, always)
}

func (s *RunState) Reject(item *ToolApprovalItem, always bool, message string) {
    s.Approvals.Reject(item, always, message)
}

注意 always 参数,always=true 表示"以后这个工具都不用再问了"。

go
// 用户审批 delete_user 调用
state.Approve(interruption, false)  // 只这一次
// 或
state.Approve(interruption, true)   // 以后 delete_user 都自动同意

always=true 是个便利功能,如果你信任某个工具,不想每次都点确认,可以一次性放行。但也要小心,一旦 always,后续调用不再询问,风险自担。

Reject 还能带 message,拒绝原因会喂回模型,让模型知道"为什么被拒",从而调整策略(比如换成不需要审批的方式)。

ApprovalStore 的实现

审批决策存在 ApprovalStore(run_context.go:43),支持两种粒度:

go
// 按 callID(单次决策)
approvals.byCallID["call_abc"] = approved

// 按 toolName(always 决策)
approvals.byTool["delete_user"] = approved

decisionFor(run_context.go:90)查找时,先查 callID(单次),再查 toolName(always)。这样既支持"这次同意",也支持"以后都同意"。


源码对照:ResumeRun,恢复执行

审批完,调 ResumeRun(run_state.go:65)继续:

go
// agents/run_state.go:65(简化)
func ResumeRun(ctx context.Context, state *RunState, opts RunOptions) (*RunResult, error) {
    rc := NewRunContext(opts.Context)
    rc.Approvals = state.Approvals  // 把审批决策还原
    rc.Usage = state.Usage          // 把 token 用量还原(不重新计)

    r := &runner{
        opts: opts, rc: rc,
        resume: state,                // 关键:把 state 塞进 runner
        maxTurns: maxTurns,
    }
    return r.loop(ctx, state.CurrentAgent, state.OriginalInput)
}

关键,r.resume = state。这让 loop 知道"我是从中断恢复的",从而做特殊处理。

Resume 的特殊处理

loop 开头(run.go:211)从 resume 恢复状态:

go
// agents/run.go:211(简化)
if r.resume != nil {
    // 从 state 恢复所有累积状态
    r.generatedItems = r.resume.GeneratedItems
    r.sessionItems = r.resume.SessionItems
    r.previousResponseID = ...
    startTurn = r.resume.CurrentTurn  // 从中断的轮次继续,不重新计数
}

三个关键的"不重新":

  1. 不重新计轮次,startTurn = r.resume.CurrentTurn。中断在第 3 轮,resume 从第 3 轮继续,不是从第 0 轮。这防止"反复中断/恢复"突破 MaxTurns 限制。

  2. 不重新调模型,resume 第一轮有个特殊分支(run.go:315),用 pendingResponse(中断时保存的模型响应)而不是重新调模型:

go
// agents/run.go:315
if resumedTurn {  // resume 的第一轮
    resp = r.resume.InterruptedResponse  // 复用上次响应,不调模型
}

为什么?因为中断前的模型响应已经在那里了,模型说"我要调 delete_user"。重新调模型,它可能说不同的话(甚至又说要调,但带不同参数)。复用原响应,保证确定性。

  1. 不重新计 token,Usage 从 state 还原。中断前模型调用消耗的 token 已经记了,resume 不重复记。

处理审批决策

resume 的第一轮,executeToolsAndSideEffects 会查 ApprovalStore(第 4 章的 partitionByApproval),之前 approve 的工具现在能执行了,reject 的会生成"用户拒绝"的输出喂回模型。

text
中断前:模型要调 [delete_user]
人审批:Approve(delete_user)
resume:delete_user 现在 approved → 执行它 → 结果加入历史 → 继续循环

设计权衡:为什么 RunState 要序列化

你可能注意到,RunState 有 MarshalJSON 和 RunStateFromJSON(run_state.go:99)。为什么要序列化?

因为审批可能发生在另一个进程。典型场景,

text
Agent 后端进程                审批前端
    │                          │
    ├─ Run 到危险工具,暂停      │
    ├─ 把 RunState 序列化成 JSON │
    ├─ 发给前端 ─────────────►  │
    │                          ├─ 用户看到"要删用户,同意?"
    │                          ├─ 点"同意"
    │   ◄─────────────── 发回审批决策
    ├─ RunStateFromJSON 重建    │
    ├─ ResumeRun 继续执行       │

后端把状态序列化发给前端,前端反序列化、记录审批、再发回,后端反序列化恢复。 这要求 RunState 能跨进程序列化。

agents-server(Part VI)就是这么做的,WS 发中断事件给前端,前端 approve/reject,WS 发回,后端 resume。第 37 章会详细讲这个端到端实现。

注意,序列化只保证 Go↔Go round-trip(run_state.go:14 注释),不保证和 Python SDK 二进制兼容。因为 Agent 注册表(registry map[string]*Agent)是 Go 特定的,反序列化时按 agent 名字找回指针,Python 的对象模型不一样。


设计权衡:审批的粒度

agents-go 的审批有三种粒度,各有用场:

粒度怎么配适合
单次Approve(item, false)"这次同意,下次再问"
永久(per-tool)Approve(item, true)"这工具我信了,别再问"
永远需要NeedsApproval=true高危工具,永不放行

默认的推荐做法,高危工具(删除、付款)永远需要审批(NeedsApproval=true);中等风险(发邮件)按参数动态决定(NeedsApprovalFunc);低风险(查询)不需要。

always=true 要慎用,它会让后续调用静默执行。适合你确实信任的工具,不适合"还没完全搞懂它会干什么"的工具。


实战对照:mini-claude-code 的 HITL

agents-go 的 HITL 是完整的"中断→序列化→跨进程审批→恢复"状态机(本章和第 13 章)。mini-claude-code 是极简版,只对危险命令做审批,在工具执行前问 y/n(approval.go):

go
// mini-claude-code/approval.go
var dangerousPatterns = []string{
    "rm ", "rmdir", "git push", "git reset", "mv ",
    ">", ">>", "curl", "wget", "kill", "sudo", ...
}

func confirmDangerous(command string) bool {
    for _, p := range dangerousPatterns {
        if strings.Contains(strings.ToLower(command), p) {
            // 危险,问用户
            fmt.Printf("⚠️ 检测到潜在危险命令: %s\n允许执行? [y/N]: ", command)
            resp := readLine()
            return resp == "y"
        }
    }
    return true // 不危险,放行
}

关键设计,拒绝不中断,而是回喂。 RunCommandTool.Execute 里,如果 Approve 返回 false:

go
if t.Approve != nil && !t.Approve(args.Command) {
    return "⚠️ 用户拒绝了此命令的执行。请换一个方案。", nil  // 回喂,不中断
}

这就是本章 HITL 状态机的简化版,agents-go 用 RunState 序列化支持跨进程审批;mini-claude-code 只在单进程内问一下,但"拒绝→回喂→模型自愈"的核心闭环是一样的。

实测:Agent 试图用 cat > main_test.go << EOF(含 >)写文件,触发审批。用户拒绝后,回喂"请换方案"。Agent 思考"The user rejected, let me try edit_file",下一轮改用 touch + edit_file 成功。完整的 HITL 自愈闭环跑通了。


小结

读完这一章,你应该理解 HITL 的完整状态机:

text
Run 调到危险工具
  → partitionByApproval 分出待审批的
  → 有待审批 → 构造 RunState,返回 interruption(所有工具都不执行)
  → 调用方处理 RunState:Approve/Reject(可带 always)
  → ResumeRun(state)
    → 从 state 恢复(轮次、usage、items)
    → 复用 InterruptedResponse(不重新调模型)
    → 查 ApprovalStore,执行 approved 的工具
    → 继续循环

五个关键设计:

  1. 暂停是原子的,有审批就全停,防 resume 重跑
  2. 复用不重算,resume 用原模型响应、原轮次、原 token 统计
  3. 审批可序列化,支持跨进程审批(前端→后端)
  4. 三种审批粒度,单次、永久、永远需要
  5. Reject 能带原因,喂回模型让它调整策略

HITL 是 Agent 从"能干活"到"敢用"的关键。 没有它,你不敢让 Agent 碰生产数据;有了它,危险操作有人的确认,可控。


下一篇

第 13 章,深入 RunState 的序列化细节。为什么需要 agent 注册表?反序列化怎么按名字找回 Agent?serialRunState 怎么把复杂的运行时状态拍平成 JSON 友好形态?这是跨进程审批的技术核心。


本文是《从 0 写 Agent》系列第 12 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。

相关文章

分享: