Harness 工程 · 模型调用
学习舱 / Harness 工程 / 模型调用

模型调用与容错:让失败变成看不见的内部事件

整理中 深度 · 应用 更新 2026-09-22

前置 Harness 概览

模型调用与容错:让失败变成看不见的内部事件

一句话定位:调用层是 Agent 主循环与模型 API 之间唯一的通道,Harness 的「稳定」从这里开始——网络会断、限流会打回、流会卡死,容错体系把这些必然发生的失败消化成用户看不见的内部事件。前置:什么是 Harness。

一切稳定都从调用开始

你写了个小工具,每天调一次模型 API 总结新闻,几个月都没出过错。

换成真正的 Agent:一轮任务要调几十次 API、每次几千 token。这时 429(限流)、529(服务过载)、超时断流开始轮番出现——不是模型变笨了,是调用量放大了上千倍,而每一次失败都会让主循环当场中断。

生产级的 Harness 怎么让这层「看起来永远稳定」?

答案是一套容错体系:识别错误、决定等多久、服务过载到顶就换模型、流卡死就掐断重连。这一层在 Harness 里通常被封装成一个函数——Claude Code 里叫 queryLoop,本篇统一叫它调用层。

调用层:主循环与模型之间的稳压层 Agent 主循环 思考 → 调工具 → 观察 模型 API 会失败:限流 / 过载 超时 / 断流 调用层(queryLoop) ① 错误识别 429 限流 · 529 过载 · 超时 · 断流 ② 退避重试 指数等待,retryAfter 优先 ③ 降级切换 服务过载到顶,换 fallback 模型兜底 ④ 看门狗 流卡死就掐断,按超时重试 请求 失败/流 失败一定发生,但被调用层消化——主循环看到的世界永远稳定

图:调用层全景——①②③④ 四个机构排成一条容错流水线,编号全文沿用

渐进拆解

类比:调用层的日常,像给客服热线打电话。两个系统的失败模式几乎一一对应:

  • 响应 429 = 「坐席全忙」——你打得太频繁,被限流挡在门外(HTTP 标准状态码;这个限是你自己的配额,换条线也绕不开)
  • 响应 529 = 「系统故障」——整个服务中心过载,谁打都打不通(这不是 HTTP 标准码,是 Anthropic 专有的 overloaded_error)
  • 固定间隔重试 = 挂断立刻重拨——而且所有被拒的人都这样重拨,线路永远被挤占
  • 指数退避 = 第一次挂断等 1 分钟,再占线等 2 分钟、4 分钟……越挫越退
  • retryAfter = 语音提示「预计等待 5 分钟」——服务端直接告诉你等多久,比瞎猜准
  • 降级 fallback = 热线打不通,改用 App 自助服务——换个能力弱些但可用的渠道,把事办了
  • 看门狗 = 电话接通后对方长时间不出声——与其干等,掐断重打

例子:一次典型的限流风暴长这样——注意等待时间怎么翻倍:

一次限流风暴的完整时间线 请求① 429 指数退避 1s 请求② 429 再翻倍 2s 请求③ 429 再翻倍 4s 请求④ 200 ✓ 等待 1s → 2s → 4s 指数翻倍;若 429 响应带 retryAfter,按服务端给的时长等,比猜准

图:重试时序——退避翻倍为摊薄重试频率;把各家时刻彼此错开,靠的是再加随机抖动(jitter)

为什么必须是指数退避,固定间隔不行? 假设服务过载,1000 个客户端同时被拒。固定间隔重试 = 1 秒后 1000 个请求又同时砸回来,服务刚喘口气又被压垮,永远满载。

指数退避让每轮重试的等待时间翻倍,把重试频率逐轮摊薄、给服务喘息——但注意,同时被拒的客户端若都用同样的退避表,重试时刻仍会整点集体砸回,只是间隔变大了;真正把各家的时刻彼此错开的是在退避之上再加抖动(jitter,随机化):每家的等待时长各乘一个随机系数,集体重砸散成细雨。AWS 的架构指南把这对分工讲得最直白:退避解决「频率」,抖动解决「扎堆」,两者缺一不可。这是保护服务,也是保护你自己下一次请求能通。

大白话:调用失败先问三个问题——能不能重试(限流、超时能;参数错、密钥错不能);能的话等多久(服务端说了算,没说就指数翻倍);一直不行怎么办(若是服务过载,换个模型兜底,别在一棵树上吊死;若是自己的限流,429 换条线绕不开,等或降速)。

技术定义:调用层是 Harness 中负责与模型 API 交换数据的模块,职责即全景图的 ①–④。一个容易忽略的细节:流式响应里,思考、文本、工具调用各是不同的 chunk 片段,调用层要分别路由。

另一个细节是 TTFT(time-to-first-token,从发出请求到吐出第一个字)——交互体验的硬指标,看门狗守的就是这条流不卡、不慢。

同一套「失败—纠错—重试」思想还延伸到两处:输出结构化校验失败时,把错误信息反喂给模型重试,几次不过就降级;测试场景用 VCR 机制录制一次真实响应、之后回放,让测试不依赖线上服务也稳定可复现。

动手:失败分流与 queryLoop

调用层的心脏是一张分流规则——每个错误进来,先定性质,再定处理:

失败了怎么办:一条分流规则 调用失败 先定性质 可重试(429 / 529 / 超时断流) retryAfter 优先 → 指数退避 → 最多 N 次 529 重试到顶 → 降级 换 fallback 模型兜底(429 只等不换) 不可重试(参数 / 鉴权错误) 重试没有意义,立刻抛给人修 限流 / 过载 / 断流 重试到顶 4xx 参数 / 鉴权

图:错误分流——先问能不能重试,再问等多久,最后才是换人

