☰
Agent模型路由实战:两挡自动挡设计,降本增效还能故障自愈
2026/10/13 8:10:36 网站建设 项目流程

说实话,我第一次给 Pi Agent 选模型的时候,思路特别朴素:挑一个能力最强的模型,把 key 配好,所有请求都往这一个接口打。结果上线第一周就被现实教育了。一个"查天气然后提醒我带伞"的小任务,因为整条调用链都在等大模型的慢响应,硬生生拖了四十多秒;更糟的是上游接口一出抖动,整条任务链直接原地瘫痪,用户那边看到的就是一个转圈转半天的死窗口。

后来我花了两周,给 Pi Agent 装上了一套 Model Router,参考自动变速箱的思路做了两挡设计——快挡走省油小模型,慢挡走动力大模型——又配了一套故障自愈机制:重试、熔断、降级、兜底链。这篇文章就是这套系统的完整复盘,包括路由打分、换挡逻辑、熔断降级、实测数据,以及我在调参过程里踩过的那些坑。适合正在做 Agent 应用、被模型不稳定和 token 成本折磨的开发者参考。

1. 单挡直驱的代价:简单任务不该为大模型买单

1.1 一次真实事故:查天气为什么会花四十秒

我最早版本的 Pi Agent 架构特别简单:一个主模型负责所有环节。用户说"帮我看看今天要不要带伞",Agent 的处理链路其实有四段:先让模型识别意图,再让模型决定调哪个工具,然后调天气接口取数,最后再让模型把结果组织成一句人能听懂的话。在单模型方案里,这四段全部要等同一个大模型接口返回,每段少则两三秒,多则七八秒,串起来轻松超过二十秒。

如果只是慢,咬咬牙还能忍。真正让我决定改架构的是那次事故:上游接口在高峰期出现了一次持续约三分钟的抖动,我的 Agent 没有任何容错逻辑,第一个请求超时后直接失败,任务链里后面所有等待这个结果的子任务全部跟着失败。用户重新触发,又撞上抖动窗口,继续失败。那三分钟里,所有走这个 Agent 的任务全军覆没,而我翻日志只能看到一堆孤零零的 timeout 报错,连个降级方案都没有。

这件事给我两个教训。第一,把所有请求都压在一个模型上,等于把鸡蛋全放一个篮子里,模型上游随便抖一下,我的业务就跟着抖。第二,不同任务对模型能力的需求差异极大,全部用大模型处理,既慢又贵,还把故障面拉得特别大。

1.2 单模型方案的三个隐性成本

后来整理需求的时候,我把单模型方案的隐性成本归纳成三条,这里列个表对比一下。

维度单模型(全部走大模型)两挡路由后的预期
简单任务延迟单轮也要等大模型慢推理,普遍 5~10 秒以上小模型秒级返回,通常 1~3 秒
费用消耗每个 token 都按大模型价格计费,简单任务也在烧钱简单任务走低价模型,综合成本大幅下降
故障爆炸半径一个模型挂了,全部任务挂快挡挂了走慢挡,慢挡挂了走兜底,业务不断

延迟成本最直观:简单任务其实不需要链式推理,但单模型方案里它必须跟着大模型走完整个生成过程,推理时间全被浪费了。费用成本更隐蔽,很多团队到月底看账单才意识到,光是"查时间""查天气""翻译一句话"这类高频简单任务,就烧掉了大模型预算的三成以上。故障爆炸半径则是三个成本里最致命的——单模型方案没有备选路径,上游一断,业务直接归零。

所以我的结论是:Agent 需要一个会换挡的路由层,至少把"手速活"和"技术活"分开处理。这就是 Model Router 的最初动机。

2. 两挡怎么划:"省油挡"管手速,"动力挡"管硬活

2.1 快挡:小模型负责高频率、低复杂度的活

我给两挡的定义很简单:快挡是"省油挡",用延迟低、价格便宜的小参数模型,负责大量重复、推理深度浅的任务。在 Pi Agent 里,快挡负责的典型任务包括:意图识别、工具名选择、实体抽取、回答格式整理、简单的礼貌性回复。

为什么这些活适合小模型?因为它们本质上都是"根据输入做一个不太复杂的判断",不需要多步推理,也不需要综合大量上下文。比如"用户这句话是想查天气还是想定闹钟",给一个小模型足够清晰的指令和候选列表,它完全可以稳定完成。实测下来,快挡模型在意图识别这类任务上的准确率能达到大模型的 95% 以上,但延迟只有后者的三分之一,价格可能只有几十分之一。

