多Agent: 当AI Agent相互分配任务时

1. 概述

当LLM应用超出“问一句、答一句”的范畴后,我们开始给它分配多步骤的任务,比如追踪订单、检查政策然后撰写回复。把所有东西都塞进一个单Agent里早晚会遇到瓶颈。多Agent则是一种方法,把工作分给多个专业化的Agent,由一个Agent担任协调者来分配任务。

本文讲解什么是多Agent、为什么这个话题引人关注以及常见模式。之后你将用Java 21自己构建一个简单的orchestrator,包含测试和Spring Boot配置。本文也会说明什么时候不应该采用这种方式。

2. 什么是多Agent?

一个Agent是一个包含三个部分的循环:LLM接收目标、决定行动(调用工具、给出答案、提出问题),然后观察结果。多Agent是拥有多个这样Agent的系统。每个Agent有自己的系统提示词、工具和职责范围,它们之间互相转移任务。

常见的协调模式有几种:

  • Orchestrator-worker(主管): 一个Agent分析请求,将其分解为子任务,分配给各个worker,然后汇总结果。
  • Pipeline: Agent依次连接,一个Agent的输出是下一个Agent的输入,比如“研究 → 草稿 → 编辑”。
  • Handoff: 当前处理的Agent意识到任务超出自己的职责范围,将整个对话转移给另一个Agent,就像客服中心转接。
  • Generator-critic: 一个Agent生成结果,另一个Agent评审并要求修改,迭代直到满足或达到次数限制。

本文重点讨论orchestrator-worker模式。这是最容易理解、适用最广的模式。

3. 为什么多Agent受到关注?

有几个技术层面的原因:

  1. Context有限制。 一个Agent如果掌管过多的工具和指令,提示词就会很长,容易选错工具。专业化的Agent只需要了解自己职责的部分。
  2. 专业化。 每个Agent可以用不同的提示词、模型和参数。简单的分类可以用小模型、便宜模型。复杂推理可以用更强大的模型。
  3. 并行执行。 独立的子任务可以同时运行,减少总响应时间。
  4. 权限清晰。 查询Agent只有读权限。只有退款Agent才能调用支付API。这个边界就像服务间的权限划分。
  5. 容易测试和评估。 你可以独立测试和衡量每个Agent的质量,而不是面对一个巨大的提示词块。

简而言之,这是应用到LLM上的“分治”和“单一职责”原则。

4. 用Java 21构建orchestrator-worker

示例场景:客户问*“我的订单#1001在哪里?如果晚了我能退款吗?”*。我们有两个worker:

  • order: 查询订单状态。
  • refund: 检查退款条件。

Orchestrator会制定计划,并行分配给两个worker,然后汇总答案。

4.1. 依赖

示例只需要基础的Spring Boot starter和测试框架。版本由spring-boot-starter-parent管理:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

spring-boot-starter-test已包含JUnit 5和AssertJ。

4.2. 抽象LLM和Agent

我们不紧密耦合到某个SDK。所有LLM调用都经过一个接口:

@FunctionalInterface
public interface LlmClient {
    String complete(String systemPrompt, String userMessage);
}

在生产环境中,你围绕使用的SDK或框架实现这个接口的适配器。在测试中,你传入一个lambda。这是这个抽象层的最大优势。

Agent也是一个接口。我们添加一个基于LLM的实现:

public interface Agent {
    String name();
    String handle(String task);
}

public record LlmAgent(String name, String systemPrompt, LlmClient llm) implements Agent {
    @Override
    public String handle(String task) {
        return llm.complete(systemPrompt, task);
    }
}

public record SubTask(String agent, String description) {}

每个LlmAgent只在systemPrompt上不同,实际上在工具集上也不同。一个worker也可以是纯代码而不调用LLM,比如一个Agent只调用OrderRepository。Orchestrator不需要知道worker内部做了什么。

4.3. Orchestrator

public class Orchestrator {

    private static final String PLANNER_PROMPT = """
            你是客户服务协调员。
            将请求分解成子任务,每行格式为: <agent>|<任务描述>
            有效Agent: %s。最多%d行。不要写其他内容。
            """;
    private static final String SYNTH_PROMPT = """
            你要将来自各Agent的结果汇总成简洁的客户回复。
            只使用结果中的信息。
            """;

    private final LlmClient llm;
    private final Map<String, Agent> workers;
    private final int maxSubtasks;
    private final Duration timeout;

