最近在整理 Agent 相关的面试问题时,看到这样一个提问:一个 Agent 系统里挂了上百个技能,用户的请求进来之后,到底应该让大模型自己决定调用哪个技能,还是先用检索的方式把候选技能筛出来?如果你的第一反应是“让模型自己选不就行了”,那这道题大概率已经是一个减分项了。
这个问题的专业叫法是技能路由(Skill Routing),它是 Agent 架构里最不起眼但最影响体验的决策层。从表面看,它只是“选择哪个技能”这么简单,实际上它决定了系统的延迟、成本、稳定性、可解释性,甚至安全边界。很多人把 ReAct 这类框架里“模型每步自己决定工具调用”当成默认答案,忽略了在技能规模变大之后,这条路会越走越窄。
这篇文章我会从面试官想听什么讲起,再拆解检索式路由和模型式路由的本质区别,最后给出一套工程上可落地的分层路由实现。你不需要背八股,照着这套思路去理解 Agent 技能路由,就能回答得比大多数候选题更扎实。
1. 面试官真正想听的不是“二选一”
1.1 一个容易被低估的面试题
很多 Agent 相关岗位的面试题看起来是在问工具选型,其实是在考察系统设计能力。技能路由这个问题最有意思的地方在于:它没有一个标准答案,但有一个标准的思考框架。
一个候选人如果只说“用大模型选更智能”,面试官立刻会追问:
- 你的 Agent 挂了 500 个技能,上下文放不下怎么办?
- 模型选错了技能,你怎么发现、怎么回滚?
- 一次路由调用要花多少 Token、多少延迟?
- 高风险技能被模型误调用了,谁负责?
所以面试官真正想听到的,是你对“决策成本”“可控性”“可观测性”这三个维度的理解,而不是单纯的技术名词。
1.2 无脑让模型选,错在哪里
“让模型自己选”这个回答之所以显得业余,是因为它把路由问题简化成了一个提示词问题。在只有三五个技能的原型项目里,这种方案确实可行。但一旦进入生产环境,技能数量增长到几十甚至几百,模型面对一长串工具描述时,选择准确率会明显下降。
更关键的是,模型决策是一个概率过程。同一个请求,今天可能会选技能 A,明天模型服务升级后可能就选技能 B。这种不确定性对 Agent 产品来说是不可接受的。用户不会因为“模型今天心情不好”而原谅你调错了工具。
1.3 技能路由的本质是决策问题
剥开所有技术术语,技能路由的本质是一道决策题:给定一个用户请求,如何从技能集合中选出最合适的那个执行单元。
这道决策题有三个约束:
- 要快。路由层不应该成为 Agent 响应链路的主要瓶颈。
- 要准。选错技能的代价不只是浪费一次调用,还可能导致后续流程全部走偏。
- 要能解释。线上出问题时,必须能回答“为什么调用了这个技能”。
带着这三个约束再去看检索式路由和模型式路由,思路就会清晰很多。
2. 先搞清楚:技术路由到底在路由什么
2.1 Agent 技能到底是什么
在讨论路由之前,先统一概念。所谓技能(Skill),在 Agent 系统里通常指一个可以被模型或程序调用的原子能力,比如:
- 查询订单接口
- 天气查询 API
- 发送即时消息
- 调用内部工单系统
- 执行一段 SQL
- 调用第三方 SaaS 服务
工程实现上,技能往往被描述为一个函数、一个工具 Schema、一个 MCP(Model Context Protocol)服务,或者一个子 Agent。技能注册表(Skill Registry)就是维护这些技能元数据的中心化组件。
2.2 路由层的输入和输出
路由层的输入是用户请求,可能还包含当前对话上下文、用户身份、环境信息。路由层的输出通常是一个技能名或技能 ID。
需要特别注意,路由层不一定只做一次决策。复杂 Agent 中,路由可能发生在多个层级:
- 主 Agent 决定先调用哪个业务域
- 业务域内部再决定调用哪个具体技能
- 技能执行过程中可能还会涌现新的子任务
所以一个健壮的技能路由设计,必须支持多级路由,并且每一级都能独立观测、独立降级。
2.3 技能数量膨胀是路由问题的导火索
很多人对技能路由重视不足,是因为做 Demo 时只有两三个技能,模型闭着眼睛都能选对。一旦技能数量膨胀,问题立刻暴露:
- 所有技能描述加起来可能超过模型上下文窗口
- 相似技能变多,模型难以区分
- 新技能上线后,模型没有稳定机制去学会使用它
- 路由日志不规范,选错后难以复盘
因此,技能路由不是一个“有就行”的模块,而是决定 Agent 能否从原型走向生产的核心组件。
3. 检索式路由:把选择问题变成匹配问题
3.1 检索式路由的三种形态
检索式路由的核心思想是:不直接让大模型做选择题,而是通过事先构建好的索引或规则,快速缩小技能候选范围。常见形态有三种。
第一种是规则匹配。通过关键词、正则、用户意图映射表来匹配技能。比如请求中包含“订单”,直接命中订单查询技能。这种方式最简单,准确率高,但覆盖不全,无法处理语义变化。
第二种是分类器路由。训练一个轻量级文本分类模型,将用户请求分类到不同技能。这种方式比关键词灵活,但需要训练数据,而且分类器本身需要持续维护。
第三种是向量检索路由。把技能描述和用户请求都转换成向量,通过余弦相似度或点积计算相关性,返回 Top-K 个技能。工程实践上通常会结合 BM25 做混合检索,实现“向量召回 + 关键词召回 + 多路融合”,这样召回效果会更稳定。
3.2 检索式路由的优点
检索式路由最大的优势是快。规则匹配和向量检索的耗时通常是毫秒级,远低于大模型推理的耗时。成本上,检索式路由不需要消耗 Token,尤其是向量检索,在离线建好索引后,在线只需要做一次向量计算。
另一个优势是可解释性强。规则命中就是命中,向量检索可以输出得分,这些信息很容易写入日志,方便排障。相比之下,模型的解释文本往往只是“事后补理由”,并不能真正反映决策过程。
3.3 检索式路由的短板
检索式路由也有明显边界。它本质上是在做相关性匹配,无法完成复杂的语义推理。比如用户问“订单被取消了,钱什么时候退回来”,这句话里没有“订单”和“退款”关键词的直接组合,规则匹配会很吃力,向量检索也可能召回多个相似技能,无法精确判断。
所以检索式路由往往不能单独作为最终决策器,它是“召回层”,而不是“精排层”。
4. 大模型式路由:靠推理做最终决策
4.1 什么是模型式路由
所谓大模型路由,就是把技能列表和用户请求一起交给大模型,让模型输出要调用的技能名。实现方式有两种主流做法。
一种是纯提示词路由。在系统提示词中列出所有技能的名称和描述,让模型从列表中选择一个。另一种是基于 Function Calling 的路由,把每个技能定义成函数,模型在推理过程中返回函数名和参数。
基于 Function Calling 的做法更可靠,因为模型输出的格式被约束住了,不需要再从文本中解析技能名。现在主流的 Agent 框架,比如 LangChain、LlamaIndex,底层都是这么做的。
4.2 大模型路由的优点
模型式路由最突出的优势是语义理解能力强。它能处理自然语言表达中的省略、指代、意图转换,不需要人工为每个技能设计关键词和规则。在技能数量较少、描述清晰、用户表达比较多样化的场景下,模型路由的效果很好。
另外,模型路由天然具备泛化能力。新技能上线时,只要把技能描述补充到提示词里,模型大概率能正确使用,不需要重新训练分类器。
4.3 大模型路由的短板
模型路由的短板也很致命,主要体现在三个方面。
第一,延迟和成本高。一次路由调用就是一次大模型推理,按照目前主流模型的响应速度,通常会比检索多出几百毫秒到数秒。如果需要反复调用,Token 成本会持续累积。
第二,稳定性不可控。模型服务升级、提示词微调、上下文长度变化,都会影响路由结果。你可能上线前一天测试全通过,第二天模型服务商调整了参数,路由结果就变了。
第三,技能数量有限制。所有技能描述都塞进提示词,会快速消耗上下文窗口。即使上下文足够,模型也会在大量候选技能之间出现“注意力稀释”,选错概率上升。
所以模型式路由更适合做“最终决策器”,而不是“全量选择器”。
5. 为什么不能无脑让大模型自己选
5.1 技能列表膨胀后,选择准确率会下降
这是最关键的一点。假设你只有 5 个技能,每个技能描述 50 个字,模型很容易选对。当技能数量增加到 200 个,技能描述总字数可能超过 1 万甚至更多,模型需要在长文本中找到最合适的那个。人类的注意力在长列表场景下会下降,模型同样如此。
更麻烦的是相似技能。比如“查询订单详情”和“查询订单物流”,描述非常接近,模型很容易混淆。此时如果只靠模型自由选择,错误率会随着技能数量的增加而快速上升。
5.2 延迟和成本不可控
每做一次路由,就相当于多一次模型调用。在普通对话场景中,一次 Agent 完整执行可能需要打多个电话;如果每次都要模型路由,叠加起来延迟就会非常明显。对于实时性要求高的场景,比如客服机器人、语音助手,几百毫秒的差距就是完全不同的体验。
成本上也是如此。大模型的 API 按 Token 计费,技能描述、用户请求、模型输出都会产生 Token 消耗。一次两次不多,一旦 Agent 请求量上来,这笔费用会非常可观。
5.3 上下文会被技能清单占满
如果把所有技能描述都放在系统提示词里,那么这个提示词可能比真正的用户问题长几十倍。这会导致两个问题:一是用户上下文被压缩,模型难以记住关键信息;二是每轮请求都要重复发送这些技能描述,浪费大量算力。
这也是为什么很多生产级 Agent 不会把所有技能一股脑塞给模型,而是先做一轮召回,再把候选技能传给模型。
5.4 安全边界和权限管控缺失
让模型自由选技能还有一个隐蔽问题——安全。模型可能因为提示词注入而被诱导调用高风险技能,比如删除数据、发送消息、修改配置。如果路由层没有权限控制和规则拦截,模型的一次错误决策就可能造成生产事故。
比较稳妥的做法是:高风险技能必须走规则或人工审批,不能完全交给模型自动决策。安全这条线,永远不能依赖模型的“自觉”。
5.5 可观测性和复盘困难
当线上 Agent 行为异常时,你需要回答“为什么调用了这个技能”。检索式路由的答案很明确:因为关键词命中,或者向量相似度最高。模型路由的答案却很模糊:模型说它认为应该调用这个技能。
这种模糊一旦变成常态,Agent 系统的调试就会非常痛苦。你很难判断是技能描述不清晰、上下文被截断,还是模型服务本身发生了变化。
6. 工程上的正确答案:分层路由
6.1 一条核心原则
技术路由设计的核心原则可以浓缩成一句话:能用规则解决的不交给检索,能用检索解决的不交给大模型。
这不是否定大模型的价值,而是把大模型用在不可替代的地方。规则适合处理高确定性场景,检索适合处理模糊匹配场景,大模型适合处理复杂语义决策和精排。三层各司其职,整个路由系统才能在准确率、延迟、成本之间取得平衡。
6.2 三层路由架构
生产环境中比较稳妥的架构是“规则层 + 召回层 + 精排层”。
规则层:精确命中关键词、正则可以一步到位的请求,直接返回技能,不走后续流程。例如包含“订单号”的请求,直接进入订单查询技能。
召回层:规则没有命中时,通过向量检索、BM25 等多路召回方式,把候选技能缩小到 3 到 5 个。这一步要配合阈值设置,召回数量不能太大,否则交给模型的候选太长;也不能太小,否则容易漏掉正确技能。
精排层:如果召回结果中 top1 的得分显著高于 top2,可以直接采用 top1。如果 top1 和 top2 得分接近,说明存在歧义,这时候才把候选技能列表交给大模型做最终决策。
最后还要有兜底策略。当所有层级都无法确定时,应该返回“未匹配到技能”或转人工,而不是硬选一个大概率错误的技能。
6.3 什么情况下让大模型介入
大模型介入的时机非常关键。按照我的实践经验,以下三种情况适合让模型做精排:
- 召回结果中 top1 和 top2 分数差距很小
- 用户请求包含多层意图,例如“下雨导致订单取消”
- 技能描述相似度高,检索方式无法有效区分
只要满足其中一种,就可以让模型做最终裁决。但在模型决策前,候选技能列表必须已经缩小到个位数,这样才能保证决策的准确率和 Token 成本都可控。
7. 完整示例:从零实现一个分层技能路由
下面我用一个可运行的 Python 示例,把分层路由的完整流程跑通。这个示例只依赖 numpy,不需要真实的大模型 API,本地就能运行。
7.1 定义技能注册表
# skill_registry.py from dataclasses import dataclass, field from typing import List @dataclass class Skill: name: str description: str tags: List[str] = field(default_factory=list) examples: List[str] = field(default_factory=list) enabled: bool = True class SkillRegistry: def __init__(self): self._skills = {} def register(self, skill: Skill): self._skills[skill.name] = skill def get(self, name: str): return self._skills.get(name) def all(self): return list(self._skills.values())这里把技能拆成了 name、description、tags、examples 四个字段。description 给向量检索和模型精排使用,tags 给规则路由使用,examples 可以用于构建评测集和路由索引。这四个字段在工程中非常重要,它们决定了后续每层路由的信息基础。
7.2 实现规则路由和向量检索路由
# routers.py import numpy as np class RuleRouter: def __init__(self, registry: SkillRegistry): self.registry = registry def match(self, query: str) -> list: hits = [] for skill in self.registry.all(): if not skill.enabled: continue for tag in skill.tags: if tag in query: hits.append(skill.name) break return hits class VectorRouter: def __init__(self, registry: SkillRegistry, embed_func): self.registry = registry self.embed_func = embed_func self.index = {} for skill in self.registry.all(): self.index[skill.name] = embed_func( f"{skill.name} {skill.description} {' '.join(skill.examples)}" ) def _cos_sim(self, vec1, vec2): vec1 = np.asarray(vec1, dtype=np.float64) vec2 = np.asarray(vec2, dtype=np.float64) norm1 = np.linalg.norm(vec1) norm2 = np.linalg.norm(vec2) if norm1 == 0 or norm2 == 0: return 0.0 return float(np.dot(vec1, vec2) / (norm1 * norm2)) def match_with_scores(self, query: str, top_k: int = 3, threshold: float = 0.5): q_vec = self.embed_func(query) scores = [] for name, vec in self.index.items(): score = self._cos_sim(q_vec, vec) if score >= threshold: scores.append((name, score)) scores.sort(key=lambda x: x[1], reverse=True) return scores[:top_k]RuleRouter 的逻辑很简单,遍历技能所有标签,只要有一个标签出现在 query 中,就命中。VectorRouter 则把所有技能描述和示例 embedding 成向量,再计算 query 和技能向量的余弦相似度。
这里用字符 n-gram 向量模拟 embedding,目的是让你先把流程跑通。生产环境应该换成真实的 embedding 模型,比如接入本地部署的 Ollama 或 vLLM 提供的向量接口。
7.3 实现大模型精排路由
# llm_router.py import json class LLMRouter: def __init__(self, client, model="gpt-4o-mini"): self.client = client self.model = model def decide(self, query: str, candidates: list) -> dict: prompt = ( "你是一个 Agent 技能路由助手。请根据用户请求从候选中选择最合适的一个技能。\n" f"用户请求:{query}\n" f"候选技能:\n{json.dumps(candidates, ensure_ascii=False, indent=2)}\n" "只输出 JSON,格式为:{\"skill\": \"技能名\", \"reason\": \"简短理由\"}\n" ) resp = self.client.chat.completions.create( model=self.model, messages=[{"role": "user", "content": prompt}], temperature=0, ) content = resp.choices[0].message.content return json.loads(content) class MockLLMRouter: """本地验证用的模拟路由,生产环境请替换为 LLMRouter。""" def decide(self, query: str, candidates: list) -> dict: for c in candidates: if c["name"] == "get_weather" and ( "天气" in query or "下雨" in query or "带伞" in query ): return {"skill": "get_weather", "reason": "命中天气关键词"} return {"skill": candidates[0]["name"], "reason": "默认选择第一个"}LLMRouter 的核心是把“让模型选技能”变成“让模型做候选精排”。注意这里传给模型的只有候选技能列表,而不是全部技能。这解决了上下文过长和注意力稀释的问题。
temperature 设为 0,并要求只输出 JSON,是为了尽可能让路由结果稳定可解析。
7.4 组装成分层路由主流程
# hybrid_router.py class HybridRouter: def __init__(self, rule_router, vector_router, llm_router, registry, min_margin=0.05): self.rule_router = rule_router self.vector_router = vector_router self.llm_router = llm_router self.registry = registry self.min_margin = min_margin def route(self, query: str): trace = [] # 第一层:规则路由 rule_hits = self.rule_router.match(query) trace.append({"step": "rule", "candidates": rule_hits}) if len(rule_hits) == 1: return self.registry.get(rule_hits[0]), trace # 第二层:向量召回 vector_hits = self.vector_router.match_with_scores(query, top_k=3, threshold=0.5) trace.append({"step": "vector", "candidates": [h[0] for h in vector_hits]}) if len(vector_hits) == 1: return self.registry.get(vector_hits[0][0]), trace if len(vector_hits) >= 2: top1, score1 = vector_hits[0] _, score2 = vector_hits[1] if score1 - score2 >= self.min_margin: return self.registry.get(top1), trace # 第三层:大模型精排 if len(vector_hits) == 0: candidates = [s.name for s in self.registry.all() if s.enabled] else: candidates = [h[0] for h in vector_hits] trace.append({"step": "llm", "candidates": candidates}) llm_candidates = [] for name in candidates: skill = self.registry.get(name) llm_candidates.append({"name": skill.name, "description": skill.description}) decision = self.llm_router.decide(query, llm_candidates) return self.registry.get(decision["skill"]), trace这个 HybridRouter 就是分层路由的骨架。规则层命中一个技能就直接返回;向量召回只有一个高置信度命中也可以直接返回;如果 top1 和 top2 得分接近,才让大模型介入。
route 方法返回了 skill 和 trace。trace 记录了每个层级命中的候选列表,这是路由可观测性的关键。线上排查时,只要看 trace 就能知道决策路径。
需要提醒的是,如果技能数量特别大,例如超过 500 个,vector_hits 为空时把所有技能传给模型仍然不合理。这种情况下应该再加一层粗排,或者设置默认拒绝策略。
7.5 完整的本地演示代码
# demo.py import hashlib import numpy as np from skill_registry import Skill, SkillRegistry from routers import RuleRouter, VectorRouter from llm_router import MockLLMRouter from hybrid_router import HybridRouter def char_ngram_embed(text: str, dim: int = 64) -> np.ndarray: vec = np.zeros(dim) for i in range(len(text) - 1): gram = text[i:i + 2] idx = int(hashlib.md5(gram.encode("utf-8")).hexdigest(), 16) % dim vec[idx] += 1 return vec def build_registry(): registry = SkillRegistry() registry.register(Skill( name="query_order", description="查询订单的状态、金额、数量、物流进度", tags=["订单"], examples=["我的订单到哪了", "查询订单金额"], )) registry.register(Skill( name="get_weather", description="查询城市实时天气、气温、降水概率", tags=["天气"], examples=["北京今天天气", "上海会下雨吗"], )) registry.register(Skill( name="send_message", description="向指定联系人发送消息", tags=["发送", "消息"], examples=["给张三发消息", "把文件发给李磊"], )) return registry def main(): registry = build_registry() rule_router = RuleRouter(registry) vector_router = VectorRouter(registry, embed_func=char_ngram_embed) llm_router = MockLLMRouter() hybrid_router = HybridRouter(rule_router, vector_router, llm_router, registry) queries = [ "帮我查一下昨天订单金额", "北京明天天气怎么样", "把项目周报发给李磊", "明天出门要不要带伞", ] for query in queries: skill, trace = hybrid_router.route(query) print(f"请求: {query}") print(f"路由结果: {skill.name}") print(f"路由轨迹: {trace}") print("-" * 50) if __name__ == "__main__": main()这里特意做了一个 MockLLMRouter,这样你不需要申请任何模型 API 就能在本地看到完整的分层路由效果。生产环境只需要把 MockLLMRouter 换成真实的 LLMRouter,并传入对应的模型客户端。
8. 运行结果与效果验证
8.1 运行方式
环境要求是 Python 3.9 及以上,安装 numpy 即可:
pip install numpy python demo.py8.2 预期输出结构
运行后,你看到的输出应该是一个结构化的路由轨迹。以“帮我查一下昨天订单金额”为例:
请求: 帮我查一下昨天订单金额 路由结果: query_order 路由轨迹: [{"step": "rule", "candidates": ["query