Skip to content

阶段路由器

让一个模型 id 依据对话最近的 tool-result 历史(而非提示词文本)逐轮在强力档位与高效档位之间做选择。

Updated View as Markdown

[[models]] 条目通常只指定一个目的地。阶段路由器指定两个 —— 一个强力档位和一个高效 档位 —— 并让对话决定由谁承担每一轮。错误、停滞的排查和探索性阅读会向上走;计划确定之后的 常规产出留在下面。

阶段路由器是条目 [models.router] 表可以取的 type 之一。auto 是同一个路由器套用上游 预设;其余类型,以及如何在它们之间选择,见路由概览。

该功能需要显式启用,默认完全不生效。不配置 [models.router] 表,shunt 的路由与以前 完全一致:只看请求的 model id,别无其他。

它读取什么

只读取请求体中结构化的协议元数据:

  • tool_use.name —— 助手调用了哪个工具
  • tool_result.is_error —— 结果是否以失败返回

两者都是 Anthropic Messages 线路格式的核心字段。路由器不读系统提示、不读用户文本、也不读 工具参数,因此不会继承那些对提示词形状做指纹识别、每逢 Claude Code 发版就失效的代理的失败 模式。

tool_result 块不携带工具名称。唯一的关联是 tool_use_id → tool_use.id,这个连接是 整个提取器赖以立足的唯一支点。

shunt 会两次遍历请求的 messages 数组。第一次只从后往前遍历 assistant 消息,走到 recent_turn_window 覆盖的范围为止,收集算作最近的 tool_use id。第二次从前往后 一次遍历两种角色,既建立 tool_use.id → tool_use.name,也把每个 tool_result 记到 它所回答的 tool_use_id 上。名字是在遍历之后才解析而不是边走边解析,所以即使结果 出现在它自己的调用之前也照样能接上。除了用于关联的 id,只读取上面这两个字段。

如果一次已完成的工具调用都没有,提取返回空,该轮走 picker 的默认值。空对话不会被评为 “efficient”。

工具词汇

评分器想知道的是某次调用是在观察、修改还是计划。provider 中立的路由器只能推断,而 shunt 知道 Claude Code 实际的工具名称,这正是它能做得更好的地方,并直接匹配下面这 14 个:

类别 工具
观察(Observe) Read、Glob、Grep、NotebookRead、WebFetch、WebSearch、BashOutput
变更(Mutate) Edit、NotebookEdit、Write
计划(Plan) TodoWrite、Task、EnterPlanMode、ExitPlanMode
未分类 其余全部,包括 Bash、Skill、KillShell 和所有 mcp__* 工具

未分类的那些名字可以用 [models.router.tool_semantics] 自己归类:四个列表(observe、 mutate、plan、new)在上表之后生效。表里已经认识的名字改不动 —— 把 Read 放进 mutate 不是覆盖,而是启动错误。改动这些列表会在下次加载时丢弃该路由器的会话固定。

包括 ExitPlanMode 在内的所有 Plan 调用都计为计划活动,而计划活动被读作探索,因此是 往上推,而不是往下。

Bash 是刻意不分类的。git status 是观察,rm -rf 是变更,cargo test 是事情已收敛的 信号,而区分它们的只有 input.command —— 对命令字符串做分类,恰恰是本设计要避免的脆弱推断。

严重度

Claude Code 只用一个没有分级的布尔值报告失败,所以由 shunt 自己把它映射到 libsy 的 0.0 / 0.3 / 0.7 / 1.0 严重度刻度上:

条件 severity
窗口内没有失败结果 0.0
窗口内有失败结果 0.7(hard)
窗口内的 Task 失败 —— 整个被委派的子 agent 运行崩塌,而不是一条命令失败 1.0(critical)
最后两条完成结果都失败且都在窗口内 —— 会话没有在恢复 1.0(critical)

它如何决策

信号评分由 NVIDIA-NeMo/Switchyard 的评分器 switchyard-libsy 负责,那是经过 校准的部分,这里不重新实现。这个依赖从何而来,以及支撑它的公开基准结果,见 Switchyard 集成。

