Files
openteam/server/internal/proxy/hold.go
T
Sakurasan 531a03c836 perf: 流式错误判定预过滤、再减一个写索引,加可选明细保留期
- 流式放行前的上游错误判定加廉价子串预过滤("error" / response.failed):
  长思考阶段几十上百条事件不再逐条 json.Unmarshal。
- usage_logs 去掉 idx_channel_created:没有任何查询按 channel_id 过滤该表
  (渠道统计走 created_at 时间窗后在内存分组),复合索引只是每行一次多余的
  B-tree 插入;schema v9 迁移里显式 DROP 旧库索引。至此该表只剩
  (user_id, created_at)、created_at 两个真被用到的索引。
- 新增可选保留期(OT_USAGE_RETENTION_DAYS,默认 0=永久保留):每天分批
  (2000 行/批、批间 50ms)清理过期 usage_logs,避免长期运行后时间窗查询
  与备份随表增长变慢;余额流水 balance_logs 不在清理范围。

参数复测(记账稳态吞吐,3 次取中位):
- batch 64(当前)30.2k rows/s;batch 128 19.0k、batch 256 14.3k(更差,保持 64)
- wal_autocheckpoint(4000)、cache_size(-64MB) 与当前配置无差别,不引入

验证:go vet、go test ./...、-race(usage/proxy/store)全绿;新增保留期三项测试
(只删明细不动流水、未开启不动数据、跨分批删完)。
2026-10-30 16:45:57 +08:00

