多智能体路由:从场景定义到Agent解析的工程实践

大家好,我是程序员小策。

场景:你正在做一个 AI 面试系统。产品经理说:"我们不光要一个通用聊天机器人,还要一个能自动出题、能给用户答案打分、还能分析用户表情神态的面试官。"

你一拍脑袋:行,不就是多建几个 Agent 嘛,每个 Agent 配一个不同的 Prompt 不就得了?

于是你噼里啪啦建了 5 个 Agent:通用聊天官、出题官、答案评分官、神态分析官、提问官。然后在前端写了 5 个入口,各自调用各自的路由。

上线第一周,一切顺利。第二周,运营跑过来说:"能不能让用户在同一个会话里,先跟通用聊天官聊两句,然后无缝切到出题模式?"你愣了一下------哦对,之前的架构里,一个会话和一个 Agent 是硬绑定的,中间没法换。

更麻烦的是------后来老板说要加一个"英文面试官",你要改多少代码?前端 5 个入口要变成 6 个?后端每个 Controller 都加一个 switch case?

这就是我今天要聊的问题:多智能体场景下,怎么设计一个灵活、可扩展、对前端透明的 Agent 路由架构。

项目来源:https://github.com/lishuangqiang/AI-Meeting


先看核心问题

你面对的不是"怎么调一个 Agent",而是三个递进的工程问题:

  1. 如何定义场景与 Agent 的映射关系?------谁来决定"出题场景"用哪个 Agent?
  2. 如何实现会话级别的 Agent 绑定?------用户在这个会话里选了出题官,下次发消息不能跳到评分官
  3. 如何让这个映射关系的变更不炸代码?------运营改了 Agent 配置,不需要开发重新上线

这三个问题如果你只用"前端传 agentId,后端直接用"来回答,那你很快就会踩坑。下面我们来拆解。


核心概念:Agent 场景路由

Agent 场景路由:将业务场景(如"面试出题")与具体的智能体实例解耦,通过配置层定义场景→Agent 的映射关系,运行时根据场景标识 + 配置动态解析出目标 Agent,同时对上层的 Controller 完全透明。

说人话就是:Controller 不关心用哪个 Agent,它只告诉系统"我是面试出题场景",至于这个场景对应哪个 Agent、那个 Agent 的 API Key 是什么------这些由路由层搞定。

打个比方,这就像你去医院看病:

你去挂号窗口说"我发烧了",分诊台不会直接给你挂"张三医生",而是先判断科室------发烧挂内科,骨折挂骨科。至于内科今天是张三坐诊还是李四坐诊,你不关心,分诊台根据排班表(配置)自动决定。如果你复查时还是这个病,系统还会把你自动分回之前那个医生(会话绑定)。

这个分诊台,就是我们代码里的 BusinessAgentResolver


代码实现:从场景定义到 Agent 解析的完整链路

来看真实代码。这是一个 AI 面试系统,定义了 5 个业务场景:

java 复制代码
public enum BusinessAgentScene {

    GENERAL_AGENT_CHAT("general-agent-chat", "通用智能体"),
    INTERVIEW_QUESTION_EXTRACTION("interview-question-extraction", "面试出题官", "面试题出题官"),
    INTERVIEW_ANSWER_EVALUATION("interview-answer-evaluation", "用户答案评分官", "面试答案评分官"),
    INTERVIEW_DEMEANOR("interview-demeanor", "神态分析官", "神态评分面试官", "表情分析面试官"),
    INTERVIEW_QUESTION_ASKING("interview-question-asking", "面试提问官");

    private final String code;
    private final String defaultAgentName;
    private final List<String> candidateAgentNames;
}

看代码:[BusinessAgentScene.java] 定义了 5 个业务场景,每个场景有唯一 code、默认 Agent 名称、以及备选名称列表(别名)。

注意看 candidateAgentNames 这个设计------每个场景除了一个"默认名称",还可以配多个"别名"。比如出题场景,默认叫"面试出题官",但也匹配"面试题出题官"。

为什么这么设计?

因为 Agent 的名称是由运营在后台配置的,运营可能今天叫"面试出题官",明天改成了"面试题出题官"。如果你的代码里只认"面试出题官"这一个字符串,运营改个名字你的系统就挂了。用候选名称列表同时匹配多个可能的名称,让代码对运营操作更鲁棒

接下来看场景如何映射到具体的 Agent。映射关系不走硬编码,走配置文件:

