大家好,我是程序员小策。
场景:你正在做一个 AI 面试系统。产品经理说:"我们不光要一个通用聊天机器人,还要一个能自动出题、能给用户答案打分、还能分析用户表情神态的面试官。"
你一拍脑袋:行,不就是多建几个 Agent 嘛,每个 Agent 配一个不同的 Prompt 不就得了?
于是你噼里啪啦建了 5 个 Agent:通用聊天官、出题官、答案评分官、神态分析官、提问官。然后在前端写了 5 个入口,各自调用各自的路由。
上线第一周,一切顺利。第二周,运营跑过来说:"能不能让用户在同一个会话里,先跟通用聊天官聊两句,然后无缝切到出题模式?"你愣了一下------哦对,之前的架构里,一个会话和一个 Agent 是硬绑定的,中间没法换。
更麻烦的是------后来老板说要加一个"英文面试官",你要改多少代码?前端 5 个入口要变成 6 个?后端每个 Controller 都加一个 switch case?
这就是我今天要聊的问题:多智能体场景下,怎么设计一个灵活、可扩展、对前端透明的 Agent 路由架构。
项目来源:https://github.com/lishuangqiang/AI-Meeting
先看核心问题
你面对的不是"怎么调一个 Agent",而是三个递进的工程问题:
- 如何定义场景与 Agent 的映射关系?------谁来决定"出题场景"用哪个 Agent?
- 如何实现会话级别的 Agent 绑定?------用户在这个会话里选了出题官,下次发消息不能跳到评分官
- 如何让这个映射关系的变更不炸代码?------运营改了 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 分一下"