维度

信号被投影到两个轴上 —— 是否出了问题,以及代理是否在产出:

维度 含义 推向
severity 窗口内最大错误严重度,[0, 1] Capable
spinning 既无读取、计划,也无写入、编辑的深回合 —— 纯空转 Capable
exploring 有读取或计划,但不写不改的深回合 Capable
production_intensity 写入+编辑占近期工具操作的比例 Efficient

spinning 与 exploring 都要求 turn_depth >= 8:早期没有写入的回合是正常探索, 不是停滞。二者按是否有调查性活动划分同一个“未产出”状态,因此至多触发一个,产出轴 不会被重复计算。

评分

每个达到满值的维度贡献一个 0.10 的信号单位;severity 按其 hard 上限 0.7 归一化,从而落在同一权重上:

raw   = 0.10 × (severity / 0.7 + spinning + exploring − production_intensity)
score = tanh(5.0 × raw)          // 符号:+ capable,− efficient
confidence = |score|

增益 5.0 才让取值范围可用 —— 没有它,置信度会封顶在 ±0.20 附近,任何有意义的阈值 都永远够不着。

这套设计是需要互证的:没有任何单一轴能独自定下结论。

一致的信号 raw confidence 能过 0.5 默认阈值吗
一个饱和 0.10 tanh(0.5) ≈ 0.46 否
两个互证 0.20 tanh(1.0) ≈ 0.76 是

0.5 这个默认值正来源于此:单独一个错误信号不足以让回合改道,两个一致的信号则够了。

判定

pick_tier 按顺序执行三条规则,最先触发的那条获胜。

  1. 硬升级 → capable。critical 级严重度、上下文被压缩,或最近窗口内同一个失败 重复出现。跳过评分器,报告 confidence: 1.0。
  2. 评分器。 落在中位值周围那条模糊带之外时,按符号走。
  3. Fall-open。 落在带内时 libsy 返回 PickOutcome::ConsultClassifier { default_tier },该回合落在 picker 的默认档位。

fall-open 并不是在跟 API 对抗 —— 这本身就是 API 文档化的 fall-open 路径。shunt 默认 不运行裁判,这让热路径上没有第二次模型调用、没有第二份凭据,并且在路由器最没把握的那些 回合上不增加延迟。下文的裁判回退就是把这些轮次交给裁判的可选开关,而且只影响 这些轮次。

以前还有第四条 —— 对测试已通过的收尾回合做硬降级。上游把它删掉了;测试通过如今 只会清除 libsy 自己的 capable hold,而 shunt 并不使用那个 hold。

规则 1 的三个触发条件里,今天的 shunt 会触发其中两个。critical 严重度通过严重度表中的 两条路径触发;上下文被压缩则来自请求头 x-claude-code-context-compacted 而不是对话记录, 这也是该触发条件能在完全没有工具活动的回合上生效的原因。重复失败那一半仍处于休眠, 因为 libsy 是把结果文本与自己的错误表比对来判定同一个失败的,而 Claude Code 发送的 is_error 布尔值支撑不了这件事。shunt 自己的“最近两个已完成结果都失败”规则,已经通过 severity 抵达规则 1。

为什么下行比上行更难

以上都是孤立地判定单个回合。之后 shunt 会在允许该判定改变会话已固定档位之前,加上自己的 门槛,因为档位切换不是免费的,在长时间的 Claude Code 会话中代价很具体:

  • 提示缓存按模型分键。 切换会丢弃已预热的前缀,并在新档位上支付一次完整的缓存写入。
  • Codex 续接把模型哈希进签名,因此切换会强制重传全部输入,把 shunt 刚省下的上传量原样 吐回去。
  • 跨提供方切换还会丢掉会话的连接池和粘性账户槽位。

若每个回合都独立评分,长会话反而可能比不路由更贵。这就是两个方向被刻意做成不对称的原因, 也是迟滞在 shunt 的集成中属于核心而非枝节的原因:

