多 Agent 编排解决了"谁先谁后、谁干什么"的问题,但真正决定系统能否在生产环境跑稳的,是三个更底层的机制:Agent 之间怎么传消息、怎么共享状态、意见不一致时谁说了算。
上一篇聊了多 Agent 编排的四种模式------串行、并行、条件分支、竞争。模式定义的是"拓扑",但拓扑之上还需要三样东西让协作真正运转:
- 传话:Agent A 怎么把消息发给 Agent B
- 共享记忆:多个 Agent 怎么读到同一份上下文
- 达成共识:A 说 X、B 说 Y,最终输出听谁的
这三件事,从简单到复杂,构成了 Agent 协作的三个层次。本文从 Dream-SaaS 的真实代码出发,逐层拆解。
一、通信模式对比:从成本视角看四种选择
Agent 之间的通信,本质上是一个工程权衡问题。没有"最好"的模式,只有"最合适"的选择。
1.1 同步调用:最简单,但级联失败是定时炸弹
同步调用是最直觉的方式------A 调 B,阻塞等结果。HTTP RPC、方法直接调用都属于此类。
它的优点是简单:调用链清晰,错误处理直接,调试方便。但隐患也很明显:
- 级联失败:B 依赖 C,C 依赖 D,D 超时 10 秒,整条链路全部阻塞。在微服务领域这叫"雪崩",在 Agent 编排领域同样存在。
- 线程耗尽:Agent 调用通常涉及 LLM 请求,单次耗时 2-10 秒。如果用同步阻塞,Tomcat 工作线程很快被占满。
- 无法并行:串行模式还好,并行模式下同步等待意味着线程数 = 并行度,资源开销大。
同步调用适合同 JVM 内、延迟可控的场景。一旦跨进程或跨服务,就需要考虑异步。
1.2 异步消息:解耦的代价是复杂度
异步消息------A 发消息后不阻塞,B 处理完后通过回调或轮询通知 A。解耦了发送方和接收方,但引入了新的复杂度:
- 消息可靠性:消息发出去了,B 收到没有?处理成功没有?需要 ACK 和重试机制。
- 顺序性:先发的消息一定先到吗?在网络分区场景下不一定。
- 调试困难:请求链路被打散,日志难以串联,需要 traceId 贯穿。
异步消息适合跨服务、高吞吐、可容忍延迟的场景。
1.3 事件总线 vs 消息队列:别混淆概念
这两个词经常被混用,但侧重点不同:
| 维度 | 事件总线(Event Bus) | 消息队列(Message Queue) |
|---|---|---|
| 模型 | 发布-订阅,一对多 | 点对点,一对一(或消费组) |
| 耦合 | 发布方不关心谁订阅 | 发送方指定目标或 Topic |
| 典型实现 | Guava EventBus、Spring ApplicationEvent | RabbitMQ、Kafka、RocketMQ |
| 适用场景 | 同进程内领域事件通知 | 跨服务可靠消息投递 |
| 持久化 | 通常不持久化 | 持久化、支持回溯 |
在 Agent 场景中,事件总线适合同 JVM 内的 Agent 间通知 (如"任务完成"事件),消息队列适合跨服务的可靠任务分发。用错了会出问题:用事件总线做跨服务通信,进程一重启消息全丢;用消息队列做同进程通知,杀鸡用牛刀且引入运维负担。
1.4 共享状态:高并发下的"双刃剑"
共享状态严格来说不是"通信",但它是 Agent 间交换信息的一种方式------多个 Agent 读写同一个状态存储(内存 Map、Redis、数据库),通过"你写我读"传递信息。
- 优点:速度快(内存读写微秒级)、实现简单、天然支持多消费者。
- 致命缺点:竞态条件。两个并行 Agent 同时写同一个 key,后写的覆盖先写的,结果不可预测。下文状态共享一章会展开作用域隔离的应对方案。
1.5 成本-复杂度对比
| 通信方式 | 实现成本 | 运维复杂度 | 可靠性 | 延迟 | 适用场景 |
|---|---|---|---|---|---|
| 同步调用 | 低 | 低 | 中(级联失败风险) | 低 | 同 JVM、短链路 |
| 异步消息 | 中 | 中 | 高(需 ACK/重试) | 中 | 跨服务、可容忍延迟 |
| 事件总线 | 低 | 低 | 低(不持久化) | 极低 | 同进程事件通知 |
| 消息队列 | 高 | 高 | 高 | 中 | 跨服务可靠投递 |
| 共享状态 | 低 | 中 | 中(竞态风险) | 极低 | 高频读、低频写 |
1.6 Dream-SaaS 实战:HybridAgentChannel 双通道设计
Dream-SaaS 同时存在同 JVM 内的 Agent 协作和跨服务的 Agent 调用。如果全部走远程 HTTP,同 JVM 内的调用白白增加网络开销;如果全部走内存,跨服务的 Agent 又无法通信。
解决方案是 HybridAgentChannel------同 JVM 走 InMemory,跨服务走 A2A HTTP,运行时根据消息标记自动路由:
bash
// HybridAgentChannel.java --- 同JVM InMemory;跨服务 A2A HTTP
@Slf4j
public class HybridAgentChannel implements AgentChannel {
private final InMemoryAgentChannel inMemory;
private final A2AClient a2aClient;
private final AgentCollaborationProperties properties;
@Override
public void send(AgentMessage message) {
if (message == null) return;
if (shouldUseA2a(message)) {
delegateRemote(message); // 跨服务走 A2A
return;
}
inMemory.send(message); // 同 JVM 走内存
}
private void delegateRemote(AgentMessage message) {
try {
String base = properties.getA2a().getRemoteBaseUrl();
String taskId = a2aClient.sendTask(
base, message.toAgent(),
message.messageType(),
message.payload() != null ? message.payload() : Map.of());
log.info("a2a delegated taskId={} toAgent={}", taskId, message.toAgent());
} catch (Exception e) {
// 远程调用失败,自动降级到本地内存通道
log.warn("a2a delegate failed, fallback in-memory: {}", e.toString());
inMemory.send(message);
}
}
}
设计要点有三:
- 路由判定 :
shouldUseA2a()检查配置开关、远程地址和消息 payload 中的transport=a2a标记。只有显式标记为远程的消息才走 A2A,默认走内存,避免不必要的网络开销。 - 自动降级 :远程调用抛异常时,catch 住并 fallback 到
inMemory.send()。这意味着网络抖动不会导致消息丢失,而是退化为本地执行。 - 降级有代价:fallback 到本地意味着远程 Agent 的专业能力不可用,会由本地的通用 Agent 兜底。这个代价需要业务方知晓,不能静默降级。
二、JSON-RPC 2.0 与 A2A 协议
跨服务 Agent 通信需要一个标准协议。Dream-SaaS 选择了 JSON-RPC 2.0 作为 A2A(Agent-to-Agent)协议的底座。
2.1 为什么选 JSON-RPC 而非 REST/gRPC
| 维度 | REST | gRPC | JSON-RPC 2.0 |
|---|---|---|---|
| 协议复杂度 | 中(资源建模) | 高(需 proto 定义) | 低(method + params) |
| 浏览器/调试友好 | 好 | 差(二进制) | 好(纯 JSON) |
| 类型安全 | 弱 | 强 | 弱 |
| 流式支持 | 需 SSE/WebSocket | 原生支持 | 不支持(需扩展) |
| Agent 场景适配 | 需为每个操作建模 REST 资源 | 需维护 proto 文件 | method 即动作,天然适合 RPC 语义 |
Agent 间的交互本质是"发指令、拿结果",是 RPC 语义而非资源 CRUD。JSON-RPC 的 method + params + id 模型足够轻量,且与 A2A 规范的消息结构天然对齐。gRPC 性能更好,但在 Agent 场景中,LLM 推理耗时通常在秒级,HTTP/JSON 的序列化开销(毫秒级)完全可以忽略。
2.2 A2AProtocol 消息模型
A2A 协议的核心消息结构非常精简:
bash
// A2AProtocol.java --- A2A JSON-RPC 2.0 消息模型
public final class A2AProtocol {
public static final String JSONRPC = "2.0";
// 核心消息:request / result / error 三态合一
public record A2AMessage(
String jsonrpc, String method, Object params,
String id, Object result, A2AError error) {
public static A2AMessage request(String method, Object params, String id) {
return new A2AMessage(JSONRPC, method, params, id, null, null);
}
public static A2AMessage result(String id, Object result) {
return new A2AMessage(JSONRPC, null, null, id, result, null);
}
public static A2AMessage error(String id, int code, String message) {
return new A2AMessage(JSONRPC, null, null, id, null,
new A2AError(code, message));
}
}
// 任务状态:PENDING → RUNNING → COMPLETED / FAILED
public record TaskState(String taskId, String status,
Object output, String error) {}
// 三个核心方法
public static final String METHOD_TASKS_SEND = "tasks/send";
public static final String METHOD_TASKS_GET = "tasks/get";
public static final String METHOD_AGENTS_CARD = "agents/card";
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_RUNNING = "RUNNING";
public static final String STATUS_COMPLETED = "COMPLETED";
public static final String STATUS_FAILED = "FAILED";
}
三个方法的职责:
- tasks/send :提交任务,立即返回
taskId(异步),不阻塞等待执行结果。 - tasks/get :根据
taskId查询任务状态和输出,客户端通过轮询获取结果。 - agents/card:获取 Agent 的能力描述(AgentCard),用于服务发现和能力协商。
任务状态机是线性的四态模型:PENDING → RUNNING → COMPLETED/FAILED。没有 PAUSED、CANCELLED 等中间态------刻意保持简单,因为 Agent 任务通常不支持暂停和恢复,加了反而增加实现复杂度。
2.3 A2AClient:异步提交 + 轮询模式
A2A 采用"提交-轮询"模式而非"提交-等待"模式,因为 LLM 任务耗时长,长时间持有 HTTP 连接不现实。客户端的 pollTaskAsync 方法用 ScheduledExecutorService 实现定时轮询,用 CompletableFuture 做异步编排:
bash
// A2AClient.java --- 异步轮询获取任务结果
public CompletableFuture<A2AProtocol.TaskState> pollTaskAsync(
String baseUrl, String taskId, int maxAttempts, long sleepMs) {
int attempts = Math.max(1, maxAttempts);
long interval = Math.max(50L, sleepMs);
CompletableFuture<A2AProtocol.TaskState> cf = new CompletableFuture<>();
AtomicInteger tryCount = new AtomicInteger(0);
AtomicReference<ScheduledFuture<?>> handle = new AtomicReference<>();
Runnable tick = () -> {
if (cf.isDone()) return;
int n = tryCount.incrementAndGet();
try {
A2AMessage req = A2AMessage.request(
METHOD_TASKS_GET, Map.of("taskId", taskId),
UUID.randomUUID().toString());
A2AMessage res = post(baseUrl, req);
if (res.error() != null) {
cf.completeExceptionally(
new IllegalStateException(res.error().message()));
cancelPoll(handle); return;
}
TaskState state = objectMapper.convertValue(
res.result(), TaskState.class);
if (state != null && (STATUS_COMPLETED.equals(state.status())
|| STATUS_FAILED.equals(state.status()))) {
cf.complete(state); // 终态,完成 Future
cancelPoll(handle); return;
}
if (n >= attempts) {
cf.completeExceptionally(
new IllegalStateException("A2A task poll timeout: " + taskId));
cancelPoll(handle);
}
} catch (Throwable t) {
cf.completeExceptionally(t);
cancelPoll(handle);
}
};
// 立即执行第一次,之后按固定间隔轮询
handle.set(POLL_SCHED.scheduleAtFixedRate(tick, 0L, interval, MILLISECONDS));
cf.whenComplete((r, e) -> cancelPoll(handle));
return cf;
}
几个实现细节值得注意:
- 轮询间隔下界 50ms :
Math.max(50L, sleepMs)防止配置过小导致轮询风暴。 - 首次立即执行 :
scheduleAtFixedRate(tick, 0L, ...)的初始延迟为 0,第一次查询不等待,减少短任务的不必要延迟。 - 自动取消 :
cf.whenComplete注册回调,无论成功、失败还是超时,都会取消定时任务,避免线程泄漏。 - 轮询线程池隔离 :
POLL_SCHED是独立的 2 线程调度池,不与业务线程争抢资源。
2.4 A2AServer:异步任务处理与状态流转
服务端收到 tasks/send 后,不阻塞等待 Agent 执行完成,而是将任务提交到 worker 线程池异步执行,立即返回 taskId:
bash
// A2AServer.java --- 收到任务后异步执行,状态流转 PENDING→RUNNING→COMPLETED/FAILED
private A2AMessage send(A2AMessage request) {
Map<String, Object> params = (Map<String, Object>) request.params();
String taskId = UUID.randomUUID().toString();
String toAgent = String.valueOf(params.getOrDefault("agent", ""));
Map<String, Object> payload = (Map<String, Object>) params.get("payload");
A2ALocalExecutor executor = resolveExecutor(toAgent, payload);
if (executor == null) {
taskStore.put(new TaskState(taskId, STATUS_FAILED, null, "无匹配的 executor"));
return A2AMessage.error(request.id(), -32000, "无匹配的本地 A2A executor");
}
// 状态流转:PENDING → RUNNING
taskStore.put(new TaskState(taskId, STATUS_PENDING, null, null));
taskStore.put(new TaskState(taskId, STATUS_RUNNING, null, null));
linkRunFromPayload(taskId, payload); // 建立 runId/orchTaskId 索引
// 提交到 worker 池异步执行
String input = extractInput(payload, params);
String agentName = executor.agentName();
workerPool.submit(() -> runTask(taskId, agentName, executor, input, payload));
// 立即返回 taskId,不等待执行结果
return A2AMessage.result(request.id(),
Map.of("taskId", taskId, "status", STATUS_RUNNING, "agent", agentName));
}
private void runTask(String taskId, String agentName,
A2ALocalExecutor executor, String input, Map<String,Object> payload) {
try {
String output = executor.execute(input, payload);
taskStore.put(new TaskState(taskId, STATUS_COMPLETED,
Map.of("output", output != null ? output : "", "agent", agentName), null));
} catch (Exception e) {
taskStore.put(new TaskState(taskId, STATUS_FAILED, null, e.getMessage()));
}
}
worker 线程池使用 BoundedDaemonExecutors.create("a2a-task-", 2, 8, 32) 创建------核心 2 线程、最大 8 线程、队列 32,有界设计防止任务积压导致 OOM。
2.5 混合通道降级:远程失败自动 fallback
回到 HybridAgentChannel 的降级逻辑:当 A2A 远程调用抛异常时,catch 住后走 inMemory.send()。这意味着即使远程服务不可用,消息也不会丢,而是由本地 Agent 处理。
但这里有一个容易被忽略的问题:降级可能导致重复执行。如果远程服务实际上收到了任务并在执行,只是响应超时了,客户端 fallback 到本地再执行一次,同一个任务就被执行了两遍。对于幂等操作(如查询)这不是问题,但对于有副作用的操作(如发消息、写数据),重复执行可能造成业务异常。
Dream-SaaS 的做法是在 payload 中携带 runId 和 orchestratorTaskId,服务端通过这些标识做去重检查------如果同一个 taskId 已经在处理中,直接返回已有状态,不重复执行。
三、状态共享的三种模式与陷阱
通信解决了"消息怎么传"的问题,状态共享解决的是"数据怎么放"的问题。多个 Agent 协作时,中间结果、上下文、执行进度需要被各方访问。
3.1 全局状态桶:快但有毒
最简单的方案:一个全局 Map<String, Object>,所有 Agent 都往里写、往里读。
- 优点:实现零成本,读写速度极快。
- 致命问题 :竞态条件。两个并行 Agent 同时写同一个 key,后写覆盖先写。更隐蔽的是"读-改-写"非原子性------Agent A 读到
count=1,加 1 后写回 2;Agent B 也读到 1,加 1 写回 2。最终结果是 2 而不是 3。
在 Agent 场景中,这种 bug 尤其难排查,因为 LLM 的输出本身就有不确定性,"结果不对"很难区分是模型问题还是状态污染。
3.2 作用域隔离:安全但复杂
将状态按作用域隔离------每个 Agent 只能写自己的命名空间,跨 Agent 的数据通过显式消息传递。例如:
bash
state.agentA.result = "..."
state.agentB.result = "..."
state.merged.output = "..." // 只有汇总节点能写
这从根本上消除了并行写冲突,但代价是:
- 状态结构需要提前规划,灵活性降低。
- Agent 之间共享数据需要经过编排层中转,增加了一层间接性。
3.3 事件溯源 + 快照:可追溯但开销大
不直接保存当前状态,而是保存所有状态变更事件(Event Sourcing)。当前状态通过回放事件派生,定期做快照(Snapshot)加速回放。
- 优点:完整审计轨迹,可以回溯任意时间点的状态,对调试 Agent 行为极有价值。
- 缺点:实现复杂度高,存储开销大,事件 schema 变更需要版本管理。
3.4 核心观点:状态即契约
状态即契约------Agent 之间共享的每一个状态字段,都是一个隐含的接口约定。字段名、类型、语义、生命周期,任何一方擅自变更都会导致协作失败。
这和微服务中的"数据库共享是反模式"同理。当两个 Agent 直接读写同一个状态键时,它们之间的耦合比任何 API 调用都紧------API 有版本号、有 schema 校验,而共享状态只有一个"约定俗成"的 key 名。
实践中应该:
- 最小化共享状态,能通过消息传递的就不要共享存储。
- 共享状态必须有 schema,哪怕是一个简单的 Java record 或 JSON Schema。
- 写操作收口,只允许特定角色(如汇总节点)写共享状态,其他角色只读。
- 状态有 TTL,过期自动清理,避免内存泄漏和"脏数据"干扰。
3.5 Dream-SaaS 实战:A2ATaskStore 双索引设计
A2ATaskStore 负责任务状态的存储和查询,采用内存热缓存 + 可选 Redis 持久化的双层架构:
bash
// A2ATaskStore.java --- 内存热缓存 + 可选Redis持久化
public class A2ATaskStore {
// 主存储:taskId → TaskState
private final Map<String, TaskState> memory = new ConcurrentHashMap<>();
// 双索引:用于编排排障,从业务标识反查 taskId
private final Map<String, String> runIndexMemory = new ConcurrentHashMap<>();
private final Map<String, String> orchTaskIndexMemory = new ConcurrentHashMap<>();
private final ObjectProvider<StringRedisTemplate> redisProvider;
public void put(TaskState state) {
memory.put(state.taskId(), state);
if (!redisActive()) return;
try {
String json = objectMapper.writeValueAsString(state);
redis().opsForValue().set(key(state.taskId()), json, ttl());
} catch (Exception e) {
log.warn("[A2ATaskStore] redis put failed taskId={}: {}",
state.taskId(), e.getMessage());
}
}
/** runId → taskId 索引:编排排障时从运行ID反查任务 */
public void linkRun(String runId, String taskId) {
runIndexMemory.put(runId.trim(), taskId.trim());
if (!redisActive()) return;
try { redis().opsForValue().set(runKey(runId.trim()), taskId, ttl()); }
catch (Exception e) { log.warn("linkRun failed: {}", e.getMessage()); }
}
}
设计要点:
- 内存优先 :
get()先查内存 ConcurrentHashMap,命中直接返回(微秒级)。未命中才查 Redis,查到后回填内存。这保证了同实例内的查询性能。 - Redis 可选 :通过
ObjectProvider<StringRedisTemplate>注入,Redis 不可用时自动降级为纯内存模式,不影响核心功能。redisActive()还检查task-store配置项,支持memory/redis/auto三种模式。 - 双索引设计 :除了主存储
taskId → TaskState,还维护了runId → taskId和orchTaskId → taskId两个索引。runId是整个编排运行的唯一标识,orchTaskId是编排层子任务 ID。当线上出问题需要排障时,可以从任意一个业务标识反查到 A2A 任务,查看执行状态和输出。这在微服务链路追踪中相当于"多入口关联 ID"。 - TTL 过期:Redis key 设置了 TTL(默认 24 小时,可配置),避免任务状态无限堆积。内存缓存没有主动过期,但因为 JVM 重启即清空,且 Redis 是持久层的 source of truth,不会造成长期问题。
- Redis 失败不影响主流程:所有 Redis 操作都被 try-catch 包裹,失败只打 warn 日志,不抛异常。这是"缓存"的正确姿态------缓存挂了不能拖垮主业务。
四、当 Agent 意见不合:冲突解决的五层机制
并行执行多个 Agent 时,最棘手的问题不是"怎么让它们跑起来",而是"结果不一样怎么办"。
Dream-SaaS 设计了五层冲突解决机制,从轻到重依次为:投票、优先级、仲裁 Agent、回退策略、最终一致性。
4.1 第一层:投票(VOTE)
多个 Agent 输出相同语义的结果时,取多数票。但直接字符串比较会遇到一个问题:LLM 的输出有随机性,同一个答案可能因为多了一个空格、换了一行、大小写不同就被判定为"不同",导致永远无法达成多数共识。
ResultMerger 的 mergeVote 方法通过 normalizeVoteKey 规范化后再计票:
bash
// ResultMerger.java --- 规范化空白后多数票,减少LLM微调导致的"永不共识"
private String mergeVote(List<TaskResult> results) {
Map<String, List<TaskResult>> buckets = new LinkedHashMap<>();
for (TaskResult r : results) {
String key = normalizeVoteKey(r.output()); // 规范化后再分桶
buckets.computeIfAbsent(key, k -> new ArrayList<>()).add(r);
}
Map.Entry<String, List<TaskResult>> winner = buckets.entrySet().stream()
.max(Comparator.comparingInt(e -> e.getValue().size()))
.orElse(null);
if (winner == null || winner.getValue().isEmpty()) return "无共识";
// 返回票数最高的原文(不是规范化后的文本)
return winner.getValue().getFirst().output();
}
// trim + 合并连续空白 + 转小写
static String normalizeVoteKey(String output) {
if (output == null) return "";
return output.trim().replaceAll("\\s+", " ").toLowerCase();
}
规范化做了三件事:trim() 去除首尾空白、replaceAll("\\s+", " ") 将连续空白(包括换行、制表符)合并为单个空格、toLowerCase() 统一小写。这样 "Yes" 和 "yes " 和 "yes\n" 会被归为同一桶。
但返回的是原文而非规范化文本------规范化只用于计票,输出保持原样,避免丢失格式信息。
4.2 第二层:优先级(PRIORITY)
投票假设所有 Agent 地位平等,但现实中不同 Agent 的权威性不同。例如代码审查场景中,Senior Reviewer 的意见权重应该高于 Junior Reviewer。
PRIORITY 策略按 agentNames 白名单顺序取权威结果:
bash
// ResultMerger.java --- 按 agentNames 白名单顺序取权威结果
private String mergePriority(List<TaskResult> results,
List<String> priorityAgentNames) {
if (priorityAgentNames != null && !priorityAgentNames.isEmpty()) {
for (String name : priorityAgentNames) {
if (name == null || name.isBlank()) continue;
for (TaskResult r : results) {
if (name.equals(r.agentName())) {
log.info("[ResultMerger] priority selected: {}", name);
return r.output(); // 按白名单顺序,命中即返回
}
}
}
}
// 白名单中没有任何成功结果,取首个成功
TaskResult top = results.getFirst();
return top.output();
}
这里的优先级不是"分数加权",而是"权威顺序"------白名单中排第一的 Agent 如果成功了,直接取它的结果,不看其他 Agent。只有白名单中的 Agent 全部失败时,才回退到取首个成功结果。这种设计适合"主-备"模式:主 Agent 正常时用主的,主挂了才用备的。
4.3 第三层:仲裁 Agent
投票和优先级都是"机械合并",不理解语义。当结果冲突复杂到无法用规则解决时,引入一个专门的仲裁 Agent,由它阅读所有分歧方的输出,给出最终裁决。
仲裁 Agent 的优势是灵活------它能理解语义、权衡利弊、给出理由。但它有两个硬伤:
- 延迟翻倍:原本 N 个 Agent 并行执行,现在还要等仲裁 Agent 读完 N 份结果再生成裁决,总延迟 = max(子 Agent) + 仲裁 Agent。
- 裁决质量不独立于模型能力:仲裁 Agent 自身同样依赖 LLM 推理,存在产生幻觉或判断偏差的可能。一旦裁决者的输出质量不稳定,正确候选反而可能被"推翻"成错误结论。
Dream-SaaS 用 RoleAgentOutputContract 对角色输出做硬约束,防止执行角色越权做任务分解:
bash
// RoleAgentOutputContract.java --- 防止执行角色越权做任务分解
final class RoleAgentOutputContract {
static final String RULES = """
【输出硬约束】
- 你是执行角色,不是任务规划器。
- 直接给出对本角色职责的结论/话术/清单,使用中文。
- 禁止输出任务拆解 JSON(禁止 id/capabilities/dependencies 数组)。
- 禁止空数组 [];信息不足时说明缺什么,并给出可执行的假设结论。
- 可引用前置任务结果,但不要复述或续写规划稿。
""";
static String withRules(String systemPrompt) {
return (systemPrompt == null ? "" : systemPrompt) + RULES;
}
}
这段 Prompt 约束会拼接到每个执行角色 Agent 的 system prompt 末尾,从输入端防止越权。但 Prompt 约束不是 100% 可靠的------LLM 偶尔会"不听话"。因此还需要输出端的检测器做双重防护。
反直觉观点:仲裁 Agent 不是万能解药。 很多团队一遇到结果冲突就想"加个仲裁 Agent",但仲裁本身引入了新的故障点和延迟。更稳健的做法是:先用规则(投票/优先级)解决 80% 的冲突,剩下 20% 真正需要语义理解的才交给仲裁 Agent,同时对仲裁结果也要做校验------如果仲裁结果与所有候选结果差异过大,应该标记为"低置信度"而非直接采信。
4.4 第四层:回退策略(四档降级)
当所有 Agent 都失败了,或者合并结果不可信时,需要有兜底方案。Dream-SaaS 的 ChatFallbackAdapter 实现了 SingleAgentFallback 接口,提供四档降级:
- A2A 远程调用:正常路径,调用远程专业 Agent。
- InMemory 本地通道:远程不可用时,fallback 到本地同 JVM Agent。
- 单 Agent 兜底 :编排整体失败时,
ChatFallbackAdapter作为通用对话 Agent 直接回答用户问题。它带有对话窗口记忆和 RAG 检索能力,保证"即使编排全挂了,用户也能得到一个回答"。 - 错误提示:连兜底 Agent 也失败时,返回友好的错误信息,而非 500 白屏。
bash
// ChatFallbackAdapter.java --- 通用对话兜底,实现 SingleAgentFallback
@Component
public class ChatFallbackAdapter implements LocalAgentAdapter, SingleAgentFallback {
private static final String SYSTEM_BASE =
"你是业务助手。可参考近期对话与长期记忆(规则/事实)作答;用简洁中文。";
@Override
public String answer(String userRequest) {
return answer(userRequest, "orch-default");
}
@Override
public String answer(String userRequest, String conversationId) {
String sessionId = (conversationId == null || conversationId.isBlank())
? "orch-default" : conversationId.trim();
String system = buildSystem(structuredBean, sessionId);
String answer = chatClientService.sendMessageWithConversation(
system, userRequest == null ? "" : userRequest, sessionId);
return answer == null ? "" : answer;
}
}
降级的核心原则是优雅降级(Graceful Degradation):每一档降级都应该让用户感知到"功能少了但还能用",而不是"系统崩了"。兜底 Agent 的回答质量可能不如专业 Agent,但至少不会让用户面对一个错误页面。
4.5 第五层:最终一致性
有些场景不需要实时一致。例如"生成一份多维度分析报告",三个 Agent 分别从不同角度分析,合并时如果某一方结果还没回来,可以先返回部分结果,缺失部分标注"生成中",后续通过 WebSocket 或轮询补全。
最终一致性不是"不处理冲突",而是按业务容忍度分级:
- 强一致(如支付结果):必须等所有 Agent 成功,否则整体失败。
- 弱一致(如内容推荐):允许部分失败,返回最佳可用结果。
- 最终一致(如报告生成):先返回部分结果,异步补全。
4.6 ResultMerger 四种合并策略总览
| 策略 | 适用场景 | 延迟开销 | 语义理解 | 失败处理 |
|---|---|---|---|---|
| CONCAT | 结果互补,需要全量展示 | 无 | 无 | 失败结果单独列出 |
| VOTE | 同类任务,结果应趋同 | 无 | 无 | 取多数票,忽略少数 |
| PRIORITY | 主备模式,有权威 Agent | 无 | 无 | 白名单全失败取首个 |
| SYNTHESIZE | 需要综合多角色结论 | 低 | 确定性合成 | 失败结果单独列出 |
SYNTHESIZE 策略是"确定性合成"------不调用 LLM,而是按固定模板生成"综合结论 + 分角色详情"。综合结论取每个角色输出的前 160 字作为摘要,分角色详情保留完整输出。这比让 LLM 做合成更可控(不会引入幻觉),但灵活性有限。
4.7 AgentOutputFailureDetector:识别"假成功"
这是一个容易被忽略但极其重要的组件。Agent 返回了一个字符串,HTTP 状态码 200,没有抛异常------但输出内容其实是错误的。Dream-SaaS 称之为"假成功",分两类:
- 传输/模型错误:输出本身是异常堆栈或 HTTP 错误信息,但被 Agent 包装成了"正常返回"。
- 角色输出污染:执行 Agent 没有给出本职结论,而是把任务分解器的 JSON 规划原样吐回来了。
bash
// AgentOutputFailureDetector.java --- 识别"假成功"
final class AgentOutputFailureDetector {
private static final Pattern TASK_PLAN_ARRAY = Pattern.compile(
"(?s)^\\s*\\[\\s*\\{.*\"id\"\\s*:.*\"capabilities\"\\s*:.*\"dependencies\"\\s*:.*}\\s*]\\s*$");
static Optional<String> detectFailure(String output) {
if (looksLikeTransportOrModelFailure(output)) {
return Optional.of(failureMessage(output));
}
if (looksLikeTaskPlanLeak(output)) {
return Optional.of("角色输出疑似任务规划JSON(非本职结论),已标失败");
}
return Optional.empty();
}
// 检测传输错误:Exception/Error 开头、HTTP 4xx/5xx、Spring AI 重试异常
static boolean looksLikeTransportOrModelFailure(String output) {
if (output == null) return false;
String t = output.trim();
if (t.isEmpty()) return false;
if (t.startsWith("Exception:") || t.startsWith("Error:")) return true;
String lower = t.toLowerCase();
if (lower.contains("nontransientaiexception")
|| lower.contains("no response body available")
|| lower.contains("org.springframework.ai.retry")) return true;
return t.length() <= 500 && lower.matches("(?s).*\\bhttp\\s+[45]\\d\\d\\b.*");
}
// 检测任务规划泄漏:数组包含 id/capabilities/dependencies
static boolean looksLikeTaskPlanLeak(String output) {
if (output == null) return false;
String t = output.trim();
if (t.equals("[]")) return true;
// 去掉 markdown 围栏 ```json ... ```
if (t.startsWith("```")) {
int nl = t.indexOf('\n');
int end = t.lastIndexOf("```");
if (nl > 0 && end > nl) t = t.substring(nl + 1, end).trim();
}
if (!t.startsWith("[") || !t.contains("\"capabilities\"")
|| !t.contains("\"dependencies\"")) return false;
if (t.length() > 8000) return false;
return TASK_PLAN_ARRAY.matcher(t)
|| (t.contains("\"id\"") && countTaskLikeObjects(t) >= 2);
}
}
检测器在 ResultMerger 合并前被调用------如果检测到"假成功",该结果会被标记为失败,不参与合并,而是进入失败列表。这和 RoleAgentOutputContract 的 Prompt 约束形成"双重防护":Prompt 约束在输入端预防,检测器在输出端兜底。
为什么需要正则匹配而不是让 LLM 判断?因为检测器运行在合并阶段,如果再调一次 LLM 来判断"这个输出是不是错误",延迟和成本都不可接受。正则和关键词匹配虽然不够智能,但速度快(微秒级)、确定性强,覆盖了高频场景。
五、结语
Agent 间的通信、状态共享和冲突解决,是多 Agent 编排从"能跑"到"跑稳"的关键跨越。几个关键结论:
- 通信选型没有银弹------同 JVM 走内存、跨服务走 A2A、事件通知走总线、可靠投递走队列。HybridAgentChannel 的双通道设计是务实选择,但降级时要防重复执行。
- 状态即契约------共享状态的耦合度高于 API 调用,必须最小化、schema 化、写操作收口、设置 TTL。双索引设计让排障从"大海捞针"变成"精准定位"。
- 冲突解决要分层------投票解决 80% 的机械冲突,优先级处理主备场景,仲裁 Agent 留给真正需要语义理解的少数情况。仲裁 Agent 本身也是故障点,不是万能解药。
- "假成功"比"明确失败"更危险------HTTP 200 不代表结果正确,必须在输出端检测异常内容。Prompt 约束 + 输出检测的双重防护,比任何单一手段都可靠。
下一篇将进入 Dream-SaaS 的真实编排架构------Supervisor + Orchestrator 双层设计,看编排层如何调度 Agent、管理 DAG、处理容错,以及 Supervisor 三层路由策略的具体实现。
下篇预告:《深入理解 AI Agent(七):Supervisor 与 Orchestrator------Dream-SaaS 双层编排架构拆解》
📘 深入理解 AI Agent · 编排篇(中)
