Files
team-api/relaykit/dispatch/types.go
T
chengqian 3035fa118c fix(dispatch): 修复 HMGET 判空失效导致冷渠道健康分被读成 0
ReadRuntime 用 `!vals[0].IsNil()` 判断 HMGET 结果是否存在,但 gredis 把 Redis
nil 数组元素包装成非 nil 空 gvar(IsNil() 恒为 false、Float64() 静默返回 0),
导致所有从未被真实流量访问过的渠道×模型被读成健康 0 分而非默认满分 1.0:管理
后台渠道健康度全线坍缩(如某渠道 (1.0+0)/2 → 0.5²×100=25 长期不动),调度侧
healthFactor 同样归零,冷模型路由权重被压死。

GET/HGET 单值回复的 IsNil() 正常,仅数组回复有此坑;仓库内 HMGET 调用点仅
ReadRuntime 与 readBreaker 两处,后者因默认值 BreakerClosed 恰为零值而侥幸
无害,一并改为按字符串判空。

- ReadRuntime:TrimSpace 判空 + ParseFloat 值域校验(succ 取 0<f<=1,EWMA
  由正数衰减而来不会精确到达 0,精确 0 视为异常数据)
- 回归测试:key 不存在须回落默认值、空串/非法值/精确 0 防御
- 通用规则写入 goframe-conventions.md 框架使用错误记录

同时补齐探测与健康分的可观测性(本次排查绕行三次的直接原因是全链路静默):

- [ChannelProbe] 探测全流程:轮次起止、模型与上游映射、结果、是否上报健康
- [ChannelHealth] 健康分变化:EWMA 变化点(Outcome.Probe 区分探测/真实流量
  来源)、快照落盘含各模型 succ 明细、维护轮次覆盖摘要
- 快照落库失败由 Debug 升为 Warning(此前静默,健康分冻结无从定位)
- 探测目标不在调度目录时告警(该场景健康分不落盘且渠道不参与调度)
- 变化类日志按「分数实际移动」触发,稳态降为 Debug,避免生产流量刷屏
2026-08-27 21:59:42 +08:00

166 lines
6.4 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 dispatch 是渠道调度核心库(纯函数 + 端口)。
//
// 本包为纯逻辑库:不做任何 I/O,所有外部状态通过 ports.go 中的端口接口注入。
// relaykit module 不依赖 gf/redis/dao,纯度由模块边界强制。
package dispatch
// Tier 渠道调度层级,固定三档枚举(不采用数值优先级)。
type Tier string
const (
TierPrimary Tier = "primary" // 首选
TierSecondary Tier = "secondary" // 备用
TierReserve Tier = "reserve" // 保底
)
// tierOrder 层级从高到低的固定顺序,用于扩组兜底。
var tierOrder = []Tier{TierPrimary, TierSecondary, TierReserve}
// BreakerState 熔断器状态。
type BreakerState int
const (
BreakerClosed BreakerState = iota // 正常
BreakerOpen // 熔断中,硬排除
BreakerHalfOpen // 探活中,每窗口放行一个真实请求
)
// Replayability 请求可重放性(按 relay mode 静态映射)。
type Replayability uint8
const (
ReplaySafe Replayability = iota // 查询 / embedding / rerank:可安全重放
ReplayCostly // 文本生成:重放有成本但无外部副作用
ReplayUnsafe // 图片/视频生成、异步任务提交:重放可能产生重复任务
)
// DeliveryState 请求送达状态(由执行器在错误发生点标注)。
type DeliveryState uint8
const (
DeliveryNotSent DeliveryState = iota // 连接失败 / 请求体未发出
DeliveryMaybeSent // 写出后连接重置 / 读超时 / EOF,可能已送达上游
DeliveryResponseReceived // 已收到完整错误响应,尚未写给客户端
DeliveryResponseStarted // 已向客户端写出响应字节,禁止任何重试
)
// Channel 候选渠道快照。由 CatalogPort 提供,字段包含调度所需的
// 静态配置与最近一次刷新时的健康/负载读值(快照间隔内的滞后可接受)。
type Channel struct {
ID int64
Name string
Tier Tier
BaseWeight float64 // 运营配置的组内权重(chn_channels.weight)
CostRatio float64 // 成本比例(chn_abilities.cost_ratio),上游实际价/平台基准价,<=0 视为 1.0
SuccEwma float64 // 成功率 EWMA ∈ [0,1],无数据时为 1
LatEwmaMs float64 // 延迟 EWMA(毫秒),无数据时为 0
Inflight int // 当前并发(租约计数)
SoftLimit int // 软并发上限;<=0 表示无容量信息(headroom 因子恒为 1)
Breaker BreakerState // 渠道级熔断状态
ModelBreaker BreakerState // 渠道×模型级熔断状态
RampElapsedMs int64 // 距新启用/熔断恢复的毫秒数;<0 表示不在爬坡期
KeyIDs []int64 // active 状态的渠道 Key ID 列表(按 id 升序);空表示由适配层自行取 Key
StrictCapacity bool // Redis 故障时 fail-closed
// 协议能力(chn_abilities 渠道×模型级),参与 protoFactor 软偏好:
// responses 入站偏好 SupportsResponses 渠道(避免经 chat 转换丢失有状态 Responses 特性),
// chat 入站偏好原生 chat 渠道(ChatViaResponses 桥接渠道降权但仍可用)。
SupportsResponses bool // 模型在该渠道支持 /v1/responses 原生协议
ChatViaResponses bool // 上游仅有 Responses 协议,chat 需经桥接发送
}
// ProtoPreference 入站端点协议偏好(软偏好,只降权不排除)。
type ProtoPreference uint8
const (
ProtoAny ProtoPreference = iota // 非文本生成端点 / 无偏好
ProtoResponses // responses 入站:偏好 SupportsResponses 渠道
ProtoChat // chat 入站:偏好原生 chat 渠道
)
// ScoredChannel 打分后的候选。
type ScoredChannel struct {
Channel
Weight float64
Breakdown WeightBreakdown
}
// WeightBreakdown 权重分解,用于决策日志与 ForwardingTrace。
type WeightBreakdown struct {
Base float64 `json:"base"`
Tier float64 `json:"tier"`
Health float64 `json:"health"`
Headroom float64 `json:"headroom"`
Cost float64 `json:"cost"`
Ramp float64 `json:"ramp"`
Proto float64 `json:"proto"`
Effective float64 `json:"effective"`
}
// RequestProfile 一次请求的调度输入。
type RequestProfile struct {
RequestID string
TenantID int64
UserID int64
APIKeyID int64
Model string
Scope []int64 // 租户渠道范围(空 = 不限制),由适配层填充
Replay Replayability
Signals SessionSignals
// Proto 入站端点协议偏好(ProtoAny=无偏好)。responses 入站软偏好
// SupportsResponses 渠道、chat 入站软偏好原生 chat 渠道,只降权不排除。
Proto ProtoPreference
// Policy 请求级策略覆盖(租户级差异化)。
// nil = 使用协调器全局策略。由适配层解析(全局 + 租户浅合并)后传入。
Policy *RoutingPolicy
}
// DecisionReason 选择原因(对应 SelectionReason,前端渠道日志展示)。
type DecisionReason string
const (
ReasonBind DecisionReason = "bind" // 绑定守卫命中
ReasonHRW DecisionReason = "hrw" // HRW 新选择
ReasonOverflow DecisionReason = "overflow" // 选中渠道低于当前可用的最高层级(溢出)
ReasonProbe DecisionReason = "probe" // HALF_OPEN 探测放行
ReasonCredRotate DecisionReason = "cred_rotate" // 凭证轮换(同渠道换 Key)
)
// ExclusionStats 本次选择的排除统计。
type ExclusionStats struct {
Breaker int `json:"breaker"` // 熔断 OPEN 排除
Lease int `json:"lease"` // 容量租约获取失败排除
Request int `json:"request"` // 本请求已失败排除
}
// Decision 一次应尝试的调度决定。
type Decision struct {
Channel Channel
KeyID int64 // 0 = 无 Key 快照信息,适配层按旧逻辑自行取
SessionKey SessionKey
Reason DecisionReason
Breakdown WeightBreakdown
CandidateCount int
Excluded ExclusionStats
}
// Outcome 一次尝试的结果,用于健康上报(fire-and-forget)。
// Class 决定健康 EWMA 的衰减档位;CLIENT / CREDENTIAL 类不上报。
type Outcome struct {
ChannelID int64
KeyID int64
Model string
Success bool
Class ErrorClass
LatencyMs float64
// Probe 标记本结果来自探测请求(管理后台渠道测试 / 自动探测 cron)而非真实流量。
// 纯核心库不消费,仅供适配层观测日志区分健康分变化来源。
Probe bool
}