不过快挡模型有一个底线要求:它必须能稳定遵循简单指令。我之前试过一个压得太狠的极小模型,连"从这句话里抽取城市名"这种活都会漏抽,那就不配当快挡了。快挡可以弱,但不能蠢到影响路由本身。

2.2 慢挡:大模型负责需要"想清楚"的活

慢挡是"动力挡",用强推理、大参数量的模型,负责需要真正想清楚的任务。在 Pi Agent 里,慢挡负责四类活:多步任务规划、复杂代码生成、长文档综合总结、以及需要综合多个工具结果再下判断的场景。

这些任务的共同点是:答案不是"从输入里直接找出来"的,而是需要模型在内部完成推理链。比如用户说"帮我订一个周五下午适合谈事的咖啡馆,最好离地铁近、安静一点",Agent 需要先拆解约束条件,再决定查哪些数据源,最后把多个结果综合筛选。这种任务交给小模型很容易出现漏条件、乱猜、编造选项的情况,交给大模型虽然慢一点贵一点,但正确率高得多。

我自己的切分原则是一句话:凡是需要多步推理才能得到答案的任务,一律走慢挡;凡是单步判断就能完成的,一律走快挡。

2.3 为什么两挡就够了,而不是三挡四挡

有人可能会问,既然要路由,为什么不搞三挡四挡,按任务难度精细分流?我一开始也这么想过,后来发现得不偿失。挡位越多,意味着需要越精确的难度度量,而任务难度的判断本身就是模糊的;每多一个挡,就要多一组阈值、多一套监控、多一堆边界情况。

两挡加一条兜底链,是在"分流效果"和"系统复杂度"之间比较划算的平衡点。快挡解决 80% 的高频简单任务,慢挡解决 15% 的复杂任务,剩下 5% 的极端情况和模型全挂的故障场景,交给兜底链处理,而不是再设一个"中间挡"。中间挡最大的问题是:它和大挡、小挡的边界很难划清,路由器天天在纠结"这活算中等还是偏难",反而制造更多不稳定。

2.4 挡位配置示例

路由器的挡位配置我放在一个 YAML 文件里,方便调整:

router: fast_gear: provider: local-small timeout_ms: 3000 max_retries: 2 note: "意图识别/抽取/格式化" slow_gear: provider: cloud-large timeout_ms: 15000 max_retries: 3 note: "多步规划/代码生成/综合判断"

两个挡位的超时时间和重试次数是分开设置的。快挡要求 3 秒内必须返回,因为它的任务本身很快;慢挡给到 15 秒,是因为复杂推理确实需要更长时间,也避免误杀正常的长响应。这里有个经验:超时时间不要所有模型统一,必须按挡位分别设置,否则要么快挡频繁超时,要么慢挡被过早掐断。

3. Model Router 的换挡控制:打分决定踩油门还是降挡

3.1 换挡信号:从哪些特征判断任务难度

路由器要决定挂哪个挡,首先要能做"难度预判"。我的方案是对任务特征做打分,分数高就挂慢挡,分数低就挂快挡。目前 Pi Agent 用了五个信号:

  • 任务类型标签:如果 Agent 的任务系统本身有分类,直接做映射,比如"规划类"强制慢挡,"查询类"默认快挡。
  • 工具调用数量:只需要调一个工具的任务,和需要串起两三个工具再综合分析的任务,难度完全不是一个量级。
  • 输入长度:长上下文往往意味着需要全局理解,短问题则大概率是单点判断。
  • 是否开放式生成:有固定 schema 的抽取任务简单,没有约束的开放性写作就难。
  • 推理强度需求:涉及数值计算、逻辑比较、条件分支的任务,对推理能力要求高。

打分公式我写得很朴素,就是一个加权和:

score = 0.35 * 任务复杂分级 + 0.25 * 工具调用数归一值 + 0.20 * 输入长度归一值 + 0.20 * 推理需求标记

权重是拍脑袋定的初值,然后靠实测数据调。怎么调后面讲,这里先说思路:分数不是精确科学,它的任务是"把明显简单的和明显复杂的分开",临界模糊地带反而要交给迟滞逻辑去稳定。

3.2 阈值与迟滞:防止挡位来回跳

有了分数,接下来就是阈值。最朴素的方案是单阈值:score 超过 0.6 就挂慢挡,否则挂快挡。但这个方案在线上会出一个很典型的问题——临界任务的分数经常在 0.58 和 0.62 之间波动,导致同一类任务一会儿走快挡一会儿走慢挡,延迟忽快忽慢,费用也不稳定。

