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
+32
-10
@@ -61,19 +61,37 @@ type LLMProfile struct {
|
||||
// or sticky routing off a session-id header. "" = not sent. Maps to
|
||||
// agent.Config.SessionHeaderKey.
|
||||
SessionHeaderKey string `json:"session_header_key"`
|
||||
// Retry overrides this profile's share of the retry ladder. Zero value =
|
||||
// inherit the global policy (LLMRetryPolicy), so an untouched profile behaves
|
||||
// exactly as before. See RetryOverride.
|
||||
Retry RetryOverride `json:"retry"`
|
||||
}
|
||||
|
||||
// RetryOverride is one profile's optional override of the three retry layers
|
||||
// that are per-endpoint: 建连(connect) / 空响应(empty) / 同 provider 安全窗口
|
||||
// (stream). Each rule's zero value means "inherit the global policy"; see
|
||||
// RetryRule for the -1 / 0 / >0 semantics.
|
||||
type RetryOverride struct {
|
||||
Connect RetryRule `json:"connect"`
|
||||
Empty RetryRule `json:"empty"`
|
||||
Stream RetryRule `json:"stream"`
|
||||
}
|
||||
|
||||
// profileCols is the read column list (hint variant, no api key) shared by the
|
||||
// list query; profileColsKey is the same with api_key for the single-row loads.
|
||||
const profileCols = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key_hint,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,'')`
|
||||
const profileColsKey = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,'')`
|
||||
const profileRetryCols = `COALESCE(retry_connect_attempts,0),COALESCE(retry_connect_interval_ms,0),COALESCE(retry_empty_attempts,0),COALESCE(retry_empty_interval_ms,0),COALESCE(retry_stream_attempts,0),COALESCE(retry_stream_interval_ms,0)`
|
||||
const profileCols = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key_hint,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols
|
||||
const profileColsKey = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols
|
||||
|
||||
// scanProfile reads one row in the profileCols / profileColsKey column order. The
|
||||
// 7th column lands in APIKeyHint or APIKey depending on which list the caller used.
|
||||
func scanProfile(sc interface{ Scan(...any) error }, into *string, p *LLMProfile) error {
|
||||
return sc.Scan(&p.ID, &p.Name, &p.Format, &p.BaseURL, &p.Proxy, &p.Model, into,
|
||||
&p.RatePerSecond, &p.RatePerMinute, &p.ContextWindowK, &p.ReasoningEffort, &p.IsDefault, &p.Priority, &p.PoolExclude, &p.ThinkingType, &p.Streaming,
|
||||
&p.MaxTokens, &p.MaxTokensField, &p.SessionHeaderKey)
|
||||
&p.MaxTokens, &p.MaxTokensField, &p.SessionHeaderKey,
|
||||
&p.Retry.Connect.Attempts, &p.Retry.Connect.IntervalMS,
|
||||
&p.Retry.Empty.Attempts, &p.Retry.Empty.IntervalMS,
|
||||
&p.Retry.Stream.Attempts, &p.Retry.Stream.IntervalMS)
|
||||
}
|
||||
|
||||
func (d *DB) ListProfiles() ([]*LLMProfile, error) {
|
||||
@@ -144,20 +162,24 @@ func (d *DB) SaveProfile(p *LLMProfile) (int64, error) {
|
||||
if len(p.APIKey) >= 4 {
|
||||
hint = "…" + p.APIKey[len(p.APIKey)-4:]
|
||||
}
|
||||
r := p.Retry.Clamped()
|
||||
if p.ID == 0 {
|
||||
var id int64
|
||||
err := d.QueryRow(`INSERT INTO llm_profiles(name,format,base_url,proxy,model,api_key,api_key_hint,rate_per_second,rate_per_minute,context_window_k,reasoning_effort,priority,pool_exclude,thinking_type,streaming,max_tokens,max_tokens_field,session_header_key)
|
||||
VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) RETURNING id`,
|
||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey).Scan(&id)
|
||||
err := d.QueryRow(`INSERT INTO llm_profiles(name,format,base_url,proxy,model,api_key,api_key_hint,rate_per_second,rate_per_minute,context_window_k,reasoning_effort,priority,pool_exclude,thinking_type,streaming,max_tokens,max_tokens_field,session_header_key,retry_connect_attempts,retry_connect_interval_ms,retry_empty_attempts,retry_empty_interval_ms,retry_stream_attempts,retry_stream_interval_ms)
|
||||
VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24) RETURNING id`,
|
||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey,
|
||||
r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
if p.APIKey == "" {
|
||||
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,rate_per_second=$6,rate_per_minute=$7,context_window_k=$8,reasoning_effort=$9,priority=$10,pool_exclude=$11,thinking_type=$12,streaming=$13,max_tokens=$14,max_tokens_field=$15,session_header_key=$16 WHERE id=$17`,
|
||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey, p.ID)
|
||||
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,rate_per_second=$6,rate_per_minute=$7,context_window_k=$8,reasoning_effort=$9,priority=$10,pool_exclude=$11,thinking_type=$12,streaming=$13,max_tokens=$14,max_tokens_field=$15,session_header_key=$16,retry_connect_attempts=$17,retry_connect_interval_ms=$18,retry_empty_attempts=$19,retry_empty_interval_ms=$20,retry_stream_attempts=$21,retry_stream_interval_ms=$22 WHERE id=$23`,
|
||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey,
|
||||
r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.ID)
|
||||
return p.ID, err
|
||||
}
|
||||
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,api_key=$6,api_key_hint=$7,rate_per_second=$8,rate_per_minute=$9,context_window_k=$10,reasoning_effort=$11,priority=$12,pool_exclude=$13,thinking_type=$14,streaming=$15,max_tokens=$16,max_tokens_field=$17,session_header_key=$18 WHERE id=$19`,
|
||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey, p.ID)
|
||||
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,api_key=$6,api_key_hint=$7,rate_per_second=$8,rate_per_minute=$9,context_window_k=$10,reasoning_effort=$11,priority=$12,pool_exclude=$13,thinking_type=$14,streaming=$15,max_tokens=$16,max_tokens_field=$17,session_header_key=$18,retry_connect_attempts=$19,retry_connect_interval_ms=$20,retry_empty_attempts=$21,retry_empty_interval_ms=$22,retry_stream_attempts=$23,retry_stream_interval_ms=$24 WHERE id=$25`,
|
||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey,
|
||||
r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.ID)
|
||||
return p.ID, err
|
||||
}
|
||||
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LLM 重试策略:五层重试的「次数 + 间隔」全局配置,见 docs/LLM重试设计.md。
|
||||
// 存在 settings 表的一个 JSON 值里 —— 它是整机一份的运行参数,不值得为它开一张表;
|
||||
// 读取走内置默认兜底,所以键不存在(全新库/从未配置过)时行为与写死常量时代完全一致。
|
||||
|
||||
const settingLLMRetryPolicy = "llm_retry_policy"
|
||||
|
||||
// RetryRule is one layer's knob pair. The zero value means "unset":
|
||||
//
|
||||
// Attempts 0 = 用内置默认次数; -1 = 关闭该层重试; >0 = 用该值
|
||||
// IntervalMS 0 = 用该层原本的间隔策略(通常是指数退避); >0 = 改用固定毫秒间隔
|
||||
//
|
||||
// -1 是「显式关掉」而不是「0 次」,因为 0 已经被「未配置」占用了。
|
||||
type RetryRule struct {
|
||||
Attempts int `json:"attempts"`
|
||||
IntervalMS int `json:"interval_ms"`
|
||||
}
|
||||
|
||||
// Interval returns the configured fixed interval, or 0 when unset (caller keeps
|
||||
// its own default ladder).
|
||||
func (r RetryRule) Interval() time.Duration {
|
||||
if r.IntervalMS <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(r.IntervalMS) * time.Millisecond
|
||||
}
|
||||
|
||||
// Or returns the rule with each unset field filled in from fallback. Used to
|
||||
// layer a profile override on top of the global policy field by field, so a
|
||||
// profile that only pins the interval still inherits the global count.
|
||||
func (r RetryRule) Or(fallback RetryRule) RetryRule {
|
||||
if r.Attempts == 0 {
|
||||
r.Attempts = fallback.Attempts
|
||||
}
|
||||
if r.IntervalMS == 0 {
|
||||
r.IntervalMS = fallback.IntervalMS
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// retry knob bounds. A count above the cap turns a blip into a token bonfire;
|
||||
// an interval above an hour outlives any transient failure worth waiting out.
|
||||
const (
|
||||
maxRetryAttempts = 20
|
||||
maxRetryIntervalMS = 3600_000 // 1h
|
||||
)
|
||||
|
||||
// Clamped returns the rule with out-of-range values pulled back into the sane
|
||||
// band (attempts within [-1, 20], interval within [0, 1h]).
|
||||
func (r RetryRule) Clamped() RetryRule {
|
||||
if r.Attempts < -1 {
|
||||
r.Attempts = -1
|
||||
}
|
||||
if r.Attempts > maxRetryAttempts {
|
||||
r.Attempts = maxRetryAttempts
|
||||
}
|
||||
if r.IntervalMS < 0 {
|
||||
r.IntervalMS = 0
|
||||
}
|
||||
if r.IntervalMS > maxRetryIntervalMS {
|
||||
r.IntervalMS = maxRetryIntervalMS
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Clamped bounds a profile's override the same way the global policy is bounded,
|
||||
// so a hand-crafted API payload can't land a value the CHECK constraint rejects.
|
||||
func (o RetryOverride) Clamped() RetryOverride {
|
||||
o.Connect, o.Empty, o.Stream = o.Connect.Clamped(), o.Empty.Clamped(), o.Stream.Clamped()
|
||||
return o
|
||||
}
|
||||
|
||||
// LLMRetryPolicy holds the五层 retry configuration. Connect/Empty/Stream are the
|
||||
// per-request layers (a profile may override them, see LLMProfile.Retry);
|
||||
// Breaker and Intent are process-wide by nature and live only here.
|
||||
type LLMRetryPolicy struct {
|
||||
// Connect:SDK 建连重试(连接重置/超时/429/5xx,流开始前)。默认 3 次、指数退避。
|
||||
Connect RetryRule `json:"connect"`
|
||||
// Empty:SDK 空响应重试(完成但无 content block,仅 openai 格式)。默认 2 次、指数退避。
|
||||
Empty RetryRule `json:"empty"`
|
||||
// Stream:同 provider 安全窗口重试(未交付输出前的断流重放)。默认 2 次、0.5s 起指数(封顶 4s)。
|
||||
Stream RetryRule `json:"stream"`
|
||||
// Breaker:轮询熔断。Attempts=连续几次瞬时失败触发熔断(默认 3,-1=瞬时失败不熔断,
|
||||
// 硬失败如余额不足/密钥失效仍立即熔断);IntervalMS=固定冷却时长(0=默认 1/5/30min 梯度)。
|
||||
Breaker RetryRule `json:"breaker"`
|
||||
// Intent:worker 以 model_error 收场后的整条意图重跑。默认 2 次、固定 3s。
|
||||
Intent RetryRule `json:"intent"`
|
||||
}
|
||||
|
||||
// Clamped returns the policy with every rule clamped.
|
||||
func (p LLMRetryPolicy) Clamped() LLMRetryPolicy {
|
||||
p.Connect, p.Empty, p.Stream = p.Connect.Clamped(), p.Empty.Clamped(), p.Stream.Clamped()
|
||||
p.Breaker, p.Intent = p.Breaker.Clamped(), p.Intent.Clamped()
|
||||
return p
|
||||
}
|
||||
|
||||
// LLMRetryPolicy reads the global retry policy. A missing or unparseable value
|
||||
// yields the zero policy — i.e. every layer on its built-in default.
|
||||
func (d *DB) LLMRetryPolicy() LLMRetryPolicy {
|
||||
var p LLMRetryPolicy
|
||||
if d == nil {
|
||||
return p
|
||||
}
|
||||
raw, ok, err := d.GetSetting(settingLLMRetryPolicy)
|
||||
if err != nil || !ok || raw == "" {
|
||||
return p
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &p); err != nil {
|
||||
return LLMRetryPolicy{}
|
||||
}
|
||||
return p.Clamped()
|
||||
}
|
||||
|
||||
// SetLLMRetryPolicy persists the global retry policy (values are clamped first).
|
||||
func (d *DB) SetLLMRetryPolicy(p LLMRetryPolicy) error {
|
||||
raw, err := json.Marshal(p.Clamped())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return d.SetSetting(settingLLMRetryPolicy, string(raw))
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A profile's retry override must survive a full round trip through the real
|
||||
// column list — this is what catches a mis-ordered scan/insert after adding six
|
||||
// columns at once. Also pins the "empty api key on update keeps the row usable"
|
||||
// path, since that UPDATE has its own parameter numbering.
|
||||
func TestProfileRetryRoundTrip(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
want := RetryOverride{
|
||||
Connect: RetryRule{Attempts: 5, IntervalMS: 2000},
|
||||
Empty: RetryRule{Attempts: -1},
|
||||
Stream: RetryRule{IntervalMS: 250},
|
||||
}
|
||||
id, err := d.SaveProfile(&LLMProfile{
|
||||
Name: "t-retry-roundtrip", Format: "openai", Model: "m", APIKey: "k", Retry: want,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id) })
|
||||
|
||||
got, err := d.ProfileByID(id)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("ProfileByID: %v", err)
|
||||
}
|
||||
if got.Retry != want {
|
||||
t.Fatalf("retry=%+v, want %+v", got.Retry, want)
|
||||
}
|
||||
// Keyless update path (the UI sends no key when the user didn't retype it).
|
||||
got.APIKey = ""
|
||||
got.Retry.Stream = RetryRule{Attempts: 3, IntervalMS: 700}
|
||||
if _, err := d.SaveProfile(got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := d.ProfileByID(id)
|
||||
if err != nil || after == nil {
|
||||
t.Fatalf("ProfileByID after update: %v", err)
|
||||
}
|
||||
if after.Retry.Stream != (RetryRule{Attempts: 3, IntervalMS: 700}) {
|
||||
t.Fatalf("stream=%+v after update", after.Retry.Stream)
|
||||
}
|
||||
if after.Retry.Connect != want.Connect || after.Retry.Empty != want.Empty {
|
||||
t.Fatalf("untouched rules changed: %+v", after.Retry)
|
||||
}
|
||||
// The listing query reads a different column list — it must agree.
|
||||
profiles, err := d.ListProfiles()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range profiles {
|
||||
if p.ID == id && p.Retry.Connect != want.Connect {
|
||||
t.Fatalf("ListProfiles retry=%+v, want %+v", p.Retry, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Out-of-range values are clamped on the way in, so the DB CHECK constraint is
|
||||
// never what the user hears about.
|
||||
func TestProfileRetryClampedOnSave(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
id, err := d.SaveProfile(&LLMProfile{
|
||||
Name: "t-retry-clamp", Format: "openai", Model: "m", APIKey: "k",
|
||||
Retry: RetryOverride{Connect: RetryRule{Attempts: -99, IntervalMS: -5}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id) })
|
||||
got, err := d.ProfileByID(id)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("ProfileByID: %v", err)
|
||||
}
|
||||
if got.Retry.Connect != (RetryRule{Attempts: -1}) {
|
||||
t.Fatalf("connect=%+v, want attempts -1 and no interval", got.Retry.Connect)
|
||||
}
|
||||
}
|
||||
|
||||
// The global policy round-trips through settings, and an unset key reads back as
|
||||
// "everything on its built-in default".
|
||||
func TestLLMRetryPolicyRoundTrip(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
before, hadBefore, err := d.GetSetting(settingLLMRetryPolicy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if hadBefore {
|
||||
d.SetSetting(settingLLMRetryPolicy, before)
|
||||
} else {
|
||||
d.Exec(`DELETE FROM settings WHERE key=$1`, settingLLMRetryPolicy)
|
||||
}
|
||||
})
|
||||
|
||||
want := LLMRetryPolicy{
|
||||
Connect: RetryRule{Attempts: 4, IntervalMS: 1500},
|
||||
Breaker: RetryRule{Attempts: 2, IntervalMS: 90_000},
|
||||
Intent: RetryRule{Attempts: -1},
|
||||
}
|
||||
if err := d.SetLLMRetryPolicy(want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := d.LLMRetryPolicy()
|
||||
if got != want {
|
||||
t.Fatalf("policy=%+v, want %+v", got, want)
|
||||
}
|
||||
if d := got.Breaker.Interval(); d != 90*time.Second {
|
||||
t.Fatalf("breaker interval=%v, want 90s", d)
|
||||
}
|
||||
|
||||
// 越界值写进去也会被夹回区间,读出来是夹紧后的值。
|
||||
if err := d.SetLLMRetryPolicy(LLMRetryPolicy{Stream: RetryRule{Attempts: 999, IntervalMS: 99_999_999}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := d.LLMRetryPolicy().Stream; got.Attempts != 20 || got.IntervalMS != 3600_000 {
|
||||
t.Fatalf("stream=%+v, want the 20 / 1h caps", got)
|
||||
}
|
||||
|
||||
// 键不存在 = 全默认。
|
||||
if _, err := d.Exec(`DELETE FROM settings WHERE key=$1`, settingLLMRetryPolicy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := d.LLMRetryPolicy(); got != (LLMRetryPolicy{}) {
|
||||
t.Fatalf("unset policy=%+v, want zero", got)
|
||||
}
|
||||
}
|
||||
@@ -367,6 +367,14 @@ CREATE TABLE IF NOT EXISTS llm_profiles (
|
||||
-- 自定义会话头:非空时每次请求带一个该名字的 HTTP 头,头值=当前运行的 session id
|
||||
-- (chat 会话/worker 意图)。用于某些按 session-id 头做提示缓存/粘性路由的网关。''=不发送。
|
||||
session_header_key TEXT NOT NULL DEFAULT '',
|
||||
-- 重试覆盖:次数 0=用全局默认/-1=关闭/>0=该值;间隔 0=用默认指数退避/>0=固定毫秒。
|
||||
-- 三组分别对应建连重试、空响应重试、同 provider 安全窗口重试,详见下方 ALTER 处注释。
|
||||
retry_connect_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
retry_connect_interval_ms INTEGER NOT NULL DEFAULT 0,
|
||||
retry_empty_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
retry_empty_interval_ms INTEGER NOT NULL DEFAULT 0,
|
||||
retry_stream_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
retry_stream_interval_ms INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -399,6 +407,24 @@ ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_max_tokens_check
|
||||
-- 自定义会话头名;补旧库。默认 '' = 不发送,旧配置行为不变。
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS session_header_key TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- 单配置的重试覆盖(见 docs/LLM重试设计.md)。三组各自一对「次数 + 固定间隔」,
|
||||
-- 语义统一:次数 0=沿用全局默认、-1=关闭该层重试、>0=用该值;间隔 0=沿用该层的
|
||||
-- 默认指数退避、>0=改用这个固定毫秒数。全部默认 0,所以旧库/旧配置行为不变。
|
||||
-- connect = 建连重试(SDK doStream:连接重置/超时/429/5xx,流开始前)
|
||||
-- empty = 空响应重试(SDK:完成但没有任何 content block,仅 openai 格式)
|
||||
-- stream = 同 provider 安全窗口重试(本项目 task_llm:未交付输出前的断流重放)
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS retry_connect_attempts INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS retry_connect_interval_ms INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS retry_empty_attempts INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS retry_empty_interval_ms INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS retry_stream_attempts INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS retry_stream_interval_ms INTEGER NOT NULL DEFAULT 0;
|
||||
-- 同 format:先删再建,保证每次启动幂等。次数下限 -1(关闭),间隔不能为负。
|
||||
ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_retry_check;
|
||||
ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_retry_check CHECK (
|
||||
retry_connect_attempts >= -1 AND retry_empty_attempts >= -1 AND retry_stream_attempts >= -1
|
||||
AND retry_connect_interval_ms >= 0 AND retry_empty_interval_ms >= 0 AND retry_stream_interval_ms >= 0);
|
||||
|
||||
-- 思考开关字段 thinking_type,从旧的单一 reasoning_effort 语义一次性拆分而来。
|
||||
-- schema.sql 每次启动都执行,故迁移必须只跑一次:仅当该列尚不存在时才回填,
|
||||
-- 否则每次启动都会把用户后来手动设的组合覆盖回去。旧 reasoning_effort 语义:
|
||||
|
||||
Reference in new issue
Block a user