AI 爆款文章创作器的后端把「一次生成一篇完整文章」拆成标题、大纲、正文、配图四个可独立执行的阶段,每个阶段产出实时通过 SSE 推给前端,用户看到的是打字机效果而不是白屏等待。整个后端基于 Spring Boot 搭建,核心思路是三个解耦:请求线程与生成任务解耦、阶段编排与业务实现解耦、流式通道与业务代码解耦。接口层收到请求后立即返回 SseEmitter 并释放 Tomcat 线程,生成任务下沉到独立线程池,按状态机逐阶段推进,每阶段的增量结果由统一的流处理器写回连接。下文按分层、流式通道、阶段流水线、存储、可观测性五个维度拆开讲,代码片段都可以直接跑。
一、后端整体分层
项目没有用微服务,单个 Spring Boot 应用按职责切成五层,职责边界靠包结构和接口约定维持。
| 层级 | 职责 | 关键组件 |
|---|---|---|
| 接入层 | REST 接口、SSE 端点、参数校验 | Controller、全局异常处理 |
| 业务层 | 阶段编排、任务状态流转 | 文章服务、生成流水线 |
| 智能体层 | 各阶段的生成逻辑 | 标题/大纲/正文/配图分析智能体 |
| 数据层 | 用户、文章、执行记录持久化 | MySQL、Redis |
| 外部服务层 | 大模型、图库、对象存储 | 大模型 API、Pexels、COS |
接入层只做三件事:校验参数、鉴权、返回 SseEmitter。真正的大模型调用全部下沉到智能体层,Controller 里看不到任何 LLM 相关代码。这样的好处是换模型、换配图渠道时,接入层一行不用改。鉴权用拦截器在 Controller 之前完成,校验 JWT 并解析出用户 ID 放进请求上下文,生成流水线只管业务,不重复做身份判断。
二、SSE 流式通道的设计
前端建立连接后,后端必须持续把生成进度推出去。实现方式用 Spring MVC 内置的 SseEmitter,配合独立线程池执行生成任务。
@GetMapping(value = "/article/generate", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter generate(@RequestParam String topic) {
SseEmitter emitter = new SseEmitter(0L); // 0L 表示不超时
GenerationTask task = new GenerationTask(topic, emitter);
generationExecutor.submit(() -> pipeline.run(task));
return emitter;
}
两个细节决定成败。new SseEmitter(0L) 必须设置,默认 30 秒超时会在长文章生成途中被 Spring 强杀连接。生成任务必须提交到独立线程池,不能放在 Tomcat 请求线程里同步等模型返回,否则并发一上来线程池就被占满。项目里生成线程池用有界队列加 CallerRunsPolicy,队列打满时由请求线程自己兜底执行,宁可慢一点也不丢任务。
SSE 事件协议在前端约定死,避免前后端各说各话。项目统一三种事件名:stage 表示阶段切换,message 携带各阶段的文本增量,done 表示整篇生成完成,错误则推 error 事件。前端用 EventSource 监听,按事件名分发到不同渲染逻辑。
const es = new EventSource(`/article/generate?topic=${encodeURIComponent(topic)}`);
es.addEventListener('stage', (e) => renderStage(JSON.parse(e.data)));
es.addEventListener('message', (e) => appendContent(e.data));
es.addEventListener('done', () => { es.close(); resetUI(); });
推送统一走封装好的 StreamHandler,send 内部捕获 IOException,客户端断连就标记状态并清理,避免往已关闭的连接上继续写。
三、多阶段生成流水线
生成一篇爆款文章按顺序推进四个阶段,每个阶段都是独立的智能体:
- 标题智能体根据用户主题产出 5 个候选标题,前端逐个展示;
- 大纲智能体在用户选定标题后生成文章大纲,推送到前端确认;
- 正文智能体按大纲逐段生成正文,流式推送渲染;
- 配图分析智能体分析正文内容,产出配图描述并生成配图。
阶段之间的推进用状态机管理,每个阶段对应一个枚举值。前端点击「生成」后收到的是事件流,事件里带 stage 字段区分阶段,前端可以针对不同阶段做不同 UI,比如正文阶段展示打字机、配图阶段展示进度条。
public void run(GenerationTask task) {
try {
sendStage(task, Stage.TITLE);
String title = titleAgent.generate(task.getTopic());
String outline = outlineAgent.generate(title);
sendStage(task, Stage.OUTLINE);
String content = contentAgent.generateByOutline(outline);
sendStage(task, Stage.CONTENT);
imageAgent.generateForContent(task, content);
sendDone(task);
} catch (Exception e) {
sendError(task, e);
}
}
3.1 阶段间上下文传递
后一个阶段依赖前一个阶段的产物,这些产物不能靠方法参数层层透传,因为智能体接口要保持纯净。项目把生成过程的中间产物放进 GenerationTask 这个请求级对象,Task 随任务提交进线程池,各阶段智能体从 Task 里取所需上下文。标题、大纲、正文三个阶段的模型调用是串行依赖,Task 里只存最近一次产物即可,不保留全量历史,省内存也避免 prompt 越积越长。
3.2 prompt 组装与温度控制
每个智能体的 prompt 在独立类里组装,阶段不同、系统提示词不同。标题阶段用低温度保证候选多样,正文阶段温度适中保证连贯,配图阶段把正文关键句抽取后转成画面描述。prompt 模板集中管理,改一版提示词不动业务代码。
失败处理走同一套流通道:任何阶段抛异常,sendError 推送 error 事件并关闭连接,前端据此恢复为可操作状态,用户不用一直看着转圈。
四、数据与存储
用户信息、文章记录、执行日志落 MySQL,会话与配额缓存走 Redis,生成的图片上传到对象存储并回写 URL。文章表存标题、大纲、正文、封面图、状态字段,生成过程中的中间产物不落库,只有最终成品入库。
CREATE TABLE article (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
title VARCHAR(200),
outline TEXT,
content MEDIUMTEXT,
cover_url VARCHAR(500),
status TINYINT DEFAULT 0,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
执行日志单独一张表,记录每个阶段的耗时、模型名称和 token 消耗数,用于统计生成成本、定位慢阶段。状态字段支持断点续跑:用户刷新页面后按文章状态恢复,已完成的阶段不重复调用模型,省 token 也省时间。
五、可观测性与两个落地坑
每个阶段打点,记录阶段名、耗时、输出长度,汇总到执行日志表。排查问题时先看哪个阶段耗时异常,再决定是换模型还是调 prompt。超时配置是第一个坑,SseEmitter 超时、网关超时、大模型读超时三处必须联动设置,任何一处取默认值,长文章必然断流。线程池参数是第二个坑,模型调用是 IO 密集,线程数按「并发用户数 × 单请求占用时长」估算,再配合队列长度压测校准,避免线程开多了把模型接口 QPS 打爆。
常见问题(FAQ)
Q1:为什么生成任务必须放独立线程池?
请求线程同步等模型返回会拖垮 Tomcat,独立线程池让请求立即返回 emitter,连接先建立、内容后推。
Q2:SSE 连接中途断开会怎样?
StreamHandler 捕获 IOException 后清理连接与任务状态,生成线程检测到已关闭即终止推送。
Q3:四个阶段可以并行吗?
正文依赖大纲、配图依赖正文,存在先后关系,只能串行,配图内部多张图可以并行。