yaml 复制代码
# application.yml
xunzhi-agent:
  agent-binding:
    general-agent-chat: "通用聊天助手"
    interview-question-extraction: "面试出题官"
    interview-answer-evaluation: "面试答案评分官"
    interview-demeanor: "神态分析官"
    interview-question-asking: "面试提问官"

对应的配置类:

java 复制代码
@Data
@Component
@ConfigurationProperties(prefix = "xunzhi-agent.agent-binding")
public class BusinessAgentBindingProperties {

    private String generalAgentChat;
    private String interviewQuestionExtraction;
    private String interviewAnswerEvaluation;
    private String interviewDemeanor;
    private String interviewQuestionAsking;

    public String resolveAgentName(BusinessAgentScene scene) {
        if (scene == null) {
            return null;
        }
        return switch (scene) {
            case GENERAL_AGENT_CHAT -> generalAgentChat;
            case INTERVIEW_QUESTION_EXTRACTION -> interviewQuestionExtraction;
            case INTERVIEW_ANSWER_EVALUATION -> interviewAnswerEvaluation;
            case INTERVIEW_DEMEANOR -> interviewDemeanor;
            case INTERVIEW_QUESTION_ASKING -> interviewQuestionAsking;
        };
    }
}

看代码:[BusinessAgentBindingProperties.java]
@ConfigurationProperties 将 yml 配置映射到 Java 对象,resolveAgentName() 用 switch 表达式做场景→名称的映射。

这里有个关键点:虽然用了 switch,但它开关的不是"调哪个 Agent 的代码逻辑",而是"从配置中取哪个字符串名称"。真正的 Agent 解析还在后面。

核心解析器:多级 Fallback 链

java 复制代码
@Service
@RequiredArgsConstructor
@Slf4j
public class BusinessAgentResolver {

    private final BusinessAgentBindingProperties bindingProperties;
    private final AgentPropertiesLoader agentPropertiesLoader;

    public AgentPropertiesDO resolveRequired(BusinessAgentScene scene) {
        Set<String> candidateAgentNames = new LinkedHashSet<>();
        
        // 第一优先级:配置文件指定的名称
        String configuredAgentName = bindingProperties.resolveAgentName(scene);
        if (StrUtil.isNotBlank(configuredAgentName)) {
            candidateAgentNames.add(configuredAgentName.trim());
        }
        
        // 第二优先级:场景枚举自带的候选名称列表
        candidateAgentNames.addAll(scene.getCandidateAgentNames());

        // 按优先级依次查找
        for (String candidateAgentName : candidateAgentNames) {
            AgentPropertiesDO agentProperties = agentPropertiesLoader.getByAgentName(candidateAgentName);
            if (agentProperties != null) {
                // 日志区分:配置命中 vs Fallback 命中
                if (StrUtil.isNotBlank(configuredAgentName) && !configuredAgentName.trim().equals(candidateAgentName)) {
                    log.warn("Configured agent not found, fallback matched scene={}, configuredName={}, matchedName={}",
                            scene.getCode(), configuredAgentName, candidateAgentName);
                } else {
                    log.info("Resolved business agent scene={}, agentName={}", scene.getCode(), candidateAgentName);
                }
                return agentProperties;
            }
        }

        // 所有层级都找不到 → 明确报错
        log.error("No agent configuration found for scene={}, candidateNames={}", scene.getCode(), candidateAgentNames);
        throw new ClientException(
                "agent binding not found for scene=" + scene.getCode(),
                InterviewErrorCodeEnum.AGENT_CONFIG_NOT_FOUND
        );
    }
}

看代码:[BusinessAgentResolver.java]核心解析器,实现多级 Fallback 链:配置名 → 默认名 → 别名 → 报错。

这个 resolveRequired() 方法的解析链路非常清晰:

复制代码
yml 配置的名称 (最高优先级)
    ↓ 找不到?
场景自带的默认名称
    ↓ 找不到?
场景自带的别名列表
    ↓ 找不到?
抛出 ClientException(而不是返回 null 让上层 NPE)

为什么使用 LinkedHashSet 而不是 List 去重。如果运营在 yml 里配的名称和场景默认名称一模一样,LinkedHashSet 自动去重但保留插入顺序(优先级顺序),确保不会重复查询数据库。

会话层 Agent 绑定

场景路由解决了"新会话匹配哪个 Agent",但同一个会话内的后续消息怎么办?每次都重新解析?不行------用户在会话里换了场景怎么办?

