多Agent协作编排引擎:工具链调度与任务分发的后端设计

一、一个Agent能做好的事,三个Agent放在一起未必能做得更好——多Agent协作的工程挑战

单Agent模式(一个LLM + 一组工具函数)在处理确定性任务时表现良好。但当任务复杂度上升——例如"分析这份季度财报,提取关键指标,生成图表,写入飞书文档"——单Agent的上下文窗口被工具描述、中间结果和推理链挤满,输出质量显著下降。

多Agent架构试图通过角色分工解决这个问题:搜索Agent负责信息检索、分析Agent负责数据解读、写作Agent负责输出生成。然而,将多个Agent串在一起只是第一步,真正的工程挑战在于:Agent之间的消息如何可靠传递?任务的依赖关系如何建模?某个Agent超时或失败时如何不拖垮整个编排链?并行执行的Agent的结果如何合并?

本文讨论多Agent协作编排引擎的后端设计,聚焦消息总线、任务编排、容错策略和资源管理四个核心模块。

二、底层机制与原理深度剖析

多Agent编排需要解决的根本问题是:将复杂任务分解为有向无环图(DAG),图中的每个节点是一个Agent任务,边代表依赖关系。编排引擎负责按照依赖顺序调度任务,管理中间结果,处理异常重试,并最终聚合为完整输出。

graph TB
    subgraph Input["任务入口"]
        A[用户提交复杂任务] --> B[任务解析与拆解]
        B --> C[构建任务DAG]
    end

    subgraph Orchestrator["编排引擎核心"]
        C --> D[任务调度器]
        D --> E{就绪任务队列}
        
        E --> F1[Slot 1: Agent执行器]
        E --> F2[Slot 2: Agent执行器]
        E --> F3[Slot N: Agent执行器]
        
        F1 --> G[消息总线]
        F2 --> G
        F3 --> G
        
        G --> H[状态管理器]
        H --> I{任务状态检查}
        I -->|有就绪任务| E
        I -->|全部完成| J[结果聚合器]
        I -->|任务失败| K[容错处理器]
        
        K -->|可重试| E
        K -->|不可恢复| L[任务失败返回]
    end

    subgraph Resources["资源管控"]
        M[配额管理器] --> D
        N[超时控制器] --> F1
        N --> F2
        N --> F3
        O[Token预算跟踪] --> F1
        O --> F2
        O --> F3
    end

    J --> P[最终输出]

核心组件的职责

  • 任务调度器:根据DAG的依赖关系决定任务执行顺序。只有所有前置依赖完成的任务才进入就绪队列。
  • 消息总线:Agent间通信的唯一通道。传递的数据包括:前置任务的输出结果、上下文信息、任务元数据。
  • 状态管理器:维护每个任务的状态(PENDING → RUNNING → SUCCESS/FAILED)和全局编排的状态。
  • 容错处理器:根据任务失败类型决策重试(瞬时错误)、跳过(非关键路径)或终止(核心路径)。
  • 配额管理器:限制并发Agent数量、Token总消耗、单任务最大执行时长。

三、生产级代码实现

3.1 任务DAG与状态机

/**
 * 多Agent任务编排引擎
 * 
 * 核心抽象:
 * - OrchestrationTask: 单个Agent任务节点
 * - TaskDAG: 任务依赖关系的DAG表示
 * - OrchestrationState: 编排全局状态机
 */
public class AgentOrchestrationEngine {

    private final TaskScheduler scheduler;
    private final MessageBus messageBus;
    private final StateManager stateManager;
    private final QuotaManager quotaManager;

    /**
     * 提交编排任务并等待结果
     * 
     * @param dag 包含所有子任务及依赖关系的DAG
     * @param context 全局上下文(用户输入、历史对话等)
     * @param options 编排查配置(超时、重试策略、并发度等)
     * @return 编排执行结果
     */
    public OrchestrationResult execute(
            TaskDAG dag,
            OrchestrationContext context,
            OrchestrationOptions options) {

        String orchestrationId = generateOrchestrationId();

        // 1. 校验DAG合法性(无环、无孤立节点、所有Agent已注册)
        ValidationResult validation = validateDAG(dag);
        if (!validation.isValid()) {
            throw new OrchestrationException("DAG校验失败: " + validation.getErrors());
        }

        // 2. 初始化全局状态
        OrchestrationState state = stateManager.initialize(orchestrationId, dag);

        // 3. 令牌桶检查(全局限流,防止编排并发过高)
        if (!quotaManager.acquireToken(orchestrationId)) {
            throw new OrchestrationException("编排配额已满,请稍后重试");
        }

        try {
            // 4. 主调度循环
            while (!state.isTerminal()) {
                // 4.1 获取所有就绪任务(前置依赖全部完成)
                List<TaskNode> readyTasks = scheduler.getReadyTasks(dag, state);

                if (readyTasks.isEmpty() && state.hasRunningTasks()) {
                    // 有任务在执行中,等待完成通知
                    TaskCompletionEvent event = messageBus.poll(
                            orchestrationId,
                            options.getIdleTimeout());
                    if (event == null) {
                        // 等待超时但仍有任务在执行 → 检查是否全部卡住
                        handleStalledTasks(state);
                        continue;
                    }
                    stateManager.applyCompletion(state, event);
                    continue;
                }

                if (readyTasks.isEmpty() && !state.hasRunningTasks()) {
                    // 无就绪任务且无运行中任务 → 编排结束
                    break;
                }

                // 4.2 提交就绪任务到执行器
                for (TaskNode task : readyTasks) {
                    // 资源检查:是否有空闲执行槽位
                    if (!quotaManager.hasAvailableSlot()) {
                        break; // 等待下次循环
                    }

                    // Token预算检查:该任务预估Token消耗是否在预算内
                    if (!quotaManager.checkTokenBudget(task)) {
                        // 预算不足:如果该任务是关键路径,终止编排
                        if (task.isCritical()) {
                            throw new OrchestrationException(
                                    "Token预算不足,关键任务无法执行: " + task.getId());
                        }
                        // 非关键任务标记为跳过
                        stateManager.skipTask(state, task.getId(),
                                "Token预算不足");
                        continue;
                    }

                    // 构建任务上下文:合并全局上下文 + 前置任务输出
                    TaskContext taskContext = buildTaskContext(
                            context, state, task);

                    // 提交执行
                    CompletableFuture<TaskResult> future = scheduler.submit(
                            task, taskContext, options);

                    future.whenComplete((result, error) -> {
                        if (error != null) {
                            handleTaskError(orchestrationId, task, error, state);
                        } else {
                            // 通过消息总线发布完成事件
                            messageBus.publish(new TaskCompletionEvent(
                                    orchestrationId, task.getId(),
                                    result, TaskStatus.SUCCESS));
                        }
                    });

                    stateManager.markRunning(state, task.getId());
                }
            }

            // 5. 检查是否所有关键任务都已完成
            if (!state.areAllCriticalTasksCompleted()) {
                return OrchestrationResult.partialFailure(
                        state.getFailedTasks(),
                        state.getCompletedTasks());
            }

            // 6. 结果聚合
            return aggregateResults(dag, state);

        } finally {
            quotaManager.releaseToken(orchestrationId);
            stateManager.archive(orchestrationId, state);
        }
    }

    /**
     * 构建任务的执行上下文
     * 
     * 包含:
     * - 全局上下文中与该任务相关的部分
     * - 所有前置依赖任务的输出(通过消息总线获取)
     * - 该任务的专属配置
     */
    private TaskContext buildTaskContext(
            OrchestrationContext globalContext,
            OrchestrationState state,
            TaskNode task) {

        TaskContext.TaskContextBuilder builder = TaskContext.builder()
                .globalInput(globalContext.getUserInput())
                .taskConfig(task.getConfig());

        // 注入依赖任务的输出
        for (String dependencyId : task.getDependencies()) {
            TaskResult depResult = state.getTaskResult(dependencyId);
            if (depResult != null) {
                builder.dependencyOutput(dependencyId, depResult.getOutput());
            }
        }

        return builder.build();
    }