方向 需要满足
高效 → 强力 评分器实际作出的判定,且越过 confidence_threshold(0.5)。便宜档位应付不了的一轮,比丢掉的缓存前缀更贵。
强力 → 高效 评分器实际作出的判定,且 min_dwell_turns 已过,且满足 deescalate_threshold(0.75)。

fall-open、模糊回合,或没有工具活动的一轮是 picker 的默认值,不是证据,因此在任何方向上都 无法移动档位。

置信度门槛没有任何豁免。它曾经为 tests_passed 捷径留过一个,因为那条捷径完全不报告 置信度。上游删掉那条规则之后,凡是算作证据的来源都会报告置信度,而一个不报告置信度的回合 会被保留而不是被信任。接替它的 libsy capable_hold —— 意思是先前的升级仍在保持 —— 同样 不是证据:它与 sticky 回合是同一个概念,而且只有 libsy 那个有状态的分类器会打上它,shunt 并不调用它。

capable_hold_turns(默认 0)在信号把档位推上去之后,再多保持强力档位若干轮 —— 当恢复过程 本身安静得容易被读成已经收敛时,这很有用。被保持的轮次把路由来源报告为 capable_hold,它同样 不算证据,无法移动固定。

告诉接手的模型为什么

[models.router.handoff_notes] 只在信号改变档位的那一轮,向转发出去的请求追加一个简短的 system 块:向上时用 escalation_note,向下时用 deescalation_note,让到岗的模型知道自己 是在接替而不是从头开始。Claude Code 的 attribution 块是第一个 system 块,不会被动;备注放在 最后。

默认情况下(only_on_wrong_signal_escalation = true),escalation_note 只限于由信号驱动 的升档,即路由来源 override 与 dimensions。沿用固定档位的轮次、没有信号的轮次以及 count_tokens 探测都不带备注;没有发生交接的轮次同样不带 —— 会话的第一个轮次,以及只是 再次确认已固定档位的后续信号。

每切换一次就付一次提示缓存未命中,因为 system 数组属于被缓存的前缀。这是在切换本身 已经放弃的按模型前缀之上再加的代价,也正因如此,这张表不显式打开就是关着的, only_on_wrong_signal_escalation 也默认取较窄一侧。

会话固定

决策按 (对外模型 id, 会话 id, agent id) 固定,两个 id 分别来自 x-claude-code-session-id 和 x-claude-code-agent-id。每个 id 的键都是 SHA-256 前缀 —— 两者都会被哈希且从不存储。同一会话中使用两个带路由器的模型,它们的档位互相独立。

子 agent 拥有自己的固定。 Task 子任务发送父级的会话 id 和它自己的 agent id,因此读写 一条独立的条目:它的失败不再让父级升档,它的轮次也不再计入父级的 dwell。Claude Code 在每个 被委派的轮次上都会发送这个头,无需任何选择加入,所以默认的 shunt 部署无需改动客户端即可生 效。存储上限是 4096 个父会话,以及与之分开的 4096 条子固定;在插入路径上完成淘汰,没有后台 任务,而且每次修剪只触及自己增长的那一侧,因此宽扇出不会淘汰正在等待它的父级。

