在最近的项目中,我们团队负责孵化一个"PPT生成智能体"。坦白讲,这可以说是我们整个系统里最复杂的一个 Agent。
为什么复杂?因为"一句话生成一份高质量 PPT"并不是调用一次大模型 API 就能搞定的。它是一个典型的长耗时、多阶段的复杂业务流。这个过程涉及到用户意图识别、需求澄清、联网检索、大纲生成、内容填充乃至配图和最终的渲染。通常来说,完整生成一份极具干货的 PPT 需要几分钟之久。
在项目初期,我们面临着两个极其棘手的灵魂拷问:
-
代码怎么写才不会变成"屎山"?
多个阶段的逻辑完全不同,有的要查库,有的要调搜索,有的要调用文生图 API,如果全塞在一个 Service 里,代码将变得无法维护。
-
生成的过程中,用户把网页关了怎么办?网络断了怎么办?
几分钟的等待期内,用户的任何中断行为都会导致前功尽弃,这对于一款商业级产品来说,体验是灾难性的。
为了解决这两个问题,我们最终采用了一套**"状态机 + 策略模式 + 状态持久化"**的架构方案。
一、整体架构
在动手写代码之前,先看整体架构。下图是这套系统的核心数据流:

每一个阶段(State)对应一个独立的 Strategy 类。状态机引擎负责加载上下文、分发任务、并在每个状态边界触发持久化。整个系统的核心设计哲学是:状态是唯一真相(Single Source of Truth),只要状态能恢复,任务就能恢复。
二、状态定义
首先,我们将整个 PPT 生成过程的各个阶段严格地抽象为若干个状态(State):
public enum PptState {
INIT, // 初始状态:接收并解析用户初步意图
REQUIREMENT, // 需求澄清:信息不足时主动向用户追问
SEARCH, // 信息收集:联网检索,获取最新资料
SCHEMA, // 结构生成:搭建 PPT 整体章节骨架
OUTLINE, // 大纲生成:为每个章节生成详细大纲
CONTENT, // 内容填充:为每页生成文案,调用文生图配插图
RENDER, // 渲染生成:结构化数据转为真实 PPT 文件
SUCCESS, // 终态(成功)
FAILED // 终态(失败)
}
几个值得注意的设计决策:
-
REQUIREMENT 是可选的
:只有当 INIT 阶段判断用户输入信息不足时,才会流转到 REQUIREMENT。否则直接跳到 SEARCH。这意味着状态转换不是严格的线性序列,而是由每个 Strategy 自主决定"下一步去哪"。
-
FAILED 是唯一的异常出口
:任何阶段抛出的、不可重试的业务异常,都统一流转到 FAILED 状态,并在那里记录错误原因,方便前端展示和人工排查。
-
SUCCESS/FAILED 是幂等终态
:一旦进入终态,后续所有重复请求都应直接返回结果,不再重新执行。
三、策略模式实现
3.1 策略接口定义
我们为每一个状态都编写了一个独立的 Strategy 类。所有策略类统一实现同一个接口:
public interface PptStateStrategy {
// 处理当前状态的核心逻辑,完成后调用 context.continueStateMachine(nextState) 推进
void process(PptStateStrategyContext context);
// 声明本策略负责处理哪个状态,引擎据此自动路由
PptState getHandleState();
}
3.2 策略的自动注册:让引擎与策略完全解耦
我们利用 Spring 的 IoC 容器,实现策略的自动注册,完全消除引擎对具体策略类的感知:
@Component
public class PptStateMachineEngine {
private final Map<PptState, PptStateStrategy> strategyMap = new HashMap<>();
// Spring 启动时自动注入所有 Strategy Bean,按 getHandleState() 注册到 Map
@Autowired
public PptStateMachineEngine(List<PptStateStrategy> strategies) {
for (PptStateStrategy strategy : strategies) {
strategyMap.put(strategy.getHandleState(), strategy);
}
}
public void dispatch(PptStateStrategyContext context) {
PptState currentState = context.getPptInstance().getState();
PptStateStrategy strategy = strategyMap.get(currentState);
if (strategy == null) {
throw new IllegalStateException("No strategy found for state: " + currentState);
}
strategy.process(context);
}
}
这样的好处是:新增一个状态,只需新增一个 Strategy 类并打上 @Component 注解,引擎完全不需要改动。 完全符合开闭原则(OCP)。
3.3 一个具体策略的实现:以 RequirementStrategy 为例
下面是需求澄清阶段的策略实现,可以完整看到一个 Strategy 的标准写法:
@Component
public class RequirementStrategy implements PptStateStrategy {
@Override
public PptState getHandleState() {
return PptState.REQUIREMENT;
}
@Override
public void process(PptStateStrategyContext context) {
PptInstance ppt = context.getPptInstance();
ChatClient chatClient = context.getChatClient();
ChatMemory chatMemory = context.getChatMemory();
// 1. 构建提示词:基于已有信息,询问大模型是否信息充足
String prompt = buildRequirementPrompt(ppt.getUserInput());
// 2. 调用大模型,获取结构化判断结果
RequirementCheckResult result = chatClient
.prompt(prompt)
.advisors(new MessageChatMemoryAdvisor(chatMemory))
.call()
.entity(RequirementCheckResult.class);
if (result.isInfoSufficient()) {
// 3a. 信息充足 → 将补全后的需求写回 PPT 实例,直接推进到 SEARCH
ppt.setRefinedRequirement(result.getRefinedRequirement());
context.continueStateMachine(PptState.SEARCH);
} else {
// 3b. 信息不足 → 将追问内容回传给用户,状态停留在 REQUIREMENT,等待用户答复
context.replyToUser(result.getFollowUpQuestion());
// 注意:此时不调用 continueStateMachine,状态机"暂停",等待下一轮用户输入
}
}
}
注意这里的关键设计:**Strategy 对"下一步去哪"有完全的自主权。**当信息不足时,流程可以暂停并等待用户输入,这是普通线性流程无法实现的。
3.4 上下文对象:各阶段数据流转的"大脑"
各个阶段独立了,但上下文(Context)是连贯的。PptStateStrategyContext 承担了所有共享资源的托管:
@Data
public class PptStateStrategyContext {
private PptInstance pptInstance; // PPT 实体(含当前状态)
private ChatClient chatClient; // 大模型调用入口
private ChatMemory chatMemory; // 对话历史(状态边界时序列化到 DB)
private SseEmitter sseEmitter; // 向前端推送进度的通道
// 推动状态机向前:先持久化快照,再调用下一个 Strategy
public void continueStateMachine(PptState nextState) {
this.pptInstance.setState(nextState);
PptStatePersistenceService.persist(this);
StateMachineEngine.getInstance().dispatch(this);
}
// 向前端推送一条消息(追问、进度通知等)
public void replyToUser(String message) {
SseUtils.send(this.sseEmitter, message);
}
}
四、断点续传实现
4.1 核心思路:在状态边界打快照
断点续传的实现,水到渠成地建立在状态机设计之上。核心思路:在每个状态转换的边界,将完整快照(包括 PPT 数据 + 对话历史 + 当前状态标识)持久化到数据库。
这样,无论任务在哪个阶段中断,我们都能从数据库中精确恢复到上一个完成的状态节点,并从那里继续执行,不会重复消耗 Token。
4.2 保存什么,怎么保存?
在 continueStateMachine() 内部,持久化逻辑如下:
// PptStatePersistenceService.java
public static void persist(PptStateStrategyContext context) {
PptInstance ppt = context.getPptInstance();
// 1. 序列化对话历史
// ChatMemory 本身是内存对象,需要手动序列化为 JSON 存库
List<Message> messages = context.getChatMemory().get(ppt.getConversationId(), Integer.MAX_VALUE);
String chatHistoryJson = JSON.toJSONString(messages);
// 2. 构建持久化实体
PptTaskSnapshot snapshot = PptTaskSnapshot.builder()
.conversationId(ppt.getConversationId())
.currentState(ppt.getState().name()) // 关键!记录当前状态
.pptData(JSON.toJSONString(ppt)) // 完整 PPT 数据
.chatHistory(chatHistoryJson) // 对话历史
.updatedAt(LocalDateTime.now())
.build();
// 3. 落库(upsert,有则更新,无则插入)
pptTaskSnapshotRepository.upsert(snapshot);
}
对应的数据库表设计(精简版):
CREATE TABLE ppt_task_snapshot (
conversation_id VARCHAR(64) PRIMARY KEY COMMENT '会话ID,业务唯一标识',
current_state VARCHAR(32) NOT NULL COMMENT '当前状态枚举值',
ppt_data LONGTEXT COMMENT 'PPT实例数据(JSON)',
chat_history LONGTEXT COMMENT '对话历史(JSON)',
updated_at DATETIME COMMENT '最后更新时间'
);
4.3 恢复流程
下面是完整的断点续传恢复逻辑:
// PptAgentService.java - 用户发起请求的入口
public void handleUserRequest(String conversationId, String userMessage, SseEmitter emitter) {
// 第一步:查询是否存在未完成的任务快照
Optional<PptTaskSnapshot> snapshot = pptTaskSnapshotRepository.findByConversationId(conversationId);
PptStateStrategyContext context;
if (snapshot.isPresent() && !isTerminal(snapshot.get().getCurrentState())) {
// ====== 断点续传分支 ======
PptTaskSnapshot snap = snapshot.get();
// 1. 反序列化 PPT 实例
PptInstance ppt = JSON.parseObject(snap.getPptData(), PptInstance.class);
// 2. 恢复对话历史(关键!大模型需要历史上下文才能"记起"之前的对话)
List<Message> messages = JSON.parseArray(snap.getChatHistory(), Message.class);
ChatMemory chatMemory = new InMemoryChatMemory();
chatMemory.add(conversationId, messages);
// 3. 如果用户此次带着新消息进来(比如回答了追问),追加到历史
if (StringUtils.isNotBlank(userMessage)) {
chatMemory.add(conversationId, new UserMessage(userMessage));
}
// 4. 重新拼装 Context
context = new PptStateStrategyContext(ppt, chatClient, chatMemory, emitter);
log.info("断点续传恢复成功,conversationId={}, 继续状态={}", conversationId, ppt.getState());
} else {
// ====== 全新任务分支 ======
PptInstance newPpt = PptInstance.create(conversationId, userMessage);
ChatMemory chatMemory = new InMemoryChatMemory();
context = new PptStateStrategyContext(newPpt, chatClient, chatMemory, emitter);
}
// 第二步:启动状态机(异步执行,避免阻塞 HTTP 线程)
asyncExecutor.execute(() -> stateMachineEngine.dispatch(context));
}
4.4 前端如何配合后端
前端的核心逻辑非常简单,只需要做两件事:
-
使用 SSE(Server-Sent Events)接收流式进度
:每个状态完成后,后端通过 SSE 推送一条进度消息,前端据此更新进度条("正在搜索资料..." / "正在生成大纲...")。
-
页面重新打开时,携带 conversationId 重新发起请求
:前端将 conversationId 存储在 localStorage 中。用户下次进入时,直接用这个 ID 请求后端,后端会自动识别并执行断点续传。
// 前端伪代码
async function startOrResumePptGeneration(userInput) {
const conversationId = localStorage.getItem('pptConversationId') || generateUUID();
localStorage.setItem('pptConversationId', conversationId);const eventSource = new EventSource(
/api/ppt/generate?conversationId=${conversationId}&input=${userInput});eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'PROGRESS') {
updateProgressBar(data.state, data.message);
} else if (data.type === 'DONE') {
showPptPreview(data.pptUrl);
eventSource.close();
} else if (data.type === 'QUESTION') {
// 需求澄清:展示追问弹窗,等待用户输入
showFollowUpDialog(data.question);
}
};
}
五、踩坑记录
坑1:ChatMemory 序列化问题
Spring AI 的 Message 对象是接口,包含 UserMessage、AssistantMessage、SystemMessage 等多个子类型。直接 JSON.toJSONString(messages) 序列化没问题,但反序列化时 JSON.parseArray(json, Message.class) 会因为多态丢失子类型信息而失败。
💡 优雅解法序列化时在每条消息里带上类型标识,反序列化时手动按类型还原,或者使用 Jackson 的 @JsonTypeInfo 注解配置多态序列化策略。
坑2:并发重入问题
用户在任务进行中反复刷页面时,可能同时触发多个恢复请求,导致同一任务被并发执行两遍,产生重复的 Token 消耗和数据错乱。
💡 优雅解法在数据库层面对 conversation_id 加分布式锁(Redis SETNX 或数据库乐观锁),任务开始前先获取锁,任务完成或终止后释放锁。如果加锁失败,说明已有执行中的实例,直接返回"任务进行中"即可。
坑3:CONTENT 阶段耗时极长
CONTENT 阶段需要为每一页 PPT 分别调用大模型生成内容,如果是 20 页 PPT,就是 20 次串行 LLM 调用。这使得整体耗时急剧上升。
💡 优雅解法将 CONTENT 阶段内部改为并发执行(使用 CompletableFuture 或虚拟线程),多页内容同时生成。同时在 CONTENT 阶段内部也做细粒度的持久化(每完成一页就存库),避免中途中断导致已完成页面的 Token 浪费。
坑4:极端条件导致状态机"跑飞"
在测试中,曾出现状态机进入死循环的情况:某个 Strategy 在异常条件下既没有调用 continueStateMachine(),也没有流转到 FAILED,导致任务永久挂起。
💡 优雅解法在引擎的 dispatch() 方法中用整体 try-catch 兜底,所有未捕获的未卜异常都强制流转到 FAILED 状态。同时辅助加入超时检测的后台 Job,定期扫描 updated_at 超过阈值的未完成任务,主动标记为 FAILED 并触发告警。
六、为什么选自研而不是工作流引擎?
不少同学会问:为什么不用 Flowable、Camunda 这类成熟的工作流引擎?
| 维度 | 工作流引擎(如 Flowable) | 自研状态机 |
|---|---|---|
| 上手成本 | 高(BPMN 语法、引擎配置) | 低(接口 + 枚举) |
| AI 上下文感知 | 不感知(需要额外桥接) | 原生支持(Context 直接持有 ChatMemory) |
| 动态流转逻辑 | 需要在 BPMN 中定义网关 | Strategy 内部自由决策 |
| 调试难度 | 高(日志分散、需专用工具) | 低(就是普通 Java 代码) |
| 适合规模 | 大型企业流程(审批等) | 中小型 AI 任务编排 |
结论:工作流引擎是重型武器,为复杂的人工审批流而生。对于 AI Agent 这类以大模型调用为核心、需要动态分支、高度感知上下文的场景,自研轻量状态机更灵活、更易调试。
七、总结一下
把这套方案提炼成可以复用的工程经验,核心有以下几条:
-
复杂 AI 任务 = 状态机 + 策略模式,而非线性脚本。
每个阶段独立成类,彼此不感知,通过 Context 传递数据。新增阶段只需新增类,完全符合 OCP。
-
状态边界是持久化的天然切点。
不需要在业务逻辑中间插入持久化代码。状态转换就是快照时机,持久化逻辑集中在
continueStateMachine()中,对各 Strategy 完全透明。 -
ChatMemory 的序列化是 AI Agent 持久化的核心难点。
不处理好对话历史的序列化/反序列化,模型恢复后就会"失忆",上下文断裂,生成质量直线下降。
-
异步 + SSE 是长耗时 AI 任务的标配前后端交互模型。
后端异步执行状态机,通过 SSE 流式推送进度,前端保存 conversationId 支持随时恢复。这套模式几乎适用于所有复杂 AI 生成任务。
-
最长阶段的内部并发化是性能优化的关键。
识别出耗时最长的阶段(如多页内容填充),将其内部改为并发执行,往往可以将整体耗时降低 50% 以上。
AI Agent 的工程化,本质上就是"如何让不可控的大模型嵌入可控的工程系统"。