从 0 实现「可暂停、可断点续跑、可运行时改需求」的 Multi-Agent 编排引擎
本文通过一个真实的微信 AI 助手项目,拆解多智能体任务编排中的三个企业级痛点:暂停/恢复、断点续执行、运行时补充指令,并给出完整的工程实现思路。
一、为什么需要"可暂停"的 Agent 编排?
想象一个场景:你在微信里对一个 AI 助手说------
"帮我在 B 站发一条动态,配文是'周末愉快',再配一张图。"
这个任务会被拆解成多步编排:
scss
ROUTER 分析意图 → 生成编排计划 (发动态 + 配图)
↓
IMAGE Agent:生成配图
↓
BILIBILI Agent:调用 API 发布动态
如果一切顺利,几秒钟就能跑完。但现实往往是:
- 配文写错了,你想中途改一下
- 图生成了,但你想换成另一张
- 任务跑到一半,你想先停一下,过会儿再继续
传统的 Agent 调用是一次性的「请求 → 响应」,跑起来就停不下来。一旦开始执行,你只能等它跑完,或者干脆杀掉重来------这既不优雅,也浪费 Token。
这就是"可状态化的任务编排"要解决的问题。
二、三个核心能力的设计
我把这个能力拆成三个正交的维度,分别攻克:
1. 暂停 / 恢复(Pause / Resume)
难点 :Agent 执行是在虚拟线程里跑的,而"暂停"这个指令来自另一个线程(微信消息回调线程)。这是一个典型的跨线程状态同步问题。
方案 :用一个 ConcurrentHashMap 保存每个用户的控制状态,用 AtomicReference 保证可见性:
java
public enum ControlState { NORMAL, PAUSED, CANCELLED }
private static final ConcurrentHashMap<String, AtomicReference<ControlState>> STATES =
new ConcurrentHashMap<>();
-
用户发"暂停" →
setPaused(userId),只是设一个标志位 -
编排执行循环里,每执行一步前主动检查 这个标志位:
javafor (int i = 0; i < sorted.size(); i++) { if (TaskControlContext.isPaused(userId)) { savePausedTask(userId, snapshot); // 保存快照 return; // 优雅退出 } executeStep(sorted.get(i)); }
这就是经典的协作式中断(cooperative interruption)思想:不强制打断正在跑的 LLM 调用,而是在步骤边界上检查,让任务"体面地"停下来。
2. 断点续执行(Checkpoint Resume)
难点 :任务停了,但要能从停的地方继续,而不是从头再来。这意味着必须保存完整的执行上下文。
方案 :引入一个不可变的快照对象 PausedTaskState:
java
public record PausedTaskState(
List<PlanStep> steps, // 拓扑排序后的步骤列表
Map<Integer, String> stepOutputs, // 已完成步骤的输出
int nextStepIndex, // 从哪个索引继续
String userId,
String sessionId, // 会话上下文
String userText, // 原始用户输入
List<ReceivedContent> attachments, // 附件(图片/文档)
String imageRunId,
List<ReceivedContent> pendingGeneratedFiles, // 跨线程暂存文件
String pendingModifyInstruction // 暂停期间的补充指令
) {}
恢复时,直接复用已完成步骤的输出,只执行剩余步骤:
java
int startIndex = state.nextStepIndex();
for (int i = startIndex; i < sorted.size(); i++) {
executeStep(sorted.get(i));
}
这里有个极易踩的坑,也是我花最多时间调试的地方:
ThreadLocal 跨线程失效 。FILE Agent 生成的文档原本存在
ThreadLocal里,但暂停时线程结束了,恢复时换了个新线程,ThreadLocal里的数据就丢了。
解决办法是在快照里显式地保存这些跨线程数据:
java
List<ReceivedContent> pendingFiles = PENDING_GENERATED_FILES.get(); // 从 ThreadLocal 取出
// 存进快照,恢复时再 set 回新的 ThreadLocal
3. 运行时补充指令(Runtime Modify)
这是最亮眼的一个能力,也是真正让 Agent 显得"智能"的地方。
场景 :任务暂停后,用户说"把配文改成'今晚八点直播见'"。这个指令要在恢复执行时,注入到还没来得及执行的步骤里,让对应 Agent 按新要求产出。
方案 :快照里加一个 pendingModifyInstruction 字段,暂停期间允许累积多条指令:
java
public PausedTaskState withModifyInstruction(String extra) {
String merged = (this.pendingModifyInstruction() == null || isBlank())
? extra
: this.pendingModifyInstruction() + ";" + extra; // 分号分隔累积
return new PausedTaskState(..., merged);
}
恢复执行时,把这段指令追加到未完成步骤的上下文末尾:
java
private String buildContextualInput(PlanStep step, ..., String modifyInstruction) {
String base = buildContextualInput(step, ...); // 原有上下文
if (modifyInstruction == null || modifyInstruction.isBlank()) {
return base;
}
return base + "\n\n【重要·用户中途修改要求(严格遵守,覆盖原指令中冲突部分)】\n"
+ modifyInstruction;
}
关键在于提示词里那个"覆盖原指令中与之冲突的部分"------这让 LLM 知道:新指令的优先级更高。
三、完整的状态机
三个能力串起来,是一个清晰的状态机:
css
┌─── 暂停 ──→ PAUSED ── 继续 ──┐
│ │
NORMAL ────────────────────────────┘
│
└─── 取消 ──→ CANCELLED(清理资源)
- NORMAL:正常执行
- PAUSED:已冻结,资源保留等待恢复
- CANCELLED:已取消,清理干净(图片缓存、附件、B站上下文等)
取消时特别注意资源清理,否则会有内存泄漏------这也是生产环境最容易出 bug 的地方。
四、为什么用 Actor 模型?
还有一个隐藏的设计:每个用户的消息用一个独立的虚拟线程(Java 21 Virtual Thread)+ 队列串行处理。这保证了:
- 同用户消息严格有序------不会出现"暂停"和"继续"乱序执行
- 不同用户完全并行------互不阻塞
java
// Per-user Actor:每个用户一个虚拟线程 + ConcurrentLinkedQueue
这是把"并发任务控制"做对的根基:状态隔离。每个用户的状态是独立的,暂停一个用户不影响其他人。
五、踩坑总结
回顾整个实现,最有价值的教训是:
| 坑 | 表现 | 解法 |
|---|---|---|
| ThreadLocal 跨线程丢失 | 恢复后拿不到已生成的文件 | 快照显式保存,恢复时回填 |
| 暂停标志位竞态 | "暂停"指令晚于"继续"到达 | Actor 串行 + AtomicReference |
| 指令覆盖不彻底 | LLM 忽略新指令 | 提示词里强调"覆盖冲突部分" |
| 取消后资源泄漏 | 图片缓存残留 | 统一 resource cleanup 流程 |
| 多次暂停状态丢失 | 二次暂停覆盖首次快照 | 快照设计为不可变 + 累积字段 |
六、总结
"可暂停的 Agent 编排"是一个看起来简单、做起来细节满满的能力。它背后是几个经典的软件工程思想:
- 协作式中断(而非强制打断)
- 不可变快照(Checkpoint 模式)
- 状态机(显式建模生命周期)
- Actor 隔离(Per-user 串行)
- 跨线程上下文传递(显式化 ThreadLocal)
这些思想不只适用于 AI Agent,任何"长任务 + 可中断 + 可恢复"的场景(批处理、ETL、流程引擎)都适用。
项目地址:一个基于 Spring AI + 通义千问的完整个人多智能体助手,已开源。 如果你也在做 Agent 编排,欢迎交流。
标签:Java Spring AI Multi-Agent 多智能体 架构设计