降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 的选取还是硬编码,多个数字人项目共用一张图时的会话隔离策略还没定型,评测集

posted @ 2026-10-10 23:36  李福春  阅读(6)  评论(0)    收藏  举报