    /**
     * 任务异常处理
     * 
     * 分级策略:
     * - 瞬时错误(超时、速率限制)→ 指数退避重试
     * - 业务错误(Agent返回无效格式)→ 尝试回退策略
     * - 系统错误(Agent不可用)→ 关键任务终止,非关键任务跳过
     */
    private void handleTaskError(
            String orchestrationId,
            TaskNode task,
            Throwable error,
            OrchestrationState state) {

        ErrorCategory category = categorizeError(error);

        switch (category) {
            case TRANSIENT:
                // 瞬时错误:指数退避重试
                if (state.getRetryCount(task.getId()) < task.getMaxRetries()) {
                    long backoffMs = (long) Math.pow(2,
                            state.getRetryCount(task.getId())) * 1000;
                    scheduler.scheduleRetry(task, backoffMs);
                    messageBus.publish(new TaskCompletionEvent(
                            orchestrationId, task.getId(), null,
                            TaskStatus.RETRYING));
                } else {
                    failTask(orchestrationId, task, error, state);
                }
                break;

            case BUSINESS:
                // 业务错误:尝试回退策略(如使用更简单的Prompt)
                TaskNode fallback = task.getFallback();
                if (fallback != null) {
                    scheduler.submit(fallback, task.getContext(), task.getOptions());
                    log.warn("任务回退: {} → {}", task.getId(), fallback.getId());
                } else {
                    failTask(orchestrationId, task, error, state);
                }
                break;

            case FATAL:
                // 致命错误:直接失败
                failTask(orchestrationId, task, error, state);
                break;
        }
    }

    private enum ErrorCategory {
        TRANSIENT,  // 可重试
        BUSINESS,   // 业务逻辑错误
        FATAL       // 不可恢复
    }
}

3.2 消息总线实现

/**
 * Agent间消息总线
 * 
 * 设计原则:
 * 1. 异步解耦:Agent之间不直接通信,全部通过消息总线
 * 2. 可靠投递:消息持久化,支持重放
 * 3. 订阅过滤:Agent仅接收关注的消息类型
 * 4. 背压控制:限制未消费消息的积压量
 * 
 * 实现选择:
 * - 内存实现(ConcurrentLinkedQueue):适合单机、低延迟
 * - Redis Pub/Sub + Stream:适合多实例、需要持久化
 * - Kafka:适合大规模、需要回溯
 * 
 * 以下展示基于Redis Stream的分布式消息总线实现
 */
@Component
public class RedisStreamMessageBus implements MessageBus {

    private final StringRedisTemplate redisTemplate;

    // Stream Key格式: orchestration:{orchestrationId}:events
    private static final String STREAM_PREFIX = "orchestration:";
    private static final String STREAM_SUFFIX = ":events";

    // 消费者组(多实例竞争消费)
    private static final String CONSUMER_GROUP = "agent-orchestrator";

    /**
     * 发布任务完成事件
     */
    @Override
    public void publish(TaskCompletionEvent event) {
        String streamKey = getStreamKey(event.getOrchestrationId());

        Map<String, String> messageBody = Map.of(
                "taskId", event.getTaskId(),
                "status", event.getStatus().name(),
                "result", serializeResult(event.getResult()),
                "timestamp", String.valueOf(System.currentTimeMillis()),
                "errorMessage", event.getErrorMessage() != null
                        ? event.getErrorMessage() : ""
        );

        // 使用Redis Stream,消息自动持久化
        StringRecord record = StreamRecords.string(messageBody)
                .withStreamKey(streamKey);

        RecordId recordId = redisTemplate.opsForStream().add(record);

        // 设置Stream的MAXLEN防止无限增长
        redisTemplate.opsForStream().trim(streamKey,
                StreamTrimConfiguration.maxLen(10000));
    }

