在博客里接入 AI 评论审核,看起来像是一件很简单的事:
- 用户提交评论;
- 调用大模型判断是否合规;
- 审核通过后生成一条回复;
- 把结果展示在页面上。
如果只考虑“正常路径”,几十行代码就能跑起来。但真正上线后,问题往往出现在正常路径之外:
- 评论事务还没有提交,异步线程就开始查询;
- 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,重试逻辑只能从头再跑,不仅浪费模型调用,还可能重复发送通知。
状态机的价值,就是让系统在任何时刻都能回答三个问题:
- 当前执行到了哪一步?
- 下一步允许做什么?
- 失败后应该从哪里恢复?
二、为什么异步任务必须在事务提交后启动
保存评论时,项目先完成内容转义、状态初始化和数据库插入:
@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;
}
它同时解决了三个问题:
- 只有
AI_REPLYING状态能够完成回复,避免重复插入; - 回复插入失败会回滚父评论状态;
- 缓存只在事务真正提交后失效。
因此,“已回复”不再是一句乐观描述,而是一个有数据库事实支撑的终态。
五、失败重试不能简单地把整个流程再跑一次
异步任务使用有限次数重试,但业务层不会盲目重放全部步骤。
处理入口会先读取当前状态:
if (!isAiProcessable(currentStatus)) {
return true;
}
if (currentStatus == AI_REPLY_FAILED) {
return processAiReply(comment, AI_REPLY_FAILED);
}
return auditThenReply(comment);
不同失败状态拥有不同的恢复入口:
AUDIT_FAILED:重新执行审核;AI_REPLY_FAILED:跳过审核,只重试回复;REJECTED、AI_REPLIED等终态:直接结束;- 被管理员修改过的状态:不再交给 AI 处理。
这样做能避免三类副作用:
- 同一条评论重复调用审核模型;
- 审核通过邮件被重复发送;
- 同一条评论出现多条 AI 回复。
真正可靠的重试必须满足两个条件:
- 能识别任务已经完成到哪一步;
- 每一步都具备幂等保护。
只有“捕获异常后再执行一次”不叫可靠重试,它只是重复风险。
六、兼容旧数据库时,状态读写必须集中
这个项目还有一个现实约束:旧表只有 is_visible,没有独立的审核状态字段。
为了在不立即改表的情况下引入状态机,详细状态暂时编码在历史 ip 字段中,格式类似:
原始IP|失败原因|详细状态码
这显然不是理想的数据模型,但项目没有让格式解析散落在业务代码里,而是统一封装到 CommentAuditStatusCodec:
String encoded = CommentAuditStatusCodec.encode(
storedIp,
AI_REPLY_FAILED,
"模型服务超时"
);
CommentAuditStatus status =
CommentAuditStatusCodec.decode(encoded, isVisible);
兼容层还处理了历史后台只修改 is_visible、没有同步详细状态的情况。当两个字段冲突时,以人工设置的可见性为准,防止 AI 覆盖管理员决策。
这是一种迁移期设计,而不是值得长期保留的最终结构。它带来的启发是:
当数据库暂时不能迁移时,至少要把兼容逻辑收敛到一个边界内。
未来增加独立的 audit_status、audit_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?
因为目标可能是一条嵌套回复。它所在的顶级评论可能:
- 不在当前分页;
- 回复列表尚未加载;
- 回复区域处于折叠状态。
最终定位过程因此分成四步:
- 后端根据目标评论 ID 计算它所在的分页;
- 前端局部刷新该页评论片段;
- 根据
threadId展开或异步加载回复树; - 等目标 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 页面,也不要求维护长连接。
它的代价同样明确:
- 每个等待中的页面都会产生周期性请求;
- 最长等待时间受轮询次数限制;
- 页面关闭后无法继续实时通知;
- 状态变化与页面更新之间存在最多一个轮询周期的延迟。
如果评论量和实时性要求继续增长,可以逐步演进:
- 用 SSE 向单个页面推送审核状态;
- 用消息队列承接审核任务,替代进程内线程池;
- 为任务增加唯一键和执行记录,获得更完整的可观测性;
- 将兼容编码迁移为独立审核字段;
- 使用 Outbox Pattern 保证数据库事务与任务消息一致发布。
但在流量有限的个人博客中,当前方案在复杂度和可靠性之间取得了合理平衡:不为了“架构先进”引入过多基础设施,同时守住了关键一致性边界。
九、总结
把 AI 接进业务系统,真正困难的从来不是发出 HTTP 请求,而是处理这些问题:
- 请求什么时候才能发出;
- 多个处理者同时修改时谁拥有最终决定权;
- 失败后从哪一步恢复;
- 状态与业务数据如何保持一致;
- 缓存什么时候失效;
- 用户怎样确认结果已经真正出现。
这个评论系统给出的答案可以概括为六条:
- 用显式状态机描述长流程,不用模糊布尔值代替业务状态;
- 在事务提交后再启动异步副作用;
- 用“预期状态 + 条件更新”阻止过期 AI 结果覆盖人工操作;
- 把状态终结和 AI 回复落库放进同一事务;
- 按失败阶段恢复,而不是无脑重跑整个流程;
- 把前端自动刷新、展开和定位纳入完成标准。
AI 可以不稳定,网络可以超时,任务可以重试,但业务状态必须始终可以解释。
这才是 AI 功能从“演示可用”走向“线上可用”的分界线。

测试一下