乐于分享
好东西不私藏

AI Agent 的"人工审核"是怎么做的?一个 90% 的人会想复杂的机制

AI Agent 的"人工审核"是怎么做的?一个 90% 的人会想复杂的机制

让 AI agent 帮你干活,迟早会碰到这种场景:它要删一个文件、转一笔账、给客户群发一封邮件。这些操作一旦做错没法撤,你肯定想加一道关:先弹个框让人确认,批了再执行。

这就是 agent 里的"人工审核"(HITL,Human-in-the-Loop)。

很多人一听就觉得这玩意儿很复杂:AI 跑到一半要停下来等人,等批准了还要从那个点接着往下跑——这不得做"断点续传"?得把 AI 的执行现场、调用栈、内存状态全存一份快照?等几天再恢复,模型还记得当时在干嘛吗?

我把这套机制从代码里扒了一遍,结论是:它比你想的简单得多,而且关键在于一个反直觉的事实——所谓"暂停",根本不发生在模型那边。

这篇用大白话 + 几段真实的提示词,把它讲透。


先记住一件事:大模型是"无状态"的

这是理解整件事的钥匙。

你每次调用大模型 API,它不记得上一次跟你说过什么。它能"接着聊",纯粹是因为你每次都把完整的对话历史重新发给它。模型看一遍历史,生成下一句,然后就"失忆"了。

第1轮:你发 [系统提示, 用户问题]                → 模型答
第2轮:你发 [系统提示, 用户问题, 模型上次的答, 新问题]  → 模型答
       └──────────── 每次都带上全部历史 ────────────┘

记住这点,后面就通了。


先看 agent 平时怎么"用工具"

agent 比普通聊天多了"会用工具"。一次完整的工具调用,其实是这么个来回:

① 你发请求(对话历史 + 可用工具列表)
② 模型说:"我要调 get_weather 工具,参数 location=Tokyo"   ← 它没直接回答,而是要调工具
③ 你的程序去执行 get_weather                              ← ★ 真正干活的是你,不是模型
④ 你把工具返回的结果("27度")追加到历史里
⑤ 再发一次请求(历史里多了刚才的结果)
⑥ 模型看到结果,说:"东京现在27度"                         ← 最终回答

注意第 ③ 步:工具是"你的程序"去执行的,不是模型执行的。 模型只会"说它想调什么工具",真正动手的是你。

这一步,就是人工审核的关键。


"暂停"其实卡在第 ③ 步

现在把工具换成危险的,比如 delete_file(删文件)。

模型在第 ② 步说:"我要调 delete_file,删 /data/old.log。"

你的程序在第 ③ 步正准备执行——但它一看:这是个危险工具,需要人工审核。于是它:

先不执行,也先不发下一个请求,转头去问人:"要删 /data/old.log 吗?"

就这么简单。"暂停"= 你的程序停在"执行工具"之前,没往下走而已。

这时候有个特别重要的点:模型那边压根没有任何东西在"挂起"。 它在第 ② 步早就把话说完了(我要调这个工具),球已经踢给你了。你迟迟不执行、不回话,模型根本不知道——它无状态,它"不知道"任何事。

所以"AI 停下来等人"这个画面是个错觉。真相是:你把活儿停了,AI 早歇着了。


那暂停的时候,要存什么?

既然要等人,而且可能等几小时、几天(中间你的服务器可能重启、可能换一台机器处理),那就得把"现场"存下来,好让之后能接上。

但要存的东西很朴素,就两样:

  1. 1. 到目前为止的完整对话历史(到"模型说要删文件"那条为止);
  2. 2. 那个待批准的工具调用(工具名、参数、调用 ID)。

存进数据库一行记录就行,大致长这样:

待审核表:
  租户、会话、运行 ID
  工具名 = delete_file,参数 = {path: /data/old.log},调用ID = call_d1   ← 给前端弹框展示用
  对话历史快照                                                          ← 恢复时要重发给模型
  状态 = 待审核

注意:存的全是普通数据(就是 JSON 文本),没有什么"内存快照""执行栈"。 正因为是纯数据,它能存进数据库,之后任何一台服务器都能读出来接着处理——不用是原来那台。


