🤖
AI审核中

AI评论审核背后的状态机与一致性设计

  Java   21分钟   124浏览   0评论

在博客里接入 AI 评论审核,看起来像是一件很简单的事:

  1. 用户提交评论;
  2. 调用大模型判断是否合规;
  3. 审核通过后生成一条回复;
  4. 把结果展示在页面上。

如果只考虑“正常路径”,几十行代码就能跑起来。但真正上线后,问题往往出现在正常路径之外:

  • 评论事务还没有提交,异步线程就开始查询;
  • AI 审核执行到一半,管理员已经手动屏蔽评论;
  • 网络超时触发重试,系统重复审核、重复回复;
  • 父评论状态已经变成“回复完成”,AI 回复却没有成功写入;
  • 后端处理完成了,前端还停留在旧评论列表;
  • AI 回复藏在折叠线程或其他分页里,用户根本找不到。

这类功能的核心不是“如何调用 AI”,而是如何把一个耗时、不稳定、可重试的外部能力,放进一个可控的业务流程中。

本文以一个 Spring Boot 博客项目的真实评论链路为例,拆解一套由状态机、事务后回调、条件更新、失败分层和前端最终定位组成的实现。

一、先把一次 AI 调用改造成业务状态机

评论只有“显示”和“隐藏”两个状态时,一个 is_visible 字段就够了。加入 AI 审核与自动回复以后,仅靠布尔值已经无法描述真实进度。

项目将评论处理过程划分成七个状态:

状态 含义 是否对外可见
PENDING 评论已提交,等待审核
APPROVED 审核通过
REJECTED 内容审核未通过
AUDIT_FAILED 审核服务异常
AI_REPLYING 正在生成 AI 回复
AI_REPLIED AI 回复已生成并保存
AI_REPLY_FAILED 审核通过,但回复生成失败

状态流转如下:

stateDiagram-v2
    [*] --> PENDING: 用户提交
    PENDING --> APPROVED: 审核通过
    PENDING --> REJECTED: 内容违规
    PENDING --> AUDIT_FAILED: 审核服务异常
    AUDIT_FAILED --> APPROVED: 重新审核通过
    AUDIT_FAILED --> REJECTED: 重新审核拒绝
    APPROVED --> AI_REPLYING: 需要 AI 回复
    APPROVED --> [*]: 博主评论或关闭自动回复
    AI_REPLYING --> AI_REPLIED: 回复生成并保存
    AI_REPLYING --> AI_REPLY_FAILED: 回复生成失败
    AI_REPLY_FAILED --> AI_REPLYING: 仅重试回复阶段
    AI_REPLIED --> [*]
    REJECTED --> [*]

这里最重要的设计,是把“审核失败”和“回复失败”拆开。

审核失败意味着评论是否合规仍不确定,重试时必须重新审核;回复失败则说明评论已经通过审核,只需要重新生成回复。如果把两者合并成一个 FAILED,重试逻辑只能从头再跑,不仅浪费模型调用,还可能重复发送通知。

状态机的价值,就是让系统在任何时刻都能回答三个问题:

  1. 当前执行到了哪一步?
  2. 下一步允许做什么?
  3. 失败后应该从哪里恢复?

二、为什么异步任务必须在事务提交后启动

保存评论时,项目先完成内容转义、状态初始化和数据库插入:

@Transactional(rollbackFor = RuntimeException.class)
public void save(Comments comment, int parentCommentId) {
    comment.setIsVisible(PENDING.getCode());
    comment.setIp(statusCodec.encode(comment.getIp(), PENDING, null));
    comment.setContent(escapeAndConvertEmoji(comment.getContent()));

    commentsMapper.insert(comment);
    cacheInvalidator.invalidateAfterCommit();
    triggerAiReviewAfterCommit(comment.getId());
}

这里不能在 insert 后立刻把任务扔进线程池。

数据库事务还没有提交时,异步线程可能已经开始执行。由于它使用另一个数据库连接,很可能查不到刚插入的评论,最终得到一个偶发的“评论不存在”。更麻烦的是,如果主事务随后回滚,异步任务却已经向外部 AI 发出了请求。

项目通过 Spring 的事务同步回调解决这个问题:

private void triggerAiReviewAfterCommit(Integer commentId) {
    Runnable task = () -> aiCommentTaskService.asyncProcessComment(commentId);

    if (!TransactionSynchronizationManager.isSynchronizationActive()) {
        task.run();
        return;
    }

    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                task.run();
            }
        }
    );
}

这段代码建立了一个清晰边界:

  • 主事务提交成功,才允许启动 AI 流程;
  • 主事务回滚,不产生任何异步副作用;
  • 测试或非事务调用场景下,仍然可以直接执行。

这个原则不只适用于 AI。发送邮件、发布消息、生成文件、调用支付或第三方接口,都应该先问一句:

