HITL 人工介入的并发难题:两个人同时审批一张证书怎么办
这是 LangGraph 生产化系列的第二篇。上一篇讲了混合检索、Supervisor 熔断和缓存三防,这篇讲一个更隐蔽的领域:人工介入(HITL)的状态管理。
完整开源:github.com/muyiyang09/… ⭐
先还原一个真实场景:教练上传国职证书 → AI 服务 OCR + 核验 + 风险评估 → 挂起等管理员最终确认。AI 建议是"通过",但决定权在人。管理员 A 正在核对编号,此时管理员 B 也打开了这张审核单。
A 点了"通过"。三秒后,B 也点了"通过"。
系统会发生什么?如果你答不出来,说明你的 HITL 还停留在 Demo 层。这篇文章拆解这背后的三层问题:状态存哪里、状态活多久、状态谁说了算。
先看官方 Demo 的 HITL 长什么样
LangGraph 的 HITL 机制本身很优雅:interrupt() 暂停图执行,状态交给 Checkpointer 持久化,人工决定通过 Command(resume=...) 注入后恢复:
from langgraph.types import interrupt, Command
async def hitl_checkpoint(state) -> dict:
"""Node 5(HITL):人工最终确认 + 组装最终结果。"""
if settings.hitl_enabled:
decision = interrupt({
"prompt": f"证书审核人工确认(风险等级:{state.get('risk_level')})",
"fields": state.get("fields"),
"verifications": state.get("verifications"),
"suggestion": state.get("suggestion"),
})
...
# 管理员提交决定后恢复执行
state_out = await CERT_REVIEW_GRAPH.ainvoke(
Command(resume={"action": payload.action}),
config={"configurable": {"thread_id": thread_id}},
)
Demo 里 interrupt + MemorySaver 十行代码就能跑。但生产环境的问题是:审核单是长生命周期对象——AI 判断只要 2 秒,人做决定要 5 分钟、5 小时,甚至第二天上班再处理。这中间,你的服务要滚动更新、要扩容副本、Redis key 会过期。于是三层问题依次浮出水面。
坑一:MemorySaver 存 HITL 状态,等于把审核单写在便利贴上
这是最容易被轻视的坑,因为它在开发环境永远不会暴露。
MemorySaver 把图状态存在进程内存里。单进程跑 Demo 没问题,但生产部署的第一个动作就是多副本 + 滚动更新,这时两个致命场景出现了:
- 滚动更新:管理员 A 点了"通过",请求落在副本 1。恰好此时发布新版本,副本 1 被杀。管理员 B 刷新页面——审核单消失了,因为状态随着副本 1 的进程一起蒸发了。
- 多副本状态分裂:AI 审核发起的 interrupt 状态存在副本 1,管理员的 resume 请求被负载均衡打到副本 2——副本 2 的 MemorySaver 里根本没有这个 thread 的状态。
我们生产配置是 SERVICE_ENV=prod 时强制切换 RedisSaver,并且构建过程是层层降级的:
def build_checkpointer():
"""构建顺序:Redis → RedisDBCheckpointer 包装 → MemorySaver 回退。
每层都有 fail-open 降级,最终保证返回一个可用 Checkpointer。"""
if settings.checkpointer_backend == "redis":
try:
from langgraph.checkpoint.redis import AsyncRedisSaver
ttl = {
"default_ttl": settings.checkpoint_ttl_minutes, # 单位是「分钟」
"refresh_on_read": True, # 读取时续期,活跃 thread 不会被误清
}
saver = AsyncRedisSaver(redis_url=settings.redis_url, ttl=ttl)
# DB 灾备包装:防止 Redis TTL 过期导致会话中断
if settings.checkpoint_db_fallback:
saver = RedisDBCheckpointer(saver)
return saver
except Exception as exc:
logger.warning("[Checkpoint] RedisSaver 构建失败,回退 MemorySaver:%s", exc)
from langgraph.checkpoint.memory import MemorySaver
return MemorySaver()
注意两个细节:
refresh_on_read=True:活跃会话每次读取都续期,TTL 只清"真的死了"的 thread;- 构建失败回退 MemorySaver 而不是拒绝启动:Checkpointer 故障不能拖垮整个 AI 服务——宁可退化为单副本可用,不可全局 500。
一句话总结:HITL 状态的生命周期以"天"计,而你的进程生命周期以"分钟"计。生命周期不匹配的状态,不能存在进程里。
坑二:Redis TTL 到期时,管理员正好打开审核单
换了 RedisSaver,滚动更新的问题解决了。但 TTL 引入了新问题:
Redis key 过期的瞬间,管理员正好点开一张 6 小时前挂起的审核单。 resume 请求到达,Checkpointer 从 Redis 读 thread 状态——miss。此时要么报"审核单不存在",要么更糟:整条审核流程从头重跑一遍(重新 OCR、重新核验、重新 interrupt),管理员看到一张"复活"的审核单。
我们给了 Checkpointer 加了一层 DB 灾备包装(RedisDBCheckpointer):每次状态写入时双写一份 JSON 到 MySQL;Redis miss 时从 DB 读回、回填 Redis、继续会话。核心读路径长这样:
async def aget_tuple(self, config):
result = await self._inner.aget_tuple(config) # 先读 Redis
if result is not None:
return result # Redis 命中,直接返回
thread_id = self._thread_id(config)
# singleflight:并发读只放一个进 DB
lock = self._locks.setdefault(thread_id, asyncio.Lock())
async with lock:
# 空值缓存(防穿透):确认 DB 无数据后,60s 内不再查
last_miss = self._empty_cache.get(thread_id)
if last_miss and time.time() - last_miss < _EMPTY_CACHE_TTL:
return None
self._empty_cache.pop(thread_id, None)
data = await session_store.get_state(thread_id) # DB 兜底
if data is None:
self._empty_cache[thread_id] = time.time()
return None
# 回填 Redis,后续请求走快速路径
await self._inner.aput(config, data["checkpoint"], data.get("metadata") or {}, {})
return CheckpointTuple(config=config, checkpoint=data["checkpoint"], ...)
你会发现这就是第一篇讲的"缓存三防"原样复用——雪崩靠 refresh_on_read 读时续期,击穿靠 singleflight,穿透靠空值缓存。同一个问题域换了皮(从"教练数据缓存"换成"会话状态灾备"),解法是可以整体搬运的。
还有一个容易被忽略的对称性设计:DB 写失败只告警、不抛异常(Redis 已写成功,主流程不能被灾备层拖死)。灾备层的定位是"锦上添花",它的故障等级必须低于主链路。
一句话总结:给 Checkpointer 配灾备,不是不信任 Redis,是不信任"人做决定的速度和 TTL 的交集"。
坑三:两个人同时审批——状态机是第一道防线
现在回答开头的问题:A 点了"通过"之后,B 再点会发生什么?
如果没有任何防护,B 的 resume 请求会带着 Command(resume=...) 去调 ainvoke。此时 thread 的状态已经是终态(图已执行到 END)——LangGraph 不会替你拦截,它会把整张图重新执行一遍。B 的"通过"变成了一次全新的审核:重新 OCR、重新核验、重新 interrupt 挂起。审核单凭空多出一个"幽灵流程",审计日志里出现两条互相独立的审核记录。
我们的防线是一个独立的 HITL 状态机,和 Checkpointer 解耦:
# hitl_state.py:审核单生命周期 pending → approved / rejected / cancelled(终态)
# 用 Redis key hitl:{thread_id}:status 记录,TTL 24h 与 Checkpointer 对齐
_TTL = 86400
_PENDING = "pending"
_TERMINAL = {"approved", "rejected", "cancelled"}
def is_terminal(status: str | None) -> bool:
"""是否已是终态(不能再 resume)。"""
return status in _TERMINAL
resume 端点在恢复图执行之前先做冲突检测:
# 冲突检测:已处理过 / 已取消 → 拒绝重复 resume
status = await hitl_state.get_status(thread_id)
if hitl_state.is_terminal(status):
raise ConflictError("该审核单已处理过")
if status is None:
raise NotFoundError("审核单不存在或已过期")
state_out = await CERT_REVIEW_GRAPH.ainvoke(
Command(resume={"action": payload.action}), ...)
...
await hitl_state.set_status(thread_id, payload.action) # 执行成功后才写终态
三个设计决定值得展开:
- 为什么不用 Checkpointer 的状态判断,而要单独的 key? Checkpointer 存的是图执行状态(节点、消息、pending writes),"这张审核单在业务上是否已结案"是业务语义,塞进图状态里会让图状态承担业务职责,两个抽象互相污染。分开之后,审核单列表页可以直接扫
hitl:*:status这类 key,不必反序列化 checkpoint。 - 为什么"执行成功后才写终态",而不是先占位? 先写终态再恢复执行,一旦
ainvoke中途失败,审核单会永久卡在"已通过"但实际什么都没发生。后写终态的代价是"检查-执行-写回"之间存在竞态窗口——两个管理员真正同时提交时,可能双双通过检测。高并发场景下这里应该升级为 Redis Lua 原子 CAS(GET+ 比对 +SET在脚本内一次完成),我们把它标记为 resume QPS 上量后的第一个加固点——当前审核操作是人工低频行为,先留竞态窗口、后加原子性,是性价比排序。 - TTL 为什么要和 Checkpointer 对齐(24h)? 如果状态机 TTL 更短,会出现"状态机认为审核单已过期可重开,但 Checkpointer 里图还挂着"的分裂;如果更长,会出现"审核单显示已通过,但 resume 已被拦截"的另一种分裂。同一生命周期的状态,TTL 必须对齐,否则两边会各自腐烂。
一句话总结:并发审批的本质不是锁问题,是"业务终态"和"图执行状态"没分家。分了家,状态机管幂等,Checkpointer 管恢复,各司其职。
一个贯穿三层的哲学:fail-open
把三层的失败策略排在一起,会发现一个统一的取向:
| 层 | 故障场景 | 策略 |
|---|---|---|
| Checkpointer 构建 | RedisSaver 初始化失败 | 回退 MemorySaver + 告警 |
| DB 灾备读 | MySQL 查询失败 | 返回 None(当无状态)+ 告警 |
| DB 灾备写 | MySQL 写入失败 | 仅告警,主流程继续 |
| HITL 状态读写 | Redis 不可用 | 降级为"无状态"(冲突检测失效) |
每一层的降级都被明确地"选择"过,而不是异常抛到哪里算哪里。 我们的原则是:审核流程的可用性 > 冲突检测的完备性——单副本开发环境 Redis 挂了,最多是失去防重复 resume 能力,审核本身还能跑;反之如果这些层选择 fail-closed(任何依赖故障就拒绝服务),AI 服务会被最外围的一个 Redis 打死。
当然 fail-open 有边界:涉及资金和合规的动作不允许 fail-open。证书审核给了 suggestion 但决定权在人,所以状态检测失效可接受;假如哪天加了"自动吊销教练资质",那一层必须 fail-closed。fail-open 不是默认值,是逐层审批的结果。
写在最后
HITL 的完整生产化清单,其实就五件事:
- ✅ Checkpointer 用 RedisSaver(多副本共享 + 崩溃恢复),
refresh_on_read防误清; - ✅ DB 灾备层双写 + miss 回填(singleflight / 空值缓存一并带上);
- ✅ 独立 HITL 状态机管业务终态,与图状态解耦,TTL 对齐;
- ✅ resume 前冲突检测,终态拒绝重复恢复;
- ⏳ resume 上量后:状态翻转升级为 Lua 原子 CAS(已标记,未实装)。
第 5 条我特意保留在清单里——诚实标注"知道但还没做"的加固点,比假装系统无懈可击更接近生产级。
如果你在准备 AI 工程化方向的面试,这块的完整设计推演在仓库文档里:
如果这篇文章帮你绕开了 HITL 的至少一个深坑,请到仓库右上角点个 ⭐ Star——这是我持续更新系列的最大动力。
📌 下篇预告:《改 prompt 之前,你敢不看分数吗?给 LangGraph 加一套离线 Eval 基建》——聊聊 Eval 和单元测试的区别、三类 metric 的实现,以及"离线 95% 通过率 ≠ 线上有效"的诚实边界。