发布前的那十几秒:AI 预检为什么不用「主编 Agent」
创作者在发布页写好内容,点一下「AI 预检」,十几秒后弹出一份报告:内容违不违规、质量分多少、哪儿能改、该打什么标签、摘要是什么、有没有和库里已有的内容撞车。
这是一次主动发起的检查,不是点「发布」时自动跑的。创作者可以连着改几轮、每次改完再点一次;确认没问题了,才去点旁边的「发布」。
这份报告由几个 AI 专家一起出。问题在于:这几个专家,谁来调度?
有一种很自然的方案:给一个「主编」模型配上所有专家的工具,让它在 ReAct 循环里自己决定先叫谁、后叫谁、要不要再叫一次(ReAct 指的是模型「想一步、调一次工具、看结果、再想下一步」这种循环)。听起来很像真实编辑部:主编拿着记者名单按需派活。
这套方案能跑,但跑起来会发现,调度这件事不该由模型定:阶段顺序是业务规则,不是模型该有的自由度 。所以本文讲的不是「把主编删掉」,而是把它从主路径降成兜底------主路径换成一张写死的阶段图,主编 Agent 留着,图跑挂了再交给它(见第六节)。
一、一份报告,五件事
先看这份预检报告要产出什么。它可以拆成互不重叠的五块:
| 角色 | 产出 |
|---|---|
| 安全审查 | 是否违规、违规类型、违规理由 |
| 质量评审 | 质量分、是不是技术内容、优化建议 |
| SEO 评审 | 推荐标签、摘要 |
| 查重 | 是否与已有内容高度相似 |
| 终审 | 对上面几块结果做复查修正 |
拆成五个角色而不是一个大 Prompt,理由是口径要独立:安全审查判「违规」,质量评审判「好不好」,这两件事的标准完全不同,塞进一个 Prompt 里会互相污染。模型很容易一边担心说得太狠会影响「质量分」,一边又想「质量分高」是不是就意味着没问题。
分成独立专家,各自拿着一段专注的角色描述做一次判断,输出一段结构化 JSON。这是 Orchestrator-Workers 模式:一群 Worker 各管一段,上面有一个编排者负责调度和汇总。
那么编排者是谁?
二、主编自己挑工具:能跑,但不稳
主编方案是把调度权交给模型本身,也就是一个「主编」Agent,配上安全/质量/SEO/终审四个专家工具:
java
List<Object> tools = Arrays.asList(
safetyExpertWorker, qualityExpertWorker, seoExpertWorker, criticExpertWorker);
// provider 是可选的额外工具源(MCP),未启用时传 null
AgentResult result = agent.run(systemPrompt, user, tools, provider, AGENT_MAX_STEPS);
Agent 循环是有界的 ReAct:每轮问模型一次,模型返回工具调用就执行、把结果回填,再问下一轮;模型不再调工具时,说明它打算收敛了,输出即为终稿。
不过 Spring AI 的模型内部自带工具执行循环,却没暴露轮次上限:直接依赖它,AGENT_MAX_STEPS 就形同虚设,模型可以一直调下去。所以把内部执行关掉,自己持有一个以步数为硬上限的显式循环:
java
ChatResponse response = chatClient.prompt()
.messages(history)
.options(ToolCallingChatOptions.builder()
.internalToolExecutionEnabled(false).build()) // 关掉内置循环
.toolCallbacks(callbacks)
.call()
.chatResponse();
这套方案有个显著好处:灵活。将来加一个新专家,只要把工具塞进列表、在 Prompt 里提一句,主编就会用。不用改任何调度代码。
但「灵活」的另一面,是每一次调度长什么样都不可预测。
三、灵活换来的三个不确定
第一,阶段顺序不可控。 安全审查和 SEO 评审,业务上明明有先后:先确认不违规,再谈标签和摘要。但模型可能先叫 SEO、也可能叫完质量忘了叫安全。顺序是自己长出来的,不是规定好的。
第二,违规内容照样跑完昂贵评估。 一篇文章如果安全审查判定违规,那它后面的质量控制、SEO、查重全是白做,反正要被拒。但模型不知道这层业务含义,它会老老实实把能叫的工具都叫一遍。每一次都是真金白银的模型调用。
第三,没法独立降级,也没法观测。 主编是一个大循环,只要它整体失败,就整体降级。但业务上这几块的重要性并不一样:安全审查没了,整份报告不成立;SEO 评审挂一次,其他几块照常给出来是有意义的。谁挂了、谁没挂,前端进度条也没处显示,因为阶段本身就是模型临场决定的,运行时才知道有哪几步。
这三个问题其实是同一个根子:调度的规则本来属于代码,交到模型手里就变成了随机。
四、把阶段写死在代码里
改法是把调度从「模型临场挑」变成「一张写死的阶段图」。
图的形状是这样的:安全审查没有前置依赖,是最前面一层;它放行之后,质量、SEO、查重三件事互不依赖,可以并行;其中质量与 SEO 的产物合成一份草稿,交给终审做一次复查;查重独立成一支,结论直接落到最终结果对象上。
落到代码里就是一张节点清单,每行只看三件事:这个节点叫什么、依赖谁(dependsOn)、什么条件下才放行(gate)。
java
DagExecutor.Node.<StageType>node(StageType.SAFETY) // 首层,无依赖
.then(r -> safetyWorker.review(ctx.title(), ctx.content())).build();
textStage(ctx, StageType.QUALITY, "quality", ...) // 依赖 SAFETY
textStage(ctx, StageType.SEO, "seo", ...) // 依赖 SAFETY
DagExecutor.Node.<StageType>node(StageType.DUPLICATE) // 依赖 SAFETY
.dependsOn(StageType.SAFETY).gate(this::safetyAllowsProceed)...build();
DagExecutor.Node.<StageType>node(StageType.CRITIC) // 依赖 QUALITY + SEO
.dependsOn(StageType.QUALITY, StageType.SEO)...build();
剩下的活由编排器干,逻辑很朴素:先把节点按依赖关系排一遍,没有依赖关系的归到同一层(也就是拓扑分层),然后层内并行、层间串行。同一波次里的节点互不依赖,一起丢进线程池跑;下一波次等上一波全部归档了才开跑,保证读得到前置产物。
改动带来三个直接结果:阶段顺序变成代码里的显式声明,不再靠模型自觉;每一层跑什么、依赖谁,翻代码就能读出来;每个阶段有独立的状态,谁成功、谁跳过、谁失败,一目了然。
五、违规就短路,别做无用功
第三节说的第二点(违规内容照样把昂贵评估跑完),阶段图正好能治,靠的是条件门控。
质量、SEO、查重这三个节点上都挂着一条放行条件(gate),判断依据是安全审查的结果,规则本身很短:安全审查没跑成、或者状态不是正常完成,就不放行;正常跑成了,再读它产出的结果里有没有违规标记。
这里有个刻意的保守默认:违规标记读不出来、甚至整份结果解析失败,也一律当成「有违规」,不放行。 判断依据不可靠的时候宁可当成有问题------挡错一次只是少出几个字段,放错一次是违规内容走完整条链路。
安全审查一判违规,后面三个节点被裁剪:它们根本不执行,直接标记为 SKIPPED,终审节点也连带跳过,最后只用安全审查的结论拼出一份结果返回。
而且这个裁剪是传染的:一个节点被裁剪,依赖它的节点也一并被裁。所以只要安全这一关卡住,整条下游分支自动收口,不用在每个节点上再写一遍判断。
前端进度条也不悬挂 :被裁掉的阶段,编排器会补一次 running → done 的事件,让用户看到的每个步骤都有收尾,只是快得几乎没停顿。
六、挂了怎么办:分层降级,绝不整体崩
阶段图再规整,也架不住某个专家超时或报错。这里的处理原则是按重要性分级。
先给调模型的那几个阶段 套一层「超时 + 重试」的壳:单次调用限时(默认 15 秒),超时或异常就再试一次(默认一共 2 次机会);两次都不行,再看这个阶段是阻断性 的还是非阻断性的:
- 非阻断阶段(质量、SEO、查重):失败就 SKIPPED,图继续跑,其余阶段照常出结果。质量分缺失就填默认值,让终审去校准;查重失败按「无高相似」处理。
- 阻断阶段 (安全审查、终审):安全审查拿不到,整份报告不成立;终审崩溃,说明最终结论没人把关。这两种情况返回
null。
(查重是系统侧检索、不走模型,所以没有超时重试这层壳,一次不成直接就按跳过算。)
返回 null 不等于报错给用户,而是交给上一层兜底。整条链路是三级:
显式阶段图 → 主编 Agent → 一次性直答调用
阶段图整段失败,落回主编 Agent(就是第二节那个 ReAct);主编也没收敛,再落回一次不带工具的结构化调用。每一级都尽力交出一份报告,任何一级都不阻断发布这个动作本身。
这层兜底同时是一道安全网:阶段图是主路径,主编 Agent 是保留的备用路径,两条路并存,互为冗余。
java
PrecheckReport graphResult = stageGraph.run(t, truncate(c, MAX_CHARS), docId);
if (graphResult != null) {
report = graphResult; // 阶段图成功,直接返回
}
if (report == null) {
// 阶段图没结果 → 主编 Agent 兜底
AgentResult result = agent.run(agentP.content, user, tools, provider, AGENT_MAX_STEPS);
}
七、并发下的监听器:只清自己那一份
预检有流式版本,阶段进度通过 SSE(Server-Sent Events,服务端单向推流)实时推给前端。做法是给工作流注册一个监听器,每个阶段状态变化时回调一次。
但监听器列表挂在工作流上,而工作流是单例:所有请求共享同一份列表。
多个用户同时在跑流式预检,各自注册了自己的监听器。清理的时候,如果图省事调用「清空全部监听器」:
java
// 千万不要这么写
stageGraph.clearStageListeners();
那么别人的流会被你一起清掉:A 用户跑完收尾,B 用户的进度事件从此再也发不出去。
所以清理必须按引用移除自己注册的那一个:
java
stageGraph.addStageListener(listener);
try {
report = stageGraph.run(t, truncate(c, MAX_CHARS), docId);
} finally {
// 铁律:只移除本次注册的监听器,绝不 clearStageListeners()
stageGraph.removeStageListener(listener);
}
监听器列表用写时复制(CopyOnWriteArrayList)保证遍历和增删的线程安全:每次增删都复制出一份新数组,正在遍历的线程手里还是旧的那份,不会被改到。所以遍历时不用加锁,代价也很小------加/删监听器是每个请求两次,遍历也只在阶段事件时才发生。
共享的注册表,清理动作必须精确到自己那一份。 「全清」在单机单人测试时永远是对的,只有在并发下才暴露。
边界
- 阶段图不是白拿的。 每次新增一个评审维度,要改图的声明、加依赖、可能还要动门控条件;主编 Agent 那种写法只要加个工具就行。这是「可预测」换「可扩展」的取舍:评审维度已经稳定的场景,这笔账划算;维度还在频繁试错的场景,图反而会成为负担。
- 两条路径并存是有维护成本的。 阶段图是主路径,主编 Agent 是兜底路径,两者都要能跑出正确结果,评审逻辑改了必须两边同时跟上。
- 波次有个 60 秒的硬上限。 单个阶段有 15 秒超时,但整波并行任务还兜了一道 60 秒,防止某波次异常卡死整体。
- 安全审查是硬门控,但它自己也是调模型。 万一安全审查本身判错(漏判违规),后面的阶段会照常跑。这道门控保证的是「判违规就不浪费」,不是「绝不会漏判」;漏判得靠输入输出两端的合规护栏去兜,那是另一层。
- 顺带一句:本文讲的主要是编排形态,预检里每个专家 Prompt 的版本化、灰度、回滚是另一套机制,不在这里展开。