    /**
     * 阻塞等待事件
     * 
     * 使用消费者组 + XREADGROUP,支持:
     * - 多实例竞争消费(同组内消息仅投递一次)
     * - 消息确认(XACK)防止丢失
     * - 未确认消息的重新投递(XPENDING + XCLAIM)
     */
    @Override
    public TaskCompletionEvent poll(String orchestrationId, Duration timeout) {
        String streamKey = getStreamKey(orchestrationId);

        // 确保消费者组存在
        try {
            redisTemplate.opsForStream().createGroup(streamKey, CONSUMER_GROUP);
        } catch (RedisSystemException e) {
            // 消费者组已存在,忽略
        }

        // 阻塞读取,超时返回null
        List<MapRecord<String, Object, Object>> records = redisTemplate
                .opsForStream()
                .read(Consumer.from(CONSUMER_GROUP, getConsumerName()),
                        StreamReadOptions.empty()
                                .count(1)
                                .block(Objects.requireNonNull(timeout)),
                        StreamOffset.create(streamKey, ReadOffset.lastConsumed()));

        if (records == null || records.isEmpty()) {
            // 超时:检查是否有未确认的挂起消息
            return checkPendingMessages(streamKey);
        }

        MapRecord<String, Object, Object> record = records.get(0);
        Map<Object, Object> value = record.getValue();

        TaskCompletionEvent event = deserializeEvent(value);

        // 确认消息已处理
        redisTemplate.opsForStream()
                .acknowledge(streamKey, CONSUMER_GROUP, record.getId());

        return event;
    }

    /**
     * 检查并认领挂起的未确认消息
     * 
     * 场景:某个消费者实例在处理消息时崩溃,消息停留在PENDING状态。
     * 其他实例通过此方法认领(XCLAIM)超过一定时间的未确认消息。
     */
    private TaskCompletionEvent checkPendingMessages(String streamKey) {
        // 获取PENDING列表中最老的消息
        PendingMessagesSummary pending = redisTemplate.opsForStream()
                .pending(streamKey, CONSUMER_GROUP,
                        Range.unbounded(), 10L);

        if (pending.getTotalPendingMessages() == 0) {
            return null;
        }

        // 认领超过60秒未确认的消息
        PendingMessage oldestPending = redisTemplate.opsForStream()
                .pending(streamKey, CONSUMER_GROUP,
                        Range.unbounded(), 1L).get(0);

        if (oldestPending.getElapsedSinceLastDeliveryMs() > 60_000) {
            List<MapRecord<String, Object, Object>> claimed = redisTemplate
                    .opsForStream()
                    .claim(streamKey, CONSUMER_GROUP, getConsumerName(),
                            Duration.ofSeconds(60),
                            RecordId.of(oldestPending.getIdAsString()));

            if (!claimed.isEmpty()) {
                return deserializeEvent(claimed.get(0).getValue());
            }
        }

        return null;
    }

    private String getStreamKey(String orchestrationId) {
        return STREAM_PREFIX + orchestrationId + STREAM_SUFFIX;
    }

    private String getConsumerName() {
        // 每个实例使用唯一消费者名
        return HostNameUtil.getHostName() + "-" + Thread.currentThread().getId();
    }
}

3.3 配额与资源管理

/**
 * Agent编排资源配额管理器
 * 
 * 三层限制:
 * L1 - 并发编排数:同时运行的编排实例上限
 * L2 - 单编排并发任务数:一个编排内同时执行的Agent数量上限
 * L3 - Token预算:单次编排的Token消耗上限
 */
@Component
public class AgentQuotaManager {

    // L1: 全局信号量,限制并发编排数
    private final Semaphore globalOrchestrationSemaphore;

    // L2: 单编排的任务并发槽位
    private static final int MAX_TASK_SLOTS_PER_ORCHESTRATION = 5;

    // L3: Token预算管理
    private final TokenBudgetTracker tokenBudgetTracker;

    public AgentQuotaManager(@Value("${agent.max-concurrent-orchestrations:10}") int maxOrch) {
        this.globalOrchestrationSemaphore = new Semaphore(maxOrch);
        this.tokenBudgetTracker = new TokenBudgetTracker();
    }

