HITL 人工介入的并发难题:两个人同时审批一张证书怎么办

8 阅读9分钟

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 没问题,但生产部署的第一个动作就是多副本 + 滚动更新,这时两个致命场景出现了:

  1. 滚动更新:管理员 A 点了"通过",请求落在副本 1。恰好此时发布新版本,副本 1 被杀。管理员 B 刷新页面——审核单消失了,因为状态随着副本 1 的进程一起蒸发了。
  2. 多副本状态分裂: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)  # 执行成功后才写终态

三个设计决定值得展开:

  1. 为什么不用 Checkpointer 的状态判断,而要单独的 key? Checkpointer 存的是图执行状态(节点、消息、pending writes),"这张审核单在业务上是否已结案"是业务语义,塞进图状态里会让图状态承担业务职责,两个抽象互相污染。分开之后,审核单列表页可以直接扫 hitl:*:status 这类 key,不必反序列化 checkpoint。
  2. 为什么"执行成功后才写终态",而不是先占位? 先写终态再恢复执行,一旦 ainvoke 中途失败,审核单会永久卡在"已通过"但实际什么都没发生。后写终态的代价是"检查-执行-写回"之间存在竞态窗口——两个管理员真正同时提交时,可能双双通过检测。高并发场景下这里应该升级为 Redis Lua 原子 CASGET + 比对 + SET 在脚本内一次完成),我们把它标记为 resume QPS 上量后的第一个加固点——当前审核操作是人工低频行为,先留竞态窗口、后加原子性,是性价比排序。
  3. 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 的完整生产化清单,其实就五件事:

  1. ✅ Checkpointer 用 RedisSaver(多副本共享 + 崩溃恢复),refresh_on_read 防误清;
  2. ✅ DB 灾备层双写 + miss 回填(singleflight / 空值缓存一并带上);
  3. ✅ 独立 HITL 状态机管业务终态,与图状态解耦,TTL 对齐;
  4. ✅ resume 前冲突检测,终态拒绝重复恢复;
  5. ⏳ resume 上量后:状态翻转升级为 Lua 原子 CAS(已标记,未实装)。

第 5 条我特意保留在清单里——诚实标注"知道但还没做"的加固点,比假装系统无懈可击更接近生产级。

如果你在准备 AI 工程化方向的面试,这块的完整设计推演在仓库文档里:

如果这篇文章帮你绕开了 HITL 的至少一个深坑,请到仓库右上角点个 ⭐ Star——这是我持续更新系列的最大动力。

📌 下篇预告:《改 prompt 之前,你敢不看分数吗?给 LangGraph 加一套离线 Eval 基建》——聊聊 Eval 和单元测试的区别、三类 metric 的实现,以及"离线 95% 通过率 ≠ 线上有效"的诚实边界。

项目地址:github.com/muyiyang09/…