于是有了会话级别的 Agent 绑定------一旦会话创建时确定了 Agent,后续消息自动沿用:

java 复制代码
@Service
public class AgentResolver {

    private final AgentConversationRepository agentConversationRepository;
    private final AgentPropertiesLoader agentPropertiesLoader;

    public Long resolveAgentId(String sessionId, Long requestedAgentId) {
        // 先查这个会话是否已经绑定了 Agent
        Long boundAgentId = findBoundAgentId(sessionId);
        if (boundAgentId != null) {
            // 已经绑定 → 直接返回绑定的 Agent(忽略请求参数中的 agentId)
            if (requestedAgentId != null && !requestedAgentId.equals(boundAgentId)) {
                log.warn("Requested agentId {} overridden by session-bound agentId {}", 
                         requestedAgentId, boundAgentId);
            }
            return boundAgentId;
        }
        // 未绑定 → 使用请求参数中的 agentId
        return requestedAgentId;
    }

    public Long findBoundAgentId(String sessionId) {
        if (StrUtil.isBlank(sessionId)) {
            return null;
        }
        return agentConversationRepository.findBySessionIdAndDelFlag(sessionId, 0)
                .map(AgentConversation::getAgentId)
                .orElse(null);
    }
}

看代码:[AgentResolver.java] 会话级 Agent 解析器,核心原则:会话绑定优先于请求参数

这个设计的精妙之处在于:对 Controller 完全透明 。Controller 创建会话时调用 BusinessAgentResolver,后续发消息时调用 AgentResolver------Controller 永远不需要知道具体用了哪个 Agent,也永远不需要在前端传来传去 agentId。

启动时预加载:AgentPropertiesLoader

每个 Agent 的 API Key、API Secret、工作流 ID 都存在数据库里(agent_properties 表)。如果每次请求都去查库,开销太大。

这个系统用了启动时全量缓存的策略:

java 复制代码
@Component
@Slf4j
@RequiredArgsConstructor
public class AgentPropertiesLoader implements CommandLineRunner {

    private final AgentPropertiesService agentPropertiesService;
    private final ConcurrentHashMap<Long, AgentPropertiesDO> agentCacheById = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<String, AgentPropertiesDO> agentCacheByName = new ConcurrentHashMap<>();

    @Override
    public void run(String... args) {
        List<AgentPropertiesDO> agents = agentPropertiesService.listActiveAgents();
        for (AgentPropertiesDO agent : agents) {
            agentCacheById.put(agent.getId(), agent);
            agentCacheByName.put(agent.getAgentName(), agent);
        }
        log.info("Loaded {} agent properties into cache", agents.size());
    }

    public AgentPropertiesDO getByAgentId(Long agentId) {
        return agentCacheById.get(agentId);
    }

    public AgentPropertiesDO getByAgentName(String agentName) {
        return agentCacheByName.get(agentName);
    }
}

看代码:[AgentPropertiesLoader.java] 实现 CommandLineRunner,应用启动时一次性加载所有 Agent 配置到内存,同时维护 ID 和 Name 两个索引。

双索引设计(byId + byName)让 BusinessAgentResolver 按名称查和 AgentResolver 按 ID 查都能 O(1) 命中。


边界情况与陷阱

陷阱一:运营改了 Agent 名称,缓存还是旧的。

AgentPropertiesLoader 只在启动时加载一次,运行期如果运营在后台修改了 Agent 的名称或 API Key,缓存不会自动刷新。

解法:提供一个 refresh() 方法供后台管理接口调用,或者在更新 Agent 配置的接口里主动刷新缓存。当前这个系统的做法是重启生效------适合 Agent 变更频率低的场景。

陷阱二:场景找不到 Agent 时返回什么?

很多同学会写成 return null,然后上层代码 agent.getId() 直接 NPE,线上排查到崩溃。

这个项目的做法是:直接抛 ClientException,带明确错误码 。这样上层不用判空,出了问题错误信息也一目了然:AGENT_CONFIG_NOT_FOUND,而不是 NullPointerException at line 97

陷阱三:会话绑定的 Agent 被删了怎么办?

如果一个会话正在进行中,运营把对应的 Agent 删除了(软删除 del_flag=1),下一次消息请求时 agentPropertiesLoader.getByAgentId() 查不到,会抛出 Agent_NULL 错误。

解法:在 AgentPropertiesLoader 中维护时可以只缓存 del_flag=0 的记录,但删除操作需要校验------如果 Agent 还有活跃会话,提示运营先结束会话再删除。


