多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受到关注?
有几个技术层面的原因:
- Context有限制。 一个Agent如果掌管过多的工具和指令,提示词就会很长,容易选错工具。专业化的Agent只需要了解自己职责的部分。
- 专业化。 每个Agent可以用不同的提示词、模型和参数。简单的分类可以用小模型、便宜模型。复杂推理可以用更强大的模型。
- 并行执行。 独立的子任务可以同时运行,减少总响应时间。
- 权限清晰。 查询Agent只有读权限。只有退款Agent才能调用支付API。这个边界就像服务间的权限划分。
- 容易测试和评估。 你可以独立测试和衡量每个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“编造”一个adminAgent,它永远不会被调用。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。