一个自己开发的 Agent Harness-模型降级篇

本文讲 Grodex 的模型故障与降级------一个供应商挂了,主循环怎么自动切换、怎么不塌、怎么把账记清楚。

0. 先建立心智模型

模型是会挂的

02 篇讲了"三家编解码器 + 规范 IR",那是格式层 的适配。但真实世界里模型不只是"格式不同",它还会:5xx、限流、网络抖动、认证失效。一个 agent 循环如果只绑一个模型,模型一挂,整个会话就停摆。

Grodex 对"模型会挂"这件事的答案是三层防御:

  1. 候选路由(ModelRoute):配置里列多个候选(不同 provider / 模型 / 账号),第一个挂了切下一个;
  2. 熔断器(CircuitBreaker):每个候选自带一个三态熔断器,连续失败就"开路",不去反复打一个坏模型;
  3. 重试预算(RetryBudget):单个候选内部,可重试的错误退避重试,预算耗尽再谈切换。

但这里有个必须守住的底线:降级不是"随便换个模型接着跑" 。模型换了,能力可能不一样(这个会工具调用、那个不会;这个支持流式、那个不支持)。Grodex 用 Lossiness 门 约束:只有显式声明允许降级的能力才能降,没声明的,宁可不降也不偷偷降级。这一条,是整个降级机制的灵魂。

1. 全景:一次采样要过的三道闸

  1. 先问熔断器:当前候选的熔断器 OPEN 了?直接拒绝,连试都不试;
  2. 尝试采样:过熔断器后,真正发请求;
  3. 出错分类:失败后按错误类型决定------退避重试 / 切下一候选 / 直接致命(见 §4)。

这个循环包在采样 actor 里,主循环完全无感:对 TurnCoordinator 来说,它只是"采到样或拿到错误",不知道背后换了几次模型。

2. 候选路由:第一个挂了,切下一个

候选列表 + 优先级

ModelRoute 持有一串按优先级排序的候选(ModelCandidate),每个候选自带三个东西:binding(provider + model + 协议)、自己的熔断器、account_id(凭据路由,区域 6 的租约体系从这里取 key)。

配置长这样(TOML):

ini 复制代码
[[model_routes.default.candidates]]
name = "primary-openai"
provider = "openai"
model = "gpt-5"
wire_protocol = "responses"
priority = 1          # 越小越先试

[[model_routes.default.candidates]]
name = "fallback-anthropic"
provider = "anthropic"
model = "claude-sonnet-5"
wire_protocol = "messages"
priority = 2

select_first 与 try_next

  • Turn 开始 :select_first() 按优先级挑第一个熔断器没开 的候选------如果 StickyScope::Session,还会先看看当前候选还健不健康,健康就继续用它;
  • 失败转移 :try_next() 从当前索引往后扫,跳过熔断器 OPEN 的,找到下一个;扫完没有 → RouteExhausted

Turn 粘性:别在对话中途偷偷换模型

StickyScope 控制"粘住一个候选多久":默认 Turn(每回合从最高优先级重新开始)、可选 Step(Turn 内跨 Step 粘住)、Session(整个会话粘住)。为什么要有粘性?因为换模型 = 行为可能变 ------用户看到的是"同一段对话前后判若两人",所以默认只在回合边界才允许重新选择。

预算:别无限试

RouteAttemptBudget 限死失败转移的代价:最多试 5 个候选 (max_candidates)、单回合采样总时长上限 120 秒(turn_deadline)。试完了还没成,就老实报错,交给循环的压缩/重试/恢复去处理(见 §7)。

Lossiness 门:不静默降级

这是最关键的设计。路由用 with_declared_degradations 显式声明 允许降级的能力维度(比如 reasoningparallel_tool_calls)。转移时,新候选如果缺了某个未声明可降级 的能力(比如你要推理、它不支持),直接判定 CandidateRejected------宁可这次调用失败,也不偷偷用一个缺能力的模型糊弄。 这正是设计文档里反复强调的"无静默降级"验收项:用户必须知道模型被换了、换成了什么、少了什么。

3. 熔断器:别反复打一个坏模型

每个候选背后是一个三态熔断器 (照 Grok 的 xai-circuit-breaker 模式):

  • CLOSED:正常放行。用一个 60 秒滑动窗口记请求成败;
  • OPEN :窗口内错误率 ≥ 50% 且样本 ≥ 10 个 → 熔断打开,所有请求直接拒(BreakerOpen,带 retry_after),不再浪费请求去打一个正在坏的服务;
  • HALF-OPEN :冷却 10 秒后,放行一个探针请求(默认单探针)。探针成功 → 关闭熔断;探针失败 → 立刻重新打开。

实现上有几个值得讲的细节:

  • 熔断判定是锁无关的快路径 (is_open() 一个原子读),高频检查不抢锁;
  • 失败状态码白名单默认 [429, 500, 502, 503, 504]------不是所有 4xx 都算失败(400 是业务错误,不该熔断);
  • HALF-OPEN 探针带"租约回收":探针被申请但超时没人用(请求半途崩了),别人可以接管,避免一个死探针卡死半开状态。

4. 重试预算与错误分类:每种错误都有自己的结局

采样 actor 内部跑着 classify_error------一个纯函数,输入 (错误, 尝试次数, 预算, 流进度),输出一个决定。决策顺序是有讲究的(注释原话:"decision order is load-bearing"):

错误分类表

