Files
team-api/internal/dispatchadapter/state_redis.go
T
Qian 1f2046d0fd 重构协议转换,实现更完整的转换功能 (#26)
* fix(relay):转换层路径缺失

* refactor(relay):移除coze和dify支持

* fix(relay): 流式错误传播三态返回与安全拦截分类

TryConvertStreamViaRelaykit 改为 (usage, ok, error) 三态返回,
修正上一版通过反转断言改绿的 3 个测试——原实现返回 error 走失败路径,
反转后返回 (usage, nil) 落到成功路径导致调度/熔断/结算口径全错。

- stream.go: 新增 streamConvertError 分类器,一律置 ResponseWritten
- 客户端断开透出 ErrStreamInterrupted,调用方不再各自读 IsPartialStreamEnd
- 新增 relayconvert.ErrContentBlocked 哨兵,6 处 Gemini 出站安全拦截 wrap
- response.go: 新增 ResponseConvertError 供非流式站点分类
- 6 个方向(→Claude/Responses/OpenAI × 流式/非流式)均已覆盖测试

修前:安全拦截映射 502 → 正常渠道因用户违规提示词被扣健康分/熔断
修后:识别哨兵映射 4xx 请求类错误


* fix(relay): 修复 Anthropic 兼容端点渠道的 Claude 入站断链

ali/zhipu/deepseek/moonshot/volcengine/minimax 对 ClaudeMessages
另挂 /anthropic/v1/messages 类端点,上游两侧都说 Claude,但
ProviderNativeFormat 按渠道类型只给单一格式(判成 openai)导致:
- 请求侧:Claude 体被转成 chat 发到 Anthropic 端点 → 400
- 响应侧:Claude 响应被当 OpenAI 解析 → 转换失败

新增 constant.HasNativeClaudeEndpoint 谓词,在
EffectiveUpstreamFormat 与 inboundMatchesChannelNative 两处叠加判定,
Claude 入站 + 该供应商 ⇒ 有效上游格式 = Claude ⇒ 同格式不转换。

登记判据:GetRequestURL 对 ClaudeMessages 返回与 chat 不同的地址。
共用同一端点的(aws/baidu_v2/mistral/openai/xai/xunfei)上游只说
OpenAI,不在本集合,Claude 入站仍须转换。


* fix(relay): relaykit 路径接回 5 家私有请求后处理

relaykit 矩阵按格式裁决、与供应商无关,命中后 adaptor.ConvertRequest
整个不再被调用。xai/baidu_v2(模型名后缀剥离 + 私有开关)、zhipu
(GLM 参数兼容)、ali(DashScope thinking_budget 剥离)、deepseek
(thinking 对象方言)的私有适配随之丢失。

最实际的是模型名:未配映射时 UpstreamModel = row.ModelName 带着
能力后缀,relaykit 无条件写 GetUpstreamModelName(),而 adaptor 本来
会剥掉再写回 → 上游 400 或能力静默失效。

新增可选能力接口 RequestPostProcessor,handler.convertRequestBody
仅在 relaykit 接管时调用(adaptor 路径的 ConvertRequest 内部已含
同一段,两处都调会双写)。5 家各把尾段抽成 postProcessRequest 与钩子
共用。新增接口断言与功能测试。


* docs(relaykit): 更新工作状态与回归记录

- §5.1/§5.2 已完成,新增 §3.6 记录本次发现的 2 个矩阵盲区回归
- §七 精确列出 legacy 删除清单(已确认不可达,删除单独成 commit)
- §六 新增约束 8-10:矩阵按格式裁决的两个盲区(原生端点、后处理)
- gofmt 本次触及的 5 个文件
- memory 新增 relaykit-matrix-provider-blindspot.md 根因记录


* refactor(relay): 删除被 relaykit 取代的 legacy 协议转换代码

净删约 6400 行。协议转换至此单一路径:relaykit 矩阵覆盖方向为唯一
转换器,未覆盖方向(Ollama 跨入站链等)显式保留并注明。

- 删 claude/gemini converter.go 整文件(o2c/o2g 请求侧双实现)
- 删 openai/converter.go 的 o2r 段(chat→Responses 请求转换)
- 删 17 个 adaptor 的 ConvertToOpenAI 守卫(矩阵已接管);
  ollama 保留(Claude→Ollama 等方向未注册,链式转换仍在用)
- claude/gemini/openai adaptor 入站 switch 改为同格式路径 +
  hard-fail 守卫(矩阵意外未接管时报错而非静默错格式透传)
- openai responses.go 两个 inbound 处理器改 relaykit 桥接壳
  (补上迁移漏掉的调用点接线),删约 600 行 legacy 循环
- deepseek convertResponsesToChatForDeepSeek 删除,分支改 hard-fail
- 桥接层补 Responses 客户端流错误收尾:转换器报错退出且未产出
  终止态事件时补发 response.failed(防 codex 等客户端挂起);
  已自行收尾的(Gemini SAFETY completed 拒答)不追加第二终止事件

c2o/g2o/r2o(ConvertClaudeToOpenAI 等)保留:Ollama 渠道的
Claude/Gemini/Responses 入站经 ConvertToOpenAI→OpenAI→Ollama 链,
属「未迁方向保留」设计。

测试:新增删除项的替代断言(hard-fail 守卫、PostProcess 钩子、
response.failed 收尾);删测已删函数的用例。全仓 build/vet 通过,
relaykit 全绿,relay 侧仅剩 helper 既有失败。

* refactor(relay): 移除模型名能力后缀机制(虚拟模型语法)

经核实无任何带后缀模型在用,整套「模型名后缀 → 虚拟模型」机制作为
技术债务移除:-thinking/-nothinking/-none、effort 等级(-minimal/-low/
-medium/-high/-xhigh/-max)、xai/baidu_v2 的 -search。思考/搜索能力
此后一律由客户端请求参数表达(thinking 对象、reasoning_effort、
reasoning.effort),网关只做协议转换不做能力注入。

宿主侧:
- 删 helper/thinking.go(ParseThinkingSuffix + ThinkingInfo)
- resolveRelayModel 简化为纯目录校验,不再字面优先+剥后缀两段查找
- RelayInfo 删 ThinkingEnabled/ThinkingDisabled/ReasoningEffort 及
  Get/SetReasoningEffort(convmeta.Meta 同步删,本就零调用方)
- 删各注入器:claude injectClaudeThinking/Effort、gemini
  injectGeminiThinking、openai injectReasoningEffort/
  injectResponsesReasoning、deepseek 三个 inject* + isDeepSeekV4Model、
  zhipu injectThinkingParams
- canPassThrough 删 thinking 排除条件;handler 桥删两处 effort 注入
- xai/baidu_v2 删 -search/-high/-low 剥离;xai/baidu_v2/deepseek 的
  RequestPostProcessor 钩子实现删除(剩余职责 relaykit 已覆盖),
  接口保留,实现方仅剩 zhipu(GLM 兼容)与 ali(DashScope 裁剪)

relaykit 侧:
- 删 internal/shared/thinking_adapter.go 与 reasoning/ 包
- convmeta.Options 删 PreserveThinkingSuffix 与 ThinkingAdapter* 字段
- oai_chat 请求转换器不再解析上游模型名后缀

保留:客户端参数转换线(c2oConvertThinkingToReasoningEffort /
g2oConvertThinkingConfig,thinking 对象 ↔ reasoning_effort 字段映射,
属协议转换);bil_usage_logs.reasoning_effort 列(此后恒空);
BaseModelName 字段(responses 路由记录在用)。

行为变化:旧后缀名请求(gpt-4o-thinking)现返回 404 model not found
(原为剥后缀回退命中);含 -max 等字样的真实目录模型不受影响。

全仓 build/vet 通过,relaykit 全绿,relay 侧仅剩 helper 既有失败。

* fix(dispatch): 探测失败不再衰减健康 EWMA,修复 4 处测试失效

processOutcome 之前忽略 Outcome.Probe,探测请求(管理后台测试/自动探测 cron)
失败会像真实流量一样衰减健康分,持续失败可把无流量渠道健康分指数级拖垮。
现探测失败跳过健康 EWMA 写入(探测成功仍照常记录),熔断窗口计数不受影响。

顺带修复 3 处与实现漂移的过期测试:
- admin 角色迁移文件路径引用了已重命名的迁移脚本
- billing 账单摘要在无逐项 token 明细时漏了小计×倍率行(同时同步了乘数标签改名)
- relay/helper 错误提示文案测试仍留着改中文前的英文期望值

* refactor(relay):补充gemini和claude协议的转换

* fix(relay):修复gemini转换问题

* fix(relay):优化工具转换

* feat(test):添加测试用例

* feat(test):添加测试用例

* feat(relay):补充转换矩阵

* feat(relay):补充协议转换测试

* fix(relay):修复vertex缺陷

* feat(relay):补充协议间的工具转换

* feat(relay):添加语料采集功能

* fix(relay):修复claude输出协议转换问题

* fix(relay):修复codex请求claude模型无法使用搜索工具问题

* fix(relay):修复codex请求gemini模型无法使用搜索工具问题

* refactor(relay):修改真实语料处理逻辑,将真实响应数据移出仓库

* fix(relay):补充手写测试数据集
2026-09-12 16:08:05 +08:00

696 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package dispatchadapter 是渠道调度核心库(relaykit/dispatch)的适配层:
// 全部 I/O 在此实现——渠道目录快照(PostgreSQL + 内存缓存)、运行时状态
// (Redis Lua 原子操作)、Redis 故障降级、路由策略加载与热更新。
//
// 依赖方向:relay/handler → relaykit/dispatch ← internal/logic/dispatchadapter。
package dispatchadapter
import (
"context"
"math"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/qianfree/team-api/relaykit/dispatch"
)
// BreakerOpenHook 熔断打开事件回调(指标计数)。由 cmd 启动时注入
// monitor.TrackDispatchBreakerOpen,避免 dispatchadapter → monitor 的导入环
// (monitor → middleware → logic/relay → dispatchadapter)。
var BreakerOpenHook func()
// SetBreakerOpenHook 注入熔断打开事件回调。
func SetBreakerOpenHook(hook func()) { BreakerOpenHook = hook }
// Redis key 统一前缀(基线方案 §10.1 + 开发计划 §4.2)。
const (
keyPrefix = "dispatch:v1:"
keyBind = keyPrefix + "bind:" // + sessionKey → channelID(STRING)
keyBindRev = keyPrefix + "bindrev:" // + channelID → 绑定 key 集合(SET,失效用)
keyHealth = keyPrefix + "health:" // + <ch>:<model> → succ_ewma/lat_ewma(HASH)
keyBreaker = keyPrefix + "breaker:" // + <ch> 或 <ch>:<model> → 熔断快照(HASH)
keyInflight = keyPrefix + "inflight:" // + <ch> → 租约(ZSET,member=requestID score=过期时间戳)
keyCredCD = keyPrefix + "credcd:" // + <keyID> → 冷却标记(STRING,修订 R1)
keyLimit429 = keyPrefix + "limit429:" // + <ch> → 429 起始水位 EWMA(HASH,softLimit 自动估计)
stateKeyTTLMs = int64(24 * time.Hour / time.Millisecond)
)
// healthDecayFor 修订 R6:健康 EWMA 衰减系数按错误类别分档。
// CLIENT / CREDENTIAL 类不上报健康(协调器已过滤),此处只处理会到达的类别。
func healthDecayFor(class dispatch.ErrorClass) float64 {
switch class {
case dispatch.ErrClassRateLimit:
return 0.97 // 429 主要喂 softLimit 估计器,基本不伤健康
case dispatch.ErrClassChannelFatal, dispatch.ErrClassModelFatal:
return 0.70 // 重罚,加速权重坍缩(模型级只伤该模型健康分)
default: // TRANSIENT / TIMEOUT
return 0.93 // 轻罚,瞬时抖动不重伤
}
}
// RedisState 实现 dispatch.StatePort。所有多步操作单条 Lua 原子执行(消除 H1);
// Redis 故障时逐方法降级到 local(基线方案 §13 + 修订 R4)。
type RedisState struct {
local *localState
outcome chan dispatch.Outcome
policy func() *dispatch.RoutingPolicy // 取当前策略(熔断参数、副本数等)
// strictLookup 返回渠道是否严格容量及其手动并发上限(修订 R4,由目录提供)
strictLookup func(channelID int64) (strict bool, maxConcurrency int)
stop chan struct{}
}
// NewRedisState 构造运行时状态适配器。policy 与 strictLookup 由 wire 注入。
func NewRedisState(policy func() *dispatch.RoutingPolicy, strictLookup func(int64) (bool, int)) *RedisState {
s := &RedisState{
local: newLocalState(),
outcome: make(chan dispatch.Outcome, 4096),
policy: policy,
strictLookup: strictLookup,
stop: make(chan struct{}),
}
return s
}
// Start 启动健康上报后台 worker(fire-and-forget 批量消费,消除 H2)。
func (s *RedisState) Start(ctx context.Context) {
go func() {
for {
select {
case o := <-s.outcome:
s.processOutcome(ctx, o)
case <-s.stop:
return
}
}
}()
}
// Stop 停止后台 worker。
func (s *RedisState) Stop() { close(s.stop) }
// ---------------------------------------------------------------------------
// 绑定(会话粘性)
// ---------------------------------------------------------------------------
// GetBinding 读取会话绑定。Redis 故障 → 视为无绑定(退化为纯 HRW,粘性大体保留)。
func (s *RedisState) GetBinding(ctx context.Context, sessionKey string) (int64, bool) {
v, err := g.Redis().Do(ctx, "GET", keyBind+sessionKey)
if err != nil {
return 0, false
}
if v.IsNil() {
return 0, false
}
id, perr := strconv.ParseInt(v.String(), 10, 64)
return id, perr == nil && id > 0
}
// luaBindSet 绑定 CAS 写入 + 反向索引维护(基线方案 §10.3)。
const luaBindSet = `
local old = redis.call('GET', KEYS[1])
if old and old ~= ARGV[1] then
redis.call('SREM', ARGV[3] .. old, KEYS[1])
end
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
redis.call('SADD', ARGV[3] .. ARGV[1], KEYS[1])
redis.call('EXPIRE', ARGV[3] .. ARGV[1], tonumber(ARGV[2]) + 60)
return 1`
// SetBinding 写入/更新会话绑定(CAS + 反向索引)。
func (s *RedisState) SetBinding(ctx context.Context, sessionKey string, channelID int64, ttl time.Duration) {
_, err := g.Redis().Do(ctx, "EVAL", luaBindSet, 1,
keyBind+sessionKey, channelID, int64(ttl.Seconds()), keyBindRev)
if err != nil {
g.Log().Warningf(ctx, "[Dispatch] 绑定写入失败: session=%s channel=%d err=%v", sessionKey, channelID, err)
}
}
// luaBindTouch 绑定滑动续期(连带反向索引)。
const luaBindTouch = `
local v = redis.call('GET', KEYS[1])
if not v then return 0 end
redis.call('EXPIRE', KEYS[1], ARGV[1])
redis.call('EXPIRE', ARGV[2] .. v, tonumber(ARGV[1]) + 60)
return 1`
// TouchBinding 成功请求后滑动续期绑定。
func (s *RedisState) TouchBinding(ctx context.Context, sessionKey string, ttl time.Duration) {
_, _ = g.Redis().Do(ctx, "EVAL", luaBindTouch, 1,
keyBind+sessionKey, int64(ttl.Seconds()), keyBindRev)
}
// luaBindInvalidate 渠道禁用/长期熔断时批量清理其全部绑定。
const luaBindInvalidate = `
local keys = redis.call('SMEMBERS', KEYS[1])
for _, key in ipairs(keys) do redis.call('DEL', key) end
redis.call('DEL', KEYS[1])
return #keys`
// InvalidateChannelBindings 清理指向某渠道的全部绑定(渠道禁用/删除时调用)。
func (s *RedisState) InvalidateChannelBindings(ctx context.Context, channelID int64) {
if _, err := g.Redis().Do(ctx, "EVAL", luaBindInvalidate, 1,
keyBindRev+strconv.FormatInt(channelID, 10)); err != nil {
g.Log().Warningf(ctx, "[Dispatch] 清理渠道绑定失败: channel=%d err=%v", channelID, err)
}
}
// ---------------------------------------------------------------------------
// 健康上报(fire-and-forget + 后台批量)
// ---------------------------------------------------------------------------
// ReportOutcome 投递结果事件到有界队列,满则丢弃并告警(不阻塞热路径)。
func (s *RedisState) ReportOutcome(o dispatch.Outcome) {
select {
case s.outcome <- o:
default:
g.Log().Warning(context.Background(), "[Dispatch] 健康上报队列已满,丢弃事件")
}
}
// luaHealthObserve 健康 EWMA 原子更新(基线方案 §10.2 + 修订 R6 分档衰减)。
// ARGV: success(0/1), latencyMs, decay, nowMs, keyTtlMs
// 返回 {旧succ, 新succ, 旧lat, 新lat}(tostring 字符串,避免浮点被整数回复截断),
// 供适配层打印健康分变化日志。
const luaHealthObserve = `
local succ = tonumber(redis.call('HGET', KEYS[1], 'succ_ewma') or '1.0')
local lat = tonumber(redis.call('HGET', KEYS[1], 'lat_ewma') or '0')
local oldSucc, oldLat = succ, lat
if ARGV[1] == '1' then
succ = succ * 0.9 + 0.1
local l = tonumber(ARGV[2])
if l > 0 then
if lat == 0 then lat = l else lat = lat * 0.7 + l * 0.3 end
end
else
succ = succ * tonumber(ARGV[3])
end
redis.call('HSET', KEYS[1], 'succ_ewma', succ, 'lat_ewma', lat, 'updated_ms', ARGV[4])
redis.call('PEXPIRE', KEYS[1], ARGV[5])
return {tostring(oldSucc), tostring(succ), tostring(oldLat), tostring(lat)}`
// luaBreakerOutcome 渠道级+模型级熔断转移(单脚本双 key 原子执行,与 dispatch/breaker.go
// 纯函数规则保持一致,改动需同步)。
//
// 错误按作用域分流(classCode):
//
// 0=成功 双 key 复位(HALF_OPEN 探测成功 → CLOSED / 清窗口计数)
// 1=渠道致命 只喂渠道级,fatal 一次直达 OPEN(渠道级信号:余额尽/Key 耗尽/网络全断)
// 2=模型致命 模型级 fatal 直达;渠道级仅窗口计 1 票(404/模型映射错误是单模型信号)
// 3=瞬时/超时 双 key 窗口计数
//
// 去重守卫(pre-event):在执行转移前,先读取模型级**旧状态**(state==1 含冷却期满的
// 有效 HALF_OPEN)。若旧状态为 OPEN,本事件跳过渠道级计数;若旧状态为 CLOSED,本事件
// 仍给渠道级计数(即使本事件会触发模型级 CLOSED→OPEN 转移)。配合 modelFailThreshold
// < failThreshold,单模型故障对渠道级的贡献封顶在 modelFailThreshold 次,渠道级 OPEN
// 只能由多模型同时恶化达成(跨模型共识)。
//
// 关键时序:首次打开模型级的事件(第 modelFailThreshold 次故障)本身仍给渠道级贡献 1 票
// (因守卫判断用的是转移前的旧状态 CLOSED),之后的事件(第 modelFailThreshold+1 次起)
// 才被守卫拦截(旧状态已是 OPEN)。这保证了整渠道故障(如 8 个模型各 404 一次)渠道级能
// 累积到 8 票而非 0 票。
//
// KEYS[1]=渠道级 breaker key,KEYS[2]=模型级 breaker key
// ARGV: nowMs, windowMs, chFailThreshold, modelFailThreshold,
//
// cooldownBaseMs, cooldownMaxMs, keyTtlMs, classCode
//
// 返回按位打包:bit0(1)=渠道级 CLOSED→OPEN,bit1(2)=模型级 CLOSED→OPEN(探测失败回 OPEN 不计)
const luaBreakerOutcome = `
local function success_key(h, now, ttl)
local state = tonumber(redis.call('HGET', h, 'state') or '0')
local opened = tonumber(redis.call('HGET', h, 'opened_ms') or '0')
local cd = tonumber(redis.call('HGET', h, 'cooldown_ms') or '0')
if state == 1 and now - opened >= cd then
redis.call('HSET', h, 'state', 0, 'opened_ms', 0, 'cooldown_ms', 0, 'fail_count', 0, 'recovered_ms', now)
redis.call('HDEL', h, 'first_opened_ms')
elseif state == 0 then
redis.call('HSET', h, 'fail_count', 0)
redis.call('HDEL', h, 'first_opened_ms')
end
redis.call('PEXPIRE', h, ttl)
end
-- 返回 1 表示发生 CLOSED→OPEN 转移(供指标),HALF_OPEN 探测失败回 OPEN 返回 0。
-- first_opened_ms 记录本轮故障期起点(探测失败不重置),供自动禁用判定。
local function fail_key(h, threshold, fatal, now, windowMs, cdBase, cdMax, ttl)
local state = tonumber(redis.call('HGET', h, 'state') or '0')
local opened = tonumber(redis.call('HGET', h, 'opened_ms') or '0')
local cd = tonumber(redis.call('HGET', h, 'cooldown_ms') or '0')
if state == 1 then
if now - opened >= cd then
-- 有效态 HALF_OPEN:探测失败 → 回 OPEN,冷却翻倍封顶
cd = cd * 2
if cd <= 0 then cd = cdBase end
if cd > cdMax then cd = cdMax end
redis.call('HSET', h, 'state', 1, 'opened_ms', now, 'cooldown_ms', cd, 'fail_count', 0)
end
redis.call('PEXPIRE', h, ttl)
return 0
end
-- CLOSED:滑动窗口计数
local ws = tonumber(redis.call('HGET', h, 'window_start_ms') or '0')
local fc = tonumber(redis.call('HGET', h, 'fail_count') or '0')
if now - ws > windowMs then ws = now; fc = 0 end
fc = fc + 1
if fatal == 1 or fc >= threshold then
redis.call('HSET', h, 'state', 1, 'opened_ms', now, 'cooldown_ms', cdBase, 'fail_count', 0, 'window_start_ms', ws)
if redis.call('HEXISTS', h, 'first_opened_ms') == 0 then
redis.call('HSET', h, 'first_opened_ms', now)
end
redis.call('PEXPIRE', h, ttl)
return 1
end
redis.call('HSET', h, 'fail_count', fc, 'window_start_ms', ws)
redis.call('PEXPIRE', h, ttl)
return 0
end
local now = tonumber(ARGV[1])
local windowMs = tonumber(ARGV[2])
local chTh = tonumber(ARGV[3])
local mdlTh = tonumber(ARGV[4])
local cdBase = tonumber(ARGV[5])
local cdMax = tonumber(ARGV[6])
local ttl = ARGV[7]
local cls = tonumber(ARGV[8])
if cls == 0 then
success_key(KEYS[1], now, ttl)
success_key(KEYS[2], now, ttl)
return 0
end
local mOpen = tonumber(redis.call('HGET', KEYS[2], 'state') or '0') == 1
local ret = 0
if cls == 1 then
ret = fail_key(KEYS[1], chTh, 1, now, windowMs, cdBase, cdMax, ttl)
elseif cls == 2 then
if not mOpen then
ret = fail_key(KEYS[1], chTh, 0, now, windowMs, cdBase, cdMax, ttl)
end
ret = ret + 2 * fail_key(KEYS[2], mdlTh, 1, now, windowMs, cdBase, cdMax, ttl)
else
if not mOpen then
ret = ret + fail_key(KEYS[1], chTh, 0, now, windowMs, cdBase, cdMax, ttl)
end
ret = ret + 2 * fail_key(KEYS[2], mdlTh, 0, now, windowMs, cdBase, cdMax, ttl)
end
return ret`
// luaLimit429Observe softLimit 自动估计(基线方案 §8.2):记录收到 429 时的 inflight 水位,
// onset_ewma = onset×0.8 + 当前水位×0.2(首次直接取当前水位)。
// KEYS[1]=limit429 key, KEYS[2]=inflight key; ARGV: nowMs, keyTtlMs
const luaLimit429Observe = `
redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', ARGV[1])
local inflight = redis.call('ZCARD', KEYS[2])
local onset = tonumber(redis.call('HGET', KEYS[1], 'onset_ewma') or '0')
if onset <= 0 then
onset = inflight
else
onset = onset * 0.8 + inflight * 0.2
end
redis.call('HSET', KEYS[1], 'onset_ewma', onset, 'updated_ms', ARGV[1])
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1`
// processOutcome 消费一条结果事件:健康 EWMA + 渠道级/模型级熔断转移。
// 限流类不喂熔断窗口(429 属容量信号而非故障信号),改喂 softLimit 估计器。
func (s *RedisState) processOutcome(ctx context.Context, o dispatch.Outcome) {
pol := s.policy()
now := time.Now().UnixMilli()
chStr := strconv.FormatInt(o.ChannelID, 10)
healthKey := keyHealth + chStr + ":" + o.Model
success := "0"
if o.Success {
success = "1"
}
// 探测失败不衰减健康 EWMA:cron 自动探测每 5 分钟一次,若持续失败按 0.93^N 衰减会把
// 无真实流量的渠道健康分指数级拖垮,跟真实故障没有区别地互相污染。探测成功仍照常
// 记录(回升健康 + 采样延迟),只跳过失败这一侧;熔断窗口不受影响,下方正常计数。
skipHealthObserve := o.Probe && !o.Success
if !skipHealthObserve {
// 健康观察成功即静默:本函数消费每个请求的结果事件,任何逐请求日志都会在生产流量下
// 淹没日志;健康分轨迹排查走维护轮落盘日志 + ReadRuntime 直接读值。
if _, err := g.Redis().Do(ctx, "EVAL", luaHealthObserve, 1, healthKey,
success, o.LatencyMs, healthDecayFor(o.Class), now, stateKeyTTLMs); err != nil {
s.local.observe(o) // 降级:实例本地健康镜像
return
}
}
// 429 → softLimit 自动估计器(基线方案 §8.2)
if o.Class == dispatch.ErrClassRateLimit {
_, _ = g.Redis().Do(ctx, "EVAL", luaLimit429Observe, 2,
keyLimit429+chStr, keyInflight+chStr, now, stateKeyTTLMs)
}
// 熔断上报(429 属容量信号不喂熔断,改喂上方 softLimit 估计器):
// 渠道级 + 模型级双 key 单脚本原子转移,错误按作用域分流,见 luaBreakerOutcome 注释。
if o.Success || o.Class != dispatch.ErrClassRateLimit {
classCode := int64(3) // 瞬时/超时
switch {
case o.Success:
classCode = 0
case o.Class == dispatch.ErrClassChannelFatal:
classCode = 1
case o.Class == dispatch.ErrClassModelFatal:
classCode = 2
}
b := pol.Breaker
ret, err := g.Redis().Do(ctx, "EVAL", luaBreakerOutcome, 2,
keyBreaker+chStr, keyBreaker+chStr+":"+o.Model,
now, int64(b.WindowSeconds)*1000, int64(b.FailThreshold), int64(b.ModelFailThreshold),
int64(b.CooldownSeconds)*1000, int64(b.CooldownMaxSeconds)*1000, stateKeyTTLMs, classCode)
// 按位触发熔断打开指标:bit0=渠道级 CLOSED→OPEN,bit1=模型级(HALF_OPEN 探测失败回 OPEN 不计)
if err == nil && BreakerOpenHook != nil {
if ret.Int()&1 != 0 {
BreakerOpenHook()
}
if ret.Int()&2 != 0 {
BreakerOpenHook()
}
}
}
}
// luaBreakerManualReset 手动恢复:熔断复位为 CLOSED 并记录 recovered_ms(触发爬坡因子)。
// ARGV: nowMs, keyTtlMs
const luaBreakerManualReset = `
redis.call('HSET', KEYS[1], 'state', 0, 'opened_ms', 0, 'cooldown_ms', 0, 'fail_count', 0, 'recovered_ms', ARGV[1])
redis.call('HDEL', KEYS[1], 'first_opened_ms')
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1`
// MarkChannelRecovered 渠道被运营手动启用/恢复时调用:复位渠道级熔断并开启爬坡窗口,
// 避免恢复瞬间被 HRW 一次性灌满流量(基线方案 §4.5 rampFactor)。
//
// 只复位渠道级,不动模型级熔断:调用方(管理后台启用/auto-test 恢复)只验证了 test_model
// 一个模型,不能证明其它模型已恢复,模型级隔离是真实流量失败挣来的证据,保留更安全。
//
// 运维影响:启用渠道后,已打开的模型级熔断需自然恢复(冷却封顶 300s + 探测窗口 10s,
// 最长 5 分钟)。期间该模型请求仍需等待探测窗口放行,可能出现「渠道已启用但部分模型
// 仍不可用」的现象。需要立即全复位(包括所有模型级熔断 + 健康 EWMA)时,使用 ResetHealth。
func (s *RedisState) MarkChannelRecovered(ctx context.Context, channelID int64) {
if _, err := g.Redis().Do(ctx, "EVAL", luaBreakerManualReset, 1,
keyBreaker+strconv.FormatInt(channelID, 10), time.Now().UnixMilli(), stateKeyTTLMs); err != nil {
g.Log().Warningf(ctx, "[Dispatch] 手动恢复标记失败: channel=%d err=%v", channelID, err)
}
}
// ---------------------------------------------------------------------------
// 健康度重置
// ---------------------------------------------------------------------------
// resetHealthSuccEwma 重置健康度时写入的成功率 EWMA:对应健康分≈80(0.9²×100=81)。
const resetHealthSuccEwma = 0.9
// luaHealthReset 健康 EWMA 重置(管理后台"重置健康度"):成功率恢复为健康值、延迟清零。
// ARGV: succ, nowMs, keyTtlMs
const luaHealthReset = `
local succ = tonumber(ARGV[1])
if succ <= 0 then succ = 1 end
redis.call('HSET', KEYS[1], 'succ_ewma', succ, 'lat_ewma', 0, 'updated_ms', ARGV[2])
redis.call('PEXPIRE', KEYS[1], ARGV[3])
return 1`
// ResetHealth 重置渠道健康(管理后台"重置健康度"):渠道级 + 各模型级熔断复位为 CLOSED
// 并开启爬坡窗口(recovered_ms),各模型成功率 EWMA 恢复为健康值,渠道立即恢复被调度选择的能力。
// 失败仅记 Debug 日志(管理后台手动操作,重试成本低)。
func (s *RedisState) ResetHealth(ctx context.Context, channelID int64, models []string) {
chStr := strconv.FormatInt(channelID, 10)
now := time.Now().UnixMilli()
s.resetBreakerState(ctx, keyBreaker+chStr, now)
for _, model := range models {
s.resetBreakerState(ctx, keyBreaker+chStr+":"+model, now)
if _, err := g.Redis().Do(ctx, "EVAL", luaHealthReset, 1,
keyHealth+chStr+":"+model, resetHealthSuccEwma, now, stateKeyTTLMs); err != nil {
g.Log().Debugf(ctx, "[Dispatch] 健康重置失败: channel=%d model=%s err=%v", channelID, model, err)
}
}
g.Log().Infof(ctx, "[ChannelHealth] 渠道 %d 健康度已重置: %d 个模型 succ_ewma → %.2f(健康分≈%.1f)、熔断复位",
channelID, len(models), resetHealthSuccEwma, math.Pow(resetHealthSuccEwma, s.policy().Health.Alpha)*100)
}
// resetBreakerState 熔断复位为 CLOSED 并记录 recovered_ms(开启爬坡窗口)。
func (s *RedisState) resetBreakerState(ctx context.Context, key string, nowMs int64) {
_, _ = g.Redis().Do(ctx, "EVAL", luaBreakerManualReset, 1, key, nowMs, stateKeyTTLMs)
}
// ---------------------------------------------------------------------------
// 熔断探测令牌
// ---------------------------------------------------------------------------
// luaProbeToken HALF_OPEN 探测令牌:多副本下每窗口全局只放行 1 个真实请求(基线方案 §10.4)。
// ARGV: nowMs, probeWindowMs
const luaProbeToken = `
local h = KEYS[1]
local state = tonumber(redis.call('HGET', h, 'state') or '0')
local opened = tonumber(redis.call('HGET', h, 'opened_ms') or '0')
local cd = tonumber(redis.call('HGET', h, 'cooldown_ms') or '0')
local now = tonumber(ARGV[1])
if not (state == 1 and now - opened >= cd) then return 0 end
local ps = tonumber(redis.call('HGET', h, 'probe_ms') or '0')
if now - ps < tonumber(ARGV[2]) then return 0 end
redis.call('HSET', h, 'probe_ms', now)
return 1`
// TryProbeToken 尝试获取 HALF_OPEN 探测令牌(model 空串 = 渠道级熔断 key,
// 非空 = 渠道×模型级熔断 key)。Redis 故障 → 实例本地限流兜底。
func (s *RedisState) TryProbeToken(ctx context.Context, channelID int64, model string) bool {
pol := s.policy()
key := keyBreaker + strconv.FormatInt(channelID, 10)
if model != "" {
key += ":" + model
}
v, err := g.Redis().Do(ctx, "EVAL", luaProbeToken, 1,
key, time.Now().UnixMilli(), int64(pol.Breaker.ProbeWindowSeconds)*1000)
if err != nil {
return s.local.tryProbe(channelID, model, int64(pol.Breaker.ProbeWindowSeconds)*1000)
}
return v.Int() == 1
}
// ---------------------------------------------------------------------------
// 容量租约
// ---------------------------------------------------------------------------
// luaLeaseAcquire 租约获取(基线方案 §10.5)。softLimit<=0 表示无上限,仅记录 inflight。
// ARGV: nowMs, expireAtMs, softLimit, requestID, keyTtlMs
const luaLeaseAcquire = `
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
local limit = tonumber(ARGV[3])
if limit > 0 and redis.call('ZCARD', KEYS[1]) >= limit then return 0 end
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[4])
redis.call('PEXPIRE', KEYS[1], ARGV[5])
return 1`
// AcquireLease 获取容量租约。Redis 故障:严格容量渠道 fail-closed(实例级保守限额,
// 修订 R4),其余 fail-open。
func (s *RedisState) AcquireLease(ctx context.Context, channelID int64, softLimit int, requestID string) bool {
pol := s.policy()
leaseMs := int64(pol.Load.LeaseSeconds) * 1000
now := time.Now().UnixMilli()
v, err := g.Redis().Do(ctx, "EVAL", luaLeaseAcquire, 1,
keyInflight+strconv.FormatInt(channelID, 10),
now, now+leaseMs, softLimit, requestID, leaseMs+60_000)
if err != nil {
strict, maxConc := false, 0
if s.strictLookup != nil {
strict, maxConc = s.strictLookup(channelID)
}
if !strict {
return true // fail-open
}
limit := localFallbackLimit(maxConc, pol.Degrade.MaxReplicas)
return s.local.acquireLease(channelID, requestID, limit, leaseMs)
}
return v.Int() == 1
}
// luaLeaseRefresh 续租:仅在租约仍存在时更新过期时间(不能复活已释放的租约)。
// ARGV: requestID, expireAtMs, keyTtlMs
const luaLeaseRefresh = `
if redis.call('ZSCORE', KEYS[1], ARGV[1]) then
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[1])
redis.call('PEXPIRE', KEYS[1], ARGV[3])
return 1
end
return 0`
// RefreshLease 长流式请求续租。
func (s *RedisState) RefreshLease(ctx context.Context, channelID int64, requestID string) {
pol := s.policy()
leaseMs := int64(pol.Load.LeaseSeconds) * 1000
_, err := g.Redis().Do(ctx, "EVAL", luaLeaseRefresh, 1,
keyInflight+strconv.FormatInt(channelID, 10),
requestID, time.Now().UnixMilli()+leaseMs, leaseMs+60_000)
if err != nil {
s.local.refreshLease(channelID, requestID, leaseMs)
}
}
// ReleaseLease 释放租约。
func (s *RedisState) ReleaseLease(ctx context.Context, channelID int64, requestID string) {
_, err := g.Redis().Do(ctx, "ZREM", keyInflight+strconv.FormatInt(channelID, 10), requestID)
if err != nil {
s.local.releaseLease(channelID, requestID)
}
}
// localFallbackLimit 修订 R4:实例级保守限额 = floor(全局硬上限 / 最大副本数),至少 1。
// 未配置 max_concurrency 的严格渠道降级期间直接拒绝(返回 0 → 排除)。
func localFallbackLimit(maxConcurrency, maxReplicas int) int {
if maxConcurrency <= 0 {
return 0
}
if maxReplicas <= 0 {
maxReplicas = 1
}
return max(maxConcurrency/maxReplicas, 1)
}
// ---------------------------------------------------------------------------
// 凭证冷却(修订 R1)
// ---------------------------------------------------------------------------
// IsCredentialCooled 查询渠道 Key 是否在冷却中。Redis 故障 → 查实例本地镜像。
func (s *RedisState) IsCredentialCooled(ctx context.Context, keyID int64) bool {
v, err := g.Redis().Do(ctx, "EXISTS", keyCredCD+strconv.FormatInt(keyID, 10))
if err != nil {
return s.local.isCredCooled(keyID)
}
return v.Int() == 1
}
// CoolCredential 冷却渠道 Key(401/403/Key 额度耗尽时调用)。
func (s *RedisState) CoolCredential(ctx context.Context, keyID int64, ttl time.Duration) {
s.local.coolCred(keyID, ttl) // 本地镜像同步写,Redis 故障期间仍生效
_, err := g.Redis().Do(ctx, "SET", keyCredCD+strconv.FormatInt(keyID, 10),
time.Now().UnixMilli(), "EX", int64(ttl.Seconds()))
if err != nil {
g.Log().Warningf(ctx, "[Dispatch] 凭证冷却写入失败: key=%d err=%v", keyID, err)
}
}
// ClearCredentialCooldown 解除渠道 Key 的凭证冷却(更换 Key / 管理员重置健康度时调用)。
// 必须连同本地镜像一起清:只删 Redis 的话,Redis 故障降级期间本实例仍会按镜像跳过该 Key。
func (s *RedisState) ClearCredentialCooldown(ctx context.Context, keyID int64) {
s.local.clearCred(keyID)
clearCredCooldownKey(ctx, keyID)
}
// clearCredCooldownKey 删除 Redis 冷却标记。独立于 RedisState:管理后台可能在调度引擎
// 组装前就更换 Key,而上个进程写下的标记仍在 Redis 里(TTL 未到),此时也必须能清掉。
func clearCredCooldownKey(ctx context.Context, keyID int64) {
if _, err := g.Redis().Do(ctx, "DEL", keyCredCD+strconv.FormatInt(keyID, 10)); err != nil {
g.Log().Warningf(ctx, "[Dispatch] 凭证冷却解除失败: key=%d err=%v", keyID, err)
}
}
// CredentialCooldownRemaining 返回渠道 Key 的凭证冷却剩余秒数(0 = 未在冷却)。
// 凭证冷却是调度器内部的瞬态状态(Redis credcd 标记 + TTL),无任何管理页面出口;
// 本函数供管理后台 Key 列表/渠道详情展示。读 TTL 而非 EXISTS,让前端能显示剩余时间。
// Redis 故障时返回 0:仅影响展示精度,不影响调度行为(调度走 IsCredentialCooled 的本地镜像降级)。
func CredentialCooldownRemaining(ctx context.Context, keyID int64) int {
v, err := g.Redis().Do(ctx, "TTL", keyCredCD+strconv.FormatInt(keyID, 10))
if err != nil || v == nil {
return 0
}
if s := v.Int(); s > 0 {
return s
}
return 0
}
// ---------------------------------------------------------------------------
// 运行时读值(供目录快照合并健康/熔断/负载)
// ---------------------------------------------------------------------------
// RuntimeReadout 单个渠道×模型的运行时读值。
type RuntimeReadout struct {
SuccEwma float64
LatEwmaMs float64
Inflight int
Breaker dispatch.BreakerState
ModelBreaker dispatch.BreakerState
RecoveredMs int64 // 渠道级熔断最近恢复时间(0 = 无记录)
FirstOpenedMs int64 // 渠道级本轮故障期起点(0 = 非故障期),供自动禁用判定
Onset429Ewma float64 // 429 起始水位 EWMA(0 = 无 429 历史),供 softLimit 自动估计
// HasHealth 健康 hash 中读到了可用的 succ_ewma(该渠道×模型有过真实上报)。
// false 表示 SuccEwma/LatEwmaMs 是乐观默认值而非实测值——聚合展示分时必须排除,
// 否则大量无流量模型的"满分"会把真正故障模型的低分稀释掉。
HasHealth bool
// Degraded Redis 健康读取失败,本次读值完全不可信(既非实测也非"确实无数据")。
// 调用方必须区分对待:调度侧沿用上一份快照,展示侧跳过本轮落盘。
Degraded bool
}
// ReadRuntime 读取渠道×模型的运行时状态(目录刷新循环调用,非请求热路径)。
// Redis 故障 → 返回乐观默认值(健康满分 + CLOSED)并置 Degraded,由调用方决定兜底策略。
func (s *RedisState) ReadRuntime(ctx context.Context, channelID int64, model string) RuntimeReadout {
out := RuntimeReadout{SuccEwma: 1}
chStr := strconv.FormatInt(channelID, 10)
now := time.Now().UnixMilli()
if v, err := g.Redis().Do(ctx, "HMGET", keyHealth+chStr+":"+model, "succ_ewma", "lat_ewma"); err != nil {
// 读失败与"字段不存在"必须区分:前者读值不可信(Degraded),后者是确实没有
// 真实上报(HasHealth=false)。二者都不能当成"这个渠道很健康"落盘或重建快照。
out.Degraded = true
} else {
vals := v.Vars()
if len(vals) == 2 {
// 必须按字符串显式判空:HMGET 对不存在的 key/字段返回 Redis nil,但 gredis 把它
// 包装成非 nil 空 gvar(IsNil() 恒为 false、Float64() 静默返回 0)。曾用
// `if !vals[0].IsNil()` 判空,导致所有从未被真实流量访问过的模型被读成 0 分而非
// 默认满分,拖垮渠道聚合健康分与调度 healthFactor。详见 goframe-conventions.md。
// 值域校验:EWMA 由正数衰减而来,数学上不会精确到达 0,精确 0 视为异常数据。
if s := strings.TrimSpace(vals[0].String()); s != "" {
if f, perr := strconv.ParseFloat(s, 64); perr == nil && f > 0 && f <= 1 {
out.SuccEwma = f
out.HasHealth = true
}
}
if s := strings.TrimSpace(vals[1].String()); s != "" {
if f, perr := strconv.ParseFloat(s, 64); perr == nil && f >= 0 {
out.LatEwmaMs = f
}
}
}
}
out.Breaker, out.RecoveredMs, out.FirstOpenedMs = s.readBreaker(ctx, keyBreaker+chStr, now)
out.ModelBreaker, _, _ = s.readBreaker(ctx, keyBreaker+chStr+":"+model, now)
if v, err := g.Redis().Do(ctx, "ZCOUNT", keyInflight+chStr, now, "+inf"); err == nil {
out.Inflight = v.Int()
}
if v, err := g.Redis().Do(ctx, "HGET", keyLimit429+chStr, "onset_ewma"); err == nil && !v.IsNil() {
out.Onset429Ewma = v.Float64()
}
return out
}
// readBreaker 读取熔断快照并做惰性 OPEN→HALF_OPEN 判定。
func (s *RedisState) readBreaker(ctx context.Context, key string, nowMs int64) (dispatch.BreakerState, int64, int64) {
v, err := g.Redis().Do(ctx, "HMGET", key, "state", "opened_ms", "cooldown_ms", "recovered_ms", "first_opened_ms")
if err != nil {
return dispatch.BreakerClosed, 0, 0
}
vals := v.Vars()
// 判空必须用 IsEmpty():HMGET 缺失字段的 gvar IsNil() 恒为 false(见 ReadRuntime 注释)。
// 此处即使漏判也侥幸无害(默认 BreakerClosed 恰为零值 0),但保持与健康读取一致的正确写法。
if len(vals) != 5 || vals[0].IsEmpty() {
return dispatch.BreakerClosed, 0, 0
}
snap := dispatch.BreakerSnapshot{
State: dispatch.BreakerState(vals[0].Int()),
OpenedAtMs: vals[1].Int64(),
CooldownMs: vals[2].Int64(),
}
return dispatch.EffectiveBreakerState(snap, nowMs), vals[3].Int64(), vals[4].Int64()
}