这就是自动变速箱里说的"挡位抖动":变速箱在相邻两个挡位之间反复横跳,既费油又顿挫。汽车工程里的解决办法是换挡延迟逻辑,我给路由器也做了同样的设计,用双阈值加迟滞带:

class TwoGearRouter: def __init__(self, low_threshold=0.45, high_threshold=0.65): self.low_threshold = low_threshold self.high_threshold = high_threshold self.current_gear = "fast" def decide_gear(self, score: float) -> str: if score >= self.high_threshold: self.current_gear = "slow" elif score <= self.low_threshold: self.current_gear = "fast" else: pass # 迟滞带内保持当前挡位 return self.current_gear

这段代码的核心逻辑是:分数大于 0.65 才升慢挡,小于 0.45 才降回快挡,中间 0.45 到 0.65 是一个缓冲区间,路由器保持当前挡位不变。实测这个简单改动就能把挡位抖动率降掉九成以上。除了迟滞带,我还加了一个最小保持时间:一次换挡后至少保持 60 秒,防止单个任务的分数波动带崩整个会话的挡位稳定性。

3.3 手动模式:任务声明里直接指定挡位

自动换挡不是万能的,所以我给任务定义里加了一个可选的挡位字段,允许任务自己声明"我要走哪个挡"。

{ "task": "帮我把这段代码改成异步版本", "gear": "slow" }

这个字段有三个取值:fast、slow、auto。默认是 auto,交给路由打分;但涉及代码生成、数据分析这类高风险任务,任务定义里可以直接写死 slow;纯查询类任务可以写死 fast,连打分都省了。这就是汽车上的换挡拨片——自动挡归自动挡,但驾驶员想强制降挡超车时,必须有控制权。在 Pi Agent 里,凡是涉及删除数据、转账、发送消息这类不可逆操作的任务,我都要求强制指定挡位,绝不交给自动打分。

4. 故障自愈:真正的"自动挡"是挂了也能继续开

4.1 故障清单:先把"会坏在哪"列清楚

装自动挡的初衷本来就是为了应对路上的意外。在模型路由这个场景里,故障来源比想象的多,我先把踩过的故障类型列一个表。

故障类型典型表现是否适合重试
超时请求发出后长时间无响应适合,但要注意重试次数
5xx 服务端错误上游返回 500/502/503适合,通常短暂抖动
429 限流请求被上游限流拒绝适合,但要退避等待
返回格式非法响应 JSON 解析失败可重试,最好在重试时附加强制格式指令
空响应/内容过滤上游返回空内容不适合,重试大概率还是空
工具调用参数错误function call 的字段缺失可重试一次,附带纠错提示

这个表看着简单,其实是我挨个踩出来的。比如"空响应"这种故障,最开始我以为重试一次就能好,结果发现内容过滤命中的请求重试一百次也是空,白白浪费时间和费用。所以故障自愈的第一原则是:先判断这个故障是不是"可重试的",再决定要不要重试,而不是无脑重试。

4.2 重试:第一道防线也要讲策略

重试是所有自愈手段里成本最低的,但策略不对反而会帮倒忙。我给 Pi Agent 定的重试规范是三条:第一,只对超时、5xx、429 这类临时性故障重试;第二,用指数退避加随机抖动,间隔从 200 毫秒起步,按 200ms、800ms、2000ms 递增,避免所有请求在同一时间点打向上游;第三,每次重试都附带当前重试次数,方便在上游接口排查问题时对齐上下文。

关于 429 限流有个小细节:如果返回头里带了 Retry-After 字段,直接按它给的时长等,比自己的退避策略更靠谱。有一次我图省事没读这个字段,结果退避间隔还没到就重试,连续三次都被限流拍回来,还不如等一次。

4.3 熔断器:别让重试变成重试风暴

光有重试还不够,因为重试本身会放大故障。当上游已经处于不稳定状态时,所有请求失败后立刻重试,等于给上游制造了一波更大的流量,这叫重试风暴。我的解法是在路由层加熔断器,状态机就是经典的 closed / open / half-open 三态。

熔断器的逻辑是:连续失败次数达到阈值,熔断器从 closed 切到 open,接下来一段时间内所有请求直接走降级路径,不再打到上游;过了恢复时间窗,切到 half-open,放两个探活请求试试水,成功就回到 closed,失败就继续 open。

