真正的 HITL 不是「弹个框等你点 yes」,是让 agent 学会优雅地暂停、把决定权交还给你、再无缝接回去。
Day 31 讲过「不可逆动作前加 human gate」——但那只是一行 input()。一旦 agent 跑在后台、跑几百轮、你不在终端前盯着,同步阻塞就崩了:agent 卡死等一个没人的终端;或者你嫌烦,干脆全 auto。生产级 HITL 不是一个对话框,是一套编排工程:agent 何时才值得打扰人(置信度路由)、怎么在不阻塞的前提下问(异步审批 + 状态持久化)、人怎么接管再交还(可中断 checkpoint)、这一切怎么留痕复盘(审批审计)。本期把 require_human 从一行 input(),升级成一个能在你睡觉时挂起、手机点一下就恢复的系统。HITL 的工程难点从来不是「要不要问人」,而是「问的时候 agent 怎么办」。
Day 31 按「可逆性」分档(可逆放手、不可逆过人)。这里加第二个轴:置信度 × 影响。两轴交叉出三档——auto(高置信 + 低影响,放手)、ask(低置信 或 中高影响,问人)、deny(越权 / 超预算,直接拒,根本不进人的队列)。关键陷阱:置信度不能让模型自评——它系统性高估。用可验证的代理信号替代:检索命中数、工具是否报错、多次采样是否自洽(self-consistency)、输出是否吻合 schema。模型说「我有把握」不算数,证据说了算。
def confidence(ctx): # 用可验证信号,不问模型「你多有把握」
sigs = [ctx.retrieval_hits >= 2,
not ctx.tool_errored,
ctx.self_consistency >= 0.8, # 多次采样是否一致
ctx.schema_ok]
return sum(sigs) / len(sigs)
def route(action, ctx):
if action.over_budget or action.unauthorized:
return "deny" # 越权不是「问人」,是 bug
c, impact = confidence(ctx), action.impact
if c >= 0.8 and impact == "low": return "auto"
return "ask" # 低置信 或 中高影响 → 问人
核心 mental model:HITL 的成本是人的注意力,而注意力是会枯竭的稀缺资源。路由的目标不是「更安全」,是「把人的每一次介入都花在刀刃上」。
deny 也丢进人的审批队列——越权动作根本不该出现在那里,混进来只会淹没真正需要判断的 ask。
input() 是同步阻塞——后台 agent 用它必死。生产 HITL 的核心是把「等人」从阻塞变成挂起 + 回调。agent 跑到 ask 档时,input() 会冻住整个 worker,干等一个可能几小时没人看的终端。正解是 durable execution 的思路:把当前状态 checkpoint 到进程外(不是留内存)→ 向审批通道(Slack / 手机推送 / 队列)发请求 → 让出资源、退出 → 人决定后通过 callback 或轮询,从 checkpoint 接着跑。两条铁律:状态必须在进程外(进程挂了审批不丢);resume 必须幂等(同一个 approval 被重放,动作不能执行两次)。再给每个审批挂 SLA 超时:挂太久 → 默认 deny 或升级,绝不无限等。
async def step(ctx):
action = plan(ctx)
if route(action, ctx) == "ask":
aid = save_checkpoint(ctx) # ① 状态落进程外,带 approval_id
notify("slack", action, aid) # ② 发请求,不等返回
raise Suspend(aid) # ③ 让出 worker,退出
return execute(action)
def on_approval(aid, decision): # 人点完后由 callback 触发
if seen(aid): return # ⑤ 幂等:回调重放不重复执行
mark_seen(aid); ctx = load_checkpoint(aid)
if decision == "approve": resume_from(ctx) # ⑥ 从断点接着跑
else: resume_from(ctx, denied=True) # 否决也回灌给 agent
input() 阻塞整个 worker——一个待审批拖垮所有并发任务。(2)状态只在内存——进程一重启 / 一崩,审批和进度全丢。(3)没幂等——callback 重放或用户连点两下,转账执行两次。(4)审批无超时——一个没人理的 ask 让任务永久挂起。
把人类介入只想成 approve / reject,是浪费。真实工作流里还有两种更值钱的动作:edit(改它的 plan / 草稿 / 参数再继续)和 takeover(人接手做几步,再还给 agent)。要支持这两种,agent loop 必须满足三个前提:可中断(不是 while True 跑到死)、状态可序列化、可从任意 checkpoint 恢复。更关键的一点常被漏掉:人改完之后,必须把修改回灌进 agent 的上下文——否则它下一步又用旧计划覆盖你的修改。所以「计划」「草稿」要做成可被人覆写的显式状态,而不是埋在 prompt 历史里。
def loop(state):
while not state.done:
if stop_requested(): return snapshot(state) # 可中断:随时存盘退出
proposal = plan(state)
decision = await_human(proposal) # approve / edit / takeover
if decision.kind == "approve": state = execute(proposal, state)
elif decision.kind == "edit":
state.plan = decision.edited # 人改的计划
state.ctx += f"[human edited plan: {decision.note}]" # 回灌!否则被覆盖
elif decision.kind == "takeover":
state = decision.human_steps # 人做几步,再 resume 还给 agent
return state
一个反直觉但重要的点:edit 比 reject 更省人力。reject 把 agent 打回重来(它可能再犯同样的错);edit 直接把它推到正轨,一次校正胜过十次否决。
while True——想介入只能 kill,进度全丢,只能从头再来。(2)人改了草稿,但没回灌 context——agent 下一轮无视你的修改。(3)checkpoint 状态不全——恢复时缺关键变量,agent「失忆」乱跑。
每条审批至少要留:动作、agent 的理由、置信度、谁批的、延迟多久、最终结果。这份日志有两个用途。第一,回归路由阈值——某类 ask 你 95% 都批 → 降级为 auto;某类 auto 出过事 → 升级为 ask。§1 的阈值不是一次拍定,是从审计数据里长出来的。第二,升级与委派——高影响动作要求不止一人 / 特定角色批(轻量 RBAC);审批挂太久自动升级给备份审批人。最后,对抗审批疲劳最有效的一招:同类低风险动作批量呈现(一屏批 10 个),而不是弹 10 次对话框。
def log_approval(rec): # 每条审批落库,供回归 + 追责
db.insert(action=rec.action, reason=rec.agent_reason,
conf=rec.confidence, approver=rec.who,
latency=rec.decided_at - rec.asked_at, outcome=rec.result)
def retune(action_type): # 用历史审批回归阈值
rows = db.query(action_type, last_days=14)
rate = mean(r.outcome == "approved" for r in rows)
if rate > 0.95 and no_incidents(rows): suggest("ask→auto") # 总在批 = 多余的 ask
if any(r.outcome == "incident" for r in rows): suggest("auto→ask")
把四点串成一个周末项目:给你常跑的后台 agent 加一层「能在你睡觉时挂起、手机点一下就恢复」的审批层,并亲手 red-team 它。
(confidence, impact) → {auto, ask, deny} 表,置信度用可验证信号代理(检索命中 / 工具报错 / self-consistency),不问模型。ask 档从 input() 改成 checkpoint 落库 + 发 Slack/推送 + 挂起退出,别阻塞 worker。retune() 回看哪些 ask 该降级、哪些 auto 该升级。做完这套,你以后看任何「自主 agent 产品」会本能地先问:它问人的时候自己怎么办(阻塞还是挂起)、我能不能改它的中间产物、审批留没留痕——而不是被 demo 里的「一键全自动」唬住。
input() 完全够,别上队列。一旦它(a)跑在后台 / cron、(b)单次运行跨越你的注意力窗口(几十分钟以上)、或(c)多个实例并发——同步阻塞就会咬你:worker 卡死、进程一崩进度全丢。判据不是「项目大小」,是「出问题时你在不在场」。不在场,才需要 durable。在场,过度工程反而是负担。retune() 里那个 no_incidents 条件就是防线——只在「总批 且 没出过事」时才建议降级,光「总批」不够。但更稳的做法是别让回归自动改阈值,只让它提议(suggest() 而非 apply()),由你定期 review。把人的偏差喂回系统、系统再自动化这个偏差,是 HITL 最隐蔽的失败——所以审计本身也要被审计。