
1. 从手动挡到自动挡Pi Agent 路由层的设计动机用 Pi Agent 写代码的人大概都有过这种体验同一个会话里前面让它改个正则表达式后面让它重构一个模块再后面让它解释一段报错。这三件事对模型能力的要求完全不在一个量级上但默认情况下Agent 会一直用同一个模型从头跑到尾。要么全程用大模型简单任务上烧钱烧得心疼要么全程用小模型复杂任务上翻车翻得怀疑人生。这就是我动手给 Pi Agent 加 Model Router 的直接原因。说白了就是给 Agent 装一个自动挡简单任务走经济挡复杂任务走性能挡中间不需要人干预。同时保留手动挡——有些场景下你就是想强制指定某个模型比如调试 prompt、复现 bug、或者对某个任务有明确的模型偏好这时候自动路由反而是干扰。Model Router 本质上是一个位于 Agent 和底层模型之间的决策层。它接收当前请求的上下文对话历史、任务类型、代码复杂度等信号输出一个路由决策这次请求该发给哪个模型。听起来简单但真正做起来难点不在路由这个动作本身而在于三件事怎么判断任务复杂度、怎么在判断错误时兜底、怎么让整个系统在模型调用失败时自愈而不是直接崩掉。这篇文章我会把整个设计和实现过程拆开讲包括两挡路由的判定逻辑、故障自愈的状态机设计、以及我在实际跑了几千次请求之后踩出来的那些坑。如果你也在做 Agent 相关的工程或者单纯好奇自动挡到底怎么实现的应该都能拿到一些可以直接抄的东西。提示本文讨论的 Model Router 是一个通用的路由层设计思路不依赖特定厂商的模型 API。你可以把它套到任何支持多模型切换的 Agent 框架上。2. 两挡设计经济挡与性能挡的判定逻辑2.1 为什么是两挡而不是三挡或连续调节一开始我设计的是三挡轻量、标准、重型。跑了一周之后发现标准这一挡几乎从来没被命中过——要么任务简单到轻量挡绰绰有余要么复杂到必须上重型挡中间地带非常窄。三挡带来的额外复杂度多一套阈值、多一组模型配置、多一层调试难度完全不划算。两挡的好处是决策边界清晰。经济挡和性能挡之间只有一个阈值调参的时候只需要盯一个数出问题的时候也只需要判断是不是阈值设偏了。连续调节比如按复杂度打分然后线性映射到模型能力听起来很美但实际中模型能力不是连续可调的你没法让一个模型发挥 60% 的实力。所以两挡是一个在表达能力和实现复杂度之间的甜点。具体来说经济挡Eco面向低复杂度、高确定性的任务。典型场景包括格式化代码、重命名变量、写简单的单元测试、解释报错信息、生成正则表达式等。这些任务的共同特点是输入输出模式固定不需要跨文件推理不需要理解业务上下文。性能挡Pro面向高复杂度、需要深度推理的任务。典型场景包括跨模块重构、架构设计、复杂 bug 定位、多文件联动修改、需要理解业务语义的代码生成等。2.2 复杂度信号从哪里来判定任务复杂度我没有用额外的模型去分类那样等于多一次调用成本和延迟都上去了而是用一组轻量级启发式信号。这些信号在 Agent 处理请求之前就能拿到计算开销几乎为零。我实际用的信号有以下几类第一类是输入长度信号。当前请求的 token 数、对话历史的总 token 数、涉及的文件数量。这里有个经验值当单次请求的上下文超过 8K token 时经济挡模型的表现会明显下降因为它的有效注意力窗口和推理深度都不够。所以 8K 是一个重要的分界线。第二类是任务类型信号。通过请求中的关键词和模式匹配来判断。比如包含重构架构设计为什么这类词的大概率需要性能挡包含格式化重命名改成替换这类词的经济挡通常够用。这个判断不需要很精确因为后面还有兜底机制。第三类是历史反馈信号。如果同一个会话里经济挡已经连续失败了两次比如模型输出被校验规则拒绝或者用户手动重试那第三次自动升级到性能挡。这个信号非常有效因为它直接反映了经济挡搞不定这个任务。第四类是显式标记。用户在请求里可以手动打标比如加上[pro]前缀强制走性能挡。这个主要用于调试和特殊场景。下面是我实际用的判定函数的核心逻辑Python 伪代码def route_decision(request, history, feedback): # 显式标记优先级最高 if request.has_tag(pro): return pro if request.has_tag(eco): return eco # 历史反馈连续失败则升级 if feedback.consecutive_eco_failures 2: return pro # 计算综合复杂度分数 score 0 # 长度信号 total_tokens request.tokens history.tokens if total_tokens 8000: score 3 elif total_tokens 4000: score 1 # 文件数量信号 if request.file_count 3: score 2 elif request.file_count 1: score 1 # 任务类型信号 if matches_any(request.text, PRO_KEYWORDS): score 2 if matches_any(request.text, ECO_KEYWORDS): score - 1 # 阈值判定 return pro if score 3 else eco这套逻辑跑下来在我的实际使用场景里主要是代码相关的 Agent 任务经济挡命中率大约 65%性能挡 35%。这个比例比较合理——大部分日常操作确实是简单任务但复杂任务的比例也不低说明阈值没有设得太偏。2.3 阈值调参的实操经验阈值这个东西没有万能值必须根据你的实际任务分布来调。我建议的做法是先跑一周的日志把所有请求的复杂度分数和实际结果成功/失败/用户是否重试记录下来然后画一个分数-成功率曲线。我的曲线大概是这样分数在 0-2 之间时经济挡成功率 92%分数 3-4 时经济挡成功率降到 71%分数 5 以上时经济挡成功率只有 43%。所以阈值设在 3 是比较合理的——低于 3 走经济挡高于等于 3 走性能挡。但这里有个坑成功率不是唯一指标还要看成本。性能挡的单次调用成本可能是经济挡的 10-20 倍。如果为了把成功率从 92% 提到 95% 而把阈值降到 2导致性能挡命中率翻倍那成本增加可能完全不值得。我的做法是算一个性价比分数(成功率提升 × 任务价值) / 成本增加只有这个分数超过某个值时才调整阈值。注意阈值调参不要频繁做。我一开始每天调一次结果发现日志波动很大根本看不出趋势。后来改成每周调一次用一周的聚合数据来判断稳定得多。3. 故障自愈当模型调用失败时发生了什么3.1 故障分类哪些错误可以自愈哪些不能故障自愈的前提是区分错误类型。不是所有失败都值得重试有些失败重试一百次也没用只会浪费时间和成本。我把错误分成三类第一类是可重试的瞬时错误。包括网络超时、速率限制429、服务端临时错误5xx。这类错误的特点是同一个请求换个时间再发大概率能成功。处理策略是带退避的重试。第二类是可降级的错误。包括模型返回格式不符合预期、输出被校验规则拒绝、模型明确表示无法完成。这类错误的特点是当前模型搞不定但换个模型可能行。处理策略是降级或升级到另一挡。第三类是不可恢复的错误。包括认证失败、请求格式错误、上下文超长。这类错误的特点是重试和换模型都没用必须修改请求本身。处理策略是直接报错把问题抛给上层。这个分类是整个自愈机制的基础。我见过一些实现把所有错误都当成可重试的结果遇到认证失败时疯狂重试把配额耗光了才发现问题。也见过把所有错误都当成不可恢复的网络抖一下就整个任务失败。分类做对了后面的事情就顺了。3.2 自愈状态机设计自愈逻辑我用一个状态机来实现。状态机的核心状态有四个NORMAL正常、RETRYING重试中、DEGRADED降级中、FAILED最终失败。状态转移规则如下当前状态触发事件下一状态动作NORMAL瞬时错误RETRYING等待退避时间后重试NORMAL可降级错误DEGRADED切换到另一挡模型NORMAL不可恢复错误FAILED抛出错误RETRYING重试成功NORMAL返回结果RETRYING重试次数超限DEGRADED切换到另一挡模型DEGRADED降级成功NORMAL返回结果DEGRADED降级失败FAILED抛出错误退避策略我用的是指数退避加抖动第一次重试等 1 秒第二次 2 秒第三次 4 秒以此类推但每次加上一个 0-500ms 的随机抖动。抖动的目的是避免多个请求同时重试造成的惊群效应。import random import time def retry_with_backoff(func, max_retries3): for attempt in range(max_retries): try: return func() except TransientError as e: if attempt max_retries - 1: raise backoff (2 ** attempt) random.uniform(0, 0.5) time.sleep(backoff) raise MaxRetriesExceeded()3.3 降级与升级的方向选择这里有个容易搞混的点降级和升级是两回事方向取决于当前在哪一挡。如果当前在经济挡遇到可降级错误那应该是升级到性能挡——因为经济挡搞不定需要更强的模型。如果当前在性能挡遇到可降级错误那应该是降级到经济挡——因为性能挡都搞不定可能是任务本身有问题用经济挡快速失败反而更好至少省成本。这个逻辑听起来反直觉但实际跑下来很合理。性能挡失败通常意味着任务描述有问题或者上下文缺失这时候用经济挡快速返回一个我搞不定的结果比让性能挡反复重试要划算得多。而且经济挡的失败信息往往更直接有助于定位问题。提示降级/升级的方向一定要和当前挡位绑定不要写死。我第一版就是写死的失败就升级结果性能挡失败时不断升级到更贵的模型成本直接爆炸。3.4 自愈的边界什么时候应该放弃自愈不是万能的必须有明确的放弃条件。我设了三个硬性边界第一总重试次数不超过 5 次。超过 5 次还没成功说明问题不是瞬时的继续重试只是浪费资源。第二总耗时不超过 60 秒。有些错误重试起来很快但累积起来可能拖很久。60 秒是一个用户体验的临界点超过这个时间用户已经觉得卡死了不如直接报错让用户决定。第三成本不超过单次请求预算的 3 倍。这个是为了防止自愈机制本身变成成本黑洞。每次重试和降级都会产生新的调用成本必须有个上限。这三个边界是或的关系任何一个触发就进入 FAILED 状态。实际跑下来大部分自愈在 1-2 次重试内就完成了触发边界的比例不到 2%。4. 实操落地从配置到跑通的完整流程4.1 模型配置与挡位映射Model Router 的第一步是配置模型。你需要至少两个模型一个经济挡一个性能挡。我用的是配置文件的方式方便随时调整router: eco: model: small-model-v2 max_tokens: 4096 temperature: 0.3 timeout: 15 pro: model: large-model-v3 max_tokens: 16384 temperature: 0.7 timeout: 45 thresholds: complexity_score: 3 max_retries: 5 max_total_time: 60 max_cost_multiplier: 3这里有几个参数值得说明。经济挡的temperature设低一点0.3因为简单任务需要的是确定性不是创造力。性能挡的temperature可以高一点0.7因为复杂任务往往需要模型做一些跳跃性的推理。timeout的差异也很大经济挡 15 秒足够性能挡因为要处理更长的上下文和更复杂的推理给到 45 秒。4.2 路由决策的埋点与日志路由决策必须埋点否则出了问题根本没法排查。我记录的字段包括请求 ID 和时间戳复杂度分数和各个信号的贡献值最终路由决策eco/pro决策依据是哪个信号触发的实际使用的模型调用结果成功/失败/错误类型耗时和 token 消耗是否触发了自愈自愈了几次这些字段看起来多但都是排查问题时必需的。我遇到过好几次路由决策看起来不对的情况最后都是靠这些埋点定位到具体是哪个信号算错了。日志格式我用的是 JSON Lines每行一个 JSON 对象方便后续用脚本分析{req_id: abc123, ts: 2024-01-15T10:30:00Z, score: 4, signals: {tokens: 3, files: 1, keywords: 0}, decision: pro, reason: token_threshold, model: large-model-v3, result: success, latency_ms: 2340, tokens_used: 5678, healed: false}4.3 灰度上线策略Model Router 不要一次性全量上线风险太大。我的做法是分三步灰度第一步影子模式。Router 正常运行、正常决策但实际请求还是走原来的固定模型。这一步的目的是验证路由决策的合理性看看 Router 认为该走性能挡的请求用经济挡跑会怎样。跑三天对比决策和实际结果的差异。第二步小流量灰度。选 10% 的请求真正走 Router 决策。这一步观察真实场景下的成功率和成本变化。跑一周如果成功率和原来持平或更好成本下降就可以进入下一步。第三步逐步放量。从 10% 到 30% 到 50% 到 100%每一步观察一天。任何一步出现成功率明显下降就回滚到上一步。这个流程看起来慢但比上线后发现路由全错、紧急回滚要快得多。我第一步影子模式就发现了两个问题一是关键词匹配把重构测试用例误判成了复杂任务其实只是重命名二是 token 阈值在长对话场景下过于敏感。这两个问题如果直接上线影响会很大。4.4 成本监控与告警Router 上线后成本监控是必须的。我设了三个告警单日成本超过预算 120%说明路由决策可能偏向了性能挡需要检查阈值。性能挡命中率超过 50%说明阈值可能设低了或者任务分布发生了变化。自愈触发率超过 5%说明模型服务不稳定或者路由决策有问题。这三个告警帮我抓到过好几次问题。有一次性能挡命中率突然从 35% 涨到 62%查日志发现是新加的一个功能模块的请求都带了大量上下文触发了 token 阈值。后来针对这个模块单独调了阈值问题解决。5. 常见问题与排查技巧实录5.1 路由决策偏差的排查思路路由决策偏差是最常见的问题表现是明明简单任务却走了性能挡或者复杂任务走了经济挡导致失败。排查思路是先看分数再看信号最后看阈值。先看分数这个请求的复杂度分数是多少如果分数本身就不对那问题在信号计算。如果分数对但决策不对那问题在阈值。再看信号各个信号的贡献值分别是多少哪个信号贡献最大我遇到过一次所有请求的 token 信号都贡献了 3 分查下来发现是对话历史没有正确截断导致历史 token 数一直累积。修复历史截断逻辑后问题解决。最后看阈值如果分数和信号都正常那就是阈值需要调整。这时候不要急着改先收集一周的数据看看整体分布再决定。5.2 自愈机制失效的典型场景自愈机制失效通常有三种表现第一种该重试的没重试。原因是错误分类错了把瞬时错误归到了不可恢复类。排查方法是看错误日志里的错误码对照分类表检查。我建议把分类表写成一个显式的映射而不是用 if-else 硬编码这样排查时一目了然。第二种重试了但一直失败。原因是重试策略不适合当前错误。比如速率限制错误如果退避时间太短重试还是会撞上限制。这时候需要加大退避基数或者引入更长的等待。第三种自愈导致成本爆炸。原因是缺少成本边界。我前面提到的成本不超过单次请求预算的 3 倍这个边界就是被这个问题逼出来的。没有这个边界之前有一次一个请求触发了 8 次重试加 3 次降级成本是正常请求的 20 多倍。5.3 常见问题速查表问题现象可能原因排查方法解决方案简单任务走性能挡阈值设低 / token 信号误触发看分数和信号贡献调高阈值 / 修复 token 计算复杂任务走经济挡失败阈值设高 / 关键词未命中看失败请求的分数调低阈值 / 补充关键词自愈不触发错误分类错误对照错误码和分类表修正分类映射自愈后成本暴涨缺少成本边界看自愈请求的成本加成本上限性能挡命中率异常升高任务分布变化 / 上下文累积看命中率趋势和 token 分布分模块调阈值 / 修复截断路由决策延迟高信号计算太重看决策耗时简化信号 / 加缓存5.4 几个我踩过的坑坑一把路由决策放在了同步路径上。一开始我在每次请求前同步计算复杂度分数结果发现信号计算尤其是 token 计数在长上下文场景下要花 100-200ms直接拖慢了整体响应。后来改成异步预计算在上一轮对话结束时就把下一轮可能用到的信号算好缓存起来决策时直接读缓存耗时降到 5ms 以内。坑二忽略了模型切换的上下文兼容性。经济挡和性能挡的模型可能对 prompt 格式有不同的偏好。我遇到过经济挡模型对 system prompt 里的某些指令不敏感导致输出格式不对。后来在 Router 里加了一层 prompt 适配根据目标模型调整 prompt 的格式和措辞。坑三自愈状态没有持久化。一开始自愈状态只存在内存里进程重启就丢了。结果遇到服务重启时正在自愈的请求全部丢失用户看到的是请求消失。后来把自愈状态写到 Redis重启后可以恢复问题解决。坑四没有区分用户主动重试和系统自动重试。用户主动重试通常意味着对结果不满意这时候应该升级到性能挡系统自动重试是技术性重试不应该影响挡位选择。我一开始把两者混在一起导致用户重试时挡位乱跳。后来在请求里加了一个retry_source字段来区分逻辑就清晰了。提示这四个坑里第一个和第三个是最容易忽视的因为它们不影响功能正确性只影响性能和可靠性。但恰恰是这两个问题在生产环境里造成的用户投诉最多。6. 两挡设计的边界与后续扩展方向两挡设计跑了大半年整体是满意的但也有一些边界情况值得说清楚。边界一极度复杂的任务性能挡也可能搞不定。比如需要跨十几个文件的大型重构或者需要理解整个业务领域的架构设计。这种情况下Router 能做的只是选一个相对更强的模型但模型本身的能力上限在那里。我的做法是当性能挡也失败时不继续升级因为没有更高的挡位了而是返回一个明确的需要人工介入的信号让用户决定下一步。边界二任务复杂度在会话过程中动态变化。一个会话可能从简单任务开始逐渐演变成复杂任务。Router 是按请求决策的不感知会话的整体走向。这导致有时候会话前半段一直走经济挡后半段突然需要性能挡但历史上下文里积累的经济挡输出质量不高影响了性能挡的表现。我的缓解方案是在会话级别加一个复杂度趋势信号如果连续多个请求的分数在上升就提前升级到性能挡。边界三多模型并行时的路由。有些场景下同一个请求可能需要多个模型协作比如一个模型生成、一个模型校验。这种场景下 Router 的决策就不只是选一个模型而是选一组模型和它们的协作方式。这个我目前还没做因为实际需求不多但设计上留了扩展点——Router 的输出可以是一个模型列表而不是单个模型。后续如果要扩展我优先考虑两个方向一是引入更精细的复杂度评估比如用一个小模型做快速分类成本可控的前提下二是支持按任务阶段路由比如生成阶段用性能挡格式化阶段用经济挡这样能进一步优化成本和质量的平衡。不过说实话两挡设计已经覆盖了我 95% 以上的需求。工程上的事情能用简单方案解决的就不要上复杂方案这是我做了这么多年最深的体会。Model Router 的价值不在于它有多智能而在于它用最小的复杂度解决了模型选择这个真实存在的问题。