class CircuitBreaker: def __init__(self, fail_threshold=5, window_sec=30, recovery_sec=30): self.fail_threshold = fail_threshold self.window_sec = window_sec self.recovery_sec = recovery_sec self.failures = [] self.state = "closed" self.open_since = None def allow_request(self) -> bool: if self.state == "closed": return True if self.state == "open": if time.time() - self.open_since >= self.recovery_sec: self.state = "half_open" return True return False return True # half_open 放少量流量试探 def record_failure(self): self.failures.append(time.time()) self.failures = [t for t in self.failures if time.time() - t < self.window_sec] if len(self.failures) >= self.fail_threshold: self.state = "open" self.open_since = time.time()

参数上我的经验是"慢开快试":熔断阈值不能太小,否则上游一个小抖动就把熔断器触发,把所有流量都赶到兜底路径,造成新的压力;恢复窗口也不能太长,否则上游已经恢复了,系统还在降级。我现在用的初值是 30 秒窗口内 5 次失败触发熔断,恢复等待 30 秒,半开状态放 2 个探活请求。

4.4 降级:从动力挡切到省油挡,也要让用户知道

熔断器触发或者慢挡连续失败之后,下一步是降级。在 Pi Agent 里,降级有两层含义。第一层是挡位降级:慢挡挂了,同一个任务改用快挡模型跑,用更简化的提示词让它先给一个"虽然不完整但能用"的结果。第二层是结果降级:快挡也挂了,就走到规则引擎,用模板回答兜住"查时间""查天气"这类结构清晰的请求。

降级有个特别重要的细节:必须给结果打标记。我会在返回的响应元数据里加上"degraded": true和"degraded_from": "slow_gear",下游业务拿到这个标记后,可以决定是给用户展示"当前响应质量可能下降"的提示,还是干脆走人工确认。这个设计我后面专门再讲,因为它是这次项目里最大的教训之一。

4.5 兜底链:模型全部不可用时,任务不能裸奔

自愈体系的最后一道防线是一条兜底链。模型这层全部不可用的极端情况下,任务依然要有一个"最差也能跑"的出路。

FALLBACK_CHAIN = [ ("slow_gear", call_slow_model), ("fast_gear", call_fast_model), ("local_model", call_local_model), ("rule_engine", call_rule_engine), ] def invoke_with_fallback(task, context): for name, caller in FALLBACK_CHAIN: try: result = caller(task, context) if validate(result): return mark_result(result, caller_name=name) except Exception: continue return build_failed_response(task)

兜底链的顺序是精心排过的:先慢挡后快挡,确保正常情况优先用高质量模型;本地模型作为第三顺位,适合网络完全断掉但本机还有小模型的场景;规则引擎是最后的底牌,只对模式清晰的请求有效。我见过一些团队的兜底链顺序是乱的,快挡放在慢挡前面,结果慢挡一抖动,流量全部压到快挡,快挡也跟着过载,整个链路反而更快崩溃。兜底链的正确用法是"逐级后撤",而不是"哪里便宜往哪里塞"。

5. 实测结果与调参踩坑:自动挡也要磨合期

5.1 装上 Router 前后的对比数据

这套两挡路由和自愈机制上线后,我在模拟项目 X 上连续统计了 7 天,和生产环境跑了一周,数据差距非常明显。

指标单模型方案两挡路由后变化
P50 延迟8.2 秒2.1 秒下降 74%
P95 延迟21 秒5.4 秒下降 74%
每千次成本100 单位37 单位下降 63%
任务成功率92%98.7%提升 6.7 个百分点

这里有个数据值得展开:成功率提升不是靠"更强的模型",而是靠故障自愈把原本会直接失败的任务接住了。单模型方案里,上游一抖动,任务直接失败,成功率跌到谷底;两挡方案里,慢挡失败会切快挡,快挡失败有兜底链,单次故障对用户几乎无感。成功率提升的大头恰恰来自重试、熔断和降级这几层"看不见的机制"。

5.2 踩坑一:挡位横跳,Agent 像个新司机

刚上线的头两天,我盯着监控发现一个诡异现象:同一类任务的延迟曲线一会儿 1.8 秒一会儿 7 秒,锯齿状来回跳。查下去才发现是路由器的打分在阈值附近波动,同一个任务有时候得分 0.63 挂快挡,有时候得分 0.68 挂慢挡,挡位频繁切换。

这个问题的根子在于我没有做迟滞处理,单阈值方案在边界上太敏感。修复方式就是前面提到的双阈值加最小保持时间,改动只有十几行,但效果立竿见影——挡位抖动率从每百次任务 7 次降到 0.3 次以下。教训是:路由器的第一优先级是稳定,不是每次都给"最优挡位"。偶尔让一个中等任务走慢挡多花点钱,比挡位反复横跳让延迟和费用都失控要好得多。

5.3 踩坑二:熔断器该"断"的时候没断

