Files
ARTEX-chixuweihu/server/llmretry_test.go
T
Autumn-27 a50bf02428 feat(llm): 五层重试的次数与间隔可配
一次模型调用的失败由内到外经过五层重试:建连(SDK)、空响应(SDK)、同 provider
安全窗口、轮询熔断、意图重跑。此前五层的次数与间隔全是写死的常量,遇到脾气各异
的中转站只能改代码。现在都能在 LLM 页配置,不配就是原来的默认值。

配置语义五层统一(db.RetryRule):次数 0=内置默认 / -1=关掉这层 / >0=该值;
间隔 0=原本的指数退避 / >0=改用固定间隔。0 被"未配置"占用,故关闭用 -1。
写库前夹紧到 次数 [-1,20]、间隔 [0,1h],DB 另有 CHECK 兜底。

粒度按层的性质分:前三层跟着端点走,可在每个模型配置里逐字段覆盖全局默认(只钉
间隔的仍继承全局次数);熔断与意图重跑是进程级语义,只有全局一份。

生效路径:①②是 provider 构建参数,保存后重建缓存;③随每次 hooks.current()
解析,故障转移换配置时重试节奏跟着换;④在失败路径上每次都要读,由 applyRetryPolicy
推给 llmpool.Registry;⑤每条意图收尾读一次。全部热生效,无需重启。

连接测试刻意不带这些参数——它有 30s 硬超时,叠上用户配的重试只会把本来能用的
端点测成超时失败。

norma 升 v0.3.5(llm.Config 新增 RetryInterval / EmptyResponseRetries /
EmptyResponseInterval)。旧库升级后行为逐字节不变:新列默认 0,settings 键不
存在即全默认。设计见 docs/LLM重试设计.md。
2026-09-09 13:35:04 -04:00

98 lines
3.5 KiB
Go

package server
import (
"testing"
"time"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
)
// 没有任何配置时,解析结果必须是全零 —— 也就是 SDK 与 task_llm 各自的内置默认,
// 与「重试可配」这个特性上线之前逐字节一致。
func TestResolveRetryUnconfigured(t *testing.T) {
got := resolveRetry(db.RetryOverride{}, db.LLMRetryPolicy{})
if got != (agent.RetryConfig{}) {
t.Fatalf("resolveRetry=%+v, want zero", got)
}
retries, backoff := sameProviderRetryPolicy(got)
if retries != sameProviderStreamRetries {
t.Fatalf("retries=%d, want %d", retries, sameProviderStreamRetries)
}
if d := backoff(1); d != time.Second {
t.Fatalf("backoff(1)=%v, want 1s (the default ladder)", d)
}
}
// The global policy applies to a profile that overrides nothing.
func TestResolveRetryFromGlobal(t *testing.T) {
pol := db.LLMRetryPolicy{
Connect: db.RetryRule{Attempts: 5, IntervalMS: 2000},
Empty: db.RetryRule{Attempts: -1},
Stream: db.RetryRule{Attempts: 4, IntervalMS: 1500},
}
got := resolveRetry(db.RetryOverride{}, pol)
want := agent.RetryConfig{
ConnectAttempts: 5, ConnectInterval: 2 * time.Second,
EmptyAttempts: -1,
StreamAttempts: 4, StreamInterval: 1500 * time.Millisecond,
}
if got != want {
t.Fatalf("resolveRetry=%+v, want %+v", got, want)
}
retries, backoff := sameProviderRetryPolicy(got)
if retries != 4 {
t.Fatalf("retries=%d, want 4", retries)
}
for _, attempt := range []int{0, 3} {
if d := backoff(attempt); d != 1500*time.Millisecond {
t.Fatalf("backoff(%d)=%v, want a fixed 1.5s", attempt, d)
}
}
}
// A profile override wins field by field: the pinned count replaces the global
// one, while the interval it left alone still comes from the global policy.
func TestResolveRetryProfileOverridesPerField(t *testing.T) {
pol := db.LLMRetryPolicy{
Connect: db.RetryRule{Attempts: 5, IntervalMS: 2000},
Stream: db.RetryRule{Attempts: 4, IntervalMS: 1500},
}
over := db.RetryOverride{
Connect: db.RetryRule{Attempts: 1},
Stream: db.RetryRule{IntervalMS: 250},
}
got := resolveRetry(over, pol)
if got.ConnectAttempts != 1 || got.ConnectInterval != 2*time.Second {
t.Fatalf("connect=%d/%v, want 1 attempt inheriting the 2s interval", got.ConnectAttempts, got.ConnectInterval)
}
if got.StreamAttempts != 4 || got.StreamInterval != 250*time.Millisecond {
t.Fatalf("stream=%d/%v, want the global 4 attempts with a pinned 250ms", got.StreamAttempts, got.StreamInterval)
}
}
// -1 turns the same-provider layer off entirely (0 retries), rather than being
// mistaken for "unset".
func TestSameProviderRetryDisabled(t *testing.T) {
retries, _ := sameProviderRetryPolicy(agent.RetryConfig{StreamAttempts: -1})
if retries != 0 {
t.Fatalf("retries=%d, want 0 (disabled)", retries)
}
}
// Values beyond the sane range are clamped on the way into the DB, so a hostile
// or fat-fingered payload can't park a worker for a day.
func TestRetryRuleClamp(t *testing.T) {
pol := db.LLMRetryPolicy{
Connect: db.RetryRule{Attempts: -50, IntervalMS: -1},
Stream: db.RetryRule{Attempts: 9999, IntervalMS: 99_999_999},
}
got := resolveRetry(db.RetryOverride{}, pol.Clamped())
if got.ConnectAttempts != -1 || got.ConnectInterval != 0 {
t.Fatalf("connect=%d/%v, want -1 attempts and no interval", got.ConnectAttempts, got.ConnectInterval)
}
if got.StreamAttempts != 20 || got.StreamInterval != time.Hour {
t.Fatalf("stream=%d/%v, want the 20-attempt / 1h caps", got.StreamAttempts, got.StreamInterval)
}
}