对比表格:Agent 路由的几种方案

方案 核心思路 优点 缺点 适用场景
前端直传 agentId 前端选择 Agent 后传 id 给后端 实现最简单 前端耦合重,切换 Agent 要改前端代码 只有 1-2 个 Agent 的原型项目
后端 Switch-Case 硬编码 Controller 里 if-else 判断场景调不同 Agent 对前端透明 每加一个场景要改 Controller 代码并重新上线 Agent 数量和场景都不变的简单系统
配置驱动 + 多级 Fallback(本项目) yml 配置 + 枚举定义 + 三级匹配链 运营可配,代码不改动;别名机制防运营改名炸系统 复杂度略高,需要理解 Fallback 链 多 Agent、运营频繁调整的中大型项目
规则引擎(Drools/表达式) 用规则引擎根据请求属性动态路由 极度灵活,可动态加规则 学习成本高,调试困难,性能开销 Agent 数量 50+ 的超大型系统

一句话总结:当你的 Agent 数量在 3-20 个之间,且运营经常调整配置时,"配置驱动 + 多级 Fallback"是最佳性价比方案。


面试追问

追问 1:如果同一个用户的同一个会话里需要切换 Agent(比如前 5 轮用通用聊天,第 6 轮切到出题模式),你的架构怎么支持?

→ 回答方向:在 AgentResolver.resolveAgentId() 中,当前逻辑是"绑定优先于请求",如果要支持切换,可以引入"Agent 升级"逻辑------check 请求中的 agentId 是否合法(不是任意 Agent,而是允许切换的场景列表),合法则更新会话绑定的 agentId。

追问 2:AgentPropertiesLoader 用 ConcurrentHashMap 满足需求吗?如果 Agent 数量上万呢?

→ 回答方向:当前场景 Agent 数量通常不超过 50,ConcurrentHashMap 完全够用。如果上万水平,考虑 Caffeine 或 Guava Cache 加过期策略。但更大的问题是------什么业务会有上万个 Agent?可能需要反思 Agent 粒度的设计是否合理。

追问 3:BusinessAgentBindingProperties 用 Switch 表达式映射场景到配置,如果场景扩展到 50 个怎么办?

→ 回答方向:可以在 yml 里直接用 Map 结构:agent-binding: { "scene-code-1": "agent-name-1", ... },然后用 Map<String, String> 接收,动态 get。但当前 5 个场景用 Switch 的好处是编译期安全检查------yml 里拼错了一个 key,IDEA 里直接红线。


总结

多智能体路由的核心不是"怎么调 Agent",而是"怎么让调用方不关心调的到底是哪个 Agent"。

读完这篇你应该能:

  • 用枚举 + 配置文件定义业务场景到 Agent 的映射关系
  • 设计多级 Fallback 链让系统对运营操作更鲁棒
  • 实现会话级别的 Agent 绑定,并知道在什么场景下需要支持切换
  • 在面试时说出"配置驱动路由 + LinkedHashSet 去重保序 + 启动预加载双索引缓存",而不只是"用 if-else 分一下"
相关推荐
qq_452396231 小时前
第十篇:《软件测试的未来:AI测试、DevOps与测试左移》
运维·人工智能·devops
IPHWT 零软网络1 小时前
从选型角度看语音网关国产化:以MX8G-A为列的架构与价值分析
人工智能·架构·信创·国产化·语音网关
武子康1 小时前
调查研究-142 全球机器人产业深度调研报告【04篇】机器人产业利润池全景:谁最容易赚钱与十大判断指标
大数据·人工智能·ai·机器人·具身智能·openclaw
阿标在干嘛1 小时前
政策快报如何让推荐准确率从8%提升到16%?画像系统实践
java·大数据·人工智能
涛声依旧-底层原理研究所1 小时前
防止Agent胡来五大安全防线
人工智能·python
我是谁??1 小时前
【1】基于 GTX1660 Super + Docker + YOLOv8 的目标检测训练完整实践(Ubuntu22.04)
人工智能·yolo·目标检测
拓朗工控1 小时前
工业视觉检测工控机采购的技术避坑指南
人工智能·计算机视觉·视觉检测·工业电脑·视觉工控机
RSTJ_16251 小时前
PYTHON+AI LLM DAY FIFITY-THREE
开发语言·人工智能·python
programhelp_1 小时前
Roblox Coding OA 面经分享|题量不小,但整体更偏工程思维
人工智能·算法·面试