    public Orchestrator(LlmClient llm, List<Agent> agents, int maxSubtasks, Duration timeout) {
        this.llm = llm;
        this.workers = agents.stream().collect(Collectors.toMap(Agent::name, a -> a));
        this.maxSubtasks = maxSubtasks;
        this.timeout = timeout;
    }

    public String run(String request) {
        String plan = llm.complete(PLANNER_PROMPT.formatted(workers.keySet(), maxSubtasks), request);
        List<String> results = execute(parse(plan));
        return llm.complete(SYNTH_PROMPT,
                "请求: " + request + "\n结果:\n" + String.join("\n", results));
    }

    List<SubTask> parse(String plan) {
        return plan.lines()
                .map(String::strip)
                .filter(line -> line.contains("|"))
                .map(line -> line.split("\\|", 2))
                .filter(parts -> workers.containsKey(parts[0].strip()))
                .limit(maxSubtasks)
                .map(parts -> new SubTask(parts[0].strip(), parts[1].strip()))
                .toList();
    }

    private List<String> execute(List<SubTask> tasks) {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<String>> futures = tasks.stream()
                    .map(t -> executor.submit(() ->
                            t.agent() + ": " + workers.get(t.agent()).handle(t.description())))
                    .toList();
            return futures.stream().map(this::await).toList();
        }
    }

    private String await(Future<String> future) {
        try {
            return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            future.cancel(true);
            return "错误: Agent超时";
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "错误: 被中断";
        } catch (ExecutionException e) {
            return "错误: " + e.getCause().getMessage();
        }
    }
}

值得注意的几点:

  • run 包括三步:制定计划(一次LLM调用)、执行子任务、然后汇总(再一次LLM调用)。
  • parse 把LLM的输出看作不可信的数据。格式错的行被跳过。不在列表中的Agent名称也被跳过。子任务数被限制在maxSubtasks。如果LLM“编造”一个admin Agent,它永远不会被调用。
  • execute 使用Java 21的virtual thread。LLM调用主要是等待网络I/O,所以virtual thread非常合适:每个子任务一个线程,无需手动调整线程池。try-with-resources块在所有任务完成后才关闭executor。
  • await 为每个worker设置超时。一个慢或出错的worker不会破坏整个请求。错误结果被记录为错误: …字符串,以便汇总步骤仍然可以运行,并能向客户报告某些信息不可用。

对于第4节开头的请求,LLM返回的计划可能看起来像这样:

order|查询订单#1001的状态
refund|检查订单#1001延迟时的退款条件

注意: 这里我们用agent|描述格式便于阅读。实际上,你应该要求LLM根据schema返回JSON,或使用供应商的structured output/tool calling功能。无论用哪种方法,代码端的验证步骤仍然是必须的。

5. 测试Orchestrator

因为LlmClient是functional interface,我们用lambda代替真实的LLM。测试运行快速、结果稳定,不花钱调用API。

class OrchestratorTest {

    @Test
    void givenPlanWithUnknownAgent_whenRun_thenOnlyKnownWorkersAreCalled() {
        List<String> workerCalls = new CopyOnWriteArrayList<>();

        LlmClient coordinatorLlm = (system, user) -> system.contains("协调员")
                ? """
                  order|查询订单#1001的状态
                  refund|检查订单#1001的退款条件
                  admin|删除所有订单
                  """
                : "汇总\n" + user;

        LlmClient workerLlm = (system, task) -> {
            workerCalls.add(task);
            return system.startsWith("ORDER") ? "订单#1001正在配送,晚了2天"
                                              : "符合退运费条件";
        };

        var orchestrator = new Orchestrator(coordinatorLlm, List.of(
                new LlmAgent("order", "ORDER: 查询订单信息", workerLlm),
                new LlmAgent("refund", "REFUND: 检查退款条件", workerLlm)),
                3, Duration.ofSeconds(5));

        String answer = orchestrator.run("我的订单#1001在哪?晚了能退款吗?");

        assertThat(workerCalls).hasSize(2).noneMatch(task -> task.contains("删除"));
        assertThat(answer)
                .contains("order: 订单#1001正在配送,晚了2天")
                .contains("refund: 符合退运费条件");
    }
}