伪代码实现这张分流图(编号呼应全景图):

def query_loop(messages, model="main"):
    delay = 1                                  # ② 退避:起始 1 秒
    while True:
        try:
            return stream_call(messages, model)  # ④ 看门狗:流卡死即抛 Timeout
        except RateLimited as e:                 # ① 429:自身配额,只等不换
            wait = e.retry_after or delay        # retryAfter 优先,否则退避
            sleep(wait); delay *= 2              # 指数翻倍:1s → 2s → 4s
        except Overloaded:                       # ① 529:服务端对该模型过载
            sleep(delay); delay *= 2
            if delay > MAX_DELAY:                # ③ 529 重试到顶:换 fallback 模型
                model = FALLBACK[model]          #   (Claude Code 官方行为;429 从不触发换模型)
                delay = 1
        except RetryableError:
            if delay > MAX_DELAY:                # ③ 其他可重试错误到顶:交还给人
                raise Unrecoverable(result)      #   (不硬换模型——错误语义不合就别换)
            sleep(delay); delay *= 2

错误速查(可重试性是唯一关键判断):

错误含义可重试处理
429限流(自身配额用太快)✓retryAfter 优先,指数退避——只等不换(Claude Code 口径:429 从不触发换模型)
529服务过载(Anthropic 专有码)✓指数退避;重试到顶换 fallback 模型(官方 fallback 触发条件)
超时 / 断流网络或服务卡死✓看门狗掐断,按超时重试
400 / 401 类参数或鉴权错误✗直接抛给人修

同一概念的叫法对照:

本篇用词文献中的其他叫法深挖去处
调用层queryLoop · LLM Gateway · Provider 抽象层可观测性
退避重试指数退避 + 抖动(jitter)· retryAfter 优先本篇
降级fallback 模型 · 模型路由与降级生产化
流量回放VCR 录制回放 · Replay可观测性

边界与取舍:容错不是越多越好

退避重试只对「可重试错误」有意义

参数错、密钥过期这类 4xx 错误,重试一百次结果一样——只会白烧时间和 token。分流的第一步永远是定性。

重试有延迟代价

指数退避救的是后台批任务;交互式场景(用户盯着屏幕等第一个字)等不了 4s+8s+16s——重试次数和上限要按场景收紧,必要时宁可快速报错。

降级是拿质量换可用,要透明

fallback 模型通常更弱,答案质量会下降——调用层该做的是降级后明确标注(「当前由备用模型回答」),让用户知情,而不是悄悄换掉装作没事。

多模型路由已经标准化

Provider 抽象层把「调 Claude 还是调 OpenAI 兼容模型」收敛成统一接口,LLM Gateway(LiteLLM 这类网关)则把路由、fallback、成本追踪做成开箱即用的配置——自建调用层前先看看网关是不是已经够用。

路由换模型有两种触发,别混为一谈。被动降级是 ③ 的延续:服务过载(529)重试到顶才换,拿质量换可用,换是不得已;主动路由是任务分发时就按性质选模型——重推理给旗舰档、扫描粗筛给轻量档,图的是成本与延迟。后者已走进产品默认行为:2026 年 9 月的 Codex 允许给每个 subagent 单独指定模型与推理档位(默认继承主会话的配置),官方文档并给出分工建议——重推理 gpt-5.6、快速扫描 gpt-5.6-terra、高频小任务 gpt-5.6-luna——「哪个任务派哪个模型」从网关配置升级成了 Agent 编排时的一等参数,派活侧的取舍见多 Agent 编排。

自测

合上文章先自己答,再展开对照:

为什么必须指数退避而不是固定间隔重试?

固定间隔让所有被拒客户端在同一时刻集体重试,形成重试风暴——服务刚喘口气又被压垮,谁也进不来。指数退避让每轮等待翻倍,把重试频率逐轮摊薄;但退避表相同的客户端仍会在同一时刻集体重试,把时刻真正彼此错开的是再加抖动(jitter,随机化系数)。若服务端返回 retryAfter,按它给的时长等更准。三件套各管一事:退避管频率、抖动管扎堆、retryAfter 管准头。

Claude Code 遇到 429 时,决定「等」还是「换模型」的依据是什么?

依据是错误语义,不是等待时长。429 是你自己的配额限流——换通道绕不开(fallback 模型同一个账号配额),所以正解只有「等」:retryAfter 优先、指数退避,Claude Code 的官方行为里 429 从不触发换模型。「换」留给语义不同的场景:529(服务端对该模型过载)或模型不可用重试到顶,才按官方 fallback 机制切换模型兜底。一个易混点:「重试到顶就换模型」是 LiteLLM 这类网关可以配置的通用策略,不是 Claude Code 对 429 的行为——判断「等还是换」先看这个错误是谁的问题:自己的配额(429)等,服务端的过载(529)等到顶再换。

参考与延伸

本系列的后续学习路线(推荐顺序):上下文压缩 → 状态机与 Checkpoint → 工具执行 → 权限机制 → 记忆机制 · Skill 机制 → 多 Agent 编排。

本节点大纲与自测题(8 个子点 · 2 问)

大纲

  • queryLoop 核心调用流程
  • 429/529 错误与指数退避、retryAfter
  • 模型降级 fallback 的触发与选择
  • 流式处理:chunk 类型、TTFT、WatchDog 看门狗
  • VCR 流量回放机制
  • Structured Output 失败纠错与降级
  • Provider 抽象层设计
  • LiteLLM:模型路由 / fallback / 成本追踪

自测——答不上来就是缺口

  • 为什么必须指数退避而不是固定间隔重试?
  • Claude Code 遇到 429 时决定「等」还是「换模型」的依据是什么?