Merge pull request #2872 from wucm667/fix/scheduler-sticky-health-escape

fix(scheduler): session_hash sticky 引入健康度逃逸,慢账号不再独占用户会话
This commit is contained in:
Wesley Liddick 2026-06-05 13:54:37 +08:00 committed by GitHub
commit f332e0a83c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 399 additions and 15 deletions

View File

@ -719,6 +719,8 @@ type GatewayConfig struct {
OpenAIPassthroughAllowTimeoutHeaders bool `mapstructure:"openai_passthrough_allow_timeout_headers"` OpenAIPassthroughAllowTimeoutHeaders bool `mapstructure:"openai_passthrough_allow_timeout_headers"`
// OpenAIWS: OpenAI Responses WebSocket 配置(默认开启,可按需回滚到 HTTP // OpenAIWS: OpenAI Responses WebSocket 配置(默认开启,可按需回滚到 HTTP
OpenAIWS GatewayOpenAIWSConfig `mapstructure:"openai_ws"` OpenAIWS GatewayOpenAIWSConfig `mapstructure:"openai_ws"`
// OpenAIScheduler: OpenAI 高级调度器粘性逃逸配置
OpenAIScheduler GatewayOpenAISchedulerConfig `mapstructure:"openai_scheduler"`
// OpenAIHTTP2: OpenAI HTTP 上游协议策略(默认启用 HTTP/2可按代理能力回退 HTTP/1.1 // OpenAIHTTP2: OpenAI HTTP 上游协议策略(默认启用 HTTP/2可按代理能力回退 HTTP/1.1
OpenAIHTTP2 GatewayOpenAIHTTP2Config `mapstructure:"openai_http2"` OpenAIHTTP2 GatewayOpenAIHTTP2Config `mapstructure:"openai_http2"`
// ImageConcurrency: 图片生成独立并发限制配置(默认关闭) // ImageConcurrency: 图片生成独立并发限制配置(默认关闭)
@ -957,6 +959,16 @@ type GatewayOpenAIWSSchedulerScoreWeights struct {
TTFT float64 `mapstructure:"ttft"` TTFT float64 `mapstructure:"ttft"`
} }
// GatewayOpenAISchedulerConfig OpenAI 高级调度器配置。
type GatewayOpenAISchedulerConfig struct {
// StickyEscapeEnabled: 是否允许 session_hash sticky 在账号健康度劣化时临时逃逸
StickyEscapeEnabled bool `mapstructure:"sticky_escape_enabled"`
// StickyEscapeTTFTMs: TTFT EWMA 超过该阈值时跳过 sticky
StickyEscapeTTFTMs int `mapstructure:"sticky_escape_ttft_ms"`
// StickyEscapeErrorRate: 错误率 EWMA 超过该阈值时跳过 sticky
StickyEscapeErrorRate float64 `mapstructure:"sticky_escape_error_rate"`
}
// GatewayUsageRecordConfig 使用量记录异步队列配置 // GatewayUsageRecordConfig 使用量记录异步队列配置
type GatewayUsageRecordConfig struct { type GatewayUsageRecordConfig struct {
// WorkerCount: worker 初始数量(自动扩缩容开启时作为初始并发上限) // WorkerCount: worker 初始数量(自动扩缩容开启时作为初始并发上限)
@ -1385,6 +1397,15 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
if err := viper.Unmarshal(&cfg); err != nil { if err := viper.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unmarshal config error: %w", err) return nil, fmt.Errorf("unmarshal config error: %w", err)
} }
if cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs == 0 {
cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs = 15000
}
if cfg.Gateway.OpenAIScheduler.StickyEscapeErrorRate == 0 {
cfg.Gateway.OpenAIScheduler.StickyEscapeErrorRate = 0.5
}
if !cfg.Gateway.OpenAIScheduler.StickyEscapeEnabled && !viper.IsSet("gateway.openai_scheduler.sticky_escape_enabled") {
cfg.Gateway.OpenAIScheduler.StickyEscapeEnabled = true
}
cfg.RunMode = NormalizeRunMode(cfg.RunMode) cfg.RunMode = NormalizeRunMode(cfg.RunMode)
cfg.Server.Mode = strings.ToLower(strings.TrimSpace(cfg.Server.Mode)) cfg.Server.Mode = strings.ToLower(strings.TrimSpace(cfg.Server.Mode))
@ -2635,6 +2656,12 @@ func (c *Config) Validate() error {
if weightSum <= 0 { if weightSum <= 0 {
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights must not all be zero") return fmt.Errorf("gateway.openai_ws.scheduler_score_weights must not all be zero")
} }
if c.Gateway.OpenAIScheduler.StickyEscapeTTFTMs <= 0 {
return fmt.Errorf("gateway.openai_scheduler.sticky_escape_ttft_ms must be positive")
}
if c.Gateway.OpenAIScheduler.StickyEscapeErrorRate < 0 || c.Gateway.OpenAIScheduler.StickyEscapeErrorRate > 1 {
return fmt.Errorf("gateway.openai_scheduler.sticky_escape_error_rate must be between 0 and 1")
}
if c.Gateway.MaxLineSize < 0 { if c.Gateway.MaxLineSize < 0 {
return fmt.Errorf("gateway.max_line_size must be non-negative") return fmt.Errorf("gateway.max_line_size must be non-negative")
} }

View File

@ -110,6 +110,15 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
if cfg.Gateway.OpenAIWS.StickySessionTTLSeconds != 3600 { if cfg.Gateway.OpenAIWS.StickySessionTTLSeconds != 3600 {
t.Fatalf("Gateway.OpenAIWS.StickySessionTTLSeconds = %d, want 3600", cfg.Gateway.OpenAIWS.StickySessionTTLSeconds) t.Fatalf("Gateway.OpenAIWS.StickySessionTTLSeconds = %d, want 3600", cfg.Gateway.OpenAIWS.StickySessionTTLSeconds)
} }
if !cfg.Gateway.OpenAIScheduler.StickyEscapeEnabled {
t.Fatalf("Gateway.OpenAIScheduler.StickyEscapeEnabled = false, want true")
}
if cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs != 15000 {
t.Fatalf("Gateway.OpenAIScheduler.StickyEscapeTTFTMs = %d, want 15000", cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs)
}
if cfg.Gateway.OpenAIScheduler.StickyEscapeErrorRate != 0.5 {
t.Fatalf("Gateway.OpenAIScheduler.StickyEscapeErrorRate = %v, want 0.5", cfg.Gateway.OpenAIScheduler.StickyEscapeErrorRate)
}
if !cfg.Gateway.OpenAIWS.SessionHashReadOldFallback { if !cfg.Gateway.OpenAIWS.SessionHashReadOldFallback {
t.Fatalf("Gateway.OpenAIWS.SessionHashReadOldFallback = false, want true") t.Fatalf("Gateway.OpenAIWS.SessionHashReadOldFallback = false, want true")
} }
@ -1714,6 +1723,21 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
}, },
wantErr: "gateway.openai_ws.scheduler_score_weights must not all be zero", wantErr: "gateway.openai_ws.scheduler_score_weights must not all be zero",
}, },
{
name: "sticky_escape_ttft_ms 必须为正数",
mutate: func(c *Config) { c.Gateway.OpenAIScheduler.StickyEscapeTTFTMs = 0 },
wantErr: "gateway.openai_scheduler.sticky_escape_ttft_ms",
},
{
name: "sticky_escape_error_rate 不能小于 0",
mutate: func(c *Config) { c.Gateway.OpenAIScheduler.StickyEscapeErrorRate = -0.1 },
wantErr: "gateway.openai_scheduler.sticky_escape_error_rate",
},
{
name: "sticky_escape_error_rate 不能大于 1",
mutate: func(c *Config) { c.Gateway.OpenAIScheduler.StickyEscapeErrorRate = 1.1 },
wantErr: "gateway.openai_scheduler.sticky_escape_error_rate",
},
} }
for _, tc := range cases { for _, tc := range cases {

View File

@ -41,6 +41,7 @@ type OpenAIAccountScheduleRequest struct {
GroupID *int64 GroupID *int64
SessionHash string SessionHash string
StickyAccountID int64 StickyAccountID int64
PreserveStickyBinding bool
PreviousResponseID string PreviousResponseID string
RequestedModel string RequestedModel string
RequiredTransport OpenAIUpstreamTransport RequiredTransport OpenAIUpstreamTransport
@ -241,6 +242,12 @@ type defaultOpenAIAccountScheduler struct {
stats *openAIAccountRuntimeStats stats *openAIAccountRuntimeStats
} }
type openAIStickyEscapeConfig struct {
enabled bool
ttftMs float64
errorRate float64
}
func newDefaultOpenAIAccountScheduler(service *OpenAIGatewayService, stats *openAIAccountRuntimeStats) OpenAIAccountScheduler { func newDefaultOpenAIAccountScheduler(service *OpenAIGatewayService, stats *openAIAccountRuntimeStats) OpenAIAccountScheduler {
if stats == nil { if stats == nil {
stats = newOpenAIAccountRuntimeStats() stats = newOpenAIAccountRuntimeStats()
@ -296,7 +303,7 @@ func (s *defaultOpenAIAccountScheduler) Select(
} }
} }
selection, err := s.selectBySessionHash(ctx, req) selection, escapedSticky, err := s.selectBySessionHash(ctx, req)
if err != nil { if err != nil {
return nil, decision, err return nil, decision, err
} }
@ -307,6 +314,9 @@ func (s *defaultOpenAIAccountScheduler) Select(
decision.SelectedAccountType = selection.Account.Type decision.SelectedAccountType = selection.Account.Type
return selection, decision, nil return selection, decision, nil
} }
if escapedSticky {
req.PreserveStickyBinding = true
}
selection, candidateCount, topK, loadSkew, err := s.selectByLoadBalance(ctx, req) selection, candidateCount, topK, loadSkew, err := s.selectByLoadBalance(ctx, req)
decision.Layer = openAIAccountScheduleLayerLoadBalance decision.Layer = openAIAccountScheduleLayerLoadBalance
@ -326,10 +336,10 @@ func (s *defaultOpenAIAccountScheduler) Select(
func (s *defaultOpenAIAccountScheduler) selectBySessionHash( func (s *defaultOpenAIAccountScheduler) selectBySessionHash(
ctx context.Context, ctx context.Context,
req OpenAIAccountScheduleRequest, req OpenAIAccountScheduleRequest,
) (*AccountSelectionResult, error) { ) (*AccountSelectionResult, bool, error) {
sessionHash := strings.TrimSpace(req.SessionHash) sessionHash := strings.TrimSpace(req.SessionHash)
if sessionHash == "" || s == nil || s.service == nil || s.service.cache == nil { if sessionHash == "" || s == nil || s.service == nil || s.service.cache == nil {
return nil, nil return nil, false, nil
} }
accountID := req.StickyAccountID accountID := req.StickyAccountID
@ -337,38 +347,48 @@ func (s *defaultOpenAIAccountScheduler) selectBySessionHash(
var err error var err error
accountID, err = s.service.getStickySessionAccountID(ctx, req.GroupID, sessionHash) accountID, err = s.service.getStickySessionAccountID(ctx, req.GroupID, sessionHash)
if err != nil || accountID <= 0 { if err != nil || accountID <= 0 {
return nil, nil return nil, false, nil
} }
} }
if accountID <= 0 { if accountID <= 0 {
return nil, nil return nil, false, nil
} }
if req.ExcludedIDs != nil { if req.ExcludedIDs != nil {
if _, excluded := req.ExcludedIDs[accountID]; excluded { if _, excluded := req.ExcludedIDs[accountID]; excluded {
return nil, nil return nil, false, nil
} }
} }
account, err := s.service.getSchedulableAccount(ctx, accountID) account, err := s.service.getSchedulableAccount(ctx, accountID)
if err != nil || account == nil { if err != nil || account == nil {
_ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash)
return nil, nil return nil, false, nil
} }
if shouldClearStickySession(account, req.RequestedModel) || !account.IsOpenAI() || !account.IsSchedulable() { if shouldClearStickySession(account, req.RequestedModel) || !account.IsOpenAI() || !account.IsSchedulable() {
_ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash)
return nil, nil return nil, false, nil
} }
if !s.isAccountRequestCompatible(ctx, account, req) { if !s.isAccountRequestCompatible(ctx, account, req) {
return nil, nil return nil, false, nil
} }
if !s.isAccountTransportCompatible(account, req.RequiredTransport) { if !s.isAccountTransportCompatible(account, req.RequiredTransport) {
_ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash)
return nil, nil return nil, false, nil
} }
account = s.service.recheckSelectedOpenAIAccountFromDB(ctx, account, req.RequestedModel, req.RequireCompact, req.RequiredCapability) account = s.service.recheckSelectedOpenAIAccountFromDB(ctx, account, req.RequestedModel, req.RequireCompact, req.RequiredCapability)
if account == nil || !s.isAccountTransportCompatible(account, req.RequiredTransport) { if account == nil || !s.isAccountTransportCompatible(account, req.RequiredTransport) {
_ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash)
return nil, nil return nil, false, nil
}
escapeCfg := s.service.openAIStickyEscapeConfig()
if reason, errorRate, ttft, shouldEscape := s.shouldEscapeStickyAccount(accountID, escapeCfg); shouldEscape {
slog.Info("sticky_escape_triggered",
"account_id", accountID,
"reason", reason,
"error_rate", errorRate,
"ttft", ttft,
)
return nil, true, nil
} }
result, acquireErr := s.service.tryAcquireAccountSlot(ctx, accountID, account.Concurrency) result, acquireErr := s.service.tryAcquireAccountSlot(ctx, accountID, account.Concurrency)
if acquireErr == nil && result != nil && result.Acquired { if acquireErr == nil && result != nil && result.Acquired {
@ -377,12 +397,22 @@ func (s *defaultOpenAIAccountScheduler) selectBySessionHash(
Account: account, Account: account,
Acquired: true, Acquired: true,
ReleaseFunc: result.ReleaseFunc, ReleaseFunc: result.ReleaseFunc,
}, nil }, false, nil
} }
cfg := s.service.schedulingConfig() cfg := s.service.schedulingConfig()
// WaitPlan.MaxConcurrency 使用 Concurrency非 EffectiveLoadFactor因为 WaitPlan 控制的是 Redis 实际并发槽位等待。 // WaitPlan.MaxConcurrency 使用 Concurrency非 EffectiveLoadFactor因为 WaitPlan 控制的是 Redis 实际并发槽位等待。
if s.service.concurrencyService != nil { if s.service.concurrencyService != nil {
if escapeCfg.enabled && acquireErr == nil && result != nil && !result.Acquired {
errorRate, ttft, _ := s.stats.snapshot(accountID)
slog.Info("sticky_escape_triggered",
"account_id", accountID,
"reason", "concurrency_full",
"error_rate", errorRate,
"ttft", ttft,
)
return nil, true, nil
}
return &AccountSelectionResult{ return &AccountSelectionResult{
Account: account, Account: account,
WaitPlan: &AccountWaitPlan{ WaitPlan: &AccountWaitPlan{
@ -391,9 +421,23 @@ func (s *defaultOpenAIAccountScheduler) selectBySessionHash(
Timeout: cfg.StickySessionWaitTimeout, Timeout: cfg.StickySessionWaitTimeout,
MaxWaiting: cfg.StickySessionMaxWaiting, MaxWaiting: cfg.StickySessionMaxWaiting,
}, },
}, nil }, false, nil
} }
return nil, nil return nil, false, nil
}
func (s *defaultOpenAIAccountScheduler) shouldEscapeStickyAccount(accountID int64, cfg openAIStickyEscapeConfig) (reason string, errorRate float64, ttft float64, shouldEscape bool) {
if !cfg.enabled || s == nil || s.stats == nil || accountID <= 0 {
return "", 0, 0, false
}
errorRate, ttft, hasTTFT := s.stats.snapshot(accountID)
if hasTTFT && ttft > cfg.ttftMs {
return "ttft", errorRate, ttft, true
}
if errorRate > cfg.errorRate {
return "error_rate", errorRate, ttft, true
}
return "", errorRate, ttft, false
} }
type openAIAccountCandidateScore struct { type openAIAccountCandidateScore struct {
@ -809,7 +853,7 @@ func (s *defaultOpenAIAccountScheduler) tryAcquireOpenAISelectionOrder(
return nil, compactBlocked, acquireErr return nil, compactBlocked, acquireErr
} }
if result != nil && result.Acquired { if result != nil && result.Acquired {
if req.SessionHash != "" { if req.SessionHash != "" && !req.PreserveStickyBinding {
_ = s.service.BindStickySession(ctx, req.GroupID, req.SessionHash, fresh.ID) _ = s.service.BindStickySession(ctx, req.GroupID, req.SessionHash, fresh.ID)
} }
return &AccountSelectionResult{ return &AccountSelectionResult{
@ -1312,6 +1356,37 @@ func (s *OpenAIGatewayService) openAIWSLBTopK() int {
return 7 return 7
} }
func (s *OpenAIGatewayService) openAIStickyEscapeConfig() openAIStickyEscapeConfig {
if s != nil && s.cfg != nil {
cfg := s.cfg.Gateway.OpenAIScheduler
enabled := cfg.StickyEscapeEnabled
if !enabled && cfg.StickyEscapeTTFTMs == 0 && cfg.StickyEscapeErrorRate == 0 {
enabled = true
}
ttftMs := float64(cfg.StickyEscapeTTFTMs)
if ttftMs <= 0 {
ttftMs = 15000
}
errorRate := cfg.StickyEscapeErrorRate
if errorRate < 0 || errorRate > 1 {
errorRate = 0.5
}
if errorRate == 0 && cfg.StickyEscapeTTFTMs == 0 && cfg.StickyEscapeErrorRate == 0 {
errorRate = 0.5
}
return openAIStickyEscapeConfig{
enabled: enabled,
ttftMs: ttftMs,
errorRate: errorRate,
}
}
return openAIStickyEscapeConfig{
enabled: true,
ttftMs: 15000,
errorRate: 0.5,
}
}
func (s *OpenAIGatewayService) openAIWSSchedulerWeights() GatewayOpenAIWSSchedulerScoreWeightsView { func (s *OpenAIGatewayService) openAIWSSchedulerWeights() GatewayOpenAIWSSchedulerScoreWeightsView {
if s != nil && s.cfg != nil { if s != nil && s.cfg != nil {
return GatewayOpenAIWSSchedulerScoreWeightsView{ return GatewayOpenAIWSSchedulerScoreWeightsView{

View File

@ -1229,6 +1229,9 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyBusyKeepsS
cfg := &config.Config{} cfg := &config.Config{}
cfg.Gateway.Scheduling.StickySessionMaxWaiting = 2 cfg.Gateway.Scheduling.StickySessionMaxWaiting = 2
cfg.Gateway.Scheduling.StickySessionWaitTimeout = 45 * time.Second cfg.Gateway.Scheduling.StickySessionWaitTimeout = 45 * time.Second
cfg.Gateway.OpenAIScheduler.StickyEscapeEnabled = false
cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs = 15000
cfg.Gateway.OpenAIScheduler.StickyEscapeErrorRate = 0.5
cfg.Gateway.OpenAIWS.Enabled = true cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true cfg.Gateway.OpenAIWS.APIKeyEnabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true cfg.Gateway.OpenAIWS.OAuthEnabled = true
@ -1277,6 +1280,253 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyBusyKeepsS
require.True(t, decision.StickySessionHit) require.True(t, decision.StickySessionHit)
} }
func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyEscapeByTTFT(t *testing.T) {
ctx := context.Background()
groupID := int64(10101)
accounts := []Account{
{
ID: 21101,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Status: StatusActive,
Schedulable: true,
Concurrency: 1,
Priority: 0,
},
{
ID: 21102,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Status: StatusActive,
Schedulable: true,
Concurrency: 1,
Priority: 1,
},
}
cache := &schedulerTestGatewayCache{sessionBindings: map[string]int64{"openai:session_hash_sticky_ttft": 21101}}
cfg := &config.Config{}
cfg.Gateway.OpenAIScheduler.StickyEscapeEnabled = true
cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs = 15000
cfg.Gateway.OpenAIScheduler.StickyEscapeErrorRate = 0.5
concurrencyCache := schedulerTestConcurrencyCache{acquireResults: map[int64]bool{21102: true}}
svc := &OpenAIGatewayService{
accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
cache: cache,
cfg: cfg,
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
concurrencyService: NewConcurrencyService(concurrencyCache),
openaiAccountStats: newOpenAIAccountRuntimeStats(),
}
fastTTFT := 14999
svc.openaiAccountStats.report(21101, true, &fastTTFT)
stableTTFT := 14999
svc.openaiAccountStats.report(21101, true, &stableTTFT)
selection, decision, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_hash_sticky_ttft", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false)
require.NoError(t, err)
require.NotNil(t, selection)
require.NotNil(t, selection.Account)
require.Equal(t, int64(21101), selection.Account.ID)
require.Equal(t, openAIAccountScheduleLayerSessionSticky, decision.Layer)
require.True(t, decision.StickySessionHit)
if selection.ReleaseFunc != nil {
selection.ReleaseFunc()
}
slowTTFT := 20000
for i := 0; i < 3; i++ {
svc.openaiAccountStats.report(21101, true, &slowTTFT)
}
selection, decision, err = svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_hash_sticky_ttft", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false)
require.NoError(t, err)
require.NotNil(t, selection)
require.NotNil(t, selection.Account)
require.Equal(t, int64(21102), selection.Account.ID)
require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer)
require.False(t, decision.StickySessionHit)
require.Equal(t, int64(21101), cache.sessionBindings["openai:session_hash_sticky_ttft"])
if selection.ReleaseFunc != nil {
selection.ReleaseFunc()
}
}
func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyEscapeByErrorRate(t *testing.T) {
ctx := context.Background()
groupID := int64(10102)
accounts := []Account{
{ID: 21201, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0},
{ID: 21202, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 1},
}
cache := &schedulerTestGatewayCache{sessionBindings: map[string]int64{"openai:session_hash_sticky_error_rate": 21201}}
cfg := &config.Config{}
cfg.Gateway.OpenAIScheduler.StickyEscapeEnabled = true
cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs = 15000
cfg.Gateway.OpenAIScheduler.StickyEscapeErrorRate = 0.5
svc := &OpenAIGatewayService{
accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
cache: cache,
cfg: cfg,
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{acquireResults: map[int64]bool{21202: true}}),
openaiAccountStats: newOpenAIAccountRuntimeStats(),
}
for i := 0; i < 3; i++ {
svc.openaiAccountStats.report(21201, false, nil)
}
selection, decision, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_hash_sticky_error_rate", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false)
require.NoError(t, err)
require.NotNil(t, selection)
require.NotNil(t, selection.Account)
require.Equal(t, int64(21201), selection.Account.ID)
require.Equal(t, openAIAccountScheduleLayerSessionSticky, decision.Layer)
require.True(t, decision.StickySessionHit)
if selection.ReleaseFunc != nil {
selection.ReleaseFunc()
}
for i := 0; i < 2; i++ {
svc.openaiAccountStats.report(21201, false, nil)
}
selection, decision, err = svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_hash_sticky_error_rate", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false)
require.NoError(t, err)
require.NotNil(t, selection)
require.NotNil(t, selection.Account)
require.Equal(t, int64(21202), selection.Account.ID)
require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer)
require.False(t, decision.StickySessionHit)
require.Equal(t, int64(21201), cache.sessionBindings["openai:session_hash_sticky_error_rate"])
if selection.ReleaseFunc != nil {
selection.ReleaseFunc()
}
}
func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyBusyEscapes(t *testing.T) {
ctx := context.Background()
groupID := int64(10103)
accounts := []Account{
{ID: 21301, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0},
{ID: 21302, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 1},
}
cache := &schedulerTestGatewayCache{sessionBindings: map[string]int64{"openai:session_hash_sticky_busy_escape": 21301}}
cfg := &config.Config{}
cfg.Gateway.OpenAIScheduler.StickyEscapeEnabled = true
cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs = 15000
cfg.Gateway.OpenAIScheduler.StickyEscapeErrorRate = 0.5
cfg.Gateway.Scheduling.StickySessionMaxWaiting = 2
cfg.Gateway.Scheduling.StickySessionWaitTimeout = 45 * time.Second
concurrencyCache := schedulerTestConcurrencyCache{
acquireResults: map[int64]bool{21301: false, 21302: true},
waitCounts: map[int64]int{21301: 999},
loadMap: map[int64]*AccountLoadInfo{
21301: {AccountID: 21301, LoadRate: 95, WaitingCount: 9},
21302: {AccountID: 21302, LoadRate: 1, WaitingCount: 0},
},
}
svc := &OpenAIGatewayService{
accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
cache: cache,
cfg: cfg,
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
concurrencyService: NewConcurrencyService(concurrencyCache),
}
selection, decision, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_hash_sticky_busy_escape", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false)
require.NoError(t, err)
require.NotNil(t, selection)
require.NotNil(t, selection.Account)
require.Equal(t, int64(21302), selection.Account.ID)
require.Nil(t, selection.WaitPlan)
require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer)
require.False(t, decision.StickySessionHit)
if selection.ReleaseFunc != nil {
selection.ReleaseFunc()
}
}
func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyEscapeDisabledKeepsLegacyBehavior(t *testing.T) {
ctx := context.Background()
groupID := int64(10104)
accounts := []Account{
{ID: 21401, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0},
{ID: 21402, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 1},
}
cache := &schedulerTestGatewayCache{sessionBindings: map[string]int64{"openai:session_hash_sticky_disabled": 21401}}
cfg := &config.Config{}
cfg.Gateway.OpenAIScheduler.StickyEscapeEnabled = false
cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs = 15000
cfg.Gateway.OpenAIScheduler.StickyEscapeErrorRate = 0.5
cfg.Gateway.Scheduling.StickySessionMaxWaiting = 2
cfg.Gateway.Scheduling.StickySessionWaitTimeout = 45 * time.Second
concurrencyCache := schedulerTestConcurrencyCache{
acquireResults: map[int64]bool{21401: false, 21402: true},
waitCounts: map[int64]int{21401: 999},
}
svc := &OpenAIGatewayService{
accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
cache: cache,
cfg: cfg,
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
concurrencyService: NewConcurrencyService(concurrencyCache),
openaiAccountStats: newOpenAIAccountRuntimeStats(),
}
slowTTFT := 20000
svc.openaiAccountStats.report(21401, true, &slowTTFT)
for i := 0; i < 5; i++ {
svc.openaiAccountStats.report(21401, false, nil)
}
selection, decision, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_hash_sticky_disabled", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false)
require.NoError(t, err)
require.NotNil(t, selection)
require.NotNil(t, selection.Account)
require.Equal(t, int64(21401), selection.Account.ID)
require.NotNil(t, selection.WaitPlan)
require.Equal(t, int64(21401), selection.WaitPlan.AccountID)
require.Equal(t, openAIAccountScheduleLayerSessionSticky, decision.Layer)
require.True(t, decision.StickySessionHit)
}
func TestDefaultOpenAIAccountScheduler_ShouldEscapeStickyAccount_ThresholdBoundary(t *testing.T) {
stats := newOpenAIAccountRuntimeStats()
accountID := int64(21501)
ttft := 15000
stats.report(accountID, true, &ttft)
stats.report(accountID, false, nil)
stats.report(accountID, true, nil)
scheduler := &defaultOpenAIAccountScheduler{stats: stats}
reason, errorRate, observedTTFT, shouldEscape := scheduler.shouldEscapeStickyAccount(accountID, openAIStickyEscapeConfig{
enabled: true,
ttftMs: 15000,
errorRate: 0.5,
})
require.False(t, shouldEscape)
require.Empty(t, reason)
require.InDelta(t, 0.16, errorRate, 1e-9)
require.InDelta(t, 15000, observedTTFT, 1e-9)
for i := 0; i < 4; i++ {
stats.report(accountID, false, nil)
}
reason, errorRate, _, shouldEscape = scheduler.shouldEscapeStickyAccount(accountID, openAIStickyEscapeConfig{
enabled: true,
ttftMs: 15000,
errorRate: 1,
})
require.False(t, shouldEscape)
require.Empty(t, reason)
reason, errorRate, observedTTFT, shouldEscape = scheduler.shouldEscapeStickyAccount(accountID, openAIStickyEscapeConfig{
enabled: true,
ttftMs: 15000,
errorRate: errorRate,
})
require.False(t, shouldEscape)
require.Empty(t, reason)
require.InDelta(t, 0.655936, errorRate, 1e-9)
require.InDelta(t, 15000, observedTTFT, 1e-9)
}
func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionSticky_ForceHTTP(t *testing.T) { func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionSticky_ForceHTTP(t *testing.T) {
ctx := context.Background() ctx := context.Background()
groupID := int64(1010) groupID := int64(1010)

View File

@ -320,6 +320,14 @@ gateway:
queue: 0.7 queue: 0.7
error_rate: 0.8 error_rate: 0.8
ttft: 0.5 ttft: 0.5
# OpenAI 高级调度器补充配置
openai_scheduler:
# 是否允许 session_hash sticky 在账号健康度恶化时临时逃逸false 可一键回退旧行为
sticky_escape_enabled: true
# TTFT EWMA 超过该阈值(毫秒)时跳过 sticky默认 15s避免轻微抖动就逃逸
sticky_escape_ttft_ms: 15000
# 错误率 EWMA 超过该阈值时跳过 sticky默认 0.5,仅在明显降级时触发
sticky_escape_error_rate: 0.5
# OpenAI HTTP upstream protocol strategy. # OpenAI HTTP upstream protocol strategy.
# OpenAI HTTP 上游协议策略(默认 HTTP/2代理明确不兼容时可临时回退 HTTP/1.1)。 # OpenAI HTTP 上游协议策略(默认 HTTP/2代理明确不兼容时可临时回退 HTTP/1.1)。
openai_http2: openai_http2: