朋友们,我最近在看 Agent 相关架构时发现一个高频面试题:Agent技能路由用检索还是大模型?如果你去翻网上的答案,大概率翻不到几篇真正讲懂这个决策的文章,更多的是一句“让模型自己选”就带过了。
但技术方案从来不是二选一,高并发在线系统也不会允许你无脑交一个 Token 给模型去“顿悟”。这个问题的本质是:在一个多技能、多工具的 Agent 系统里,如何又快又准地把用户意图分发到正确的技能处理器上。想通了这一点,面试时你不但能给出完整方案,还能讲清楚权衡依据、混合策略和降级方案,后面我会逐步展开。
今天这篇文章,我会从概念拆起,给你一套可运转的技能路由实践方案,包括:
- 检索式路由(向量检索 + 规则召回)的实现思路;
- 大模型路由(LLM Router + 结构化输出)的配置方法;
- 混合路由器架构与回退策略;
- 以及线上性能观测和避坑建议。
无论你是正在做 Agent 应用开发,还是准备面试,这篇文章都值得收藏。
1. 背景与核心概念
1.1 什么是 Agent 技能路由
翻译成大白话就是:系统里挂了很多个技能模块,比如查天气、写代码、搜专利、办工单、调数据库。用户来了一句请求后,系统先判断这句话该调用哪个技能模块,再把请求转发过去,这个过程就是技能路由。
举个例子:
- 用户说“今天北京天气怎么样?” → 路由到天气查询技能;
- 用户说“帮我把这段 Python 代码改成 Java” → 路由到代码转换技能;
- 用户说“查一下当前订单审批卡在哪个节点” → 路由到工单/流程查询技能。
如果只有两三个技能,硬编码规则也能扛;但到了几十个技能规模时,路由就必须可扩展、可维护、可观测。于是就有了检索式路由和大模型路由这两个流派。
1.2 路由问题为什么面试高频
面试官问这个问题,通常不是考你会不会调接口,而是看你能不能把意图识别、检索系统、大模型调度这三件事组合起来。一个合格的 Agent 架构师必须回答清楚:
- 为什么不能无脑“让模型自己选”?
- 为什么不能全量规则写死?
- 如何让路由准确率、延迟、成本可量化?
如果你只回答“让模型自己选”,表面上是把决策权交给了大模型,实际上是放弃了:可控性、可观测性和成本管理。面试官一听就知道你没做过高并发或生产级 Agent。
1.3 检索与大模型路由的本质区别
| 维度 | 检索式路由 | 大模型路由 |
|---|---|---|
| 核心机制 | 通过向量相似度、关键词、规则找到最匹配技能 | 利用模型语义理解直接输出技能 ID |
| 延迟 | 低,毫秒级 | 较高,几百毫秒到秒级 |
| 成本 | 低,近乎固定 | 高,按 Token 计费 |
| 可解释性 | 高,能看到匹配分数和候选排名 | 较低,需要额外做溯源 |
| 扩展性 | 新技能只需注册+向量化 | 新技能需维护提示词和候选集 |
| 适合场景 | 技能数量多、请求量大的线上系统 | 意图复杂、规则难以覆盖的冷启动场景 |
你会发现,二者不是替代关系。很多生产级 Agent 系统采用检索为主 + 大模型兜底的混合路线,让低延迟需求和冷门长尾请求各得其所。
2. 检索式路由:先看匹配度再谈智能
2.1 为什么先说检索
结构化系统的第一原则是确定性优先。技能路由本质上是一个分类问题,分类问题最稳妥的做法是先把答案范围收敛出来,再决定要不要加智能处理。检索式路由的核心假设是:只要技能描述足够完整、向量空间足够好,大部分请求可以直接命中正确技能。
它的另一个天然优势是可审计。每条请求路由时,系统可以记录命中的技能、匹配分数、Top-K 候选,出现问题能快速归因。
2.2 最小实现步骤
假设你有以下技能:
skills = [ { "id": "weather_query", "name": "天气查询", "description": "查询任意城市的当前天气、未来 7 天预报、空气质量、紫外线指数等气象数据。", "keywords": ["天气", "温度", "降雨", "空气质量", "天气预报"], }, { "id": "code_convert", "name": "代码转换", "description": "将一段源代码从一种编程语言转换为另一种语言,例如 Python 转 Java、JavaScript 转 TypeScript。", "keywords": ["代码", "python", "java", "转换", "转"], }, { "id": "patent_search", "name": "专利检索", "description": "检索专利数据库,查找专利标题、摘要、申请人、公开号、法律状态等专利信息。", "keywords": ["专利", "申请号", "公开号", "patent", "发明"], }, ]先做最简单 BM25 召回:
from rank_bm25 import BM25Okapi # 把技能描述和关键词拼接成可检索文本 corpus = [ f"{s['name']} {' '.join(s['keywords'])} {s['description']}" for s in skills ] tokenized_corpus = [doc.split() for doc in corpus] bm25 = BM25Okapi(tokenized_corpus) def bm25_route(query: str, top_k: int = 1): tokenized_query = query.split() scores = bm25.get_scores(tokenized_query) ranked = sorted(zip(scores, skills), key=lambda x: x[0], reverse=True) return ranked[:top_k] print(bm25_route("北京明天会下雨吗"))输出结果:
[(5.31, {'id': 'weather_query', ...})]这种简单的关键词召回能解决少量技能的冷启动问题,但要想处理同义改写、模糊表达,就需要升级为向量检索,把“下雨”和“降水概率”映射到相近的向量区间。
2.3 向量数据库选型与配置
生产环境我一般建议把技能描述离线向量化,存入向量数据库,例如 LiteLLM、Milvus、Qdrant、Chroma、pgvector 等,在线查询时通过 embedding 模型把用户请求编码成向量,再检索 Top-K。
以下是使用 Qdrant 的示意:
from qdrant_client import QdrantClient from qdrant_client.models import Distance, VectorParams, PointStruct client = QdrantClient(host="localhost", port=6333) COLLECTION_NAME = "skill_router" # 创建集合:向量维度需与 embedding 模型输出维度一致 client.recreate_collection( collection_name=COLLECTION_NAME, vectors_config=VectorParams(size=1024, distance=Distance.COSINE), ) # 写入技能向量 vectors = model.encode([f"{s['name']} {s['description']}" for s in skills]) points = PointStruct( id=idx, vector=vectors[idx].tolist(), payload=s, ) client.upsert(collection_name=COLLECTION_NAME, points=points)在线的路由查询:
query_vec = model.encode([user_query]) hits = client.search( collection_name=COLLECTION_NAME, query_vector=query_vec[0].tolist(), limit=3, )需要注意的坑点:
- 维度要匹配:更换 embedding 模型后,旧集合的向量维度会失效,需要重建集合并重新入库。
- 阈值要调:不要只看 Top-1,要设置最低得分阈值,得分过低时说明技能库里没有合适技能,进入兜底分支。
- 技能描述质量决定上限:向量检索对描述编写方式敏感,建议采用“功能 + 触发词 + 示例问法”三段式描述,比一句话简介效果好很多。
2.4 混合检索 + 多路召回
搜索引擎的经验放在技能路由里同样适用。只做向量检索有时会漏掉精确关键词,只做 BM25 又扛不住口语化表达。成熟的方案是向量检索 + BM25 多路召回,最后用 RRF(Reciprocal Rank Fusion)合并排序。
def reciprocal_rank_fusion(ranked_lists, k=60): scores = {} for ranked_list in ranked_lists: for rank, doc in enumerate(ranked_list): doc_id = doc["id"] scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank + 1) return sorted(scores.items(), key=lambda x: x[1], reverse=True)把向量 Top-20 和 BM25 Top-20 同时交给 RRF 合并,最终取 Top-5,再叠加规则过滤(例如“专”字出现时强制优先专利检索),召回质量和稳定性都能显著提升。
3. 大模型路由:给 Agent 装上判断力
3.1 为什么要用大模型路由
检索式路由的天花板在于:它只能在现有的技能描述里找关联,如果用户问法非常飘逸、绕弯子,或者技能之间边界模糊,检索得分就容易失真。这时候,大模型的语义理解和推理能力能帮上忙。
大模型路由最常见的实现方式是:
- 把技能清单和说明写进 System Prompt;
- 让模型输出一个 JSON,包含技能 ID 和置信度;
- 程序解析 JSON,决定调用哪个技能或进入兜底。
3.2 结构化 Prompt 示例
ROUTER_SYSTEM_PROMPT = """ 你是一个 Agent 技能路由器。根据用户输入,从以下技能中选择最合适的一个。 技能清单: {skill_list} 输出要求: 1. 只输出 JSON,不要输出多余文本。 2. JSON 格式:{{"skill_id": "xxx", "confidence": 0.0-1.0, "reason": "简短判断理由"}} 3. 如果没有合适技能,输出 {"skill_id": "fallback", "confidence": 0.0, "reason": "没有匹配技能"} 4. 一个用户输入只能选一个技能。 """调用代码:
import json from openai import OpenAI client = OpenAI() def llm_route(user_query: str, skills: list) -> dict: skill_lines = "\n".join([ f"- {s['id']}: {s['name']}。功能:{s['description']}" for s in skills ]) resp = client.chat.completions.create( model="gpt-4o-mini", # 按实际环境调整 messages=[ {"role": "system", "content": ROUTER_SYSTEM_PROMPT.format(skill_list=skill_lines)}, {"role": "user", "content": user_query}, ], temperature=0, response_format={"type": "json_object"}, ) content = resp.choices[0].message.content return json.loads(content)这段代码里最关键的不是 model_name,而是temperature=0和response_format={"type": "json_object"}。
- temperature=0 是让输出尽量确定,尽量不发散;
- 结构化输出是让下游解析稳定,不背解析的锅。
3.3 大模型作为校验器的进阶玩法
大模型路由不一定要做“第一棒”。更稳的模式是让检索先出候选,大模型只做重排/校验:
- 检索 Top-5 候选送到大模型;
- 大模型在候选技能里二选一或三选一;
- 如果候选都不合适,大模型给出“无匹配”,走兜底流程。
这个模式的收益非常大:检索负责缩小范围、控制成本和延迟,大模型负责最后决策,准确率比纯检索高,延迟和成本比纯大模型路由低。我推荐生产系统按这个思路设计。
4. 混合路由架构与降级策略
4.1 推荐的系统流程
我构建过的 Agent 技能路由系统一般分三层:
第一层:规则层 - 精确关键词匹配(如 "专利"、"天气") - 强制路由(如用户明确指定技能名) 第二层:检索层 - 向量检索 + BM25 多路召回 + RRF 合并 - 命中阈值以上直接返回技能 第三层:模型层 - 检索未命中/低置信度时,交给大模型路由 - 大模型输出技能 ID 与置信度 - 置信度过低时走兜底流程(多轮澄清、FAQ、转人工)伪代码:
def route(user_query: str, threshold=0.75): # 第一层:规则 forced = rule_route(user_query) if forced: return {"skill_id": forced, "engine": "rule"} # 第二层:检索 retrieved = retrieval_route(user_query) # 返回 (技能id, 分数) if retrieved.score >= threshold: return {"skill_id": retrieved.id, "engine": "retrieval"} # 第三层:大模型 llm_result = llm_route(user_query) if llm_result.confidence >= threshold: return {"skill_id": llm_result.skill_id, "engine": "llm"} return {"skill_id": "fallback", "engine": "fallback"}4.2 为什么混合架构更适合生产
单一方案在生产里往往不抗造:
- 纯规则:技能一多,规则维护成本爆炸;
- 纯检索:长尾和模糊场景误判率高;
- 纯大模型:成本高、延迟高、且大模型偶发的“幻觉技能 ID”非常致命;
- 纯“让模型自己选”:你失去了对系统行为的控制边界,出了问题很难解释。
混合架构的优势在于:用规则保证高频确定场景的稳定性,用检索吃掉大部分常规请求,把最难的、最异常的交给大模型,同时设置回退和日志观测。
4.3 降级策略:路由挂了怎么办
线上系统必须有降级预案。常见的降级策略有:
| 组件状态 | 策略 |
|---|---|
| 向量数据库不可用 | 降级到 BM25 纯关键词路由 |
| 大模型不可用/超时 | 走检索结果直接返回,即使置信度不高也返回 Top-1 并提示确认 |
| 检索分数都过低 | 直接走兜底话术,不再调用大模型,避免把脏请求放大 |
| 全部不可用 | 返回默认技能(如 FAQ 或人工客服)并记录异常 |
另一条非常重要的原则:路由链路必须设置超时时间,尤其是大模型调用。例如检索层 20ms 内完成,大模型层 1s-2s 超时,整体路由时间控制在 2s 以内,防止技能分发成为整个 Agent 的瓶颈。
5. 实战:一个完整的技能路由 Demo
下面我们用 Python 写一个最小的混合路由服务,技能还是刚才那三个。你可以直接复制到本地跑通。
5.1 项目结构
skill-router-demo/ ├── main.py # 入口脚本 ├── skills.py # 技能注册表 ├── router.py # 路由逻辑 └── requirements.txt # 依赖5.2 技能注册表 skills.py
SKILLS = [ { "id": "weather_query", "name": "天气查询", "description": "查询任意城市的当前天气、未来 7 天预报、空气质量、紫外线指数等气象数据。", "keywords": ["天气", "温度", "降雨", "空气质量", "天气预报", "下雪"], }, { "id": "code_convert", "name": "代码转换", "description": "将一段源代码从一种编程语言转换为另一种语言,例如 Python 转 Java、JavaScript 转 TypeScript。", "keywords": ["代码", "python", "java", "javascript", "typescript", "转换", "翻译代码"], }, { "id": "patent_search", "name": "专利检索", "description": "检索专利数据库,查找专利标题、摘要、申请人、公开号、法律状态等专利信息。", "keywords": ["专利", "申请号", "公开号", "patent", "发明", "知识产权"], }, ] FALLBACK_SKILL_ID = "fallback"5.3 路由逻辑 router.py
import json from rank_bm25 import BM25Okapi from openai import OpenAI class SkillRouter: def __init__(self, skills): self.skills = skills corpus = [ f"{s['name']} {' '.join(s['keywords'])} {s['description']}" for s in skills ] tokenized_corpus = [doc.split() for doc in corpus] self.bm25 = BM25Okapi(tokenized_corpus) self.client = OpenAI() def rule_route(self, query: str): """第一层:规则强制命中""" for skill in self.skills: for kw in skill["keywords"]: if kw in query: return skill["id"] return None def retrieval_route(self, query: str, threshold: float = 1.2): """第二层:BM25 检索命中(生产上可替换为向量检索)""" tokenized_query = query.split() scores = self.bm25.get_scores(tokenized_query) ranked = sorted( zip(scores, self.skills), key=lambda x: x[0], reverse=True ) best_score, best_skill = ranked[0] if best_score >= threshold: return best_skill["id"], best_score return None, best_score def llm_route(self, query: str): """第三层:大模型路由""" skill_lines = "\n".join([ f"- {s['id']}: {s['name']}。功能:{s['description']}" for s in self.skills ]) system_prompt = f""" 你是一个 Agent 技能路由器。根据用户输入,从以下技能中选择最合适的一个。 技能清单: {skill_lines} 输出要求: 1. 只输出 JSON,不要输出多余文本。 2. JSON 格式:{{"skill_id": "xxx", "confidence": 0.0-1.0, "reason": "简短判断理由"}} 3. 如果没有合适技能,输出 {{"skill_id": "fallback", "confidence": 0.0, "reason": "没有匹配技能"}} """ resp = self.client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": query}, ], temperature=0, response_format={"type": "json_object"}, ) return json.loads(resp.choices[0].message.content) def route(self, query: str): forced = self.rule_route(query) if forced: return {"skill_id": forced, "engine": "rule"} best_id, best_score = self.retrieval_route(query) if best_id: return {"skill_id": best_id, "engine": "retrieval", "score": best_score} llm_result = self.llm_route(query) skill_id = llm_result.get("skill_id") confidence = llm_result.get("confidence", 0.0) if skill_id and skill_id != FALLBACK_SKILL_ID and confidence >= 0.6: return {"skill_id": skill_id, "engine": "llm", "confidence": confidence} return {"skill_id": FALLBACK_SKILL_ID, "engine": "fallback"}5.4 入口测试 main.py
from skills import SKILLS from router import SkillRouter router = SkillRouter(SKILLS) test_queries = [ "上海明天降温吗", "把下面这段 Python 代码转成 Java", "帮我查一下华为申请的专利公开号", "今天心情不错推荐一部电影", ] for q in test_queries: result = router.route(q) print(f"Query: {q}\nRoute -> {result}\n")5.5 运行结果与解释
Query: 上海明天降温吗 Route -> {'skill_id': 'weather_query', 'engine': 'rule'} Query: 把下面这段 Python 代码转成 Java Route -> {'skill_id': 'code_convert', 'engine': 'rule'} Query: 帮我查一下华为申请的专利公开号 Route -> {'skill_id': 'patent_search', 'engine': 'retrieval', 'score': ...} Query: 今天心情不错推荐一部电影 Route -> {'skill_id': 'fallback', 'engine': 'fallback'}前两个因为关键词命中直接走规则层;第三个通过检索分数命中;第四个既没有关键词、检索分数也不高,于是交给大模型。如果大模型也认为没有匹配技能,就回退到 fallback。这就是一个完整的闭环。
需要注意的是,上面用了 BM25 模拟检索层,真实项目建议换用向量检索,召回效果会好很多。
6. 常见问题与排查思路
6.1 路由命中错误技能
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| “查下深圳天气”命中专利检索 | 技能描述中关键词重叠或向量空间纠缠 | 检查技能描述,尽量减少歧义词;增加测试集做回归 |
| 长句子命中多个技能 | 多路召回时阈值过低 | 调高检索阈值,或者让大模型做二选一 |
| 新增技能后旧请求全部误路由 | 新技能的描述风格差异大 | 统一技能描述格式,做好语义相似度检查 |
排查思路很简单:把路由的每一层日志打出来,看看是规则层、检索层还是模型层误判,再针对那一层做优化。不要把问题混在一起排查。
6.2 延迟和成本超预期
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 路由平均耗时 2 秒以上 | 大模型调用成为主要瓶颈 | 加缓存、加超时、提升检索层命中率 |
| 大模型调用量占比高 | 检索阈值设置偏高 | 降低阈值,让更多请求在检索层闭环 |
| 检索结果不稳定 | embedding 模型频繁切换 | 固定 embedding 模型版本 |
6.3 大模型输出 JSON 解析失败
可能原因:
- 模型未启用 JSON Mode;
- 模型返回了额外解释文字;
- 提示词里 JSON 示例格式有问题。
解决:
# 解析失败时做一次容错 try: result = json.loads(content) except json.JSONDecodeError: # 尝试提取大括号部分 start = content.find("{") end = content.rfind("}") + 1 result = json.loads(content[start:end])但更推荐从源头解决:response_format={"type": "json_object"}或使用函数调用(Function Calling)强制结构化输出。
7. 最佳实践与工程建议
7.1 技能描述是路由质量的地基
无论你最终选择检索还是大模型,技能描述的质量直接影响路由准确率。建议每条技能描述统一为三段式:
功能定位(一句话) 具体能力(列 3-5 个细分能力点) 示例问法(提供真实用户可能说的 2-3 个问句)示例:
技能ID: patent_search 功能定位: 查询专利信息和法律状态 具体能力: 支持按申请人、公开号、申请号、IPC分类号检索;可查询法律状态、同族专利、引证信息。 示例问法: - 帮我查一下华为最近公开的发明专利 - 查询专利公开号 CN123456789A 的法律状态这样写的好处是:关键词维度能覆盖,语义向量空间的边界也更清晰。
7.2 建立路由评估集
很多 Agent 项目最大的问题是没有评估集,导致改一版路由,线上效果像开盲盒。我建议从第一天就维护一份路由评测集:
[ { "query": "帮我查一下华为申请的专利公开号", "expected_skill_id": "patent_search", "engine_hint": "any" }, { "query": "上海明天会下雨吗", "expected_skill_id": "weather_query", "engine_hint": "any" } ]每次调整技能描述、更换 embedding 模型、改 Prompt 时,都跑一遍评测集,记录准确率变化。没有评估集的路由系统,优化就是玄学。
7.3 安全与权限边界
技能路由还承担着安全网关的角色,必须坚持最小权限原则:
- 不同技能配置独立的权限校验,不要用一个 Agent 主令牌打通所有技能;
- 路由层对用户输入做敏感词和注入检测,不能把恶意指令转发给高权限技能;
- 生产环境变更路由策略时,先在灰度环境验证,再逐步放流量;
- 涉及删除、写操作、生产数据的技能,路由命中后需要二次确认。
7.4 可观测性是最终底线
路由日志至少记录以下字段:
| 字段 | 说明 |
|---|---|
| request_id | 链路追踪 ID |
| user_id | 用户标识(脱敏) |
| query | 原始请求 |
| engine | 命中引擎:rule / retrieval / llm / fallback |
| skill_id | 最终技能 |
| score | 检索分数或模型置信度 |
| latency_ms | 路由耗时 |
| llm_reason | 大模型判断理由(如有) |
有了这些数据,你可以定期分析:哪些请求到了 fallback?哪些技能老是抢别人的流量?哪些 query 耗时特别长?路由优化就能从感性决策变成数据决策。
8. 回到面试题:到底用检索还是大模型
最后回到开头那个问题。
面试官问“Agent技能路由该用检索还是大模型”,不是让你二选一,而是看你有没有架构思维。你可以这样回答:
技能路由不能简单二选一。我的方案是分层混合路由:第一层用规则处理确定性的显式指令;第二层用向量检索加 BM25 多路召回覆盖大多数常规请求,保证低延迟和低成本;第三层才用大模型处理长尾和模糊query,同时设置置信度阈值和 fallback。这样既控制了成本,也保住了准确性,还能通过日志观测逐步优化。
如果面试官继续追问“那如果只有一个选择呢”,你可以说:
如果资源充足、对延迟不敏感,可以先用纯大模型路由快速验证效果;如果技能量很大、请求量很高,那检索一定是主路由,大模型只做兜底。最推荐的做法是用评测集量化对比,而不是拍脑袋选型。
这样回答,既有方案又有取舍,还能体现你对成本和工程落地的理解,深度完全不一样。
希望这篇技能路由实战文章对你的 Agent 开发和面试准备有实际帮助。如果觉得内容有用,欢迎收藏备用;如果你在项目里实践了这套方案,欢迎在评论区聊聊你的路由命中率和踩坑经历。