错误类型 可重试? 可转移? 实际行为
认证失效 (401) 致命,交给认证层刷新(预算内最多 2 次)
客户端错误 (4xx) 致命(业务错误,重试无用)
上下文超长 致命,交给压缩(02 篇 §6)
限流 (429) 是(阈值内) 退避重试;连续超阈值 → 致命
服务端 5xx 是(预算内) 退避重试;预算耗尽 → 切下一候选
网络/传输 首次尝试先重建客户端(HTTP/1.1 回退),再退避重试
空响应 退避重试

is_failover_eligible() 只有 Transport / 5xx / 限流 返回 true------只有这些"服务端/网络问题"才值得换一家试;认证和业务错误换模型也没用,直接返回。

退避:2s × 2ⁿ,封顶 30s,±20% 抖动

重试不是傻等:retry_backoff 用指数退避,基值 2 秒,每次翻倍,封顶 30 秒,再套 ±20% 抖动(用全局原子 + 线程 id 去相关,避免所有请求同时醒来打爆服务端)。

客户端重建:第一次传输错误先换"方言"

第一次传输错误不走普通重试,而是 RetryWithClientRebuild------重建 HTTP 客户端(回退到 HTTP/1.1)。这是从 Grok 学来的细节:某些代理/服务器对 HTTP/2 兼容性差,重建到 HTTP/1.1 往往就好了,不用浪费一次完整的退避。

子 Agent 用保守预算

主循环用默认预算(5 次重试 / 120s),子 Agent 用更保守的预算:2 次重试 / 60s / 最多 1 次认证刷新 / 限流阈值 1。因为子 Agent 挂了可以重派,不值得为它烧掉主循环的资源。

5. 语义提交栅栏:为什么"已经吐了字,就不能透明换模型"

这是整篇里最容易被忽略、但最能体现工程判断的一条。

StreamProgress 追踪流式响应里已经产生什么语义内容 :吐过文本了?工具调用开始了?一旦 has_crossed_semantic_fence() 为真(吐过文本或开始工具调用),classify_error 直接判定致命,禁止透明重试和切换

为什么?因为已经流给用户的内容无法撤回。如果换个模型重试:

  • 用户已经看到了旧模型吐的一半;
  • 重试可能重复矛盾地输出;
  • 你没法跟用户说"刚才那段作废,换个模型重说"------太荒谬了。

所以语义栅栏的规则是:没吐任何东西之前,怎么重试/切换都行;一旦吐了,就一条道走到黑 ------这条采样要么成功收尾,要么带着半截输出进入回合级恢复(压缩/重试/取消,见 §7)。透明降级只发生在"用户还没看到任何东西"的时候。

6. 可观测:每一次切换都入账

降级不是悄悄发生的。ModelRoute 发出 RouteEvent,落入两个观察面:

  • 运行时事件流 :CandidateSelected / CandidateSucceeded / CandidateFailed { failover: bool } / CandidateRejected(被 Lossiness 门拦下,带原因)/ RouteExhausted / BreakerOpened;
  • rollout 日志 :write_route_eventModelRouteEvent 进区域 7 的同一事实源------降级轨迹可重放、可审计,和别的动作一样。

也就是说:"这个回合模型从 openai 切到了 anthropic,因为 5xx" 这件事,和一次工具调用一样,是有据可查的事件。

7. 和其它区域的联动

降级不是孤岛,它和前面几篇讲的东西接成闭环:

  • 与压缩:上下文超长错误 → 致命 → 交给 02 篇 §6 的压缩(强制压缩一次再重试);
  • 与能力冻结 :Turn 内换模型必须过 Lossiness 门------新模型缺的能力如果是没声明可降级的,宁可不换(和 03 篇的"能力冻结"同一纪律);
  • 与持久化 :ModelRouteEvent 进 rollout,崩溃恢复后能看到上次是怎么降级的(04 篇);
  • 与子 Agent:子 Agent 用保守预算,降级不了就失败重派(区域 8)。

8. 小结

一句话总结故障与降级:用"候选切换 + 熔断 + 重试"扛住模型会挂的现实,用"Turn 粘性 + Lossiness 门 + 语义提交栅栏"守住不糊弄用户的底线。 前者是可用性,后者是诚实------能降级,但绝不静默降级;能切换,但只在你还没看到任何内容之前;每次切换,都有一行事件可查。


相关推荐
Geek漫游指南23 分钟前
AI 会回答还不够:ProofOps 业务研判平台落地实战
后端
SimonKing37 分钟前
白嫖国产多模态大模型:商汤 SenseNova 接入指南
java·后端·程序员
小江的记录本37 分钟前
【ORM框架】MyBatis核心原理、ORM思想、MyBatis vs JPA
java·数据库·后端·spring·spring cloud·oracle·mybatis
卷无止境1 小时前
FastAPI生产环境密钥管理全解析,从一个.env文件说起
后端·python·fastapi
祀爱1 小时前
C# MQTT 连接服务
后端·c#·.net
卷无止境1 小时前
SigV4与HTTPS,两套完全不同维度的安全机制
后端·python·fastapi
青石路1 小时前
好好的OceanBase官方驱动你不用,非要用第三方驱动,ArrayIndexOutOfBoundsException了吧
java·后端
深念Y1 小时前
微服务抽取路线图:从胖单体到 ARM 集群
前端·arm开发·数据库·后端·微服务·云原生·架构
苏灿烤鱼1 小时前
一个用 Nim 写的隐私优先 Twitter 替代前端,源码级技术剖析
后端·github·twitter