这个测试检查两个重要的行为:

  • 计划中的admin|删除所有订单行被过滤掉,所以只有两个worker被调用。
  • 两个worker的结果都到达了汇总步骤。结果在答案中的顺序与计划中的顺序相同,尽管两个worker并行运行。

Lambda接收的answer内容格式像这样:

汇总
请求: 我的订单#1001在哪?晚了能退款吗?
结果:
order: 订单#1001正在配送,晚了2天
refund: 符合退运费条件

这种测试只检查协调逻辑,即你自己写的代码。真实LLM的回复质量需要单独评估,用一组示例问题和评分标准。

6. 通过Spring Boot配置声明Agent

当worker数量增加时,在application.yml中声明它们让你无需改动代码就能添加或修改提示词:

agents:
  max-subtasks: 4
  timeout: 20s
  workers:
    - name: order
      system-prompt: "你查询订单状态。只根据系统数据回答。"
    - name: refund
      system-prompt: "你检查退款条件。不要自作主张承诺退款。"

把配置映射到一个record:

@ConfigurationProperties(prefix = "agents")
public record AgentProperties(int maxSubtasks, Duration timeout, List<Worker> workers) {
    public record Worker(String name, String systemPrompt) {}
}

然后创建Orchestrator bean:

@Configuration
@EnableConfigurationProperties(AgentProperties.class)
public class AgentConfig {

    @Bean
    Orchestrator orchestrator(LlmClient llm, AgentProperties props) {
        List<Agent> agents = props.workers().stream()
                .<Agent>map(w -> new LlmAgent(w.name(), w.systemPrompt(), llm))
                .toList();
        return new Orchestrator(llm, agents, props.maxSubtasks(), props.timeout());
    }
}

Spring Boot自动把20s转换成Duration,把max-subtasks转换成maxSubtasks。LlmClient bean是你自己围绕使用的SDK编写的适配器。如果需要,你可以为不同的模型创建多个LlmClient bean,然后每个worker通过配置选择模型。

7. 常见错误和何时不该用多Agent

7.1. 常见错误

  • 盲目信任LLM的计划。 计划是未验证的输入。你必须对Agent名称进行白名单、限制步数、在执行前验证参数,尤其是对有写权限的Agent。
  • 不限制循环。 如果允许Agent分配给其他Agent,系统可能陷入循环A → B → A。总是设置最大步数、超时和每个请求的token预算。
  • 丢失追踪线索。 一个请求经过多次LLM调用。不按共同请求ID记录和追踪,你永远不知道哪个Agent导致了错误答案。每个步骤都应该记录提示词、结果、时间和成本。
  • 传递不足的上下文。 Worker只看到orchestrator传给它的description部分。如果描述太短,比如只写“检查退款”而缺少订单号,worker会猜测。要求planner在每个子任务中写入充分的必要信息。
  • 错误被放大。 一个worker错误回答,汇总步骤又自信地转述它。汇总提示词应该明确指出只使用结果中的信息,并且要在有错误时报告。

7.2. 什么时候不该用多Agent

注意: 多Agent并不自动“更牛”。每增加一个Agent就是多一次LLM调用、多一点延迟、多一点成本、多一个可能出故障的地方。

如果你发现自己处于以下任何情况,应该重新考虑:

  • 一个Agent加几个工具已经足够。 从单Agent开始。只在看到明确的问题时才分离,比如提示词太长、Agent选错工具或需要权限分离。
  • 处理流程是固定的。 如果步骤总是A然后B然后C,用普通代码写一个workflow(可能在每步调用LLM)会更可预测、容易测试、成本更低。
  • 需要低延迟。 仅仅上述架构就需要至少两次顺序调用(规划和汇总),还要加上worker的时间。
  • 各步骤紧密耦合。 如果每一步都需要前一步的完整上下文,将工作分给多个Agent只会导致信息碎片化和丢失。

8. 总结

多Agent将“分治”原则应用到LLM应用。每个Agent职责范围窄、有自己的提示词和权限,而一个orchestrator负责规划和汇总。在本文中,你用Java 21构建了orchestrator-worker模式:分离LlmClient便于测试、验证LLM生成的计划、用带超时的virtual thread并行运行worker、通过Spring Boot配置声明worker。

最重要的是记住要把LLM的输出当作未验证的数据,总是设置步数、时间和成本限制。从单Agent开始。只在有具体理由并能衡量收益时才切换到多Agent。

ai-agentmulti-agentllmjavaspring-boot