
前阵子我给团队自用的 Pi Agent——一个跑在终端里的 coding agent——做多模型接入时被一个现象卡了很久单独用最强的那款推理模型响应质量确实顶但一次普通的小文件重构就要二十多秒费用看着肉疼换成便宜的快模型生成速度快了一个数量级可一旦遇到跨文件追踪类改动就开始一本正经地胡说八道改完的代码根本跑不起来。后来我给它装了一套两挡的 Model Router问题才算真正解决。这篇文章就是这套 Model Router 的设计笔记两挡怎么切、故障怎么自愈、哪些参数值得调全部摊开说。配套涉及的代码、配置、以及踩过的坑我都会给到可直接抄作业的版本。先说结论Model Router 并不是在多个模型之间随机选一个而是把任务映射到不同能力档位同时把“模型挂了怎么办”这件事自动化。两挡设计的核心是承认一个事实——没有任何单一模型能在延迟、成本、准确率三个维度上同时最优。Pi Agent 这类 coding agent 的任务负载天然就不均匀有的任务是机械性的格式化有的任务是牵一发动全身的架构调整把它们不加区分地塞给同一个模型本质上就是花钱买罪受。1. 先说清楚我为什么非要给 Pi Agent 换个“自动挡”手动挡开过吧低速用低挡位、高速挂高挡位全凭驾驶员对路况的判断。而大多数 Agent 的模型调用方式是另一种东西——它更像永远挂着五挡起步堵车时油耗吓人上了高速反而拉不起来。我最初给 Pi Agent 接模型的时候就是这个状态全部请求都发往最强的推理模型结果如下。把一次编码会话里的请求按类型拆开统计至少能分成三大类。第一类是局部机械性任务格式化文件、补全某个函数的 docstring、修正 lint 报错、处理一个类型标注。这类任务的特征是上下文局部、逻辑清晰、对模型推理能力要求不高占比通常能到五六成。第二类是中等范围任务给某个模块补单元测试、在两个文件之间做小改动的适配、把一部分逻辑抽成函数。这类任务需要模型理解模块内部的结构但对整个仓库的全局把握要求一般。第三类是重任务跨五六个文件的接口重构、需要全局 grep 之后才能定位的 bug 修复、依赖升级后的大规模迁移。这类任务的特征是信息分散、依赖链长、一步错步步错。有意思的是我最初用的那款强推理模型三类任务全部都能接但是第二类和第一类任务交给它效果反而不好。格式化这种任务它经常输出一大段“顺带优化”的改动把不该碰的代码碰了单函数补全它来回重写制造不必要的 diff。而那个快模型前两类任务干得又快又稳一旦碰到第三类就出现典型的“局部合理、全局失控”单看每一个文件好像改得都对合在一起编译直接失败或者把调用方的适配完全漏掉。自动挡的比喻落在这里就特别贴切Model Router 的角色就是根据当前的路况——也就是任务的复杂度、信息分散程度、延迟预算——自动切换“挡位”。低挡位用低成本快模型处理机械劳动高挡位用强推理模型处理复杂重构。这个思路单独拎出来谁都能想到但真正落地时要回答的问题很具体拿什么指标判断该挂几挡判断错了怎么办挡位切过去之后模型崩了怎么退回来整套链路捋顺了才敢说真的装上了自动挡。1.1 任务负载分析不统计你永远不知道模型分配有多亏我先贴一组实测数据。给 Pi Agent 接上完整链路之后我跑了大概两周的真实会话覆盖日常开发、bug 修复、测试补全、小规模重构四类场景对所有请求做了结构化统计。请求量分布大概是局部机械性任务占 58%中等范围任务占 31%重任务占 11%。对照当时“全走强模型”的配置58% 的请求其实是低效的平均响应延迟比快模型高出 4-7 倍单次 token 成本高一个数量级而且产生的不必要 diff 变多了反过来增加了人工 review 的时间。那段时间我印象最深的一个例子是让 Agent“给这个函数补个错误处理的 try-except”——它居然把整个函数的实现重写了一版还顺手改了相邻函数的签名。代码本身没有错但这种“过度服务”在工程场景里非常讨厌。它打乱了 git 历史也让本来就该是几分钟的小改动变成了二十分钟的沟通与回滚。1.2 为什么“按模型能力一刀切”是伪最优有些人会反驳我直接用最强的模型顶多多花点钱但至少不用维护一套路由逻辑。这个说法在请求量低的时候成立一旦 Agent 真正融入日常开发流程、每天产生几千次模型调用就不成立了。原因不只是钱。第一强模型在简单任务上会“自由发挥”。语言模型的训练目标决定了它倾向于生成完整、自洽的内容而不是最小改动。让它改一个报错它会顺手重构让它补一行注释它会补充一整套设计说明。对 coding agent 来说这种输出会让 diff review 变成折磨。第二延迟会累积。Agent 的每个步骤之间是串行的单次调用多出 8 秒一个十步的任务就多了 80 秒用户的等待体验会直接崩掉。第三成本摩擦。coding agent 的特点是 token 消耗大头在 prompt 端——它要把仓库文件内容塞进上下文。同样的上下文长度强模型的输入价格可能是快模型的十倍这意味着同样的任务成本被人为放大。所以“一刀切用强模型”不是最优而“一刀切用快模型”更不是——准确率不够的模型在重任务上会产出一堆看似合理实则错误的代码而 Agent 的自我修复循环又要烧掉额外的时间和 token。两挡设计是对这两个极端的一种实用妥协。2. 两挡的判定机制不靠猜靠可量化的任务特征路由判定的最核心问题就是给一个任务是丢给快挡还是深度挡一开始我以为可以靠用户的 prompt 里“帮我重构”“帮我看看 bug”这类关键词来粗判实测下来完全不行。用户说“帮我看看这段代码有没有问题”可能是查一个 typo也可能是要你找出并发竞争条件。所以我把判定逻辑从“文本猜测”改成了“任务特征量化”。具体来说我采集了四组特征。第一组是变更范围改动涉及几个文件、这几个文件之间有没有引用关系。这个信息在 coding agent 的工具调用历史里很容易拿到Agent 在动手之前通常已经执行过 grep、ls、读取文件等操作把这些操作的对象收集起来就能画出一张粗粒度的“变更地图”。第二组是上下文规模当前会话累计读入的 token 数、即将要写的文件的预估 token 数。第三组是任务类型标签通过就简单规则把用户请求分到“机械操作”“局部修改”“跨文件改动”“全局排查”里。第四组是用户显式指定如果用户在 prompt 里写了“用快速模式”或者“这是复杂重构请仔细处理”直接覆盖自动判定。然后我把这四组特征合成一个 0 到 10 的复杂度评分。评分超过阈值就挂深度挡否则走快挡。这里有一个我后来觉得特别重要的设计原则默认快挡只凭明确信号升挡。不要试图把每个中等任务都精确分类更不要默认分配给深度挡再降级因为升挡的代价是一次慢响应而降挡的代价可能是整轮修复循环白跑。2.1 复杂度评分一个够用但不复杂的算法我实现的评分函数大致长这样def estimate_scope(task) - float: score 0.0 # 1. 文件数量1个文件是02-3个是24个以上是4 files task.mentioned_files score min(len(files), 4) * 1.0 # 2. 跨文件引用本文件符号被其他文件引用则加权重 if task.has_cross_references: score 2.5 # 3. 上下文规模预估 token 超过 30k 说明信息量大 if task.est_context_tokens 30_000: score 2.0 # 4. 任务类型标签 score { format: 0, typo_fix: 0, single_func: 1, local_refactor: 3, unit_test_gen: 4, cross_file_refactor: 6, global_bug_hunt: 7, }.get(task.kind, 3) return score def decide_lane(task, budget_ms8000): if task.user_override: return task.user_override scope estimate_scope(task) if scope 7.0 or task.est_context_tokens 50_000: return deep if budget_ms 5000: return fast return fast # 默认挡只有明确信号才升级这里有个细节我得强调__默认返回 fast__这个“兜底”不是偷懒而是我反复对比后得出的结论。对同一批真实请求做回放默认走快挡、仅在明确特征触发时升深度挡系统整体成功率与全深度挡的差距只有 3 个百分点但延迟和成本大幅下降。原因是快挡模型出错的场景大部分是“理解不全面”而 Agent 自身的循环——让模型看到报错、再试一次——能把这种错误纠正过来深度挡的真正优势场景是那些一次理解错误就会导致方向性跑偏的高信息密度任务这类任务占比其实没想象中高。2.2 用户显式干预与自动判定的优先级路由系统必须给用户留一个“physical override”的物理按键。我遇到过太多次这样的场景自动判定认为是局部修改给了一个快模型结果快模型因为看不全整个模块的接口改出了编译错误。虽然自愈机制能兜底但浪费了额外两三次往返。所以我们在 prompt 里约定了一套简单语法/fast和/deep前缀以空格或换行开头都行。用户一旦显式指定端口就锁定不会因为评分低而被覆盖。反过来自动判定也有否决权——如果用户选择了/fast但任务特征是预计 token 超过 50k、或者要动的文件超过 6 个路由会强制升挡并给出一条日志说明。理由是快挡模型的上下文窗口和处理能力在这种输入下大概率撑不住强行执行只会浪费更多 token 和时间。这种“用户显式挡位优先但在极端特征下自动覆盖”的策略比单纯听用户的更稳。2.3 挡位映射表我们在两挡上分别挂了什么模型真实环境里两个挡位的模型类型和参数差异也很大。我用一张表说明配置项快挡fast lane深度挡deep lane模型规格低参数开源模型或供应商的 mini 版本强推理商用模型或大参数模型典型超时6 秒30 秒单次最大输出 token20488192上下文窗口8k-16k64k-128k平均单次成本极低高适用场景格式化、局部补全、lint 修复、少量修改跨文件重构、全局 bug 排查、复杂测试生成挂载深度挡时要注意一个现象模型上下文窗口变大的同时非必要信息的阅读量也会涨。有一次深度挡模型在一个 80k 上下文的请求里把无关的历史文件也纳入参考最终输出了一个考虑过多边角情况的方案把简单问题复杂化了。所以我在路由层加了一道“上下文裁剪”逻辑如果任务的est_context_tokens超过窗口一半先把历史对话中已完全删除的文件内容文档化用摘要代替全文。这个小改动让深度挡的响应质量不降反升。3. 故障自愈才是这套路由的灵魂从重试到熔断再到级联降级很多 Model Router 的文章讲讲路由策略就结束了但真实世界的在线系统不是这样运作的。模型供应商会 429 限流、会超时、会返回 5xx本地部署的模型可能出现 GPU 显存不足、推理进程被杀最坑的是模型返回 200 状态码但内容是垃圾——要么 JSON 格式损坏要么生成了一段与任务完全无关的文本。如果把这些情况全部交给 Agent 自身去“重试一次”就会陷入两个问题报错信息不够结构化Agent 不知道重试时该改变什么重试次数没有上限上游队列被打爆。自愈机制的核心是在路由层建立一整套“调用-检测-补偿”的闭环。我把整套链路分成五个环节前置探针、单次调用封装、有限重试、熔断器、级联降级。每个环节解决不同的故障面。3.1 前置探针不把请求发给明显不健康的模型探针做的事情很简单——在真实请求之前用一个极小的含 prompt 的请求比如让模型回复“ok”探测当前模型的延迟和可用性。我设置了两个探针频率常规 5 分钟一次以及每次路由决策前的一次 0.1 美元开销的实时探测。实时探测只在大流量切换或上一次请求失败后的 30 秒内启用平时靠周期探针的滑动窗口数据。这里有个教训探针请求不能太简单否则起不到检测作用。一开始我用的是空的 prompt结果本地模型返回飞快但真实请求因为上下文变长导致 OOM显存溢出。后来我把探针 prompt 改成了 500 token 左右的垃圾文本并保持与真实请求相近的输出 token 规模mini 版本输出 50 token。这样探针结果与真实情况的相关系数明显提高。另外探针失败不算立即降级而是要连续两次失败才确认“该模型不可用”避免一次抖动就切流量引发连锁切换。3.2 单次调用封装把超时、错误码和格式校验都包进去每个 LLM 调用都被封装成一个带超时和校验的执行单元。很多人写 Agent 的时候只 catch 网络层异常但 coding agent 场景下的“失败”远不止网络错误。我归类了四类必须处理的失败失败类型表现处理动作超时/网络错误请求无响应、连接被断开走重试最多 2 次指数退避限流429 响应头走重试等待时间从响应头的 retry-after 读取服务端错误500、502、503走熔断判断连续失败触发切换输出格式错误JSON 解析失败、无工具调用字段带着“上次格式出错”的提示重新生成一次输出格式错误是最隐蔽的坑。有一次模型正常响应了但返回的 JSON 里多了一个tool_calls: []字段解析器直接报错。如果直接把解析错误抛给 AgentAgent 会困惑——它看不到原始响应也不知道该怎么改。正确做法是路由层捕获异常后把原始输出附在重试 prompt 里并追加一句“注意上次输出缺失或格式错误请严格按 Schema 输出”。多数情况下模型重试一次就恢复正常这比让 Agent 自己排查省一个往返。3.3 熔断器与级联降级挡位坏了就挂另一个挡熔断器是我从微服务治理里搬过来的起到的作用是“防止对已知故障源的无意义重试”。实现很简单每个挡位维护一个连续失败计数连续失败 3 次熔断器打开 30 秒这期间该挡位不接受任何请求直接切换去另一个挡位。30 秒后进入半开状态放 1 个探测请求成功则关闭熔断器失败则回到打开状态等待时间指数增加30s、60s、120s封顶 10 分钟。级联降级需要特别设计顺序。我的策略是“深度挡故障 - 快挡重试快挡故障 - 深度挡重试”。听起来像是废话但实际有一个值得注意的顺序问题原则上低挡故障要优先尝试高挡吗我一开始也是这么想的后来发现这样设计会遇到“过度降级”的问题——快挡只是慢了一点就被降级成深度挡成本飙高。所以我把“挡位不可用”和“挡位降级”分开了延迟超时只影响重试次数只有连续失败才触发熔断降级。级联降级的最后一级是“结构化兜底”如果两个挡位都不可用路由层不会返回一个凌乱的报错给 Agent而是返回一个预定义的动作建议。例如{status: all_models_down, suggest: wait_60s, fallback: ...}Agent 接到这个结构化响应后会停止当前任务等待你手动恢复模型服务而不是盲目标记任务失败。对于 coding agent 来说这种“暂停”比“失败”更合理——因为用户的代码工作区还在稍后重试的代价远低于从零开始。3.4 自愈循环的关键切换时要带“前一次失败的信息”这是整套自愈机制里我认为最值得记住的一条经验。当深度挡失败、任务被切换到快挡时快挡不能真的“从零开始”。它需要知道三样东西这个任务原来打算怎么改、上一个模型改到哪一步、因为什么原因停下来。我在路由的 prompt 组装层增加了一个“失败上下文”字段。切换挡位时自动把前一次失败的模型输出、错误类型、以及 Agent 之前已经产生的工具调用历史拼接进新 prompt 的开头让新模型既能接着干活又能避免重复犯同样的错。实测中这个改动让跨挡接管的成功率提高了将近一半——否则快挡经常会忽略深度挡已经做过的前期分析直接重新读文件、重新设计既慢又乱。4. 接入前后的实测数据与一次真实故障回顾在这一节我把硬数据放出来。同一批测试任务152 个真实编码场景对比三种配置全深度挡、全快挡、两挡路由。结果如下指标全深度挡全快挡两挡路由平均首词延迟8.7s2.1s3.4s单任务平均耗时28s15s14s任务成功率无需人工介入96.2%78.9%95.1%每千次调用成本100%基准约 12%约 61%总 token 消耗基准高 30%低 5%因避免无谓重试两挡路由比全深度挡只损失了 1.1 个百分点的成功率但延迟降了一半成本降了快四成。比全快挡试成本高了一些但成功率提高了 16 个百分点。对一个 coding agent 来说这个性价比是值得的。成本差异的根源在于约 58% 的简单请求走了快挡而复杂请求虽然占比只有 11%成本占比却超过一半——这种错配被路由修正后总成本自然下来了。4.1 一次真实故障全过程限流引发连锁熔断器如何救场记录一次真实的故障复现过程能让你更直观地理解自愈链路是怎么咬合的。某天下午我们对一个约 8k 行规模的模块做接口重构任务属于典型重任务路由正确判定挂深度挡。但当天几家商用模型供应商集体出现服务波动深度挡第一次调用就收到 429 限流。按配置限流会带 retry-after 重试于是等待 3 秒后重试第二次仍然 429。此时连续失败数达到 2还没触发熔断器但路由层检测到这是同一供应商的持续性限流于是提前采取行动——把本次请求切到快挡。快挡模型接收时带上了失败上下文它先完成了 60% 的机械性修改新增函数签名、更新调用处的参数列表但在一个关键的数据结构转换上出了错生成的代码语义不对。幸运的是 Agent 自身的执行环节发现了编译错误自动把报错信息塞回给快挡模型继续修。快挡模型修了一轮仍然有问题此时任务的中止条件是“两轮修复失败”。路由层这时再次介入因为快挡连续失败也已达到 3 次熔断器打开深度挡的限流窗口刚好过去任务又切回深度挡。深度挡模型看到的是快挡已完成的改动 编译错误的堆栈 路由层附带的失败记录。它只用了两步就把那个数据结构转换修正了整个流程耗时约 4 分钟。这个过程如果没有任何自愈机制用户会看到什么深度挡第一次限流后Agent 可能直接报错终止或者盲目重试三四次拖到超时。有了路由层的熔断器和级联降级用户只会在界面上看到一次“模型切换中”的提示任务继续推进。那次之后我把“限流连续两次即触发临时切换”写进了配置不再等熔断器的 3 次阈值——因为 429 不像超时它带有明确的服务端状态信号可靠度更高。4.2 为什么快挡模型反而容易触发“自愈循环”另一个观察让我调了很多天参数快挡模型引发的“修复循环”频率比深度挡高得多。所谓修复循环就是模型生成代码 - Agent 编译/测试报错 - 把报错塞回去 - 模型再生成往复循环。统计下来快挡模型平均一个任务要 2.3 轮修复深度挡只要 1.2 轮。这看起来是劣势但源码角度的整体耗时反而持平——因为快挡每轮生成速度极快修复循环中的等待时间短用户体感并不差。这个现象提醒我不要把“修复轮次”当作单纯的坏事。快挡模型的正确用法是利用它的高回复速度快速试错深度挡的正确用法是一次性把方向定好减少后续修复。两挡结合时如果深度挡已经在前面定好了大方向快挡的失败率会显著下降。所以我在配置里增加了一条深度挡完成分析后如果后续步骤是纯执行性的增删改若干行允许降级到快挡执行。这是两挡设计的进阶用法——不只按任务开头的复杂度分类而是按任务内部不同阶段动态切换。5. 可落地的实现骨架配置、路由和自愈的最小闭环如果你也想在 Pi Agent 或者自己的 coding agent 里实现这套东西我这里给一份最小可运行的设计骨架。它不依赖任何特定框架核心就是三个部分配置读取、路由决策、调用执行与重试。我直接用 Python 描述具体逻辑你可以对照翻译成自己项目的语言。5.1 配置层把挡位参数全部外置# router_config.yaml lanes: fast: provider: local-vllm model: qwen2.5-14b-instruct timeout_ms: 6000 max_output_tokens: 2048 max_retries: 2 retry_backoff_base_ms: 1000 deep: provider: vendor-api model: deepseek-v3 timeout_ms: 30000 max_output_tokens: 8192 max_retries: 3 retry_backoff_base_ms: 1500 circuit_breaker: failure_threshold: 3 open_window_s: 30 half_open_probe_count: 1 open_window_max_multiplier: 4 routing: default_lane: fast score_threshold_deep: 7.0 token_threshold_deep: 50000注意两个参数_max_retries控制重试数我建议重试最大次数不要超过 3_open_window_max_multiplier是熔断打开时间的指数上限倍数防止长时间把某供应商打入冷宫。这些参数看起来简单但影响极大。之前我把快挡的 timeout 从 6 秒改到 8 秒整体 P95 延迟就从 11 秒跳到了 15 秒因为大量慢响应占满了 Agent 的执行队列所以参数一定要基于你自己的延迟分布来调不能照抄。5.2 路由与自愈的执行循环class LaneManager: def __init__(self, cfg): self.lanes {name: Lane(name, params) for name, params in cfg[lanes].items()} self.cbs {name: CircuitBreaker(**cfg[circuit_breaker]) for name in self.lanes} self.stats {} def call_with_self_healing(self, task, request_builder): failed_contexts [] for lane in self._lane_order(task): breaker self.cbs[lane] if breaker.is_open(): continue for attempt in range(self.lanes[lane].max_retries 1): # 组装带失败上下文的 prompt prompt request_builder(task, failed_contexts) try: response self.lanes[lane].complete(prompt) self._validate_or_raise(response) # JSON 校验等 breaker.record_success() return response except ModelError as err: failed_contexts.append(self._extract_failure(lane, attempt, err)) breaker.record_failure() if err.is_rate_limit and attempt 0: continue # 直接重试不等退避 time.sleep(self._backoff(lane, attempt)) return self._structured_fallback(failed_contexts)代码里的_lane_order(task)就是挡位顺序决策函数如果当前任务按复杂度判为深度挡顺序是[deep, fast]反之是[fast, deep]。每次失败后failed_contexts会被带上这样切到下一个挡位时模型能看到前面的失败信息。这是自愈的大部分价值所在——切换本身不复杂复杂的是不让切换丢失信息。5.3 结构化回退的格式约定如果两挡都失败返回的结构化响应要足够规范让 Agent 能理解并暂停。我定义的格式大致是{ status: all_models_down, last_errors: [ {lane: deep, error: rate_limit_429, at: ..., retry_after_s: 12}, {lane: fast, error: timeout, at: ...} ], advice: wait_30s_then_retry, preserve_workspace: true }关键字段是_last_errors和_advice。Pi Agent 拿到这个结构后会中止当前动作把工作区状态完整保留等 30 秒后自动重新发起。实测中这种“暂停-重试”比“终止-报错”对用户的伤害小很多——用户不会丢任何已生成的上下文只需要在终端看到一句“上游模型波动30 秒后自动恢复”。6. 调优笔记那些文档里不会写的坑最后这部分我认为是这个项目里最值钱的沉淀。参数调优不是靠灵感和直觉而是靠一次次的故障回放。以下几个坑都是我在两周多的内外测里真金白银蹚出来的。第一超时设置不是越短越好。一开始我给快挡设了 4 秒超时结果本地部署的 14B 模型在并发高时有大量请求超过 4 秒触发不必要的重试和切挡反而把 P95 延迟推高了。后来我统计了快挡模型的延迟分布P50 是 1.8 秒P95 是 5 秒P99 是 7 秒把超时定在 6 秒是合理的——6 秒能覆盖绝大多数正常请求只有真正的异常比如服务器卡死才会触发超时。如果你想更精细可以按 P98 或 P99 来定但一定记得留 20% 余量。第二重试千万不要用“无脑乘 3”。在没有退避和抖动的情况下同一时刻进来的几十个请求全部失败然后全部在同一秒重试上游直接被压成雪崩。我在重试逻辑里加了完整指数退避加抖动def _backoff(self, lane, attempt): base self.lanes[lane].retry_backoff_base_ms exponent min(attempt, 3) sleep_ms min(base * (2 ** exponent), 8000) jitter random.uniform(0, sleep_ms * 0.2) return (sleep_ms jitter) / 1000这样的意义是就算多个请求同时失败它们的重试时间会因为 jitter 错开避免瞬时限流的死循环。第三故障自愈里被低估的一环是“日志追踪”。没有 request_id 贯穿整个链路你根本无法判断某一轮失败到底发生在哪个挡位、重试了几次、切换时带上了什么信息。我把每一次路由决策和自愈动作都结构化打点request_id、lane_from、lane_to、error_type、attempt、duration_ms、token_usage。有了这些日志每次线上问题都能在 10 分钟内复盘清楚。比如前面说的 429 提前切换规则就是靠日志看到连续失败次数分布后加上的——没有日志这个决策依据根本无从谈起。第四成本观察比预想的要微妙。我本来以为两挡路由的最大收益来自“简单请求走便宜模型”但实际统计后最大的成本节省点其实是“避免无谓重试”。全深度挡配置下一个任务常常因为深度挡的一次延迟就触发重试每次重试都会携带庞大的上下文烧掉的 token 可能相当于完成一个简单任务的 5 倍。路由层通过熔断和切换避免了这种“昂贵的重试”比单纯换便宜模型更省钱。第五两挡路由的一个心智负担是“模型行为的不一致”。快挡写出的代码风格和深度挡不同如果用户来回切换最终生成的代码库可能呈现出两种风格的混杂。这个问题无法彻底消除只能缓解。我的做法是在 prompt 里固化一份项目级代码风格规范变量命名、错误处理方式、注释密度让两个挡位的模型都遵循同一份规范。效果还不错至少跑了一周代码 review 时没有人再抱怨“这一段看着不像同一个人写的”。第六也是最后一条路由判定的回放测试非常重要。我刚把评分逻辑调出来时很自信地部署上线结果第一天就出了幺蛾子——一个“把日志模块的 timeout 参数改一下”的请求因为提到了多个文件被判成深度挡白白多花了 3 秒和大量 token另一个“跨模块重构”却因为 grep 到的文件被判定为“related 判断失败”误入快挡导致修复了 4 轮。后来我写了一个回放脚本把历史请求喂给新的路由逻辑对比它与实际人工标注的挡位差异改了一版特征权重才收敛。你如果也要调参强烈建议先回放再部署不要一上来就在真实流量上试错。写完这套 Model Router我最大的体会是给 Agent 装自动挡真正难的不是路由算法而是对“故障”这两个字的理解是否足够宽。网络超时是故障输出格式错误是故障模型一本正经地胡说八道也是故障——只不过后者需要靠验证环节和级联切换来兜。两挡设计本质上是对模型能力的一种诚实认知我们不祈求某一个模型解决所有问题而是用一个聪明点的调度器把召回一堆不那么完美的模型组合起来让它们的短板互相填补。如果你也在做 coding agent 的模型接入建议重点从日志和 request_id 做起先把“当前发生了什么”这件事搞清楚再一步步调路由和自愈逻辑会少走很多弯路。