通用重试:指数退避 + 满抖动,判据取自 canonical error
落点在 ChatModel,位于整个 filter 栈之下(与 beamai beamai_chat_model
同一取舍)。这不是随手放的——filter 看到的必须是「一次逻辑调用」:
:chat filter(memory / 计时 / 记账) └ ChatModel.call ──┐ ├ 尝试 1 ✗ → 退避 → 尝试 2 ✓ ← 重试在这里面 └ 返回一个响应
若重试在 filter 链之上(或做成一个 filter),一次网络抖动就会让
memory-filter 把同一轮 delta 写两遍、让计时 filter 记出两条记录。放在链下,
重试重入碰不到任何 filter,around_chat 的副作用每轮只发生一次。
要观测每次真实尝试,用 :on-retry 回调——它是为此存在的,不是日志糖。
流式只在首个 token 之前重试:token 已经投递给 sink 之后重跑会让下游看到
重复内容,那之后的失败原样抛;要容错请在 turn 层重跑整轮。首个 token 之前
(建连失败、503、首字节超时)没有任何东西出门,重发安全——stream-call 用
:retry-gate + 独立预算 :stream-retries 表达这一点(designs/provider-reliability.md §2)。
空补全(HTTP 200 但无有效内容)走独立预算 :empty-retries,不占 :max-retries。
判据是 canonical error 的 :retryable?,不是 HTTP 状态码——那是
provider 边界的事(见 provider.http.client/response->error,它顺带把
Retry-After 解析成 :retry-after-ms 塞进错误 map,core 只读那个数)。
core 因此对 HTTP 一无所知;本 ns 无外部依赖,只读 canonical error 里的两个键
(:retryable? / :retry-after-ms)。(异步版 run-async 用到 core 内的链结果
组合子 im.ttalk.agent.filter,仍无第三方依赖。)
同步 / 异步两个入口共用一套判据:run 与 run-async 的重试次数、
retryable-ex?、退避曲线(含 Retry-After 与满抖动)都走同一批函数,
只差「等待」的实现——同步 Thread/sleep,异步 CompletableFuture/delayedExecutor
(绝不能在异步路径上 sleep:那会睡掉传输层 executor 的线程)。
参数三级取值:单次 opts > provider config > 框架默认;:max-retries 0
即关闭(等价于直接调用,零开销)。
通用重试:指数退避 + 满抖动,判据取自 canonical error
**落点在 ChatModel,位于整个 filter 栈之下**(与 beamai `beamai_chat_model`
同一取舍)。这不是随手放的——filter 看到的必须是「一次逻辑调用」:
:chat filter(memory / 计时 / 记账)
└ ChatModel.call ──┐
├ 尝试 1 ✗ → 退避 → 尝试 2 ✓ ← 重试在这里面
└ 返回一个响应
若重试在 filter 链**之上**(或做成一个 filter),一次网络抖动就会让
memory-filter 把同一轮 delta 写两遍、让计时 filter 记出两条记录。放在链下,
重试重入碰不到任何 filter,`around_chat` 的副作用每轮只发生一次。
要观测每次**真实尝试**,用 `:on-retry` 回调——它是为此存在的,不是日志糖。
**流式只在首个 token 之前重试**:token 已经投递给 sink 之后重跑会让下游看到
重复内容,那之后的失败原样抛;要容错请在 turn 层重跑整轮。首个 token 之前
(建连失败、503、首字节超时)没有任何东西出门,重发安全——`stream-call` 用
`:retry-gate` + 独立预算 `:stream-retries` 表达这一点(designs/provider-reliability.md §2)。
**空补全**(HTTP 200 但无有效内容)走独立预算 `:empty-retries`,不占 `:max-retries`。
**判据是 canonical error 的 `:retryable?`**,不是 HTTP 状态码——那是
provider 边界的事(见 `provider.http.client/response->error`,它顺带把
`Retry-After` 解析成 `:retry-after-ms` 塞进错误 map,core 只读那个数)。
core 因此对 HTTP 一无所知;本 ns **无外部依赖**,只读 canonical error 里的两个键
(`:retryable?` / `:retry-after-ms`)。(异步版 `run-async` 用到 core 内的链结果
组合子 `im.ttalk.agent.filter`,仍无第三方依赖。)
**同步 / 异步两个入口共用一套判据**:`run` 与 `run-async` 的重试次数、
`retryable-ex?`、退避曲线(含 `Retry-After` 与满抖动)都走同一批函数,
只差「等待」的实现——同步 `Thread/sleep`,异步 `CompletableFuture/delayedExecutor`
(绝不能在异步路径上 sleep:那会睡掉传输层 executor 的线程)。
参数三级取值:**单次 opts > provider config > 框架默认**;`:max-retries 0`
即关闭(等价于直接调用,零开销)。(compute-backoff attempt opts)(compute-backoff attempt
{:keys [base-delay multiplier max-delay]
:or {base-delay 1000 multiplier 2.0 max-delay 30000}}
rand-fn)第 attempt 次重试的退避毫秒(含满抖动)。attempt 从 0 开始。
基础退避 = min(base-delay × multiplier^attempt, max-delay),再在 [0, 基础退避] 上取满抖动——抖动是为了避免一批并发请求同时醒来把服务端 再打挂一次(重试风暴)。
rand-fn 可注入,测试用。
第 attempt 次重试的退避毫秒(含满抖动)。attempt 从 0 开始。 基础退避 = min(base-delay × multiplier^attempt, max-delay),再在 [0, 基础退避] 上取满抖动——抖动是为了避免一批并发请求同时醒来把服务端 再打挂一次(重试风暴)。 rand-fn 可注入,测试用。
框架默认重试配置。
Retry-After 也受它约束:return(缺省,把空响应原样交回——与过去一致)
/ :throw(抛 :empty-completion-error,交给主备切换 / 上层处理)三格预算各自记账、互不挪用(designs/provider-reliability.md §2)。
框架默认重试配置。
- :max-retries 最大重试次数(**不含**首次调用);0 = 关闭
- :base-delay 首次退避基准(毫秒)
- :multiplier 退避倍率
- :max-delay 单次退避上限(毫秒)——`Retry-After` 也受它约束
- :respect-retry-after? 是否优先采用错误里的 :retry-after-ms
- :empty-retries 空补全的独立预算(HTTP 200 但无有效内容时补打几次;0 = 不补打,空响应原样返回)
- :empty-exhausted 空补全预算用完仍空时怎么办:`:return`(缺省,把空响应原样交回——与过去一致)
/ `:throw`(抛 :empty-completion-error,交给主备切换 / 上层处理)
- :stream-retries 流式「首个 token 之前」失败的独立预算(token 出门后一律不重试)
三格预算各自记账、互不挪用(designs/provider-reliability.md §2)。(resolve-opts config)(resolve-opts config opts)三级合并重试配置:框架默认 < provider/model config 的 :retry < 单次 opts 的 :retry。
每一级都接受 nil(跳过)/ true(用上一级结果)/ false(关闭,等价
{:max-retries 0 :empty-retries 0 :stream-retries 0})/ map(作为配置合并)。
返回: 合并后的配置 map(:max-retries 为 0 表示不重试)
三级合并重试配置:框架默认 < provider/model config 的 :retry < 单次 opts 的 :retry。
每一级都接受 nil(跳过)/ true(用上一级结果)/ false(关闭,等价
`{:max-retries 0 :empty-retries 0 :stream-retries 0}`)/ map(作为配置合并)。
返回: 合并后的配置 map(`:max-retries` 为 0 表示不重试)(retryable-ex? e)异常是否可重试——唯一判据是 canonical error 的 :retryable?。
provider 边界已经把 401 / 400 这类判成不可重试、把 429 / 5xx / 连接失败判成
可重试(见 model.error/http-response->error),这里不再二次猜测:没有
:retryable? 的异常(框架自身的 bug、用户工具抛的)一律不重试。
猜错的代价不对称——对一个本质不可重试的错误反复打 API 是在烧钱和触发风控。
异常是否可重试——**唯一判据是 canonical error 的 `:retryable?`**。 provider 边界已经把 401 / 400 这类判成不可重试、把 429 / 5xx / 连接失败判成 可重试(见 `model.error/http-response->error`),这里不再二次猜测:没有 `:retryable?` 的异常(框架自身的 bug、用户工具抛的)一律**不重试**。 猜错的代价不对称——对一个本质不可重试的错误反复打 API 是在烧钱和触发风控。
(run f
{:keys [max-retries on-retry rand-fn sleep-fn empty? empty-retries
empty-exhausted retry-gate record-attempts? provider retry-kind]
:or {rand-fn rand}
:as opts})在重试保护下执行 f(无参函数)。
参数:
resolve-opts 的结果,另可带
:kind ∈
:error(按 :max-retries 退避重试)/ :empty-completion(空补全补打)/
opts 的 :retry-kind(流式路径传 :stream):empty-retries(缺省 1)立即补打
(不退避、不占 :max-retries、不抬高退避指数);超预算按 :empty-exhausted:
:throw 抛 :empty-completion-error,否则(缺省)把空结果原样返回:attempts(含首次)返回: f 的返回值(:record-attempts? 时带 :attempts)
抛出: 最后一次失败的异常——canonical error 原样不变(:retryable? / :status 全程不丢,
README 的错误模型承诺「各边界可单向转换」,重试层没有资格改写它);试过不止一次时
ex-data 追加 :attempts(经 errors/with-ex-data,消息 / cause / 栈逐字保留)。
在重试保护下执行 `f`(无参函数)。
参数:
- f: 无参函数;失败时**抛** ex-info(data 即 canonical error)
- opts: `resolve-opts` 的结果,另可带
- :on-retry (fn [{:keys [attempt delay-ms error exception kind]}])
每次重试**之前**调用,用来观测真实尝试次数。`:kind` ∈
`:error`(按 :max-retries 退避重试)/ `:empty-completion`(空补全补打)/
opts 的 `:retry-kind`(流式路径传 `:stream`)
- :rand-fn 随机数函数(测试用)
- :sleep-fn (fn [ms])(测试用;缺省 Thread/sleep)
以下为 designs/provider-reliability.md §2 新增,**不给即旧行为,逐字不变**:
- :empty? (fn [result] bool);真 ⇒ 按 `:empty-retries`(缺省 1)**立即**补打
(不退避、不占 :max-retries、不抬高退避指数);超预算按 `:empty-exhausted`:
`:throw` 抛 :empty-completion-error,否则(缺省)把空结果原样返回
- :retry-gate (fn [] bool);每次决定重试前问一次,假 ⇒ 不重试(流式:本次尝试已吐过 token)
- :record-attempts? 真 ⇒ 成功结果(map/record)上 assoc `:attempts`(含首次)
- :provider 空补全错误的 :provider
返回: `f` 的返回值(`:record-attempts?` 时带 :attempts)
抛出: 最后一次失败的异常——canonical error 原样不变(`:retryable?` / `:status` 全程不丢,
README 的错误模型承诺「各边界可单向转换」,重试层没有资格改写它);试过不止一次时
ex-data 追加 `:attempts`(经 `errors/with-ex-data`,消息 / cause / 栈逐字保留)。(run-async f
{:keys [max-retries on-retry rand-fn sleep-fn empty? empty-retries
empty-exhausted retry-gate record-attempts? provider
retry-kind]
:or {rand-fn rand}
:as opts})run 的异步孪生:f 是无参函数,返回链结果(deferred 或普通值)。
与 run 的差别只有两处,其余(判据 retryable-ex?、三格预算、退避曲线含
Retry-After 与满抖动、:on-retry 回调时机、:attempts 记录)逐字共用:
flt/fcatch 接(同步抛出也接得住);Thread/sleep——用 async/delayed(CompletableFuture/delayedExecutor)。注入了 :sleep-fn 时仍走它(测试用:立即返回、只记账)。返回: deferred(或普通值——f 同步返回且没触发重试时就是普通值,形态保持见
filter-chain-design.md §2.6.4 契约 C1)。
抛出/落 error channel: 最后一次失败的异常(规则同 run)。
递归深度 ≤ 总尝试次数,不吃栈。
`run` 的异步孪生:`f` 是无参函数,返回**链结果**(deferred 或普通值)。 与 `run` 的差别只有两处,其余(判据 `retryable-ex?`、三格预算、退避曲线含 `Retry-After` 与满抖动、`:on-retry` 回调时机、`:attempts` 记录)**逐字共用**: 1. 失败经 deferred 的 error channel 传播,用 `flt/fcatch` 接(同步抛出也接得住); 2. 退避不 `Thread/sleep`——用 `async/delayed`(`CompletableFuture/delayedExecutor`)。注入了 `:sleep-fn` 时仍走它(测试用:立即返回、只记账)。 返回: deferred(或普通值——`f` 同步返回且没触发重试时就是普通值,形态保持见 filter-chain-design.md §2.6.4 契约 C1)。 抛出/落 error channel: 最后一次失败的异常(规则同 `run`)。 递归深度 ≤ 总尝试次数,不吃栈。
(wrap f config opts)把 f 包成带重试的同名函数。config/opts 语义同 resolve-opts。
把 `f` 包成带重试的同名函数。config/opts 语义同 `resolve-opts`。
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |