1. 为什么“选模型”这件事,最后都变成了“写路由”
做过 Agent 项目的人大概都有过这么一个阶段:一开始兴致勃勃地对比各家模型,看榜单、跑评测、算成本,恨不得把每个模型的脾气都摸透。结果真到了要上线的时候,发现最头疼的根本不是“哪个模型最强”,而是“这一堆模型到底怎么协同工作”。
我最早做 Agent 的时候也踩过这个坑。当时项目里接了三个模型:一个擅长长文本理解,一个擅长工具调用,还有一个便宜量大适合做兜底。想法很美好——按任务类型分派不就行了?结果代码写出来是这样的:业务逻辑里到处是if task_type == "xxx": use_model_a(),散落在十几个文件里,改一个规则要翻半天,测试覆盖率还上不去。更麻烦的是,模型供应商一更新版本,或者某个模型突然限流,整个链路就得重新梳理一遍。
后来我才想明白一件事:模型选择本质上不是“选型问题”,而是“路由问题”。你选的不是某一个模型,而是一套“什么情况下用哪个模型”的决策规则。这套规则如果散落在业务代码里,它就是技术债;如果把它抽出来,写进 Runtime 层,它就变成了可维护、可测试、可观测的基础设施。
这篇文章就围绕这个思路展开。我会从路由规则的设计原则讲起,拆解 Runtime 层应该承担哪些职责,给出可直接参考的规则配置方案,再分享几个我在实际项目中踩过的坑和排查技巧。不管你是刚接触 Agent 开发,还是已经在维护多模型系统,应该都能从中找到能直接抄作业的部分。
2. 路由规则到底在解决什么问题
2.1 多模型并存的现实困境
先说一个很多人不愿意承认的事实:没有任何一个模型能在所有场景下都表现最好。有的模型在长上下文理解上确实强,但工具调用的稳定性一般;有的模型函数调用几乎不出错,但遇到需要深度推理的任务就容易绕圈子;还有的模型便宜、响应快,适合做意图识别和简单问答,但复杂任务一上就露怯。
这就导致一个很自然的想法:能不能让不同的模型各司其职?答案是能,但前提是你得有一套清晰的调度机制。我见过不少项目,模型接了三四个,但调度逻辑就是一层简单的if-else,结果就是:
- 规则越加越多,最后没人敢改
- 某个模型挂了,没有降级方案,整个服务不可用
- 成本失控,因为贵的模型被大量调用在简单任务上
- 出了问题很难排查,不知道是模型本身的问题还是路由规则的问题
这些问题的根源都一样:路由逻辑和业务逻辑耦合在一起,没有独立的 Runtime 层来承载。
2.2 把路由写进 Runtime 的核心价值
所谓“把路由规则写进 Runtime”,意思是把模型选择的决策逻辑从业务代码中抽离出来,放到一个独立的运行时层。这个 Runtime 层不关心业务具体在做什么,它只关心一件事:给定当前请求的特征,应该走哪条路径、调用哪个模型、用什么参数、失败了怎么办。
这样做的好处是实打实的:
- 可维护:规则集中管理,改路由不用动业务代码
- 可测试:路由逻辑可以单独写单元测试,不用起整个服务
- 可观测:每次路由决策都有日志,出了问题能快速定位
- 可扩展:新增模型或调整策略,只需要改配置,不需要改调用方
我自己的经验是,把路由抽到 Runtime 之后,新增一个模型的时间从原来的半天缩短到十几分钟,而且几乎不会引入回归问题。这个投入产出比是非常划算的。
2.3 路由规则的基本构成要素
一套完整的路由规则,至少需要包含以下几个要素:
| 要素 | 说明 | 示例 |
|---|---|---|
| 匹配条件 | 根据什么特征来决定路由 | 任务类型、输入长度、用户等级 |
| 目标模型 | 匹配成功后调用哪个模型 | 模型A、模型B、模型C |
| 调用参数 | 该模型对应的参数配置 | 温度、最大token、超时时间 |
| 降级策略 | 主模型失败后的备选方案 | 切换到备用模型或返回兜底结果 |
| 优先级 | 多条规则命中时的取舍顺序 | 精确匹配优先于模糊匹配 |
这五个要素缺一不可。我见过只写了匹配条件和目标模型的路由,结果模型超时了没有降级,请求直接挂掉。也见过规则没有优先级,两条规则同时命中,行为完全不可预测。
提示:路由规则的设计原则是“宁可简单可预测,不要复杂但模糊”。规则数量控制在可维护范围内,超过一定数量就应该考虑分层或分组。
3. Runtime 层应该承担哪些职责
3.1 请求特征提取与标准化
Runtime 层的第一件事,是把进来的请求“翻译”成路由规则能理解的特征。业务侧传过来的可能是一个用户消息、一段上下文、一些元数据,Runtime 需要从中提取出关键特征,比如:
- 输入文本的长度(字符数或 token 数)
- 任务类型(问答、工具调用、内容生成、代码执行等)
- 是否包含工具调用需求
- 用户的历史行为特征(如果是多轮对话)
- 请求的优先级或紧急程度
这一步的关键是标准化。不同业务方传过来的字段可能命名不一样,Runtime 要统一成内部约定的格式。我一般会定义一个RequestProfile结构,所有路由决策都基于这个结构来做,这样规则配置就不用关心上游的字段差异。
# 请求特征标准化示例 class RequestProfile: def __init__(self, raw_request): self.text_length = len(raw_request.get("message", "")) self.task_type = raw_request.get("task_type", "general") self.needs_tool = raw_request.get("tools") is not None self.priority = raw_request.get("priority", "normal") self.user_tier = raw_request.get("user_tier", "free")这个结构看起来简单,但它是整个路由体系的基础。特征提取不准确,后面的规则再精细也没用。
3.2 规则匹配与决策执行
特征提取完之后,Runtime 要拿这些特征去匹配规则。匹配的过程一般是从高优先级到低优先级依次尝试,命中第一条就执行。这里有个设计选择:是“首次命中即停止”还是“收集所有命中再决策”?
我的建议是首次命中即停止,原因很简单:可预测。如果收集所有命中再打分,规则之间的相互作用会变得非常复杂,排查问题的时候很难说清楚为什么走了这条路径。首次命中虽然看起来“不够智能”,但它足够透明,出了问题一眼就能看出来是哪条规则生效了。
规则匹配的伪代码大概长这样:
def route(profile, rules): for rule in sorted(rules, key=lambda r: r.priority, reverse=True): if rule.matches(profile): return rule.target_model, rule.params return DEFAULT_MODEL, DEFAULT_PARAMS实际项目中,规则可以用 YAML 或 JSON 配置,方便非开发人员调整。比如:
rules: - name: "long_context_route" priority: 100 condition: text_length: "> 8000" target: "model_long_context" params: temperature: 0.3 max_tokens: 4096 fallback: "model_general" - name: "tool_call_route" priority: 90 condition: needs_tool: true target: "model_tool_expert" params: temperature: 0.1 fallback: "model_general"这种配置方式的好处是,调整路由策略不需要改代码,改完配置热加载即可生效。
3.3 降级、重试与熔断机制
路由规则写好了,不代表就万事大吉。模型服务不是百分之百可靠的,超时、限流、返回异常都是家常便饭。Runtime 层必须内置降级和重试机制。
我的做法是给每条路由规则都配一个fallback目标。主模型调用失败时,自动切换到备用模型。如果备用模型也失败,再走最终的兜底逻辑(比如返回一个预设的友好提示,或者把请求放入重试队列)。
重试策略要区分错误类型:
- 超时错误:可以重试,但要有次数上限,一般 1-2 次
- 限流错误:不建议立即重试,应该切换到备用模型或排队等待
- 参数错误:重试没有意义,直接走降级
- 服务端错误:可以重试,但要注意幂等性
熔断机制也很重要。如果某个模型连续失败达到阈值,Runtime 应该暂时把它从可用列表中摘除,过一段时间再试探性恢复。这样可以避免大量请求持续打到已经不可用的服务上,造成雪崩。
3.4 调用日志与可观测性
路由决策如果没有日志,出了问题就是黑盒。我在 Runtime 层会记录每次路由的完整决策链路:
- 请求特征是什么
- 命中了哪条规则
- 最终选择了哪个模型
- 调用耗时、token 消耗、返回状态
- 是否触发了降级或重试
这些日志一方面用于排查问题,另一方面也是优化路由规则的依据。比如你发现某条规则命中率极低,或者某个模型的降级率很高,就知道该调整策略了。
提示:日志里不要记录完整的用户输入内容,只记录特征和决策结果,避免隐私风险。如果需要调试,可以记录输入内容的哈希值用于关联。
4. 路由规则的设计模式与实操方案
4.1 基于任务类型的静态路由
这是最基础也最常用的一种模式:根据任务类型直接映射到对应模型。比如:
| 任务类型 | 目标模型 | 理由 |
|---|---|---|
| 意图识别 | 轻量模型 | 任务简单,追求速度和成本 |
| 知识问答 | 通用模型 | 需要一定的理解和生成能力 |
| 工具调用 | 函数调用强的模型 | 对结构化输出要求高 |
| 长文总结 | 长上下文模型 | 输入长度大,需要强理解能力 |
| 代码生成 | 代码专精模型 | 领域特化,效果更好 |
这种模式的优点是简单直观,缺点是粒度粗。同一个任务类型下,不同请求的难度可能差异很大,一刀切的路由不一定最优。
我的改进做法是在任务类型基础上叠加一个“复杂度评估”。复杂度可以通过输入长度、是否包含多步推理需求、用户历史交互轮数等特征来估算。复杂度高的请求走更强的模型,复杂度低的走轻量模型。
4.2 基于成本与性能的动态权衡
静态路由的另一个问题是无法应对成本波动。假设某个模型突然涨价,或者某个模型响应变慢,静态规则不会自动调整。
动态权衡的思路是:给每个模型维护一个“性价比评分”,评分综合考虑当前的成功率、平均延迟、单位 token 成本。路由时优先选择评分最高的模型,同时设置一个最低质量阈值,低于阈值的模型不参与竞争。
评分公式可以简单设计为:
score = (success_rate * quality_weight) / (latency * latency_weight + cost * cost_weight)权重根据业务场景调整。如果是面向用户的实时对话,延迟权重要高一些;如果是后台批处理任务,成本权重要高一些。
这种模式实现起来复杂一些,但收益也很明显。我在一个项目中用了动态评分之后,整体成本下降了约三成,而用户侧的质量感知几乎没有变化。
4.3 基于用户分层的差异化路由
如果产品有免费用户和付费用户,路由策略也应该差异化。付费用户优先走高质量模型,免费用户走性价比模型,但都要保证基本的可用性。
具体做法是在请求特征里加入user_tier,然后在规则里针对不同层级设置不同的目标模型和参数。比如:
rules: - name: "premium_user_route" priority: 200 condition: user_tier: "premium" target: "model_high_quality" params: temperature: 0.7 max_tokens: 8192 - name: "free_user_route" priority: 100 condition: user_tier: "free" target: "model_cost_effective" params: temperature: 0.7 max_tokens: 2048这里要注意的是,免费用户的体验也不能太差,否则会影响转化。我的经验是,免费用户可以用轻量模型,但在关键节点(比如首次使用、重要操作)可以临时提升到更好的模型,给用户留下好印象。
4.4 规则配置的版本管理与灰度发布
路由规则一旦上线,就会直接影响线上行为。所以规则的变更必须像代码一样管理:有版本、有审核、有灰度、有回滚。
我的做法是把规则配置放在版本控制系统里,每次变更都走代码评审流程。上线时先在小流量环境验证,确认没有问题再全量。如果发现异常,可以快速回滚到上一个版本。
灰度发布可以通过在规则里加一个traffic_ratio字段来实现:
rules: - name: "new_model_trial" priority: 150 condition: text_length: "> 4000" target: "model_new" traffic_ratio: 0.1 # 只有10%的流量走这条规则 fallback: "model_general"这样可以在不影响大部分用户的前提下,验证新模型或新策略的效果。
5. 常见问题与排查技巧实录
5.1 路由规则不生效的排查思路
这是最常见的问题:明明配了规则,但请求就是没走预期的模型。排查的时候按以下顺序检查:
- 特征提取是否正确:先打印出
RequestProfile,确认字段值符合预期。我遇到过因为上游传的字段名变了,导致text_length一直是 0,长文本规则永远不命中。 - 规则优先级是否冲突:检查是否有更高优先级的规则先命中了。可以在日志里记录命中的规则名称,一眼就能看出来。
- 条件表达式是否正确:YAML 里的条件表达式容易写错,比如
"> 8000"写成了">8000",解析失败后规则被静默跳过。 - 配置是否加载成功:热加载有时候会失败,检查一下配置文件的加载日志。
5.2 模型降级频繁触发的处理
如果发现某个模型的降级率很高,说明这个模型在当前场景下不够稳定。处理方式分几步:
- 先看错误类型,是超时、限流还是返回异常
- 如果是超时,考虑调整超时阈值或减少
max_tokens - 如果是限流,考虑降低该模型的流量比例或申请更高配额
- 如果是返回异常,检查输入是否符合模型要求,或者该模型是否适合当前任务
我遇到过一次降级率飙升,最后发现是因为某个模型的 API 版本升级了,返回格式有细微变化,导致解析失败。这种问题只能通过日志和监控及时发现。
5.3 成本失控的预警与止损
成本问题往往在月底账单出来才被发现,那时候已经晚了。我的做法是设置实时成本监控:
- 每个模型维护一个当日消耗计数器
- 设置日预算阈值,超过 80% 时告警,超过 100% 时自动切换到低成本模型
- 记录每个请求的成本,便于按业务维度分析
这样可以在成本失控之前及时干预,而不是等到账单出来才后悔。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决建议 |
|---|---|---|---|
| 规则不命中 | 特征提取错误 | 打印 RequestProfile | 修正特征提取逻辑 |
| 走了错误的模型 | 优先级冲突 | 查看命中规则日志 | 调整规则优先级 |
| 降级频繁 | 模型不稳定 | 分析错误类型 | 调整参数或切换模型 |
| 成本超预期 | 路由策略不当 | 按模型统计消耗 | 优化路由规则 |
| 响应变慢 | 模型延迟增加 | 监控各模型延迟 | 动态调整权重 |
| 配置不生效 | 热加载失败 | 检查加载日志 | 重启或修复加载逻辑 |
提示:排查路由问题时,最有效的办法是在日志里记录完整的决策链路。不要只记录最终结果,要记录“为什么选了这个结果”。
6. 从单点路由到分层路由的演进
6.1 什么时候需要分层路由
一开始规则少的时候,单层路由就够了。但随着业务发展,规则数量会快速增长,单层路由会变得难以维护。这时候就需要考虑分层路由。
分层路由的思路是把路由决策拆成多个阶段:第一层做粗粒度的分类(比如按业务线或任务大类),第二层在分类内部做细粒度的模型选择。每一层的规则数量控制在可维护范围内,整体复杂度就不会失控。
我一般会在规则数量超过 20 条的时候开始考虑分层。分层的维度可以是业务线、任务类型、用户群体等,具体看业务特点。
6.2 分层路由的配置结构
分层路由的配置可以设计成树形结构:
layers: - name: "business_layer" rules: - condition: {business: "chat"} next_layer: "chat_routing" - condition: {business: "code"} next_layer: "code_routing" - name: "chat_routing" rules: - condition: {text_length: "> 8000"} target: "model_long_context" - condition: {needs_tool: true} target: "model_tool_expert" - default: "model_general"这种结构清晰很多,每一层只关心自己维度的决策,规则之间不会互相干扰。
6.3 分层路由的测试与验证
分层之后,测试策略也要相应调整。除了单条规则的单元测试,还需要做端到端的路由测试,确保从请求进入到最终模型选择的整条链路符合预期。
我的做法是维护一组“路由测试用例”,每个用例包含输入特征和期望的目标模型。每次修改规则后,跑一遍测试用例,确认没有回归。这个投入在规则数量多了之后非常值得。
7. 一些实操中的经验与建议
7.1 规则命名要见名知意
规则名称不要用rule_1、rule_2这种,要用能说明意图的名字,比如long_context_route、premium_user_route。这样排查问题的时候,看日志里的规则名就能大概知道发生了什么。
7.2 默认路由一定要有
不管规则配得多全,一定要有一个默认路由兜底。我见过因为所有规则都没命中,请求直接报错的案例。默认路由可以走一个通用模型,保证基本可用性。
7.3 定期回顾路由规则
路由规则不是配完就不管了。业务在变,模型在更新,规则也需要定期回顾。我一般每个月会看一次路由统计,检查有没有规则长期不命中、有没有模型的降级率异常、成本分布是否合理。
7.4 不要过度优化
路由规则的目标是“够用且可维护”,不是“理论最优”。我见过有人为了追求极致的成本优化,把规则设计得非常复杂,结果维护成本远超节省的成本。找到平衡点就好,不要为了优化而优化。
7.5 保留人工干预的入口
不管自动化做得多好,总要留一个手动切换模型的入口。有时候线上出了问题,需要快速把流量切到备用模型,这时候如果只能改配置再发布,就太慢了。我的做法是提供一个管理接口,可以一键切换全局或某个业务线的默认模型。
8. 一个可参考的最小实现
8.1 目录结构
agent_runtime/ ├── config/ │ └── routing_rules.yaml ├── core/ │ ├── profile.py # 请求特征提取 │ ├── router.py # 路由决策 │ ├── executor.py # 模型调用执行 │ └── fallback.py # 降级处理 ├── monitor/ │ ├── logger.py # 决策日志 │ └── metrics.py # 指标统计 └── tests/ └── test_router.py # 路由测试8.2 核心路由逻辑
class Router: def __init__(self, rules_config): self.rules = self._load_rules(rules_config) self.default_model = rules_config.get("default_model") def route(self, profile): for rule in self.rules: if self._match(rule.condition, profile): self._log_decision(rule.name, profile) return rule.target, rule.params self._log_decision("default", profile) return self.default_model, {} def _match(self, condition, profile): for key, expected in condition.items(): actual = getattr(profile, key, None) if not self._compare(actual, expected): return False return True这个实现很简洁,但已经覆盖了核心功能。实际项目中可以根据需要扩展,比如加入动态评分、熔断状态检查等。
8.3 测试用例示例
def test_long_context_route(): profile = RequestProfile({"message": "x" * 9000}) router = Router(load_config()) model, params = router.route(profile) assert model == "model_long_context" def test_tool_call_route(): profile = RequestProfile({"message": "hello", "tools": ["search"]}) router = Router(load_config()) model, params = router.route(profile) assert model == "model_tool_expert"这些测试用例看起来简单,但它们能在你修改规则时快速告诉你有没有破坏已有行为。我强烈建议在项目初期就把这套测试框架搭起来,后面会省很多事。
9. 关于模型选型与路由的一些个人体会
做了几个 Agent 项目之后,我越来越觉得,模型选型这件事的权重被高估了,而路由设计的权重被低估了。很多人花大量时间对比模型跑分,却不愿意花半天时间把路由逻辑抽出来。结果就是模型换了一茬又一茬,系统架构还是那个样子,每次换模型都是一次伤筋动骨。
把路由写进 Runtime,本质上是一种架构上的投资。前期多花一点时间,后面每次调整策略都会轻松很多。而且这套机制一旦建立起来,你对模型的选择就更加从容了——不用再纠结“选哪个”,因为你可以“都要”,让路由规则来决定什么时候用哪个。
最后分享一个小技巧:如果你现在还没有独立的 Runtime 层,可以先从“把路由逻辑抽到一个单独的模块”开始,不用一步到位。哪怕只是把散落在各处的if-else集中到一个文件里,也是一个好的开始。后面再逐步完善配置化、降级、监控这些能力。架构演进是渐进的,不用一开始就追求完美。