    /**
     * 获取编排执行令牌
     * 
     * 如果全局并发已满,调用方会阻塞等待(有超时机制)。
     * 推荐在调用时包装超时逻辑:
     * if (!semaphore.tryAcquire(30, TimeUnit.SECONDS)) { throw quotaExceeded; }
     */
    public boolean acquireToken(String orchestrationId) {
        try {
            return globalOrchestrationSemaphore.tryAcquire(30, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        }
    }

    public void releaseToken(String orchestrationId) {
        globalOrchestrationSemaphore.release();
        tokenBudgetTracker.clearBudget(orchestrationId);
    }

    /**
     * 检查是否有空闲任务槽位
     */
    public boolean hasAvailableSlot() {
        // 当前运行的编排 × 每编排最大槽位数
        int usedSlots = countRunningTasks();
        int maxSlots = MAX_TASK_SLOTS_PER_ORCHESTRATION 
                * (10 - globalOrchestrationSemaphore.availablePermits());
        return usedSlots < maxSlots;
    }

    /**
     * 检查Token预算
     * 
     * 预算计算:单次编排基础预算 + 按任务权重分配
     * 
     * 设计考量:
     * - 基础预算:2000 Token(系统Prompt + 编排指令)
     * - 任务预算:根据任务的estimatedTokens分配
     * - 预留缓冲:总预算的10%作为合并、重试的缓冲
     */
    public boolean checkTokenBudget(TaskNode task) {
        String orchestrationId = task.getOrchestrationId();
        long currentUsage = tokenBudgetTracker.getUsage(orchestrationId);
        long budget = tokenBudgetTracker.getBudget(orchestrationId);
        long estimatedCost = task.getEstimatedTokens();

        // 保留10%缓冲用于结果合并
        long availableWithBuffer = (long) (budget * 0.9) - currentUsage;

        return estimatedCost <= availableWithBuffer;
    }

    private int countRunningTasks() {
        // 从状态管理器获取当前运行中任务总数
        return 0; // 实际实现从StateManager聚合
    }
}

四、边界分析与架构权衡

1. 编排的粒度——什么时候应该用多Agent?

不是所有任务都适合多Agent。判断标准:任务的子步骤之间是否有明确的依赖关系且各步骤的计算特征差异显著?例如"搜索→分析→写作"的三个步骤,各自需要不同的工具集和Prompt策略,拆分为独立Agent有收益。而"读取文件A + 读取文件B + 对比内容"这类同质化步骤,单Agent内的工具调用链完全足够。每增加一个Agent都会引入通信开销和失败概率。

2. 同步编排 vs 异步编排

同步编排(用户等待完整结果)适合延迟容忍度在30秒内的场景。异步编排(提交任务、轮询结果或回调通知)适合耗时可能数分钟到数十分钟的复杂任务。设计上建议编排引擎本身是异步的(内部全部使用消息驱动),对外暴露同步和异步两种接口——同步接口只是异步编排 + 阻塞等待的语法糖。

3. 结果聚合的冲突解决

当多个并行Agent都输出相似结论时(如两个分析Agent都得出了"Q2收入增长15%"),结果聚合简单——取置信度更高的那个。当输出相互矛盾时(一个说增长15%,另一个说下降3%),需要冲突解决策略。简单策略是让第三个"裁决Agent"基于证据链判断;复杂策略需要追溯到各Agent的输入数据,判断数据源差异是否导致了结论差异。无论哪种策略,编排引擎应保留完整的中间结果链(provenance),支持人工审查。

4. Agent间的循环依赖

DAG验证应检测并拒绝循环依赖。但在实际场景中,可能出现合法的"迭代"需求:写作Agent写完→审查Agent发现问题→写作Agent修改→审查Agent再检查。这种迭代应建模为有限次数的循环而非无限递归:在DAG中显式定义最大迭代次数(如2轮),每次迭代创建新的任务节点。

五、总结

多Agent编排引擎的本质是将LLM从"单一推理器"提升为"分布式推理系统"的协调层。在工程落地过程中,三个维度值得优先投入:

第一,可靠的消息传递。Agent间的消息丢失或重复不应影响编排的最终正确性。采用消息确认机制(XACK)和幂等消费(基于消息ID去重)是基本保障。

第二,精细的超时控制。每个Agent任务都需要独立的超时设置——搜索Agent的超时可能是5秒,推理Agent可能是30秒。全局统一超时必然导致某个环节过早(或过晚)终止。

第三,可观测性。一次多Agent编排可能涉及数十次LLM调用和工具调用,没有完整的调用链追踪(类似分布式链路追踪),排查"最终结果为什么不对"几乎不可能。建议为每个编排生成唯一的TraceID,贯穿所有子任务。

更多推荐