这个副作用是否依赖当前事务已经成功提交?

如果答案是“是”,就不应在事务提交前执行。

三、人工审核与 AI 审核同时发生,谁说了算

异步任务最大的风险之一,是旧结果覆盖新操作。

假设 AI 开始审核时评论处于 PENDING,模型调用需要五秒。在这五秒内,管理员已经手动屏蔽了评论。AI 返回“审核通过”后,如果代码只是按 ID 更新:

UPDATE article_comments
SET status = 'APPROVED'
WHERE id = ?;

管理员刚完成的操作就会被一个过期的 AI 结果覆盖。

项目没有采用无条件更新,而是实现了带预期状态的迁移:

transitionAuditStatus(
    commentId,
    APPROVED,
    null,
    PENDING,
    AUDIT_FAILED
);

底层会先读取当前状态,再把读取到的状态字段放进更新条件:

UPDATE article_comments
SET is_visible = ?,
    ip = ?,
    update_time = ?
WHERE id = ?
  AND is_visible = ?
  AND ip = ?;

只有数据库中的值仍与刚才读取的一致,更新才会成功。如果管理员已经修改了评论,UPDATE 影响行数就是 0,AI 结果会被判定为过期并主动放弃。

这本质上是一种轻量级乐观锁:

  • 不阻塞管理员操作;
  • 不需要持有长事务等待模型返回;
  • 能阻止旧任务覆盖新状态;
  • 重复执行同一个任务也不会产生重复迁移。

在项目代码中,每条状态迁移还会明确列出允许的来源状态。例如:

PENDING / AUDIT_FAILED -> APPROVED
APPROVED -> AI_REPLYING
AI_REPLYING -> AI_REPLIED
AI_REPLYING -> AI_REPLY_FAILED
AI_REPLY_FAILED -> AI_REPLYING

状态不符合预期时,系统不会“努力修正”,而是认为当前任务已经失去执行权。

对于异步系统来说,放弃过期结果通常比强行完成任务更安全

四、回复状态和回复记录必须在同一事务完成

另一个容易被忽略的问题,是父评论状态和 AI 回复记录的一致性。

如果代码先把父评论更新为 AI_REPLIED,然后再插入回复:

updateStatus(commentId, AI_REPLIED);
insertAiReply(aiReply);

第二步一旦失败,页面会看到“已回复”,数据库里却没有任何 AI 回复。

项目把这两个动作放进同一个事务方法:

@Transactional(rollbackFor = RuntimeException.class)
public boolean completeAiReply(Integer commentId, Comments aiReply) {
    boolean transitioned = updateAuditStatusInternal(
        commentId,
        AI_REPLIED,
        null,
        Collections.singleton(AI_REPLYING)
    );

    if (!transitioned) {
        return false;
    }

    if (commentsMapper.insert(aiReply) <= 0) {
        throw new IllegalStateException("AI reply was not inserted");
    }

    cacheInvalidator.invalidateAfterCommit();
    return true;
}

它同时解决了三个问题:

  1. 只有 AI_REPLYING 状态能够完成回复,避免重复插入;
  2. 回复插入失败会回滚父评论状态;
  3. 缓存只在事务真正提交后失效。

因此,“已回复”不再是一句乐观描述,而是一个有数据库事实支撑的终态。

五、失败重试不能简单地把整个流程再跑一次

异步任务使用有限次数重试,但业务层不会盲目重放全部步骤。

处理入口会先读取当前状态:

if (!isAiProcessable(currentStatus)) {
    return true;
}

if (currentStatus == AI_REPLY_FAILED) {
    return processAiReply(comment, AI_REPLY_FAILED);
}

return auditThenReply(comment);

不同失败状态拥有不同的恢复入口:

  • AUDIT_FAILED:重新执行审核;
  • AI_REPLY_FAILED:跳过审核,只重试回复;
  • REJECTEDAI_REPLIED 等终态:直接结束;
  • 被管理员修改过的状态:不再交给 AI 处理。

这样做能避免三类副作用:

  • 同一条评论重复调用审核模型;
  • 审核通过邮件被重复发送;
  • 同一条评论出现多条 AI 回复。

真正可靠的重试必须满足两个条件:

  1. 能识别任务已经完成到哪一步;
  2. 每一步都具备幂等保护。

只有“捕获异常后再执行一次”不叫可靠重试,它只是重复风险。

六、兼容旧数据库时,状态读写必须集中

这个项目还有一个现实约束:旧表只有 is_visible,没有独立的审核状态字段。

为了在不立即改表的情况下引入状态机,详细状态暂时编码在历史 ip 字段中,格式类似:

原始IP|失败原因|详细状态码