326 lines
9.6 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 proxy
import (
"bytes"
"encoding/json"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/channel"
"github.com/openteam/server/internal/proxy/convert"
)
// streamHold 流式容错缓冲(借鉴 magpie 的 holdWriter):首个内容事件到达之前,
// 上游的错误事件、空流、断连都还能换渠道重试——客户端只在"确定有货"之后才看到正文。
// 扣住期间照常发 SSE 注释心跳,防反代的空闲超时掐掉长思考。
//
// SSE 只增不删:即使响应头已因心跳先行写出(writeHead),换渠道重试也只是在同一条
// 流上重头写事件,客户端看到的是"注释、注释、…、正确内容",无感知切换。
// 缓冲超限则直接放行退化为普通转发——判定失效不能吞正文。
type streamHold struct {
c *gin.Context
w http.ResponseWriter
f http.Flusher
mu sync.Mutex
head bool // 响应头已写给客户端
passed bool // 已放行:事件直写,不再缓冲
buf bytes.Buffer
lastN atomic.Int64 // 最近一次向客户端写出时间(unix nano),心跳据此判断空闲
}
// maxHoldBytes 放行前最多缓冲多少字节:超过说明"内容判定"没认出上游的数据形态,
// 再缓冲只会吞正文,直接放行。
const maxHoldBytes = 1 << 20
func newStreamHold(c *gin.Context) *streamHold {
f, _ := c.Writer.(http.Flusher)
h := &streamHold{c: c, w: c.Writer, f: f}
h.lastN.Store(time.Now().UnixNano())
return h
}
// writeHead 把 200 + event-stream 响应头写给客户端(幂等)。写出后状态码不可再改,
// 但流本身还能继续:之后的失败以 SSE 错误事件收尾。
func (h *streamHold) writeHead() {
h.mu.Lock()
defer h.mu.Unlock()
h.writeHeadLocked()
}
func (h *streamHold) writeHeadLocked() {
if h.head {
return
}
h.head = true
h.c.Header("Content-Type", "text/event-stream")
h.c.Header("Cache-Control", "no-cache")
h.w.WriteHeader(http.StatusOK)
}
// writeEvent 写一条已转换的 SSE 输出:放行前缓冲,放行后直写。
func (h *streamHold) writeEvent(p []byte) error {
h.mu.Lock()
defer h.mu.Unlock()
if h.passed {
_, err := h.writeLocked(p)
return err
}
h.buf.Write(p)
if h.buf.Len() > maxHoldBytes {
h.releaseLocked()
}
return nil
}
// release 放行:把缓冲写给客户端,后续事件直写。首个内容事件到达时调用。
func (h *streamHold) release() {
h.mu.Lock()
defer h.mu.Unlock()
h.releaseLocked()
}
func (h *streamHold) releaseLocked() {
if h.passed {
return
}
h.writeHeadLocked()
if h.buf.Len() > 0 {
_, _ = h.w.Write(h.buf.Bytes())
h.buf.Reset()
}
if h.f != nil {
h.f.Flush()
}
h.passed = true
h.lastN.Store(time.Now().UnixNano())
}
func (h *streamHold) released() bool {
h.mu.Lock()
defer h.mu.Unlock()
return h.passed
}
// headSent 响应头是否已写出(心跳先行或已放行)。此后错误只能以 SSE 事件交付。
func (h *streamHold) headSent() bool {
h.mu.Lock()
defer h.mu.Unlock()
return h.head
}
// writeDirect 注释/错误事件直写(需要响应头先行)。与心跳 goroutine 共用一把锁,
// gin 的 ResponseWriter 不是并发安全的。
func (h *streamHold) writeDirect(p []byte) error {
h.mu.Lock()
defer h.mu.Unlock()
h.writeHeadLocked()
_, err := h.writeLocked(p)
return err
}
func (h *streamHold) writeLocked(p []byte) (int, error) {
n, err := h.w.Write(p)
if err == nil && h.f != nil {
h.f.Flush()
}
h.lastN.Store(time.Now().UnixNano())
return n, err
}
// cancelIfHeld 只在尚未放行时调用 cancel:与 release 互斥,
// 杜绝首 token 计时器把一条已经放行的健康流掐死。
func (h *streamHold) cancelIfHeld(cancel func()) {
h.mu.Lock()
defer h.mu.Unlock()
if !h.passed {
cancel()
}
}
// heartbeat 距上次写出超过 interval 就补一行 ": ping"(SSE 注释行,客户端解析器忽略,
// 不影响计费与转换)。挂在 nginx/Cloudflare 后面时长思考会被空闲超时掐掉。
func (h *streamHold) heartbeat(interval time.Duration) func() {
if interval <= 0 {
return func() {}
}
done := make(chan struct{})
var once sync.Once
go func() {
tk := time.NewTicker(interval)
defer tk.Stop()
for {
select {
case <-done:
return
case <-tk.C:
if time.Since(time.Unix(0, h.lastN.Load())) < interval {
continue
}
// 心跳写失败说明客户端已断开;主循环下一次写出会以断连记账
_ = h.writeDirect([]byte(": ping\n\n"))
}
}
}()
return func() { once.Do(func() { close(done) }) }
}
// ---------------------------------------------------------------------------
// SSE 事件判定
// sseEventHasContent 判定一条(客户端协议形状的)SSE 数据是否携带实质内容:
// 文本、思考、工具调用都算;role/usage/ping/message_start 这类元事件不算。
// hold 容错以"首个内容"为放行点——内容没到,上游的错误就还能换渠道消化。
func sseEventHasContent(line []byte, proto string) bool {
s, ok := sseDataPayload(line)
if !ok {
return false
}
var m map[string]any
if json.Unmarshal([]byte(s), &m) != nil {
return false
}
switch proto {
case convert.ProtoChat:
choices, _ := m["choices"].([]any)
if len(choices) == 0 {
return false
}
c0, _ := choices[0].(map[string]any)
delta, _ := c0["delta"].(map[string]any)
if delta == nil {
return false
}
if t, _ := delta["content"].(string); t != "" {
return true
}
if t, _ := delta["reasoning_content"].(string); t != "" {
return true
}
if tc, ok := delta["tool_calls"].([]any); ok && len(tc) > 0 {
return true
}
return false
case convert.ProtoMessages:
typ, _ := m["type"].(string)
switch typ {
case "content_block_start":
cb, _ := m["content_block"].(map[string]any)
t, _ := cb["type"].(string)
// 任何块的开启都算内容(text/tool_use/thinking/server_tool_use/…)
return t != ""
case "content_block_delta":
delta, _ := m["delta"].(map[string]any)
if t, _ := delta["text"].(string); t != "" {
return true
}
if t, _ := delta["thinking"].(string); t != "" {
return true
}
if t, _ := delta["partial_json"].(string); t != "" {
return true
}
return false
}
return false
case convert.ProtoResponses:
typ, _ := m["type"].(string)
return strings.HasSuffix(typ, ".delta") || typ == "response.output_item.added"
}
// 未知协议:任何数据事件都算内容(退化为旧语义,不吞正文)
return true
}
// sseUpstreamError 识别上游以 200+流式形式回的错误事件:
// - Anthropic:{"type":"error","error":{...}}(event: error 的 data 行)
// - OpenAI 系:data: {"error":{...}}
// - Responses:{"type":"response.failed","response":{...,"error":{...}}}
//
// 返回错误消息文本;不是错误事件时 ok=false。只在放行前判定——放行后错误
// 属于客户端可见的回复内容,不能拿去换渠道。
func sseUpstreamError(line []byte) (msg string, ok bool) {
// 廉价的子串预过滤:绝大多数事件与错误无关,先排除再谈解析。
// 放行前每条事件都会过这里,长思考阶段的几十上百条事件不值得逐条反序列化。
if !bytes.Contains(line, []byte(`"error"`)) && !bytes.Contains(line, []byte("response.failed")) {
return "", false
}
s, ok := sseDataPayload(line)
if !ok {
return "", false
}
var m map[string]json.RawMessage
if json.Unmarshal([]byte(s), &m) != nil {
return "", false
}
if raw, exists := m["error"]; exists && string(raw) != "null" && len(raw) > 0 {
return streamErrMessage(raw), true
}
typ := strings.Trim(strings.TrimSpace(string(m["type"])), `"`)
switch typ {
case "error":
return "upstream stream error", true
case "response.failed":
var resp struct {
Error json.RawMessage `json:"error"`
}
if json.Unmarshal(m["response"], &resp) == nil && len(resp.Error) > 0 && string(resp.Error) != "null" {
return streamErrMessage(resp.Error), true
}
return "upstream response failed", true
}
return "", false
}
func streamErrMessage(raw json.RawMessage) string {
var obj struct {
Message string `json:"message"`
}
if json.Unmarshal(raw, &obj) == nil && obj.Message != "" {
return obj.Message
}
var s string
if json.Unmarshal(raw, &s) == nil && s != "" {
return s
}
return "upstream stream error"
}
// classifyUpstreamError 把一次上游失败归类为 (原因, 是否可换渠道重试)。
// 余额/配额耗尽重试无意义,要和普通限流区分开——429 不一定是限流
// (智谱 429 1113 "余额不足");402 原本直接透传,现在也换渠道:
// 渠道 A 没余额不代表渠道 B 没有。402 恒为配额;429/403/401 带配额字样的也是配额。
func classifyUpstreamError(status int, body []byte) (channel.FailKind, bool) {
quota := status == http.StatusPaymentRequired || quotaSignalIn(body)
switch {
case quota && (status == http.StatusPaymentRequired || status == http.StatusTooManyRequests ||
status == http.StatusForbidden || status == http.StatusUnauthorized):
return channel.FailQuota, true
case status == http.StatusTooManyRequests:
return channel.FailRate, true
case status >= 500:
return channel.FailServer, true
default:
return channel.FailServer, false
}
}
// quotaSignalIn 从上游错误体识别"余额/配额耗尽"特征。
var quotaSignals = []string{
"insufficient", "balance", "billing", "arrears",
"exceeded your current quota", "quota exceeded",
"额度不足", "余额不足", "欠费",
}
func quotaSignalIn(body []byte) bool {
s := strings.ToLower(string(body))
for _, sig := range quotaSignals {
if strings.Contains(s, sig) {
return true
}
}
return false
}