在 AI 爆款文章创作器项目里,技术难度最高的两块是多阶段生成管线和 SSE 流式输出。管线把「写一篇文章」拆成大纲、初稿、润色、质检四个独立阶段,每一阶段都要求模型输出结构化 JSON 供下一阶段消费;SSE 则负责把模型逐 token 生成的内容以打字机效果实时推到前端。两者叠加之后,任务状态机、并发控制与断线重连就成了真正需要打磨的工程问题。
一、多阶段生成管线为什么比单次生成难
项目最初用的是一次性提示词:用户丢一个主题,让模型一口气输出整篇文章。长文质量很快暴露问题,超过 1500 字的内容经常跑题、结构失衡,而且一旦某段写得差,整篇只能重新生成。改成多阶段管线后,把内容生产拆成 outline → draft → edit → metadata 的分步流程,每个阶段只做一件事,输出质量和稳定性都明显提升。
| 对比维度 | 单次生成 | 多阶段管线 |
|---|---|---|
| 输出质量 | 长文易跑题,结构不稳定 | 每阶段专注单一任务,质量可控 |
| 可调试性 | 出问题只能整篇重试 | 哪一步坏了就重跑哪一步 |
| 模型选型 | 全程同一个模型 | 大纲用便宜模型,写作用强模型 |
| 失败成本 | 整段推倒重来 | 每步独立计费,可局部重试 |
| 扩展性 | 加功能要改整条提示词 | 加阶段等于加管线节点 |
阶段之间传递结构化 JSON 比传自由文本可靠得多。大纲阶段返回的标题、章节、要点以字段形式落入初稿阶段,初稿阶段不需要再让模型猜结构,直接按字段填充正文。
1.1 四阶段的编排方式
- 大纲阶段:用户提交主题后,系统要求模型只返回 JSON,包含标题、章节列表与每节要点;
- 初稿阶段:把大纲拆成单个章节逐节生成正文,章节之间互不依赖,可并行执行;
- 润色阶段:把拼好的全文交给编辑型模型统一口吻、删冗余,只改内容不新增信息;
- 质检与元数据阶段:校验字数、敏感词与结构完整度,同时生成标题与摘要等元数据,落库。
// 阶段产物:大纲结构化对象,作为阶段间的数据契约
public record Outline(
String title,
List<Section> sections,
List<String> keywords
) {
public record Section(String heading, List<String> points, int targetWords) {}
}
每个阶段独立可调试,是这套设计最大的收益。初稿写得不好,只重跑写作阶段,不用动前面的大纲;润色阶段对全文统一把关,弥补逐节生成的割裂感。
1.2 阶段产物的健壮性处理
流式场景下大纲 JSON 是边生成边推送的,完整结构可能还没到齐,前端已经在拼装界面了。项目里的做法是缓冲拼接:前端把分片文本先拼进缓冲区,直到拿到结束标记再做整体解析;解析失败时降级为纯文本展示,不让用户看到解析异常。阶段之间约定契约版本号,改版时新老版本并存,避免一次变更把所有在途任务打挂。
二、SSE 流式输出的实现难点
大模型是逐 token 自回归生成,一篇 1000 字的回复可能要 5 到 15 秒。如果走一次性返回,用户前 5 秒看到的是一片空白,中途断网前面所有等待全部作废。流式把首 token 延迟压到 200 到 800 毫秒,用户几乎立刻看到第一个字,感知延迟接近模型吐字速度。
2.1 为什么选 SSE 而不是 WebSocket
| 对比维度 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 服务端单向推送客户端 | 全双工双向 |
| 协议基础 | 仍是 HTTP 长连接 | 独立升级协议 |
| 自动重连 | 浏览器原生支持 | 需要手写 |
| 代理友好度 | Nginx 默认支持 | 需显式配置升级头 |
| 适用场景 | AI 逐字输出、通知、日志 | IM、协作编辑、游戏 |
聊天和内容生成类应用,99% 用 SSE 就够。前端不需要双向通道,模型只往一个方向吐字,浏览器原生 EventSource 还自带重连语义,省掉大量协议代码。
2.2 Spring Boot 侧的实现要点
@GetMapping(value = "/article/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@RequestParam Long taskId) {
SseEmitter emitter = new SseEmitter(0L); // 不设超时,连接寿命交给心跳
generationTaskRunner.start(taskId, emitter);
return emitter;
}
Controller 线程拿到 SseEmitter 后立即释放,推送在独立线程执行。生产环境还要补三件事:通过 onCompletion、onTimeout、onError 三个回调做资源释放;定期推送注释行心跳,防止网关在空闲窗口断开长连接;前端收到结束事件后必须主动关闭,否则浏览器会按 SSE 规范自动重连,造成重复请求。心跳消息用注释行发送,不触发前端事件回调,又能保持连接活性;网关侧的 Nginx 还要关闭代理缓冲,否则内容会攒到一块才推到用户眼前,打字机效果直接失效。
三、管线与流式叠加后的工程问题
两个特性组合到一起,挑战就不只在单点了。生成任务要有状态机跟踪,任务表里 status 字段沿 queued → outlining → drafting → polishing → done → failed 流转,每步重试都是幂等的。模型调用是慢链路,要限制在途请求数量,超出上限的任务进入排队而不是无限拉起调用,防止一个慢请求拖垮连接池,流式返回还要给数据流加超时保护。每阶段结束时把 token 用量落库,成本核算和按用户维度的超限拦截都依赖这条记录。日志不能打全量流,一篇 3000 字的回复逐 token 打印会产生几千行日志,只记录首包耗时和总 token 数就够定位问题了。
常见问题(FAQ)
Q1:多阶段管线和单次生成哪个成本更低?
分步生成失败可局部重试,综合成本通常更低。
Q2:SSE 断线之后能续传吗?
协议支持断点续传字段,但长文场景一般直接整篇重发。
Q3:前端为什么要主动关闭流式连接?
不关闭浏览器会按规范自动重连,导致重复请求和重复计费。