一、 事情的起因:为什么不能"只要报错就扣渠道分"?
在上一篇里,我们在不拖慢主请求的前提下,把每次渠道调用尝试(Attempt)的数据都收上来了。
刚开始我想得很简单:既然数据都有了,那 HTTP 200 就是成功,非 200 或者报错就是失败,拿 成功数 / 总尝试数 算个成功率不就行了吗?
但把真实流量放进测试环境跑了一圈之后,我发现如果这么粗暴地算,不仅渠道会被冤死,连我们自己的评分窗口都会被搞乱。为此我设计了两个布尔标记来先给请求"定性":
ValidQualitySample(是否计入质量考核窗口) :只有真正发到了渠道、并且能反映渠道好坏的尝试(包括正常成功,以及应该由渠道背锅的失败 ),这个值才是true。至于用户参数错、客户端自己取消、用户没钱了,全部设为false------保留原始记录备查,但绝不占用 600 条有效评分窗口的名额!SuccessfulCapacitySample(是否计入成功容量峰值) :只有彻头彻尾正常跑完的成功请求,这个值才是true,用来统计这个渠道一分钟最多能成功扛住多少请求(RPM/TPM)。
go
type AttemptClassification struct {
Result AttemptResult // 具体归因枚举:success / timeout / client_cancelled 等 10 种
ValidQualitySample bool // 进不进 600 条质量考核窗口?
SuccessfulCapacitySample bool // 算不算成功容量样本?
StatusCode int
}
想法虽然简单,但在实际写代码和跑测试环境对账时,我在流式状态追踪、错误分锅、以及 Token 统计补账 上实打实踩了 8 个深坑。下面我按踩坑和填坑的过程一个个拆解。
二、 归因与流式追踪踩过的 5 个真坑
坑 1:客户端断连和上游报错同时发生,为什么要用 ForceEndReason 强行覆盖 sync.Once?
在流式传输(SSE)时,网关里有 3 个协程在同时跑:读上游响应的 scanner 协程、往客户端写数据的 dataHandler 协程、以及定时发保活包的 ping 协程。
为了记录这条流是怎么结束的,我设计了 StreamStatus 结构体。正常情况下,3 个协程谁先结束,谁就调 SetEndReason 记下原因。为了防止第一个协程记了错误原因后,后面的协程退出时又调一次把它覆盖成正常的 eof,SetEndReason 内部用了 sync.Once 锁死,只认第一次写入。
但在测试"下游客户端主动断开连接(比如用户在 IDE 里点了 Stop,或者调用方 Timeout 到期主动掐断连接)"时,撞上了两个竞态大坑:
- 断连引发连环报错 :当客户端主动断开 TCP 连接时,网关会主动去关闭跟上游渠道的
resp.Body。这一关不要紧,正在读上游数据的scanner协程立马就会抛出一个ReadResponseBodyFailed或scanner_error!如果先记下了上游读取报错,这次"用户主动取消"就被冤枉成了"渠道断流故障"。 [DONE]与断连同毫秒撞车 :有时候上游刚好吐完最后一个字发来了[DONE],scanner协程抢先一步调SetEndReason("done")占住了sync.Once;但其实就在同一毫秒,客户端已经断开了连接,最后一段数据根本没写出去!
怎么填坑的:
第一,在 StreamStatus 里除了带 sync.Once 的 SetEndReason,专门开了一个绕过 sync.Once 的后门方法叫 ForceEndReason:
go
type StreamStatus struct {
EndReason StreamEndReason
EndError error
endOnce sync.Once
}
// 普通结束原因:用 sync.Once 保证只记第一个触发退出的原因
func (s *StreamStatus) SetEndReason(reason StreamEndReason, err error) {
s.endOnce.Do(func() {
s.EndReason = reason
s.EndError = err
})
}
// 强制覆盖原因:专供客户端断连(client_gone)使用,无视 sync.Once 直接覆盖!
func (s *StreamStatus) ForceEndReason(reason StreamEndReason, err error) {
s.EndReason = reason
s.EndError = err
}
只要流式主循环或写协程检测到 c.Request.Context().Done()(客户端断开了),立刻调 ForceEndReason(StreamEndReasonClientGone) 强行覆盖 掉任何已有的状态(哪怕刚刚收到了 [DONE]),并且马上关闭上游 resp.Body 停止生成,免得为已经放弃的请求继续消耗上游的 Token!
第二,在归因函数 classifyAttemptResult 的最开头,把客户端断开放在第一顺位 ,并且用 errors.Is + 错误文本匹配做双重兜底(防止有些旧代码用字符串拼接重建 Error 时把底层的 context.Canceled 错误链弄丢了):
go
func classifyAttemptResult(apiErr *types.NewAPIError, status int, streamEndReason string, streamNormalEnd bool) AttemptClassification {
if status == 0 && apiErr != nil {
status = apiErr.StatusCode
}
classification := AttemptClassification{Result: AttemptUnknown, StatusCode: status}
// 第一顺位:客户端主动断开优先于同时出现的上游错误,避免把用户取消记成渠道断流!
// 同时用 errors.Is 和字符串匹配双重兜底,防止外层包装 Error 时丢失原始错误链。
if isClientClosedStream(streamEndReason) || apiErr != nil && (errors.Is(apiErr, context.Canceled) || strings.Contains(strings.ToLower(apiErr.Error()), "context canceled")) {
classification.Result = AttemptClientCancelled
return classification // ValidQualitySample 为 false,不进质量窗口
}
...
坑 2:上游返回 HTTP 200,但里面藏着"空流"或错误 JSON 怎么抓?
很多第三方中转渠道非常不讲武德:上游明明挂了或者超载了,它返回的 HTTP 状态码居然还是 200 OK!常见的有三种情况:
- 空流 :返回
200 OK后,连一个有效字都没吐,直接EOF把流关了; - 200 包错误体 :返回
200 OK,但正文里是一段错误 JSON(有的按 OpenAI 格式放在顶层error,有的按 Claude 风格嵌套在二层{"type":"error","error":{...}}); - 流式中途断流 :前面正常吐了几个字,没发
[DONE]就突然非正常断开了。
怎么填坑的:
我们设了三道拦截,专门扒掉 HTTP 200 的假外衣:
- 空流拦截(
EmptyStreamError) :流扫描结束时,如果发现收到的有效响应块数量为 0,哪怕状态码是 200,也直接生成ErrorCodeEmptyResponse,并把状态码强行改写成502 Bad Gateway(但如果此时客户端已经主动断开了,就打上SkipRetry标记不再重试); - 200 错误体两层拆包与改写 :对响应体和 SSE 里的
error / response.failed事件做两层拆包检测(同时兼容顶层error和嵌套error),一旦抓到错误,通过upstreamFailureStatus把200强行改写成502; - 收口归因二次检查 :就算底层没抛出
apiErr,在classifyAttemptResult里看到apiErr == nil时,也绝不直接判成功,必须核对streamEndReason是不是正常结束(done/eof):
go
if apiErr == nil {
if status == 429 || status >= 500 {
classification.Result = AttemptUpstreamRejected
classification.ValidQualitySample = true // 渠道责任,进窗口扣分!
return classification
}
// 哪怕状态码是 200 且没有 apiErr,只要流不是正常结束的,一律抓出来记为 stream_interrupted!
if strings.TrimSpace(streamEndReason) != "" && !streamNormalEnd && !isNormalStreamEnd(streamEndReason) {
classification.Result = AttemptStreamInterrupted
classification.ValidQualitySample = true // 渠道责任,进窗口扣分!
return classification
}
classification.Result = AttemptSucceeded
classification.ValidQualitySample = true
classification.SuccessfulCapacitySample = true
return classification
}
坑 3:流式吐了一半报错了,为什么绝对不能 Retry 换渠道?
顺着上面"流式中途报错"的场景,我们在联调重试逻辑时遇到了第三个坑:流式请求报错时,能不能触发网关的 Retry 自动换下一个渠道?
这必须严格分情况:
- 如果上游只是刚发了
created / in_progress这种纯元数据事件、还没有向客户端写出任何实质内容(forwardedContent == false) 就报错了,这时候可以安全地静默换下一个渠道重试,客户端完全无感,同时给刚才失败的渠道记一条失败Attempt; - 但如果已经向客户端吐了半截话(
forwardedContent == true) ,这时候上游突然断流报错了,网关绝对不能再去 Retry 换渠道!因为一旦换个新渠道重新生成,新渠道从头吐出来的话就会直接拼接在刚才那半截话后面,客户端收到的就是一团前后矛盾的乱码!
怎么填坑的:
在 resolveStreamUpstreamError 里用 forwardedContent 严格门控:只要已经向客户端写过实质内容,报错时立刻打上 ErrOptionWithSkipRetry() 禁止重试。
而且当最终要向客户端返回错误时,如果 c.Writer.Written() 为真(说明 HTTP 200 和 text/event-stream 的头早就发给客户端了),此时如果调 c.JSON() 写回错误,只会把一段裸 JSON 塞进事件流里导致客户端解析崩溃。所以我们专门写了 writeStreamTerminalError:
go
func writeStreamTerminalError(c *gin.Context, payload any) {
data, err := common.Marshal(payload)
if err != nil {
return
}
// 流已经开播时,按标准的 SSE error 事件帧格式发送错误,并把字节数如实记入 SentBytes
helper.WriteScoreEvent(c, "event: error\ndata: "+string(data))
_ = helper.FlushWriter(c)
}
坑 4:【测试环境真 Bug】为什么渠道报了 6 次 500 错误,可靠性还能拿 100 分满分?
这是我们在测试环境对账时抓到的一个真实线上 Bug!
当时我们在测试环境跑压测:
- 8 号渠道 跑了 600 条请求,里面有 594 条成功,6 条
HTTP 500失败; - 9 号渠道 跑了 600 条请求,里面有 593 条成功,7 条
HTTP 500失败。
按我们的评分规则(可用率 99.9% 满分 100,99.0% 及格 60 分,低于 99.0% 直接判 0 分):
- 8 号渠道可用率是
594 / 600 = 99.0%,可靠性应该得 60 分; - 9 号渠道可用率是
593 / 600 = 98.83%,跌破了 99% 红线,可靠性应该得 0 分!
结果打开面板一看,8 号渠道和 9 号渠道的可靠性分竟然全都是 100 分!
怎么填坑的:
我赶紧去翻 CalculateAvailability 的代码,我发现:
不管是成功还是 500 报错,在请求结束收口时都会填上结束时间 EndAt != nil,而且因为 500 是渠道的锅,它的 ValidQualitySample 也是 true。早期写可用率统计时,错误地把"有结束时间的有效样本"当成了成功!结果那 6 条和 7 条 500 报错既进了分母,又进了分子,算出来的可用率永远是 600 / 600 = 100%!
我立刻修复了 CalculateAvailability,死认 event.Result == AttemptSucceeded 才算成功分子:
go
func CalculateAvailability(events []ChannelAttemptEvent) AvailabilityResult {
result := AvailabilityResult{}
for _, event := range events {
// 1. 先过滤:非有效质量样本(如 400 参数错、客户端取消)或额度耗尽,不进分母!
if !event.ValidQualitySample || event.Result == AttemptQuotaExhausted {
continue
}
result.NonQuota++ // 分母 +1
// 2. 只有 Result 明确等于 AttemptSucceeded 才进分子!(修复前错看了 EndAt)
if event.Result == AttemptSucceeded {
result.Success++
}
}
if result.NonQuota > 0 {
value := float64(result.Success) / float64(result.NonQuota)
result.Availability = &value
}
return result
}
修完之后,我们对测试环境里 8 号、9 号渠道的历史窗口做了受控补算,8 号渠道的总分从 75 分修正回 55 分 (可靠性 60 分),9 号渠道从 75 分直接降到了 25 分 (可靠性 0 分),并补上了回归测试 TestAvailabilityUsesPersistedResultNotEndTime。
坑 5:600 条记录里夹了 1 条 HTTP 400,把整个窗口卡死在 599 条!
在修上面 8、9 号渠道的同时,10 号渠道 暴露出了第 5 个坑:
10 号渠道一共跑了 601 条请求,其中事件 ID 8352 是一条用户传错参数的 HTTP 400(ValidQualitySample = false),其余 600 条全是正常的成功请求(最后一条有效事件的 ID 是 10213)。
结果 10 号渠道的第一个窗口(ch10-w1)虽然封窗了,但状态却一直卡在 accumulating(累计中),死活生成不了参考权重!
一查数据库窗口表,发现这个窗口认领的事件范围是 7216 ~ 10212------旧的认领逻辑把那条 HTTP 400 也算进 600 条名额里了,导致真正的第 600 条有效事件 10213 被关在了窗外! 等到算分函数把 7216 ~ 10212 里的事件捞出来一过滤 ValidQualitySample == true,发现只有 599 条有效样本,不够 600 条,直接拒出正式分!
怎么填坑的:
我把进度查询(CountOpenScoreWindowEvents)、窗口认领(ClaimNextScoreWindow)和事件加载(LoadScoreWindowEvents)三个地方的 SQL 口径全部统一成只按 valid_quality_sample = true 计数,并写了专门的单测验证:
- 连续插入第
1 ~ 600条事件,其中第300条是HTTP 400(ValidQualitySample = false); - 此时查窗口进度返回
599,调ClaimNextScoreWindow返回nil(坚决不封窗); - 直到第
601条有效事件插入后,才成功封窗:此时窗口的起止 ID 是1 ~ 601(ID 跨度为 601),但加载出来的有效样本数精准等于600(第 300 条自动被踢掉)!
三、 Token 来源分级与补账踩过的 3 个真坑
解决了错误归因,最后一块硬骨头就是 Token 用量(Usage)与吐字速度(OTPS) 。这里我也踩了 3 个实打实的坑:
坑 6:本地估算的 Token 混进速度统计,以及 NaN / Inf 极值风险
在网关里,一次请求的 Token 数量可能有三种来源,我给它们排了严格的优先级(observed(3) > estimated(2) > missing(1)):
observed(真实观测值,优先级最高) :上游渠道在响应体或流式末尾明确返回的官方usage;estimated(估算值,优先级次之) :上游没返回usage,网关用本地分词器算出来的估算值;missing(缺失,优先级最低) :既没返回,也没法估算。
如果流处理器和计费模块先后调了两次 AttachUsage,mergeToken 会自动保留等级更高的那个(用 observed 覆盖 estimated),如果发现数值不一致还会自动打上 Conflict = true 留痕:
go
func mergeToken(field string, primary, fallback TokenValue, result *UsageSnapshot) TokenValue {
if primary.Value != nil && fallback.Value != nil && (*primary.Value != *fallback.Value || primary.Source != fallback.Source) {
result.Conflict = true
// 记录冲突原因,例如 "input_tokens conflict: kept observed"
}
if rankUsageSource(primary.Source) > rankUsageSource(fallback.Source) {
return normalizedToken(primary)
}
...
怎么填坑的:
在收口算每秒吐字速度(OTPS = 输出 Token / 生成耗时)时,我加了两道硬防线:
- 估算值禁止算 OTPS :输入 Token 如果是
estimated还可以用来分桶,但输出 Token 只有明确是UsageSourceObserved时才允许算 OTPS ,否则一律置为nil; - 生成耗时与浮点数极值保护 :生成耗时优先取
lastOutputAt - firstTokenAt(没记末次输出时间才回退用endAt - firstTokenAt)。如果首末时间在同一毫秒(generationMs <= 0),或者算出来的浮点数出现math.IsNaN/math.IsInf,直接返回nil,绝不让非法浮点数混进数据库:
go
func CalculateOTPS(outputTokens *int64, generationMs *int64) *float64 {
if outputTokens == nil || generationMs == nil || *outputTokens <= 0 || *generationMs <= 0 {
return nil
}
value := float64(*outputTokens) * 1000 / float64(*generationMs)
if math.IsNaN(value) || math.IsInf(value, 0) {
return nil
}
return &value
}
坑 7:迟到的 Usage 为什么不能直接 UPDATE 原表?(以及乱序到达的坑)
在流式转发里,有时候流结束收口函数 handle.End() 已经跑完了(事件已经塞进了内存队列甚至写进了数据库),计费协程才慢吞吞地把最终的 Token 算完调 handle.AttachUsage()。
这时候有两个大坑:
- 如果直接
UPDATE channel_score_events覆盖原表:一旦那个窗口已经在算分或者算完分了,回头对账时就会发现"当时算出来的分"和"现在表里的 Token 数"对不上,变成死无对证的悬案。 - 补偿记录比原事件先到数据库 :因为原事件是走内存队列异步批量写进数据库的,而如果
compensateLateUsage直接同步写数据库,就可能出现 "原事件还在 Redis Stream 里排队没入库,迟到补偿却已经写进数据库了" 的孤儿记录!
怎么填坑的:
我专门建了一张迟到用量补偿表(channel_score_usage_compensations) 。当 AttachUsage 发现 h.ended == true 时,自动自增版本号(v1, v2...)走补偿路径,并且在落库函数 PersistLateUsageCompensation 里加上了原事件已入库前置校验 和 (attempt_id, version) 唯一索引幂等校验:
go
func PersistLateUsageCompensation(ctx context.Context, compensation UsageCompensation) error {
// 关键防线:必须先确认原终态事实已经写入主表,拒绝主表未落库前的孤儿补偿!
published, err := model.PublishedChannelScoreEventExists(ctx, compensation.AttemptID)
if err != nil {
return err
}
if !published {
return errors.New("late usage compensation requires a published attempt fact")
}
return model.AppendUsageCompensation(ctx, &model.ChannelScoreUsageCompensation{
AttemptID: compensation.AttemptID,
Version: compensation.Version,
Reason: model.UsageCompensationReasonLateUsage,
// ...
})
}
坑 8:诊断重建(RebuildAffectedWindow)把补偿 Token 贴回去时,为什么 OTPS 没变?
有了迟到补偿表之后,当我们需要调用 RebuildAffectedWindow 把补偿表里的 Token 贴回原窗口事件副本、重算一个诊断分(DiagnosticScore)来看影响时,我踩到了第 8 个坑:
一开始在 applyCompensation 函数里,我只写了把补偿表里的 InputTokens 和 OutputTokens 覆盖回事件副本:
go
rebuilt[i].InputTokens = compensation.InputTokens
rebuilt[i].OutputTokens = compensation.OutputTokens
结果跑测试发现,重算出来的效率分跟补偿前一模一样!
查了半天才发现:因为窗口算分函数在收集桶内延迟和速度指标时,看到事件副本里的 rebuilt[i].OTPS 已经有值了,就直接拿旧的 OTPS 去算了,压根没用新补上来的 OutputTokens 重新算!
怎么填坑的:
在 applyCompensation 覆盖 Token 的同时,显式加上一行 rebuilt[i].OTPS = nil,逼着后面的算分逻辑根据新的输出 Token 重新计算速度:
go
func applyCompensation(events []ChannelAttemptEvent, compensation model.ChannelScoreUsageCompensation) []ChannelAttemptEvent {
rebuilt := make([]ChannelAttemptEvent, len(events))
copy(rebuilt, events) // 拷贝切片副本,绝不污染原始入库事件
for i := range rebuilt {
if rebuilt[i].AttemptID != compensation.AttemptID {
continue
}
rebuilt[i].InputTokens = compensation.InputTokens
rebuilt[i].OutputTokens = compensation.OutputTokens
// 关键细节:覆盖 Token 后必须将原 OTPS 清为 nil,防止后续算分沿用旧的 OTPS!
rebuilt[i].OTPS = nil
}
return rebuilt
}
四、 改完之后的效果
把这 8 个坑全部填平并加上回归测试之后,整条归因与 Token 链路彻底跑顺了:
- 责任划分一清二楚 :客户端断连靠
ForceEndReason和第一顺位拦截不再冤枉渠道;上游HTTP 200藏错误 JSON 或空流被精准扒皮改写成502扣分;流式吐了半截话报错自动禁止 Retry 并补发标准 SSEevent: error帧。 - 窗口封口与可用率严丝合缝 :600 条有效样本里哪怕夹杂再多的
HTTP 400或用户没钱报错,也绝不会占名额把窗口卡死在 599 条;8 号、9 号渠道的500报错实打实地反映在了可靠性扣分上。 - Token 与迟到补账全程可追溯 :估算值绝不污染 OTPS,迟到的 Usage 按
(attempt_id, version)幂等追加在补偿表里,随时可以做诊断重算而不会改乱线上正式账单。