LangGraph4j LangChain4j JAVA 多Agent编排详解

LangGraph4j 多Agent编排详解:为什么这么做,怎么做

引言

在多Agent系统中,如何协调多个AI Agent协同完成复杂任务是一个核心挑战。本文深入解析基于LangGraph4j的多Agent编排系统,重点阐述为什么选择这种架构 以及如何具体实现


一、为什么选择LangGraph4j?

1.1 多Agent编排的核心挑战

在构建多Agent系统时,我们面临以下核心问题:

  1. 任务依赖管理:任务A的输出是任务B的输入,如何确保执行顺序?
  2. 并行执行优化:无依赖的任务如何并行执行以提高效率?
  3. 状态共享:多个Agent如何共享中间结果和上下文?
  4. 错误处理:某个Agent失败时,如何不影响其他Agent?
  5. 流程可视化:如何清晰地表达复杂的执行流程?

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)
  ↓ 执行具体任务
结果汇总

为什么这样设计?

  1. 动态规划:Supervisor根据用户请求和可用Agent动态生成计划,无需硬编码
  2. 职责分离:Supervisor负责"思考"(规划),Worker负责"执行"(干活)
  3. 易于扩展:新增Worker只需注册,Supervisor会自动识别并使用
  4. 智能调度: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需要:

  1. 理解需求:分析用户真正想要什么
  2. 任务分解:将复杂任务拆解为可执行的子任务
  3. 分配Agent:根据任务特点选择合适的Worker
  4. 建立依赖:确定任务间的执行顺序

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指定执行者,确保任务分配给正确的Worker
  • executionStrategy控制是否流式输出,平衡用户体验和性能

四、如何构建执行图?

4.1 为什么需要GraphBuilder?

TaskPlan只是数据结构,需要转换为可执行的图。GraphBuilder的作用是:

  1. 创建节点:为每个任务创建执行节点
  2. 建立边:根据依赖关系连接节点
  3. 处理入口出口:连接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 为什么这样设计?

  1. 状态图模式:用图结构表达复杂的执行流程,直观清晰
  2. Supervisor-Worker模式:职责分离,Supervisor规划,Worker执行
  3. 异步执行:提高效率,支持并行执行无依赖任务
  4. 状态管理:通过WorkspaceState统一管理数据,实现任务间传递
  5. 事件驱动:通过事件系统提供可观测性

7.2 关键实现技巧

  1. 依赖解析 :通过fromTask列表自动构建依赖关系
  2. 根节点识别:找到无前置依赖的节点作为入口
  3. 叶子节点识别:找到无后续节点的节点作为出口
  4. 状态传递:以taskId为key保存结果,依赖任务通过taskId获取
  5. 错误隔离:每个节点独立执行,失败不影响其他节点

总结

本文深入解析了基于LangGraph4j的多Agent编排系统,重点阐述了:

  1. 为什么选择LangGraph4j:解决任务依赖、并行执行、状态共享等核心问题
  2. 为什么采用Supervisor-Worker模式:实现动态规划、职责分离、易于扩展
  3. 如何实现任务规划:Supervisor分析需求,生成结构化的TaskPlan
  4. 如何构建执行图:GraphBuilder将TaskPlan转换为可执行的StateGraph
  5. 如何执行任务:TaskExecutor封装执行逻辑,支持静默和流式两种模式

通过这种设计,我们实现了声明式的多Agent编排,让复杂的多Agent协作变得简单可控。


关键代码位置

  • Orchestrator.java: 编排器核心逻辑
  • GraphBuilder.java: 图构建实现
  • TaskExecutor.java: 任务执行器接口
  • WorkspaceState.java: 状态管理
  • TaskExecuteController.java: REST API入口
相关推荐
程序员鱼皮1 小时前
又被 Cursor 烧了 1 万块,我麻了。。。
前端·后端·ai·程序员·大模型·编程
重整旗鼓~2 小时前
3.会话功能-AiServices工具类
java·语言模型·langchain
tonydf2 小时前
动态表单之后:如何构建一个PDF 打印引擎?
后端
allbs2 小时前
spring boot项目excel导出功能封装——4.导入
spring boot·后端·excel
代码不停2 小时前
Java单链表和哈希表题目练习
java·开发语言·散列表
Dxxyyyy2 小时前
零基础学JAVA--Day37(坦克大战1.0)
java·开发语言
用户69371750013842 小时前
11.Kotlin 类:继承控制的关键 ——final 与 open 修饰符
android·后端·kotlin
用户69371750013842 小时前
10.Kotlin 类:延迟初始化:lateinit 与 by lazy 的对决
android·后端·kotlin
OranTech2 小时前
第28节课-夕夕超市收银系统(下)-笔记
java