压缩会升档,并且升档会保持。 Claude Code 只在上下文压缩后的第一轮附带一次 x-claude-code-context-compacted。shunt 把该轮按 compacted 评分,这会触发评分器的硬覆盖, 即使压缩后的历史没有任何工具活动可读也会路由到强力档位;同时把该标记闩存到会话固定上,使其 后的轮次得到同样的结论。只有当客户端设置了 CLAUDE_CODE_GATEWAY_HINT_HEADERS=1 时这个头才 会到达 shunt(参见客户端环境变量)。同一个变量还会 发送 x-claude-code-request-class,此时由它而不是上面的 agent id 规则来判定哪些轮次属于被 委派的工作。

  • 不发送会话头的调用方以无状态方式路由,这类请求上的压缩标记只对当前这一轮有效。
  • count_tokens 探测不记录任何内容 —— Claude Code 发送它时携带的历史落后一轮,若记录便会 让陈旧历史左右固定。会话一旦已被固定,探测与紧随其后的轮次读取同一份固定,因此到达相同 档位;而在尚未固定的会话上,探测按自己的历史评分,可能落在与后一轮不同的档位。
  • 固定能挺过配置热重载。但固定记得它所依据的路由器表的指纹:修改某个路由器,只有它自己 的固定会被丢弃。该指纹以解构方式读取配置结构体而不是点号取字段,因此日后新增键时会编译 失败,而不是让过期的固定悄悄存活下来。
  • 固定在 session_ttl_seconds 后过期。重启之间不持久化任何内容。压缩闩存随承载它的固定 一起清除 —— 在该过期时,或在热重载改变路由器表时。

被委派的工作也可以完全离开这个路由器:[models.subagents] 覆盖层会在路由器运行之前就把子任务分流走,因此该子任务从不在这里被评分或固定。

客户端看到什么

被选中的档位只向上游传递。响应回报客户端请求的那个 id,因此 Claude Code 的 /model 显示和 --resume 模型恢复继续正常工作:

POST /v1/messages   {"model": "claude-auto", ...}
  → 上游收到        {"model": "claude-opus-4-8", ...}
  → 客户端收到      {"model": "claude-auto", ...}

决策由两个响应头报告,无需你去推断:

头部 值
x-gateway-routed-model 路由器选中的目标(claude-opus-4-8)
x-gateway-route-source 原因(override、dimensions、sticky、no_signal、fall_open、capable_hold、llm-classifier)

七个里有六个是信号标签。llm-classifier 只会出现在配置了下文 裁判回退的条目上,并且只出现在由裁判决定的那一轮;没有配置时,无法定夺的 一轮仍然报告为 fall_open,也不会发出裁判调用。评分器还定义了 ambiguous,它不能出现 在响应里。其他路由器类型有自己的标签,列在各类型自己的页面上(random、 random_session、noop,以及 LLM classifier 的 classifier_* 标签等),[models.subagents] 覆盖层也有自己的标签(命中 by_type 时 是 subagent_type,回退到 target 时是 subagent,llm_classifier 形式下则是 classifier 的标签)。

只有当路由器和 [models.subagents] 覆盖层都没有决定这一轮时,才不会带上这两个头,这样“被路由到高效档位”与“根本没走路由器”才能 区分开。count_tokens 探测不加这两个头:在尚未固定的会话上,探测按自己落后一轮的 历史评分,因此它的档位不一定是被测量那一轮的档位。

GET /routes 会在 routers 中列出每个已配置的路由器,附带它的 algorithm 与 targets; 对这个类型还会给出两个档位的目标,以及当没有信号做出决定时 picker 回退到的档位。 配置了裁判的路由器还会把它列在 judges 里。启用指标后 会导出三个计数器:覆盖所有路由器类型的 shunt.router.decisions,以及 shunt.stage_router.decisions 和 shunt.stage_router.flips —— 一旦配置了裁判,还会多出 第四个 shunt.router.judge_calls。档位切换是昂贵事件,而决策 计数器无法区分刚刚迁移过来的会话和本来就在那里的会话,所以 flip 单独计数。

如何配置

[[models]]
id = "claude-auto"                     # 要出现在 /model 中必须以 claude/anthropic 开头
display_name = "Auto (stage router)"

[models.router]
type = "stage_router"
capable_target = "claude-opus-4-8"
efficient_target = "claude-sonnet-4-6"
# picker = "efficient_first"
# confidence_threshold = 0.5
# recent_turn_window = 3
# min_dwell_turns = 3
# deescalate_threshold = 0.75
# session_ttl_seconds = 3600
# capable_hold_turns = 0

