LangGraph4j 多Agent编排详解:为什么这么做,怎么做
引言
在多Agent系统中,如何协调多个AI Agent协同完成复杂任务是一个核心挑战。本文深入解析基于LangGraph4j的多Agent编排系统,重点阐述为什么选择这种架构 以及如何具体实现。
一、为什么选择LangGraph4j?
1.1 多Agent编排的核心挑战
在构建多Agent系统时,我们面临以下核心问题:
- 任务依赖管理:任务A的输出是任务B的输入,如何确保执行顺序?
- 并行执行优化:无依赖的任务如何并行执行以提高效率?
- 状态共享:多个Agent如何共享中间结果和上下文?
- 错误处理:某个Agent失败时,如何不影响其他Agent?
- 流程可视化:如何清晰地表达复杂的执行流程?
1.2 LangGraph4j的解决方案
LangGraph4j基于**状态图(StateGraph)**的概念,完美解决了上述问题:
- 图结构:用节点(Node)表示Agent,用边(Edge)表示依赖关系,直观清晰
- 状态管理 :通过
WorkspaceState统一管理所有中间数据,实现Agent间的数据传递 - 自动调度:框架自动根据依赖关系调度执行,支持并行和串行混合
- 错误隔离:每个节点独立执行,失败不影响其他节点
这就是为什么我们选择LangGraph4j:它提供了声明式的编排方式,让我们专注于业务逻辑而非执行细节。
二、为什么采用Supervisor-Worker模式?
2.1 设计动机
传统的多Agent系统往往存在以下问题:
- 硬编码流程:任务流程写死在代码中,无法动态调整
- 缺乏智能规划:无法根据任务特点智能分配Agent
- 扩展性差:新增Agent需要修改代码
2.2 Supervisor-Worker模式的优势
我们采用Supervisor-Worker模式,核心思想是:
Supervisor(主管Agent)
↓ 分析用户请求,生成任务计划
TaskPlan(任务计划)
↓ 包含任务列表和依赖关系
Workers(工作者Agent)
↓ 执行具体任务
结果汇总
为什么这样设计?
- 动态规划:Supervisor根据用户请求和可用Agent动态生成计划,无需硬编码
- 职责分离:Supervisor负责"思考"(规划),Worker负责"执行"(干活)
- 易于扩展:新增Worker只需注册,Supervisor会自动识别并使用
- 智能调度:Supervisor了解每个Worker的能力,可以做出最优分配
2.3 具体实现:Orchestrator类
java
@Slf4j
public class Orchestrator {
private final ServiceNode supervisorNode; // 主管节点
private final List<ServiceNode> workerNodes; // 工作者节点列表
/**
* 执行流程:规划 → 构建图 → 执行
*/
public void execute(String userRequest, UserContext userContext) {
// Step 1: Supervisor生成任务计划
TaskPlan taskPlan = plan(userRequest, userContext);
// Step 2: 根据计划构建执行图
CompiledGraph<WorkspaceState> graph = buildGraph(taskPlan, userContext);
// Step 3: 执行图
graph.invoke(init);
}
}
为什么分三步?
- plan():让Supervisor理解需求并规划,这是"思考"阶段
- buildGraph():将计划转换为可执行的图结构,这是"准备"阶段
- invoke():实际执行,这是"行动"阶段
这种分离使得每个阶段职责清晰,易于测试和维护。
三、如何实现任务规划?
3.1 为什么需要任务规划?
用户请求往往是模糊的,比如"帮我写一份市场分析报告"。Supervisor需要:
- 理解需求:分析用户真正想要什么
- 任务分解:将复杂任务拆解为可执行的子任务
- 分配Agent:根据任务特点选择合适的Worker
- 建立依赖:确定任务间的执行顺序
3.2 如何实现:plan()方法详解
java
public TaskPlan plan(String userRequest, UserContext userContext) {
// 1. 构建规划提示词
// 为什么需要这些信息?
// - userRequest: 用户的具体需求
// - subAgents: 可用的Worker列表及其能力,让Supervisor知道"谁能做什么"
// - chatHistory: 历史对话,提供上下文记忆
String planningPrompt = """
{
"userRequest": "%s",
"subAgents": %s,
"chatHistory": %s
}
""".formatted(userRequest, subWorkersDesc(), historyContext);
// 2. 调用Supervisor AI生成计划
// 为什么用流式调用?
// - 可以实时反馈规划进度
// - 支持长文本生成
TokenStream chat = supAi.chat(planningPrompt);
// 3. 等待完整响应
CountDownLatch latch = new CountDownLatch(1);
StringBuilder builder = new StringBuilder();
chat.onCompleteResponse(e -> {
builder.append(e.aiMessage().text());
latch.countDown();
});
chat.start();
latch.await();
// 4. 解析JSON为TaskPlan对象
// 为什么返回JSON?
// - 结构化数据,易于解析
// - Supervisor可以明确指定任务ID、依赖关系等
String response = builder.toString();
TaskPlan taskPlan = JSON.parseObject(response, TaskPlan.class);
return taskPlan;
}
3.3 TaskPlan的结构设计
java
@Data
public class TaskPlan {
private String summary; // 计划摘要:为什么需要?让用户知道Supervisor理解了什么
private Integer totalTasks; // 总任务数:为什么需要?用于进度跟踪
private List<Task> tasks; // 任务列表:核心数据
}
@Data
public class Task {
private String id; // 任务ID:为什么需要?用于依赖关系标识
private String title; // 任务标题:为什么需要?便于理解和调试
private String description; // 任务描述:为什么需要?Worker需要知道具体做什么
private String agentId; // 负责的Agent ID:为什么需要?指定执行者
private TaskInputs inputs; // 输入配置:为什么需要?定义数据来源
private ExecutionStrategy executionStrategy; // 执行策略:为什么需要?控制输出方式
}
public static class TaskInputs {
private Boolean fromUser; // 是否来自用户:为什么需要?区分数据来源
private List<String> fromTask; // 依赖的任务ID列表:为什么需要?定义执行顺序
}
设计要点:
fromTask列表定义了依赖关系,这是构建执行图的关键agentId指定执行者,确保任务分配给正确的WorkerexecutionStrategy控制是否流式输出,平衡用户体验和性能
四、如何构建执行图?
4.1 为什么需要GraphBuilder?
TaskPlan只是数据结构,需要转换为可执行的图。GraphBuilder的作用是:
- 创建节点:为每个任务创建执行节点
- 建立边:根据依赖关系连接节点
- 处理入口出口:连接START和END节点
4.2 如何实现:GraphBuilder.build()详解
java
public static CompiledGraph<WorkspaceState> build(
TaskPlan taskPlan,
Map<String, ServiceNode> workersByID,
UserContext userContext
) throws GraphStateException {
// ========== 第一步:创建状态图 ==========
// 为什么需要状态图?
// - 状态图是LangGraph4j的核心抽象
// - WorkspaceState::new 是状态工厂方法,用于创建初始状态
StateGraph<WorkspaceState> graph = new StateGraph<>(WorkspaceState::new);
// ========== 第二步:为每个任务创建节点 ==========
// 为什么需要节点?
// - 节点是图的执行单元,每个节点对应一个Agent任务
for (Task task : taskPlan.getTasks()) {
// 1. 创建执行器:为什么需要执行器?
// - 执行器封装了具体的执行逻辑(调用AI、处理结果等)
// - ExecutorFactory根据策略创建不同类型的执行器(静默/流式)
TaskExecutor executor = ExecutorFactory.create(
task,
workersByID.get(task.getAgentId()), // 根据agentId找到对应的ServiceNode
userContext
);
// 2. 添加节点到图:为什么用异步执行?
// - CompletableFuture.supplyAsync()实现真正的异步执行
// - 无依赖的任务可以并行执行,提高效率
// - executorService是线程池,管理线程资源
graph.addNode(task.getId(), state -> {
return CompletableFuture.supplyAsync(() -> {
try {
log.debug("异步执行节点: taskId={}, thread={}",
task.getId(), Thread.currentThread().getName());
// executor.apply(state)执行任务,返回更新后的状态
return executor.apply(state);
} catch (Exception e) {
log.error("节点执行异常: taskId={}", task.getId(), e);
throw new RuntimeException("节点执行失败: taskId=" + task.getId(), e);
}
}, executorService);
});
}
// ========== 第三步:建立依赖关系(添加边)==========
// 为什么需要边?
// - 边定义了节点间的依赖关系和执行顺序
// - LangGraph4j会根据边自动调度执行
Map<String, Task.TaskInputs> inputMap = taskPlan.getTasks().stream()
.collect(Collectors.toMap(Task::getId, Task::getInputs));
Set<String> allTargets = new HashSet<>();
for (Map.Entry<String, Task.TaskInputs> entry : inputMap.entrySet()) {
String taskId = entry.getKey();
Task.TaskInputs inputs = entry.getValue();
// 如果任务有前置依赖,添加边
if (inputs != null && inputs.getFromTask() != null) {
for (String fromTask : inputs.getFromTask()) {
// 添加边:fromTask → taskId
// 这意味着taskId必须等待fromTask完成后才能执行
graph.addEdge(fromTask, taskId);
allTargets.add(taskId); // 记录有前置依赖的节点
}
}
}
// ========== 第四步:连接START和END ==========
// 为什么需要START和END?
// - START是图的入口,END是图的出口
// - LangGraph4j需要知道从哪里开始,到哪里结束
// 找到根节点(无前置依赖的节点)
Set<String> allNodes = inputMap.keySet();
List<String> rootNodes = allNodes.stream()
.filter(node -> !allTargets.contains(node)) // 不在allTargets中的节点就是根节点
.toList();
// 为什么可能有多个根节点?
// - 多个独立的任务可以同时开始
// - 例如:任务A和任务B都只依赖用户输入,可以并行执行
if (rootNodes.isEmpty()) {
throw new GraphStateException("任务图中没有找到入口任务");
}
// 连接START到所有根节点
for (String root : rootNodes) {
graph.addEdge(StateGraph.START, root);
}
// 找到叶子节点(无后续节点的节点)
Set<String> nodesWithTargets = new HashSet<>();
for (Task task : taskPlan.getTasks()) {
if (task.getInputs() != null && task.getInputs().getFromTask() != null) {
nodesWithTargets.addAll(task.getInputs().getFromTask());
}
}
List<String> leafNodes = allNodes.stream()
.filter(node -> !nodesWithTargets.contains(node))
.toList();
// 连接所有叶子节点到END
for (String leaf : leafNodes) {
graph.addEdge(leaf, StateGraph.END);
}
// ========== 第五步:编译图 ==========
// 为什么需要编译?
// - 编译会优化图结构,检查错误
// - 编译后的图才能执行
return graph.compile();
}
4.3 执行图示例
假设Supervisor生成了以下计划:
- Task1(Researcher):检索资料,依赖用户输入
- Task2(Analyst):分析数据,依赖Task1
- Task3(Writer):写报告,依赖Task2
构建的图结构:
sql
START → Task1 → Task2 → Task3 → END
如果Task1和Task4都只依赖用户输入:
sql
START → Task1 ─┐
→ Task4 ─┼→ Task5 → END
这样Task1和Task4可以并行执行。
五、如何执行任务?
5.1 为什么需要TaskExecutor?
不同的任务有不同的执行需求:
- 静默任务:后台处理,结果只保存到state,不向用户输出
- 流式任务:实时向用户输出,提供更好的交互体验
5.2 如何实现:执行器模式
java
// 执行器接口:为什么需要接口?
// - 统一执行规范
// - 支持多种实现(静默/流式)
public interface TaskExecutor extends NodeAction<WorkspaceState> {
Task getTask();
ServiceNode getServiceNode();
}
// 工厂方法:为什么用工厂模式?
// - 根据策略动态创建执行器
// - 隐藏创建细节
public static TaskExecutor create(Task task, ServiceNode serviceNode, UserContext userContext) {
ExecutionStrategy strategy = task.getExecutionStrategy();
return switch (strategy) {
case SILENT -> new SilentExecutor(task, serviceNode, userContext);
case STREAMING -> new StreamingExecutor(task, serviceNode, userContext);
default -> new SilentExecutor(task, serviceNode, userContext);
};
}
5.3 SilentExecutor实现详解
java
public class SilentExecutor extends BaseTaskExecutor {
@Override
public Map<String, Object> apply(WorkspaceState state) {
// 1. 发送任务开始事件:为什么需要?
// - 让用户知道任务进度
// - 提供可观测性
userContext.emit(TaskStatusEvent.builder()
.type(EventType.TASK_START)
.taskId(task.getId())
.build());
// 2. 获取任务输入:为什么需要?
// - 从state中获取用户输入和依赖任务的结果
// - 组装成完整的输入数据
Map<String, Object> taskInput = getTaskInput(state);
// 3. 调用AI服务:为什么用流式调用?
// - 即使不向用户输出,流式调用也能提供更好的错误处理
// - 可以实时监控AI响应
TokenStream tokenStream = aiService.chat(Json.toJson(taskInput));
// 4. 等待完成并收集结果
StringBuilder resultBuilder = new StringBuilder();
CountDownLatch latch = new CountDownLatch(1);
tokenStream.onCompleteResponse(e -> {
resultBuilder.append(e.aiMessage().text());
latch.countDown();
});
tokenStream.start();
latch.await();
// 5. 保存结果到state:为什么需要?
// - 后续任务可以从state中获取这个结果
// - 实现任务间的数据传递
Map<String, Object> stateUpdate = saveTaskResult(state, resultBuilder.toString());
// 6. 发送完成事件
userContext.emit(TaskStatusEvent.builder()
.type(EventType.TASK_COMPLETE)
.taskId(task.getId())
.build());
return stateUpdate; // 返回更新后的状态
}
}
5.4 状态传递机制
java
// 在BaseTaskExecutor中
protected Map<String, Object> getTaskInput(WorkspaceState state) {
Map<String, Object> taskInput = new HashMap<>();
// 1. 获取用户输入:为什么需要?
// - 某些任务需要直接使用用户输入
Optional<String> userMessage = state.userMessage();
if (userMessage.isPresent()) {
taskInput.put("userMessage", userMessage.get());
}
// 2. 获取依赖任务的输出:为什么需要?
// - 任务可能依赖其他任务的结果
// - 通过taskId从state中获取
TaskInputs inputs = task.getInputs();
if (inputs != null && inputs.getFromTask() != null) {
for (String fromTaskId : inputs.getFromTask()) {
Optional<Map<String, Object>> fromTaskResult = state.value(fromTaskId);
if (fromTaskResult.isPresent()) {
// 将依赖任务的结果添加到输入中
taskInput.put(fromTaskId, fromTaskResult.get());
}
}
}
return taskInput;
}
// 保存任务结果:为什么需要?
// - 后续任务可以通过taskId获取这个结果
// - 实现任务间的数据传递
protected Map<String, Object> saveTaskResult(WorkspaceState state, String result) {
Map<String, Object> stateUpdate = new HashMap<>();
// 以taskId为key保存结果,这样依赖任务可以通过taskId获取
stateUpdate.put(task.getId(), result);
return stateUpdate;
}
六、完整执行流程
6.1 用户请求到结果返回
java
@PostMapping("/execute")
public Flux<TaskStatusEvent> execute(@RequestBody TaskExecuteRequest request) {
// 1. 构建Agent系统:为什么需要?
// - 从数据库加载配置,初始化AI服务
// - 构建Orchestrator实例
taskExecuteService.buildArmory(request.getOrchestratorId());
// 2. 获取Orchestrator:为什么需要?
// - Orchestrator包含了Supervisor和Worker的配置
Orchestrator orchestrator = taskExecuteService.getOrchestrator(request.getOrchestratorId());
// 3. 创建用户上下文:为什么需要?
// - 管理SSE事件流
// - 提供用户ID和会话ID用于记忆
UserContext userContext = new UserContext();
userContext.setUserId(request.getUserId());
userContext.setSessionId(request.getSessionId());
// 4. 启动事件分发器:为什么需要?
// - 将任务执行过程中的事件推送给前端
// - 实现实时反馈
userContext.startEventDispatcher(emitter);
// 5. 异步执行:为什么异步?
// - 不阻塞HTTP请求线程
// - 通过SSE流式返回结果
new Thread(() -> {
orchestrator.execute(request.getMessage(), userContext);
userContext.complete();
}).start();
return Flux.create(emitter -> {
// SSE事件流
});
}
6.2 执行时序图
scss
用户请求
↓
Controller.execute()
↓
TaskExecuteService.buildArmory() [构建Agent系统]
↓
Orchestrator.execute()
├─→ plan() [Supervisor生成计划]
│ ├─→ 构建提示词
│ ├─→ 调用Supervisor AI
│ └─→ 解析TaskPlan
│
├─→ buildGraph() [构建执行图]
│ ├─→ 创建StateGraph
│ ├─→ 添加节点(TaskExecutor)
│ ├─→ 添加边(依赖关系)
│ └─→ 编译图
│
└─→ graph.invoke() [执行图]
├─→ START → 根节点
├─→ 并行执行无依赖任务
├─→ 等待依赖完成
├─→ 执行后续任务
└─→ 叶子节点 → END
七、设计要点总结
7.1 为什么这样设计?
- 状态图模式:用图结构表达复杂的执行流程,直观清晰
- Supervisor-Worker模式:职责分离,Supervisor规划,Worker执行
- 异步执行:提高效率,支持并行执行无依赖任务
- 状态管理:通过WorkspaceState统一管理数据,实现任务间传递
- 事件驱动:通过事件系统提供可观测性
7.2 关键实现技巧
- 依赖解析 :通过
fromTask列表自动构建依赖关系 - 根节点识别:找到无前置依赖的节点作为入口
- 叶子节点识别:找到无后续节点的节点作为出口
- 状态传递:以taskId为key保存结果,依赖任务通过taskId获取
- 错误隔离:每个节点独立执行,失败不影响其他节点
总结
本文深入解析了基于LangGraph4j的多Agent编排系统,重点阐述了:
- 为什么选择LangGraph4j:解决任务依赖、并行执行、状态共享等核心问题
- 为什么采用Supervisor-Worker模式:实现动态规划、职责分离、易于扩展
- 如何实现任务规划:Supervisor分析需求,生成结构化的TaskPlan
- 如何构建执行图:GraphBuilder将TaskPlan转换为可执行的StateGraph
- 如何执行任务:TaskExecutor封装执行逻辑,支持静默和流式两种模式
通过这种设计,我们实现了声明式的多Agent编排,让复杂的多Agent协作变得简单可控。
关键代码位置:
Orchestrator.java: 编排器核心逻辑GraphBuilder.java: 图构建实现TaskExecutor.java: 任务执行器接口WorkspaceState.java: 状态管理TaskExecuteController.java: REST API入口