批准之后,到底怎么"接着跑"?

来看真实的提示词,你就明白"恢复"有多朴素了。

暂停前,模型返回的是这个(它要调工具):

{ "role": "assistant", "content":null,
  "tool_calls"
: [{ "id": "call_d1",
    "function"
: { "name": "delete_file", "arguments": "{\"path\":\"/data/old.log\"}" } }] }

→ 你的程序一看是危险工具,停下,存状态,弹框等人。

用户点了"批准"。 你的程序现在做三件事:

  1. 1. 真正执行 delete_file → 得到结果 {"ok": true};
  2. 2. 把结果作为一条"工具结果消息"追加到历史里;
  3. 3. 把完整历史再发一次给模型:
{ "messages": [
    {
 "role": "system",    "content": "You are an agent..." },
    {
 "role": "user",      "content": "删除 /data/old.log" },
    {
 "role": "assistant", "content":null, "tool_calls": [{ "id": "call_d1", ... }] },
    {
 "role": "tool", "tool_call_id": "call_d1", "content": "{\"ok\":true}" }   ← ★ 刚执行的结果
  ]
 }

模型收到这个,继续说:"已删除 /data/old.log。"

看出来了吗?这个"恢复后"的请求,跟一个从没暂停过的普通工具调用,长得一模一样。 模型看到的就是"一段以工具结果结尾的对话",它自然往下接。它完全不知道中间隔了一次人工审核、隔了多久。


那"拒绝"呢?

更有意思。拒绝的话,你不执行那个工具,而是编一条"被拒绝"的工具结果塞进去:

{ "role": "tool", "tool_call_id": "call_d1", "content": "用户拒绝执行此操作。" }

模型看到这条,就当"这个工具返回了'被拒'",于是改口:"好的,已取消删除。需要我做别的吗?"

所以你发现了:批准和拒绝,在发给模型的内容上,只差那一条工具结果的内容——一个是真实执行结果,一个是"被拒"。模型靠这条结果决定下一步怎么说,机制完全一样。


把整个流程串起来

模型说"要调危险工具"
   │
   ▼
程序准备执行 → 发现需审核 → 暂停:
   - 存(对话历史 + 待批工具)到数据库
   - 通知前端"请审核"
   - 释放服务器资源(不傻等)
   │
   ▼
人来决定(几分钟 or 几天都行)
   ├─ 批准 → 执行工具,拿真实结果
   ├─ 拒绝 → 不执行,造"被拒"结果
   │
   ▼
恢复(可以换一台服务器):
   - 从数据库读出对话历史
   - 把工具结果追加进去
   - 完整历史重发给模型 → 模型继续

为什么能"冻几天再恢复"还不出错? 因为恢复要的只是"那段对话历史"——它是纯文本,存在库里,不会过期、不依赖任何内存。读出来重发就行。这也是为什么"这个审批我过两天再看"完全成立。

还有个细节:恢复时不会让模型重新想一遍要调什么工具——它暂停前已经决定好了(删 /data/old.log),你直接拿那个决定去执行就行,只是"补上执行这一步"。


收尾:没有魔法,全是朴素工程

回头看,所谓"AI 跑一半停下来等人、批了再续上"的高级感,拆开后其实是:

  • • 暂停:你的程序在"执行危险工具"前停住,不往下走(模型那边啥都没挂起);
  • • 存档:把对话历史 + 待批工具存成一行数据(纯 JSON,能跨机器);
  • • 恢复:执行(或拒绝)那个工具 → 追加一条工具结果 → 把完整历史重发给模型;
  • • 模型:全程不知道有过审核,它只是看到"一段以工具结果结尾的对话",照常往下说。

理解这件事的钥匙,始终是那句:大模型无状态,它不记得任何事,全靠你每次把历史喂给它。 所以"暂停/恢复"从来不是模型的能力,而是你**控制"要不要执行工具、什么时候把历史再发一次"**的结果。

下次再有人跟你说"我们的 agent 支持人工审核、断点续传",你可以微微一笑:没有断点,也没有续传,只有一条迟迟没被追加的工具结果。