两个目标都是普通的公开模型 id,因此各自沿常规路由阶梯解析,并保留自己的故障转移链、账户池、 适配器、effort 和 service_tier。目标自身也是路由器会导致启动失败,所以解析在结构上就是 一跳。

所有键都记录在配置参考中。包含刻意排除项在内的设计记录见 ADR-0004; [models.router] 判别键和其余算法的记录见 ADR-0005。

裁判回退

信号能决定大多数轮次,但不是全部。可选的 [models.router.classifier] 表用来指定一个裁判 —— 一个只在信号无法定夺的轮次被问及该轮需要哪个档位的模型。

[models.router.classifier]
target = "claude-haiku-4-5"    # 只被询问,从不提供
base_threshold = 0.5           # 让任务留在高效档位的 p_solve 下限

不配置这张表,一切照旧:无法定夺的一轮 fall-open 到 picker 默认值。配置之后,只有那一轮 会交给裁判。已经被 pin 占住的会话不会被裁判,count_tokens 探测不会被裁判,请求通过鉴权 之前也不会发出任何裁判调用。classify_trigger = "user_turn" 把范围进一步缩小到最近一条 消息是人类用户回合的轮次,于是工具续轮沿用会话的 pin,而不再另付一次裁判调用。

裁判是普通的公开 model id,使用的是它自己那条路由的凭证而非调用方的:保留的 x-shunt-* 槽位以及 cookie、authorization、x-api-key、anthropic-beta 都会在 调用发出前被剥离。解析到 passthrough 上游的裁判目标之所以属于启动错误,原因正在 于此 —— passthrough 的含义是转发调用方自己的凭证,而刚刚被剥离的恰恰就是这份凭证, 于是无物可发。auth = "none" 的目标则没有问题:该模式表示这个端点根本不需要凭证, 所以前面没有任何鉴权的本地或自托管裁判是受支持的配置,而不是错误。调用 消耗的是该目标自己的账号池配额,所以请给裁判单独配一条 [[models]] 条目。

在真正去问裁判的那些轮次里,鉴权覆盖的是整个条目 —— 被请求的 id、两个档位目标,再加上裁判, 并且各自连整条故障转移链一起计入 —— 所以给一个应答目标全是 passthrough 的条目加上会注入凭证 的裁判,就会让那些轮次开始要求客户端凭证。鉴权失败或被策略拒绝的请求一次裁判调用都不会发出。 不会走到裁判的轮次,仍然只按它实际解析出的链鉴权,和没有这张表时完全一样 —— 既包括评分器自己 定夺的那一轮,也包括被 [models.subagents] 覆盖层在路由器之前分流走的那一轮。

[models.router] 上的六个键为每一次内部调用设定上限:judge_timeout_ms(默认 30000, 同时覆盖响应头和响应体的端到端期限)、judge_max_response_bytes(65536)、每个会话的 max_judge_calls(8,在裁判调用发出时扣减,因此并发回合也无法超出),以及三个 gated_* 键 —— 它们只约束 escalation 或 advisor 条目 扣住的回合,所以在阶段路由器上不约束任何东西。越过上限会取消对应的上游调用。

由裁判判定的轮次报告 x-gateway-route-source: llm-classifier,并和其他决策一样把会话 pin 住。超时、响应过大或无法解析、上游错误、预算耗尽 —— 所有失败都按 fall_open 处理, 所以裁判挂掉付出的是延迟而不是可用性。每一次调用都会带着它的结果记入 shunt.router.judge_calls。

限制

  • 没有轮次中途升档。 响应头一旦提交,shunt 不会重放该轮,因此在一轮开始时选定的档位会 承担整轮。
  • 这个裁判只决定档位。 其他需要裁决的类型各有自己的表: llm_classifier、composite 和 advisor 直接配置在 [models.router] 上。只有 llm_classifier 的 mode = "escalation" 和 advisor 会把 回合扣住等待裁决,这个裁判不会。
  • [server.codex_endpoint] 不在范围内,它使用自己的路由表。
Navigation

Type to search…

↑↓ navigate↵ selectEsc close