降SpringAI阿里第12掌-时乘六龙-分身多Agent
降SpringAI阿里第12掌-时乘六龙-分身多Agent
三个角色不是三个模型,是三块切开的上下文。
[读者] 已经用单 Agent + Tools 跑通数字人 Web 运行页的 Spring 开发者
[痛点] System Prompt 越堆越长,工具越挂越多,回答开始串味
[现在读] 要把接待、知识、查询三件事拆给不同角色的时候
[读完] 能用 Supervisor / Routing / Handoff 组织多 Agent,并说清为什么不是「一个 Agent + 更多 Tools」
[旧方案] 单 Agent + 12 个 Tools,一套 System Prompt 全包
|
v
[新需求] 接待要轻快、知识要严谨、查询要实时,三套人格三套上下文
|
v
[冲突] 三条职责共用一份记忆、一份提示词、一份工具清单
|
v
[后果] 选错工具、答非所问,改一处 Prompt 全局回归
我是老李,前面十一掌我们把这个数字人项目从零搭到了 Graph Runtime:注册登录、项目 CRUD、开场白结束语、Agent 模型与 System Prompt、Web 运行页、LiveKit 实时交互都已经能跑,ChatClient、Memory、Streaming、Tool、MCP、RAG 也都接进来了。
但系统一直是「一个 Agent 干所有事」。到了这一掌,运营要把数字人拆成三个角色:主接待 Agent、项目知识 Agent、业务查询 Agent。
接待、知识、查询三件完全不同的活,为什么非要塞进同一个 Agent 的上下文里?
Supervisor、Routing、Handoff 三条路摆在面前,控制权到底该交给谁?
选错了,是把简单问题做成分布式系统,还是把复杂问题硬压回一个单点?
01、故事
周一早上九点,灰度环境刚放量。运营在后台把数字人「小盟」的标题改成「春季产品答疑」,主题色调成深蓝,背景图换成一张办公场景照。
任务是让这个运行页在同一场对话里同时接住三类问题:寒暄澄清、项目资料、实时指标。
V1 只有两样东西:一个 ChatClient,一套 System Prompt,上面挂 12 个工具——读项目元信息、改主题色、改背景图、搜文档、查活跃会话数、查平均会话时长,等等。
上午十点,第一个问题来了:「你们这个数字人支持换背景图吗?」Agent 先调了 searchDocs,检索到「背景图上传规格」,又顺手调了 getProjectMeta 把当前背景图 URL 贴了出来,最后还补了一句「当前项目平均会话时长 3 分钟」——那是它从另一条工具描述里联想出来的,用户根本没问。
十点二十,运营在群里问:「上周活跃会话多少?」Agent 一本正经地念了文档里的指标定义,从头到尾没调用查询工具。
十一点,产品经理只想改开场白,Agent 顺手把主题色也改了。
我盯着日志看了很久。三个问题是三类活,可这个 Agent 并不区分它们,它只是在同一份上下文里挑工具。
下午的评审会上,运营提了明确要求:接待归接待,知识归知识,数据归数据。数字人外表可以只有一个人格,背后必须分工。
一身兼数职
一日见三短
一问三不知
一拆天地宽
02、问题
旧方案失效的地方很清楚:三个职责共用一份 System Prompt、一份 ChatMemory、一份工具清单。
业务影响立刻显形。接待话术串味,本该死守边界的知识回答开始自由发挥,业务数据被人格化措辞包装成「大概」「可能」。运营不敢再让数字人上真实流量,因为没人能预测它这轮会调哪个工具。
技术表现集中在三个指标上。第一,System Prompt 的 token 长度随职责线性增长,工具描述从 4 个涨到 12 个,每次调用都要付这份固定成本。第二,ChatMemory 是一份,接待轮的寒暄、知识轮的文档片段、业务轮的查询参数混在一起,下一轮 Agent 看到的是三锅粥。第三,改任何一条职责的提示词,都要回归另外两条职责——没有边界,就没有局部修改。
这一掌要交付的完成标准有三条:
- 三个角色能在同一张图里协作,一次会话内可以接待 → 知识、接待 → 业务地流转;
- 每个角色只带自己的提示词、只挂自己的工具、只持有自己那段记忆;
- 能用一句话解释清楚:为什么这里必须多 Agent,而不是一个 Agent 加更多 Tools。
第三条最容易被跳过,但它其实是最硬的验收项。说不清,就说明拆得没道理。
上下文渐肥
工具日益杂
一改牵全身
回归无止境
03、原理
Agent 的成本不在模型调用次数,而在它每次决策时要看多少东西。
一次 ChatClient 调用塞进上下文的,大致是五项之和:System Prompt、全部工具的名称与 JSON Schema 描述、历史消息、检索回来的片段、本轮用户输入。这五项加起来就是上下文预算。12 个工具的描述哪怕每个只占几十个 token,累计也是几百到上千,而且不管这轮问题是否相关,都要付这份钱。
反直觉判断:拆多 Agent 的第一收益不是「能力更强」,而是「上下文更窄」。三个 Agent 各自只带自己那份提示词、自己那几个工具,单次决策的候选空间被砍掉了大半。能力上限并没有提高,出现误选的概率反而下降了。
第二个必须建立的概念是控制权,也就是「谁决定下一个动作」。
- Routing:由前置的分流逻辑决定,通常是一次轻量分类调用或一组规则,worker 只是被动执行。
- Supervisor:由一个中心 Agent 决定,它能看见全局,通过委派把活分出去,并在合适的时机收回结果。
- Handoff:由当前正在跑的 Agent 自己决定,它把控制权和一部分状态直接交出去,交接后自己就停下了。
第三个概念是状态边界,也就是「谁能看到哪些消息」。Routing 天然隔离,每个分支一份干净上下文;Supervisor 默认共享,大家在同一份图状态里读写;Handoff 介于两者之间——交接时只把该带过去的那部分传下去。
把这三点连起来看:控制权越集中,编排越简单,但中心节点越容易成为瓶颈和状态污染源;控制权越分散,局部越灵活,但全局一致性越难保证。这三条线,就是 Supervisor、Routing、Handoff 的真正差别,而不是 API 长得不一样。
兵不在多寡
而在分合间
权归一处易
责分三方难
04、架构
[输入] 用户消息 + 项目 ID + 会话 ID
|
v
[模块] Router 节点 -> 三选一 -> Supervisor 收敛节点
|
v
[数据/状态] OverAllState:input / route / handoff / hops / skills / answer
|
v
[处理] 接待 Agent(澄清) / 知识 Agent(检索) / 业务 Agent(工具查询),Skills 按需注入
|
v
[输出] 单条回复 + 本轮路由轨迹(route / hops / 命中的 skill)
边界划在三个地方。
第一,工具边界。getProjectMeta、updateThemeColor、updateBackgroundUrl 归接待 Agent;searchDocs、向量检索归知识 Agent;queryActiveSessions、queryAvgDuration 归业务 Agent。任何两个 Agent 不共享同一个工具,这是硬约束。
第二,记忆边界。三个角色用三份独立的 ChatMemory,会话 ID 相同但命名空间不同,避免上一轮的检索片段污染轮询参数。
第三,控制边界。Router 决定「第一跳去哪」,Supervisor 决定「还要不要继续跳、跳到哪」。任何一跳都不会无限循环,因为 Supervisor 手里握着最大跳数。
收益是上下文变小、修改局部化、失败可归因——日志里能看到这条回复走的是哪条路径。
代价有三个:一次会话可能产生多次模型调用,延迟变高;Router 的误判会让整条链路从一开始就跑偏;图状态成了新的共享可变区,少写一个 key 策略就会出现覆盖。
适用条件是明确的:只有当不同职责对提示词、工具集、记忆的要求互相排斥时,拆多 Agent 才划算。如果只是工具多,加 Tools 就够了;只有当「一套人格装不下」时,才轮到多 Agent 上场。
一路分三径
三径归一梁
梁上有尺度
尺度定生死
05、实战一次
环境与版本:JDK 21 LTS,Spring Boot 3 主线,alibaba/spring-ai-alibaba v1.1.2.2 稳定版。分支从 main 切出 chapter/12-multi-agent。
以下是按官方 examples/multiagent-patterns 目录结构整理的实现,未在当前环境实测,以下为预期结果。1.1.2.2 的确切 artifactId 与模块划分,以主仓库对应 tag 下的 modules 目录和 Release 说明为准,若有出入需按当前官方文档核验。
依赖。
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
<version>1.1.2.2</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-graph-core</artifactId>
<version>1.1.2.2</version>
</dependency>
配置。
spring:
ai:
dashscope:
api-key: ${DASHSCOPE_API_KEY}
chat:
options:
model: qwen-plus
temperature: 0.3
digital-human:
multi-agent:
max-hops: 3
三个 ChatClient。工具与职责各自封闭,互不交叉。
@Configuration
public class DigitalHumanAgentConfig {
public static final String RECEPTION = "reception_agent";
public static final String KNOWLEDGE = "knowledge_agent";
public static final String BUSINESS = "business_agent";
@Bean
public ChatClient receptionChatClient(ChatClient.Builder builder,
ChatMemoryRepository receptionMemory) {
return builder.clone()
.defaultSystem(L"""
你是数字人「小盟」的主接待。只做寒暄、身份确认、需求澄清。
你只掌握项目元信息(标题、主题色、背景图)。
如果用户问的是项目资料或实时数据,不要自己回答,
在回答末尾单独输出一行 handoff=knowledge_agent 或 handoff=business_agent。
""")
.defaultAdvisors(MessageChatMemoryAdvisor.builder(receptionMemory).build())
.build();
}
@Bean
public ChatClient knowledgeChatClient(ChatClient.Builder builder,
ChatMemoryRepository knowledgeMemory,
VectorStore vectorStore) {
return builder.clone()
.defaultSystem(L"""
你是数字人项目知识 Agent,只回答已索引的项目文档内容。
检索不到资料时明确回答「资料中未覆盖」,禁止猜测。
不输出寒暄,不做业务数据查询。
""")
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(knowledgeMemory).build(),
QuestionAnswerAdvisor.builder(vectorStore).build())
.build();
}
@Bean
public ChatClient businessChatClient(ChatClient.Builder builder,
ChatMemoryRepository businessMemory,
BusinessQueryTools queryTools) {
return builder.clone()
.defaultSystem(L"""
你是数字人业务查询 Agent,只负责把自然语言转成查询参数并调用查询工具。
只输出查询结果与口径,不复述文档原理,不做寒暄。
""")
.defaultAdvisors(MessageChatMemoryAdvisor.builder(businessMemory).build())
.defaultTools(queryTools)
.build();
}
}
Skills:把能力说明从主 Prompt 里挪出来,按需注入。这是本掌避免「一次把所有上下文塞进主 Prompt」的关键。
public record Skill(String name, String description, String content) {
}
@Component
public class SkillRegistry {
private final Map<String, Skill> skills = new LinkedHashMap<>();
public SkillRegistry() {
register(new Skill("project-meta", "项目元信息口径",
"项目元信息字段:title、themeColor、backgroundUrl。修改前必须确认用户明确要求。"));
register(new Skill("metrics-glossary", "业务指标口径",
"活跃会话数 = 当日有消息的会话去重数;平均会话时长 = 会话时长总和 / 会话数。"));
register(new Skill("escalation", "转人工口径",
"用户连续两次表达不满,或明确要求人工时,输出 handoff=human。"));
}
private void register(Skill skill) {
skills.put(skill.name(), skill);
}
public String renderFor(String agentName) {
List<String> names = switch (agentName) {
case "reception_agent" -> List.of("project-meta", "escalation");
case "knowledge_agent" -> List.of();
case "business_agent" -> List.of("metrics-glossary");
default -> List.of();
};
if (names.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder("\n可用能力说明:\n");
for (String n : names) {
Skill s = skills.get(n);
sb.append("-").append(s.description()).append(":").append(s.content()).append("\n");
}
return sb.toString();
}
}
Router 节点:前置分流,输出只能是三个枚举值之一。
public class RouterNode implements NodeAction {
private static final Set<String> ALLOWED =
Set.of(DigitalHumanAgentConfig.RECEPTION,
DigitalHumanAgentConfig.KNOWLEDGE,
DigitalHumanAgentConfig.BUSINESS);
private final ChatClient routerClient;
public RouterNode(ChatClient.Builder builder) {
this.routerClient = builder.clone()
.defaultSystem(L"""
你是数字人请求路由器。只输出一个路由名,不要输出任何解释。
可选值:
reception_agent:寒暄、澄清、项目元信息(标题/主题色/背景图)
knowledge_agent:项目文档、框架配置、原理类问题
business_agent:实时数据、会话统计、指标口径
""")
.build();
}
@Override
public Map<String, Object> apply(OverAllState state) {
String input = String.valueOf(state.value("input").orElse(""));
String raw = routerClient.prompt().user(input).call().content();
String route = raw == null ? "" : raw.trim();
if (!ALLOWED.contains(route)) {
route = DigitalHumanAgentConfig.RECEPTION;
}
return Map.of("route", route, "hops", 0);
}
}
public class RouterEdge implements EdgeAction {
@Override
public String apply(OverAllState state) {
return String.valueOf(state.value("route").orElse(DigitalHumanAgentConfig.RECEPTION));
}
}
接待节点:负责澄清,并在需要时把一个结构化交接标记写进状态。这就是 Handoff。
public class ReceptionNode implements NodeAction {
private final ChatClient client;
private final SkillRegistry skills;
public ReceptionNode(ChatClient client, SkillRegistry skills) {
this.client = client;
this.skills = skills;
}
@Override
public Map<String, Object> apply(OverAllState state) {
String input = String.valueOf(state.value("input").orElse(""));
String prompt = input + skills.renderFor(DigitalHumanAgentConfig.RECEPTION);
String answer = client.prompt().user(prompt).call().content();
String text = answer == null ? "" : answer;
String handoff = "none";
int idx = text.indexOf("handoff=");
if (idx >= 0) {
handoff = text.substring(idx + 8).trim().split("\\s+")[0];
text = text.substring(0, idx).trim();
}
return Map.of("answer", text, "handoff", handoff);
}
}
知识节点与业务节点结构一致,只把工具集与技能集换掉。
public class KnowledgeNode implements NodeAction {
private final ChatClient client;
public KnowledgeNode(ChatClient client) {
this.client = client;
}
@Override
public Map<String, Object> apply(OverAllState state) {
String input = String.valueOf(state.value("input").orElse(""));
String answer = client.prompt().user(input).call().content();
return Map.of("answer", answer == null ? "" : answer, "handoff", "none");
}
}
public class BusinessNode implements NodeAction {
private final ChatClient client;
public BusinessNode(ChatClient client) {
this.client = client;
}
@Override
public Map<String, Object> apply(OverAllState state) {
String input = String.valueOf(state.value("input").orElse(""));
String answer = client.prompt().user(input).call().content();
return Map.of("answer", answer == null ? "" : answer, "handoff", "none");
}
}
Supervisor 节点:控制权的收敛点。它决定继续交接还是结束,并且强制最大跳数。
public class SupervisorNode implements NodeAction {
private final int maxHops;
public SupervisorNode(int maxHops) {
this.maxHops = maxHops;
}
@Override
public Map<String, Object> apply(OverAllState state) {
int hops = ((Number) state.value("hops").orElse(0)).intValue() + 1;
String handoff = String.valueOf(state.value("handoff").orElse("none"));
boolean stop = "none".equals(handoff) || hops >= maxHops;
return Map.of("hops", hops, "next", stop ? "end" : handoff);
}
}
public class SupervisorEdge implements EdgeAction {
@Override
public String apply(OverAllState state) {
return String.valueOf(state.value("next").orElse("end"));
}
}
装配成图。
@Configuration
public class MultiAgentGraphConfig {
@Bean
public CompiledGraph digitalHumanGraph(ChatClient.Builder builder,
SkillRegistry skills,
DigitalHumanAgentConfig agents,
@Value("${digital-human.multi-agent.max-hops:3}") int maxHops) {
KeyStrategyFactory keyStrategyFactory = () -> {
Map<String, KeyStrategy> strategies = new HashMap<>();
strategies.put("input", new ReplaceStrategy());
strategies.put("route", new ReplaceStrategy());
strategies.put("handoff", new ReplaceStrategy());
strategies.put("next", new ReplaceStrategy());
strategies.put("answer", new ReplaceStrategy());
strategies.put("hops", new ReplaceStrategy());
return strategies;
};
StateGraph graph = new StateGraph("digital-human-multi-agent", keyStrategyFactory)
.addNode("router", node_async(new RouterNode(builder)))
.addNode(DigitalHumanAgentConfig.RECEPTION,
node_async(new ReceptionNode(agents.receptionChatClient(builder, null), skills)))
.addNode(DigitalHumanAgentConfig.KNOWLEDGE,
node_async(new KnowledgeNode(agents.knowledgeChatClient(builder, null, null))))
.addNode(DigitalHumanAgentConfig.BUSINESS,
node_async(new BusinessNode(agents.businessChatClient(builder, null, null))))
.addNode("supervisor", node_async(new SupervisorNode(maxHops)))
.addEdge(StateGraph.START, "router")
.addConditionalEdges("router", edge_async(new RouterEdge()),
Map.of(DigitalHumanAgentConfig.RECEPTION, DigitalHumanAgentConfig.RECEPTION,
DigitalHumanAgentConfig.KNOWLEDGE, DigitalHumanAgentConfig.KNOWLEDGE,
DigitalHumanAgentConfig.BUSINESS, DigitalHumanAgentConfig.BUSINESS))
.addEdge(DigitalHumanAgentConfig.RECEPTION, "supervisor")
.addEdge(DigitalHumanAgentConfig.KNOWLEDGE, "supervisor")
.addEdge(DigitalHumanAgentConfig.BUSINESS, "supervisor")
.addConditionalEdges("supervisor", edge_async(new SupervisorEdge()),
Map.of("end", StateGraph.END,
DigitalHumanAgentConfig.RECEPTION, DigitalHumanAgentConfig.RECEPTION,
DigitalHumanAgentConfig.KNOWLEDGE, DigitalHumanAgentConfig.KNOWLEDGE,
DigitalHumanAgentConfig.BUSINESS, DigitalHumanAgentConfig.BUSINESS));
return graph.compile();
}
}
上面这段装配里,三个 ChatClient 是从已有的 Config 里取 Bean,实际项目中不要像这里一样传 null,而是直接注入三个已经构建好的 Bean,代码会更干净。
对外接口。
@RestController
@RequestMapping("/api/digital-human")
public class DigitalHumanRunController {
private final CompiledGraph graph;
public DigitalHumanRunController(CompiledGraph graph) {
this.graph = graph;
}
@PostMapping("/run")
public Map<String, Object> run(@RequestBody Map<String, String> body) {
OverAllState state = graph.invoke(Map.of("input", body.getOrDefault("input", "")))
.orElseThrow(() -> new IllegalStateException("graph returned empty state"));
Map<String, Object> result = new LinkedHashMap<>();
result.put("route", state.value("route").orElse(""));
result.put("hops", state.value("hops").orElse(0));
result.put("answer", state.value("answer").orElse(""));
return result;
}
}
启动。
export DASHSCOPE_API_KEY=sk-xxxx
mvn -q spring-boot:run
请求。
curl -X POST http://localhost:8080/api/digital-human/run \
-H "Content-Type: application/json" \
-d '{"input":"你们这个数字人支持换背景图吗?"}'
示例输出。
{
"route": "knowledge_agent",
"hops": 1,
"answer": "支持。背景图需为 JPG 或 PNG,建议宽高比 16:9,单张不超过 2MB。"
}
验证三件事:接待类问题 route 落在 reception_agent;文档类问题落在 knowledge_agent 且答案带出处;数据类问题落在 business_agent 且回复里只有数字与口径,没有寒暄。三个角色在同一张图里跑通,V1 就算立住了。
先跑一条线
再分三岔口
跑通再论道
未通莫谈优
06、排查
第一条诊断链。
现象:用户明明问的是「上周活跃会话多少」,回复里带了一句「我们很乐意帮您了解」,然后才给数字。
怀疑:接待 Agent 的措辞风格漏进了业务链路。
检查:把每轮 state 打印成一行日志,记录 route 和最终的 answer 来源节点。
证据:日志显示 route 是 business_agent,hops 是 1,answer 长度正常,但前半句语气明显不属于业务 Agent 的 System Prompt。
根因:三个角色虽然写了三份 ChatMemory,但 MessageChatMemoryAdvisor 的 conversationId 都取默认值,落到同一份底层存储里,业务 Agent 把接待轮的历史读了出来。
修复:给三个 Advisor 各自设置独立的 conversationId 前缀,会话隔离之后再验证同一问题。
第二条诊断链。
现象:某次请求日志里出现 4 次 hops,耗时比平常高出一个量级。
怀疑:Supervisor 和 Handoff 形成了循环。
检查:打印每次 supervisor 的 next 值,以及 handoff 的来源节点。
证据:reception 连续两次输出 handoff=knowledge_agent,knowledge 每次都回答「资料中未覆盖」,但 reception 又因为原句里含「项目」把它判成知识类问题,重新交出去。
根因:handoff 的判定是字符串匹配,缺少「已经交接过」的状态,也没有终止条件。
修复:Supervisor 引入 maxHops 上限(已在 V1 埋了字段),并在 knowledge 返回「未覆盖」时把 handoff 强制写成 none。
错误尝试: 第一反应是把接待 Agent 的 System Prompt 写得更长,加一句「绝对不要回答指标口径,绝对不要输出寒暄」。这条改动上线后,问题一点没解决,反而把接待 Agent 的回复弄得又硬又长。原因是根因在记忆共享,而不在措辞——模型看不到自己那份被污染的历史,它只是照着读到的上下文顺着说。这就是典型的「用提示词去修状态问题」。
现象莫轻信
怀疑须有据
日志是铁证
根因只一个
07、优化
V2 的四条改动,全部来自第 06 章的证据。
第一条,记忆隔离。三个 Advisor 分别使用 reception:、knowledge:、business: 前缀的 conversationId,从根上切断跨角色上下文污染。改完再跑同一句「上周活跃会话多少」,回复里不再出现接待语。
第二条,终止条件。Supervisor 依据 maxHops 收敛,hops 达到上限时无论 handoff 是什么都走 end。这条改动的价值不在性能,而在可预期——任何一次请求的模型调用次数现在有上界。
第三条,Skills 按需注入。原来三条职责的能力说明都写在 System Prompt 里,现在改成 SkillRegistry 按 agent 名渲染,知识 Agent 默认拿到空字符串,业务 Agent 只拿到指标口径那一段。主 Prompt 变短,改口径只需要动 Skill 内容,不用碰任何 Agent 的提示词。
第四条,交接显式化。把字符串匹配升级成结构化输出:让接待 Agent 直接返回一个带 handoff 字段的 JSON,解析失败就回退到 none,不再靠关键词猜。
验证方式:三种典型问题各跑一遍,检查 route、hops、answer 三个字段的取值是否符合预期;再故意构造一个「既问资料又问数据」的复合问题,确认它在 maxHops 内收敛而不是挂住。
根因既已明
修改才有向
改完须复验
复验方安心
08、演进
[同一输入] 用户问:「上周活跃会话多少?」
|
+--[V1] 单 Agent 读全部历史与 12 个工具
| 代价:串味、误选、Prompt 改一处动全身
|
+--[V2] Router 分流 + 业务 Agent 独占工具 + Supervisor 收敛
| 代价:多一跳模型调用、图状态需要显式 key 策略
|
[Trade-off]
得到:上下文更窄、职责可归因、修改局部化
失去:单次延迟、编排复杂度、一个新的一致性风险点
适用边界:三种职责对提示词/工具/记忆的要求互相排斥时
正确性上,V2 把「谁该回答」从模型自由发挥变成了显式契约,误选工具的空间被结构性压缩。
稳定性上,V2 引入了新的失败面:Router 误判和状态 key 冲突。前者会让整条链路跑偏,后者表现为偶发的答案被覆盖。这两点在 V1 里都不存在,因为 V1 根本没有路由和共享状态。
复杂度上,V1 是一个类,V2 是六个节点加一张图。节点数涨了,但每个节点的可读性反而变好了——这是拆分真正的价值。
成本上,V2 的模型调用次数在复合问题上会多于 V1。接待 → 知识、接待 → 业务这类交互至少要两跳。要用 hops 的分布去衡量,而不是拍脑袋。
适用范围上,V2 只适合职责互相排斥的场景。如果三个所谓角色其实共享同一份知识、同一套工具,拆开只是把一次调用变成三次,那是负优化。
遗留问题有三个:人工介入(HITL)还没有接;并行执行还没用上,两个互不依赖的查询目前是串行的;Skill 的加载目前是按 agent 硬编码,还没有做成按语义匹配。这三条留给后续章节。
旧法有旧价
新法有新债
得失算清楚
才敢换车道
09、洞见
9.1 多 Agent 的本质是上下文隔离,不是模型分工
反直觉判断:三个 Agent 用的是同一个模型,能力完全相同,唯一的区别是它们各自看见的上下文不同。如果拆完之后三个 Agent 还是共享一份记忆、一份工具描述,那这次拆分在收益上是零,只是把一次调用变成了三次。判断拆分是否有效,只需要问一句:拆完之后,每个 Agent 看见的东西是不是变少了。
9.2 控制权越集中,编排越简单,中心节点也越危险
Supervisor 模式把所有决策权收在一个节点上,好处是全局可观测、终止条件好加、调试路径清晰。代价是这个节点会变成新的瓶颈和新的状态污染源——它看见所有人的输出,也就有机会把所有人的错误串起来。反直觉判断:Supervisor 越聪明,越容易变成单点。所以它应该被刻意做「笨」:只做转发和收尾,不做内容生成。
9.3 Handoff 不是「更灵活」,是把复杂度转移给了 Agent
Handoff 让当前 Agent 自己决定把活交给谁,看起来最自然,实际上是把路由决策塞进了一个本该专注回答的模型里。它带来的直接后果是不确定性:同一个问题,模型今天交出去,明天可能自己答了。所以 Handoff 适合边界清楚、跳数可控的场景,并且必须配最大跳数和终止判断,否则循环会静悄悄地烧钱。
9.4 Skills 的意义是把 Prompt 从静态变成按需
把能力说明一次性塞进 System Prompt,是所有单 Agent 方案的默认做法,也是最容易失控的地方——它随职责线性增长,且每轮都付一次。Skills 把这部分拆成带名字的片段,由代码决定哪个 Agent 拿哪几段,本质上是在给上下文做「懒加载」。它和 RAG 的区别是:RAG 检索的是数据,Skills 检索的是行为约定。
9.5 判断要不要多 Agent,就看三份上下文能不能合并
一个可以直接拿来用的判断方法:把三个角色的 System Prompt、工具清单、记忆内容分别列出来,如果它们有八成重合,那就是一个 Agent 加 Tools 的事;如果重合度很低,甚至互相冲突,那就必须拆。接待要短、知识要严、业务要准,这三种要求写在同一段提示词里必然互相抵消——这才是本掌拆分的真正理由。
分是手段也
合是目的也
分寸之间事
最忌走极端
10、系统落地
原来有什么:一个跑通全链路的数字人项目,注册登录、项目 CRUD、标题/主题色/背景图、开场白结束语、Agent 模型与 System Prompt、Web 运行页、LiveKit 实时交互都已具备;后端是一个单 Agent 加十余个工具,所有请求走同一条链路。
本篇新增了什么:一张三节点的多 Agent 图,前置 Router 负责分流,三个角色分别持有自己的提示词、工具与记忆,Supervisor 负责收敛并强制跳数上限,SkillRegistry 负责按 Agent 按需注入能力说明。对外接口没有变化,仍然是同一个运行页和同一个 run 接口。
现在能做什么:接待、知识、查询三类问题被稳定导向各自的角色;改一条职责的口径不需要回归另外两条;每一次回复都能从日志里看出它走了哪条路径、跳了几次。
还缺什么:人工介入还没接,复合问题目前是串行处理,Skill 的选取还是硬编码,多个数字人项目共用一张图时的会话隔离策略还没定型,评测集

浙公网安备 33010602011771号