这显然不是理想的数据模型,但项目没有让格式解析散落在业务代码里,而是统一封装到 CommentAuditStatusCodec

String encoded = CommentAuditStatusCodec.encode(
    storedIp,
    AI_REPLY_FAILED,
    "模型服务超时"
);

CommentAuditStatus status =
    CommentAuditStatusCodec.decode(encoded, isVisible);

兼容层还处理了历史后台只修改 is_visible、没有同步详细状态的情况。当两个字段冲突时,以人工设置的可见性为准,防止 AI 覆盖管理员决策。

这是一种迁移期设计,而不是值得长期保留的最终结构。它带来的启发是:

当数据库暂时不能迁移时,至少要把兼容逻辑收敛到一个边界内。

未来增加独立的 audit_statusaudit_reason 和版本字段时,只需要替换编解码器与持久化实现,不必修改所有业务分支。

七、后端完成不等于用户看见

AI 回复写入数据库以后,用户仍然停留在原页面。要完成交互闭环,前端需要知道最终应该定位谁。

状态接口返回的信息不只是一个状态码,还包括:

  • 当前评论 ID;
  • 是否仍需等待 AI 回复;
  • AI 回复及其 ID;
  • 所属文章 ID;
  • 顶级评论线程 ID;
  • 拒绝原因。

前端每两秒轮询一次状态,并根据身份选择最终目标:

if (status === 3 && response.aiReply?.id) {
    // 普通访客:定位 AI 回复
    refreshCommentsAndLocate(response, response.aiReply.id);
} else if (response.needAiReply === false) {
    // 博主评论或关闭自动回复:定位原评论
    refreshCommentsAndLocate(response, response.id);
}

为什么还需要 threadId

因为目标可能是一条嵌套回复。它所在的顶级评论可能:

  • 不在当前分页;
  • 回复列表尚未加载;
  • 回复区域处于折叠状态。

最终定位过程因此分成四步:

  1. 后端根据目标评论 ID 计算它所在的分页;
  2. 前端局部刷新该页评论片段;
  3. 根据 threadId 展开或异步加载回复树;
  4. 等目标 DOM 出现后滚动并播放定位动画。
function ensureThreadRepliesVisible(threadId, targetId) {
    const replies = document.getElementById('replies-' + threadId);
    const expandButton = findExpandButton(threadId);

    if (replies && replies.children.length === 0 && expandButton) {
        loadReplies(expandButton);
    } else if (replies) {
        replies.classList.add('show');
    }

    waitForCommentElement(targetId, scrollToCommentElement);
}

这一层看似只是 UI 细节,实际上决定了异步任务对用户是否真的“完成”。

后端日志显示成功,只能证明数据处理完成;目标内容出现在正确分页、自动展开并进入用户视口,才算完成了用户可见链路。

八、这套方案仍有哪些取舍

当前方案使用前端轮询,优点是实现简单、兼容传统 Thymeleaf 页面,也不要求维护长连接。

它的代价同样明确:

  • 每个等待中的页面都会产生周期性请求;
  • 最长等待时间受轮询次数限制;
  • 页面关闭后无法继续实时通知;
  • 状态变化与页面更新之间存在最多一个轮询周期的延迟。

如果评论量和实时性要求继续增长,可以逐步演进:

  1. 用 SSE 向单个页面推送审核状态;
  2. 用消息队列承接审核任务,替代进程内线程池;
  3. 为任务增加唯一键和执行记录,获得更完整的可观测性;
  4. 将兼容编码迁移为独立审核字段;
  5. 使用 Outbox Pattern 保证数据库事务与任务消息一致发布。

但在流量有限的个人博客中,当前方案在复杂度和可靠性之间取得了合理平衡:不为了“架构先进”引入过多基础设施,同时守住了关键一致性边界。

九、总结

把 AI 接进业务系统,真正困难的从来不是发出 HTTP 请求,而是处理这些问题:

  • 请求什么时候才能发出;
  • 多个处理者同时修改时谁拥有最终决定权;
  • 失败后从哪一步恢复;
  • 状态与业务数据如何保持一致;
  • 缓存什么时候失效;
  • 用户怎样确认结果已经真正出现。

这个评论系统给出的答案可以概括为六条:

  1. 用显式状态机描述长流程,不用模糊布尔值代替业务状态;
  2. 在事务提交后再启动异步副作用;
  3. 用“预期状态 + 条件更新”阻止过期 AI 结果覆盖人工操作;
  4. 把状态终结和 AI 回复落库放进同一事务;
  5. 按失败阶段恢复,而不是无脑重跑整个流程;
  6. 把前端自动刷新、展开和定位纳入完成标准。

AI 可以不稳定,网络可以超时,任务可以重试,但业务状态必须始终可以解释。

这才是 AI 功能从“演示可用”走向“线上可用”的分界线。

如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论