java
做 Multi-Agent 时踩了三个坑才跑通。这篇文章把设计思路、坑点、代码都写清楚,希望帮你少走弯路。
项目开源:<https://github.com/shuguang-liu/langgraph4j-demo>
一、单 Agent 的困境:为什么我最终选择了 Multi-Agent
最初我用 ReAct 模式做了一个智能客服,一个 Agent 挂载订单、物流、工单三个工具。跑得挺好,直到我往里加了第四个、第五个工具。
问题开始出现:
第一,工具描述越来越长,prompt 越来越臃肿。 每个工具的 @Tool(description=...) 都要塞进 system prompt,在初始化的时候,你要把所有的Tool都加载到defult里面。
第二,不同领域的意图互相干扰。 用户问"LangChain 是什么",Agent 不应该去查订单;用户问"订单到哪了",也不该走 RAG 检索。但在单 Agent 里,这些判断全压在一个 LLM 上。 第三,没法做差异化处理。 订单查询需要调工具,技术问答需要走 RAG,报表统计需要查数据库。三种完全不同的处理链路,硬塞在一个 Agent 里会越来越乱。
所以我把架构拆成了 Multi-Agent------每个 Agent 专注一个领域,由一个"主管"决定任务分给谁。
二、选 Supervisor 还是网络式?三种拓扑对比
Multi-Agent 有三种主流拓扑:
| 拓扑 | 结构 | 优点 | 缺点 |
|---|---|---|---|
| Supervisor | 一个主Agent + 多个副Agent | 简单、可控、易调试 | 主管是单点 |
| 网络式 | Agent 互相通信 | 去中心化、灵活 | 难调试、容易死循环 |
| 层级式 | 多层主管 | 适合复杂组织 | 复杂度高 |
我选了 Supervisor。理由很简单:
- 3~6个Agent,规模不大,不需要去中心化
- 主管可以统一记录"谁做了什么",方便审计
- 出问题只需要看主Agent的日志
最终结构:
css
用户问题 → Supervisor(主Agent)
↓ 判断交给谁
┌─────────┼─────────┐
↓ ↓ ↓
cs A tech A data A
│ │ │
└─────────┴─────────┘
↓
回到 Supervisor
↓
END
关键设计:专家执行完必须回到 Supervisor,由它决定"任务完成了"还是"继续交给下一个专家"。
三、一张图讲清楚:Supervisor 模式怎么跑
用 LangGraph4j 的 StateGraph 表达:
对应代码:
java
return new StateGraph<MultiAgentState>(MultiAgentState.SCHEMA, serialize)
.addNode("supervisor", AsyncNodeAction.node_async(supervisorNode))
.addNode("auto_tools", AsyncNodeAction.node_async(autoToolsNode))
.addNode("cs", AsyncNodeAction.node_async(csExpertNode))
.addNode("tech", AsyncNodeAction.node_async(techExpertNode))
.addNode("data", AsyncNodeAction.node_async(dataExpertNode))
.addNode("tools", AsyncNodeAction.node_async(toolsNode))
.addEdge(START, "supervisor")
.addConditionalEdges(
"supervisor", AsyncEdgeAction.edge_async(multiAgentState -> multiAgentState.next()),
Map.of(
"cs", "cs",
"tech", "tech",
"data", "data",
"auto_tools","auto_tools",
"end", END))
// Agent 判断: 有tool_calls -> tools/auto_tools; 无->supervisor
.addConditionalEdges("cs",
AsyncEdgeAction.edge_async(expertRouter),
Map.of("tools", "tools",
"auto_tools", "auto_tools",
"supervisor", "supervisor"))
.addConditionalEdges("tech",
AsyncEdgeAction.edge_async(expertRouter),
Map.of("tools", "tools",
"auto_tools", "auto_tools",
"supervisor", "supervisor"))
.addConditionalEdges("data",
AsyncEdgeAction.edge_async(expertRouter),
Map.of("tools", "tools",
"auto_tools", "auto_tools",
"supervisor", "supervisor"))
// 工具执行后回到Agent
.addConditionalEdges("tools",
AsyncEdgeAction.edge_async(backToExpert),
Map.of("cs", "cs",
"tech", "tech",
"data", "data",
"supervisor", "supervisor"))
.addConditionalEdges("auto_tools",
AsyncEdgeAction.edge_async(backToExpert),
Map.of("cs", "cs",
"tech", "tech",
"data", "data",
"supervisor", "supervisor"))
.compile(compileConfig);
几个关键点:
supervisor是入口,也是所有专家执行完的归宿expertRouter是条件边------判断专家有没有请求调工具tools和auto_tools是工具执行节点,区分敏感和安全
四、踩了 3 个坑才跑通:Multi-Agent 的隐藏难点
坑 1:专家节点不能执行工具
最初我这样写专家节点:
java
ChatResponse response = chatClient.prompt()
.messages(messages)
.toolCallbacks(ToolCallbacks.from(tools))
.call()
.chatResponse();
问题来了------chatClient 会自动执行工具,然后把最终回答返回。这意味着我没有机会在"工具执行前"暂停。
为什么要暂停? 因为我要做 HITL(Human-in-the-Loop)------退款、创建工单这类写操作,必须人工审批。
解决方案 :把工具执行抽成独立节点。
java
var compileConfig = CompileConfig.builder()
.checkpointSaver(saver)
.internalToolExecutionEnabled(false) // 禁用自动执行
.interruptBefore("tools") // 调用工具暂停
.build();
禁用自动执行后,LLM 返回的 AssistantMessage 会带着 tool_calls,然后由条件边路由到工具节点。
代价 :State 里要记 current_expert,否则工具执行完不知道回哪个专家。
坑 2:工具执行完回哪个专家
工具节点执行完,怎么知道该回到 cs、tech 还是 data?
最初我写错了:
java
private NodeAction<MultiAgentState> buildToolsNode(ToolCallbackProvider provider) {
return state -> {
// ... 执行工具
return Map.of("message", newMessages); // 没告诉下一步去哪
};
}
结果:backToExpert 拿到的 current_expert 是空字符串,总是回到 supervisor------但 supervisor 看到"最后一条是 ToolResponseMessage",不知道该干什么,流程卡死。
修复:在 supervisor 里记录当前Agent:
java
String decision = chatClient.prompt().user(prompt).call().content().trim().toLowerCase();
return Map.of("next", decision, "current_expert", decision); // ← 同时记录
然后 backToExpert 条件边:
java
return state -> {
String expert = state.currentExpert();
System.out.println("[backToExpert] 回到: " + expert);
return expert.isEmpty() ? "supervisor" : expert;
};
坑 3:死循环
跑通之后发现一个诡异现象:
text
[supervisor] 分配给: cs
[cs_expert] 处理中...
[supervisor] 分配给: cs
[cs_expert] 处理中...
[supervisor] 分配给: cs
...
永远分配给 cs,停不下来。
原因 :supervisor 判断时看的是 messages.get(0)(用户原始问题),每轮都看到同一个问题,所以每次判断结果都一样。
修复 :先判断"任务是否已完成"------如果最后一条是无 tool_calls 的 AssistantMessage,说明Agent已经给出了最终回答:
java
if (!messages.isEmpty()) {
Message lastMsg = messages.get(messages.size() - 1);
if (lastMsg instanceof AssistantMessage aiMsg && !aiMsg.hasToolCalls()) {
System.out.println("[supervisor] 任务已完成 → end");
return Map.of("next", "end");
}
}
关键点 :Multi-Agent 的 Supervisor 需要"任务状态记忆" ------不能只看原始问题,还要看"之前做了什么"。
五、给 Agent 加"暂停键":Human-in-the-Loop 落地
HITL 的两个核心机制:
机制一:interruptBefore
java
var compileConfig = CompileConfig.builder()
.checkpointSaver(saver)
.releaseThread(false)
.interruptBefore("tools") // 进入 tools 节点前暂停
.build();
机制二:Checkpointer 保存状态
java
var saver = new MemorySaver();
没有 Checkpointer,暂停后状态就丢了,恢复时找不到现场。
完整流程
text
用户提问 → Agent 判断要调工具 → 暂停在 tools 前
↓
返回 PENDING_APPROVAL
↓
用户审批(批准 / 拒绝)
↓
批准:invoke(GraphInput.resume(), config) → 执行工具
拒绝:updateState 设 rejected=true → 走 reject 分支
拒绝分支的实现
拒绝不是"清理状态"------而是让流程走到另一个节点:
java
if (!approved) {
graph.updateState(config, Map.of("rejected", true));
}
MultiAgentState state = graph.invoke(GraphInput.resume(), config).orElseThrow();
条件边里判断:
java
if (Boolean.TRUE.equals(state.value("rejected").orElse(false))) {
return "reject";
}
为什么这样做 :拒绝也是业务流程的一部分------State 里留下 rejected=true 作为审计记录,同时让 Agent 生成"已取消"的反馈。
按工具风险分级
不是所有工具都需要审批。查询类自动执行,写操作才审批:
java
private static final Set<String> SENSITIVE_TOOLS = Set.of("createTicket");
boolean hasSensitive = aiMsg.getToolCalls().stream()
.anyMatch(tc -> SENSITIVE_TOOLS.contains(tc.name()));
String route = hasSensitive ? "tools" : "auto_tools";
| 路径 | 是否暂停 |
|---|---|
tools(敏感) |
✅ 暂停 |
auto_tools(安全) |
❌ 直接执行 |
六、跑起来是什么样?日志 + 演示
场景 1:查订单(安全工具,不审批)
text
[supervisor] 分配给: cs
[cs_expert] 处理中...
[cs_expert] hasToolCalls = true
[expertRouter] 有 tool_calls → auto_tools
[auto_tools] 执行工具
[auto_tools] queryOrder → 【订单详情】订单号:ORD12345678 | 金额:200元 | 状态:已下单
[backToExpert] 回到: cs
[cs_expert] hasToolCalls = false
[expertRouter] 无 tool_calls → supervisor
[supervisor] 任务已完成 → end
关键:没有任何暂停,直接出结果。
场景 2:退款(敏感工具,需审批)
第一次请求:
json
{
"status": "PENDING_APPROVAL",
"threadId": "user-001",
"message": "Agent 想调用工具,请确认"
}
批准:
json
{
"status": "COMPLETED",
"answer": "工单已创建:订单 ORD12345678,类型 refund"
}
场景 3:多轮会话
第 1 轮问"查订单 ORD12345678",第 2 轮问"物流到哪了"------Agent 记得订单号,直接查物流。这靠的是 Checkpointer + threadId:
java
var config = RunnableConfig.builder().threadId("user-001").build();
graph.invoke(Map.of("message", List.of(new UserMessage(question))), config);
注意 :1.9.0-beta6 版本配了 Checkpointer 后默认会释放线程 ,多轮对话必须加 releaseThread(false)。
七、架构演进的方向:从单点能力到分层编排
回头看这次升级,本质上是从"用框架"到"用框架编排" :
| 阶段 | 特征 | 问题 |
|---|---|---|
| ReAct 单 Agent | 一个 LLM + 一堆工具 | 工具多了意图识别变差 |
| Multi-Agent | 主管 + 多专家 | 架构清晰,可扩展 |
| Subgraph 组合 | 多个 Multi-Agent 系统互相编排 | 更复杂,暂未涉及 |
未来的方向:如果有多个 Multi-Agent 系统(比如电商客服系统 + 金融风控系统),可以用 Subgraph 把它们组合到一个更大的顶层图里。
但现阶段,Multi-Agent 已经解决了我的问题------不同领域的能力有了清晰的分工,HITL 让敏感操作可控,Checkpointer 让多轮对话自然。
架构没有终极答案,只有当下最合适的取舍。
延伸阅读
- LangGraph4j 官方文档:langgraph4j.github.io/langgraph4j...
- 我的另一个项目:RAG 知识库问答系统 github.com/shuguang-li...
- Multi-Agent 完整代码:github.com/shuguang-li...
以上。希望能帮到正在做 Agent 的你。