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。
This commit is contained in:
1 parent
377ccc7e5a
commit
a50bf02428
21 files changed
+1216
-146
No files matched your search
+43
-8
@@ -32,8 +32,9 @@ type State struct {
|
||||
// Open reports whether the breaker is currently open (profile should be skipped).
|
||||
func (s State) Open() bool { return time.Now().Before(s.OpenUntil) }
|
||||
|
||||
// softTripAfter is how many consecutive TRANSIENT failures (429 / 5xx / network)
|
||||
// trip the breaker. Deterministic failures (no credit, bad key) trip on the first.
|
||||
// softTripAfter is the DEFAULT number of consecutive TRANSIENT failures (429 /
|
||||
// 5xx / network) that trip the breaker. Deterministic failures (no credit, bad
|
||||
// key) trip on the first regardless. Overridable via Registry.SetPolicy.
|
||||
const softTripAfter = 3
|
||||
|
||||
// Registry holds the circuit-breaker state of every profile, keyed by profile id.
|
||||
@@ -48,6 +49,43 @@ type Registry struct {
|
||||
// restart. Both may be nil (no DB); both are called off the hot path.
|
||||
persist func(id int64, st State)
|
||||
forget func(id int64)
|
||||
|
||||
// softTrip / cooldown are the operator-set overrides (0 = use the built-in
|
||||
// default / ladder). They live here rather than being read per failure because
|
||||
// Trip runs on the failure path of every request.
|
||||
softTrip int
|
||||
cooldown time.Duration
|
||||
}
|
||||
|
||||
// SetPolicy overrides the breaker's two knobs. softTrip: how many consecutive
|
||||
// transient failures trip it (0 = default softTripAfter; negative = transient
|
||||
// failures never trip it, leaving only the deterministic ones). cooldown: a fixed
|
||||
// cooling-off window (0 = the 1min/5min/30min ladder). Safe to call at any time.
|
||||
func (r *Registry) SetPolicy(softTrip int, cooldown time.Duration) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.softTrip, r.cooldown = softTrip, cooldown
|
||||
}
|
||||
|
||||
// tripAfter is the effective consecutive-transient-failure threshold. Callers
|
||||
// hold r.mu. A negative override yields 0, which Trip reads as "never soft-trip".
|
||||
func (r *Registry) tripAfter() int {
|
||||
if r.softTrip == 0 {
|
||||
return softTripAfter
|
||||
}
|
||||
return max(r.softTrip, 0)
|
||||
}
|
||||
|
||||
// coolFor is the cooling-off window for the trips-th trip (1-based): the fixed
|
||||
// override when set, else the ladder (last rung repeats). Callers hold r.mu.
|
||||
func (r *Registry) coolFor(trips int) time.Duration {
|
||||
if r.cooldown > 0 {
|
||||
return r.cooldown
|
||||
}
|
||||
if trips-1 < len(backoff) {
|
||||
return backoff[trips-1]
|
||||
}
|
||||
return backoff[len(backoff)-1]
|
||||
}
|
||||
|
||||
// NewRegistry builds an empty registry. persist/forget may be nil.
|
||||
@@ -107,13 +145,10 @@ func (r *Registry) Trip(id int64, errMsg string, hard bool) (tripped bool) {
|
||||
st.Fails++
|
||||
st.LastError = errMsg
|
||||
st.LastAt = time.Now()
|
||||
if hard || st.Fails >= softTripAfter {
|
||||
softTrip := r.tripAfter()
|
||||
if hard || (softTrip > 0 && st.Fails >= softTrip) {
|
||||
st.Trips++
|
||||
d := backoff[len(backoff)-1]
|
||||
if st.Trips-1 < len(backoff) {
|
||||
d = backoff[st.Trips-1]
|
||||
}
|
||||
st.OpenUntil = time.Now().Add(d)
|
||||
st.OpenUntil = time.Now().Add(r.coolFor(st.Trips))
|
||||
st.Fails = 0 // counted into this trip; start fresh for the half-open probe
|
||||
tripped = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package llmpool
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A configured soft-trip threshold replaces the default: two transient failures
|
||||
// are enough, and the fixed cooldown replaces the 1/5/30min ladder.
|
||||
func TestSetPolicyOverridesThresholdAndCooldown(t *testing.T) {
|
||||
reg := NewRegistry(nil, nil)
|
||||
reg.SetPolicy(2, 90*time.Second)
|
||||
if reg.Trip(1, "429", false) {
|
||||
t.Fatal("tripped on the first transient failure, want the second")
|
||||
}
|
||||
if !reg.Trip(1, "429", false) {
|
||||
t.Fatal("should trip on transient failure #2")
|
||||
}
|
||||
st := reg.Get(1)
|
||||
if d := time.Until(st.OpenUntil); d < 80*time.Second || d > 90*time.Second {
|
||||
t.Fatalf("cooldown=%v, want ~90s", d)
|
||||
}
|
||||
// Every later trip keeps the same fixed window instead of climbing the ladder.
|
||||
reg.Trip(1, "429", true)
|
||||
st = reg.Get(1)
|
||||
if d := time.Until(st.OpenUntil); d < 80*time.Second || d > 90*time.Second {
|
||||
t.Fatalf("second cooldown=%v, want ~90s (fixed)", d)
|
||||
}
|
||||
}
|
||||
|
||||
// A negative threshold turns off soft tripping entirely: transient failures never
|
||||
// open the breaker, deterministic ones still do immediately.
|
||||
func TestSetPolicyDisablesSoftTrip(t *testing.T) {
|
||||
reg := NewRegistry(nil, nil)
|
||||
reg.SetPolicy(-1, 0)
|
||||
for i := range 10 {
|
||||
if reg.Trip(1, "429", false) {
|
||||
t.Fatalf("transient failure #%d tripped the breaker, want never", i+1)
|
||||
}
|
||||
}
|
||||
if reg.IsOpen(1) {
|
||||
t.Fatal("breaker should stay closed for transient failures")
|
||||
}
|
||||
if !reg.Trip(1, "no credit", true) {
|
||||
t.Fatal("a hard failure must still trip immediately")
|
||||
}
|
||||
if d := time.Until(reg.Get(1).OpenUntil); d < 50*time.Second || d > 60*time.Second {
|
||||
t.Fatalf("cooldown=%v, want the default first rung (~1min)", d)
|
||||
}
|
||||
}
|
||||
|
||||
// The zero policy is the historical behaviour: 3 transient failures, ladder cooldown.
|
||||
func TestZeroPolicyKeepsDefaults(t *testing.T) {
|
||||
reg := NewRegistry(nil, nil)
|
||||
reg.SetPolicy(0, 0)
|
||||
for i := 1; i < softTripAfter; i++ {
|
||||
if reg.Trip(1, "429", false) {
|
||||
t.Fatalf("tripped after %d transient failures, want %d", i, softTripAfter)
|
||||
}
|
||||
}
|
||||
if !reg.Trip(1, "429", false) {
|
||||
t.Fatalf("should trip on failure #%d", softTripAfter)
|
||||
}
|
||||
if d := time.Until(reg.Get(1).OpenUntil); d < 50*time.Second || d > 60*time.Second {
|
||||
t.Fatalf("cooldown=%v, want the first ladder rung (~1min)", d)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user