让 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. 到目前为止的完整对话历史(到"模型说要删文件"那条为止); 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. 真正执行 delete_file→ 得到结果{"ok": true};2. 把结果作为一条"工具结果消息"追加到历史里; 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 支持人工审核、断点续传",你可以微微一笑:没有断点,也没有续传,只有一条迟迟没被追加的工具结果。
夜雨聆风