熔断器上线后遇到过一个反向问题:上游明明已经在剧烈抖动了,我的熔断器却迟迟不触发,导致所有请求都还在往故障点打。查日志发现是熔断计数窗口设置得太长,5 次失败分布在 30 秒的窗口内,但实际业务量一秒钟可能才 2 个请求,30 秒都未必能攒够 5 次失败,熔断器变成了摆设。

后来我把触发条件改成"连续失败次数"而不是"窗口内累计失败次数",连续 3 次失败就直接熔断,效果立刻不一样。这个参数要根据业务请求量来调:请求量大的系统,窗口内累计阈值合适;请求量小的系统,连续失败阈值更靠谱。没有放之四海的参数,只有适合自己流量的参数。

5.4 踩坑三:降级把错误答案包装成了正确答案

这是整个项目里教训最重的一个。有段时间我发现知识库里的回答质量突然下降,排查了很久才找到原因:慢挡接口不稳定,降级逻辑把任务切给了快挡模型,快挡模型返回了一个明显不完整的答案,但我的下游解析器把它当成慢挡的完整答案存进了知识库。降级机制本意是"有总比没有好",结果因为少了质量标记,反而把低质量结果变成了正式结果。

修复方案是两部分的:第一,所有降级路径返回的响应必须带degraded=true标记;第二,下游解析器遇到降级标记的结果,只做临时展示,不写入知识库,必须经过二次确认。这个改动之后,降级路径就从"偷偷摸摸地坏"变成了"明明白白地降级",用户能看到提示,系统也知道这是临时方案。

5.5 调参清单:可以直接照抄的初始值

最后把我的调参清单整理成一张表,给想复刻的读者一个起点。

参数初始值说明
快挡超时3 秒简单任务应该快速失败
慢挡超时15 秒留足复杂推理时间
重试退避200ms / 800ms / 2000ms指数退避加随机抖动
熔断连续失败阈值3 次连续失败即熔断
熔断恢复等待30 秒半开后放 2 个探活请求
路由低阈值0.45低于则回快挡
路由高阈值0.65高于则升慢挡
最小保持时间60 秒换挡后保持当前挡位

这些参数全部来自实测,不同业务量级可能要调整,但作为起步值,它们能帮你绕过我之前踩的大部分坑。

6. 想抄作业?先把这几条落地经验记住

6.1 分阶段上线,别一口气全上

如果你现在就要给 Agent 装这套自动挡,我建议分三个阶段走。第一阶段只做两挡路由和基础重试,先验证快慢挡的划分是否合理;第二阶段加熔断和降级,重点调熔断参数;第三阶段再上兜底链和降级质量标记。一次全上,出了问题你根本定位不到是哪个环节,我在项目里就吃过这个亏,上线第一天熔断器疯狂误触发,搞得所有任务都在绕行,还以为是路由打分写错了。

6.2 监控必须按挡位拆开

路由系统的监控不能只看总体成功率,一定要按挡位、按故障类型、按降级路径分别统计。我现在的监控面板至少有这几个视图:快挡成功率、慢挡成功率、熔断器状态变化、降级调用占比、每挡 token 消耗。其中"降级调用占比"尤其重要,它直接反映自愈机制的负担程度,如果长期高于 10%,说明上游稳定性有问题,或者挡位划分不合理,该调参了。

6.3 定期回看"打错挡"的任务样本

两挡路由不是配完就完事。我会每周抽一批任务样本,重点看两类:一类是该走慢挡却走了快挡、导致回答质量差的任务;一类是该走快挡却走了慢挡、导致延迟和费用虚高的任务。把这些样本的分数拉出来,调整打分公式的特征权重。比如头两周我发现输入长度这个特征的权重给高了,很多长文搬运任务被误判成复杂任务,白白走了慢挡,调低权重后费用立刻下来一截。

6.4 风险操作必须手动挡

最后一条,也是我认为最重要的一条:自动路由只适合低风险任务。凡是涉及删除、修改、转账、对外发送消息这类不可逆操作,任务定义里必须强制指定挡位,甚至强制指定具体的模型链路,绝不交给自动打分。自动挡解决的是"性价比"问题,手动挡解决的是"安全"问题,两者不能互相替代。

我在这个项目里最大的体会是:给 Agent 做 Model Router,本质上是在把"对模型能力的预期管理"沉淀成系统能力。模型永远可能抽风,上游永远可能抖动,但系统只要设计了退路,这些意外就只是链路里的一个分支,而不是业务的一次事故。这套自动挡装完之后,我再也没有半夜爬起来看上游故障告警——因为它已经学会自己换路了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询