Skip to content

Latest commit

 

History

History
508 lines (423 loc) · 57.7 KB

File metadata and controls

508 lines (423 loc) · 57.7 KB

和Newapi上游的区别

  1. 【已实现】模型健康度(5 分钟切片 → 小时聚合 → 成功率展示)
  • 原始需求(保留):实现维护模型健康度(健康度是一个时间的比值,每5分钟一个单位,如果该单位内只有失败的请求,那么记为失败时间片,如果有一个或多个成功请求并且(返回的byte长度大于1k或完成token大于2或实际响应模型回复大于2char),记为成功时间片,可查看不同小时时间段的模型成功率),实现后端和对应前端,,设计数据结构和新表实现良好性能。实现对非管理员隐藏可自定义模型和时间的查询(在控制台),并实现在导航栏添加新的页面(新页面所有用户即使非登录也可查看),显示所有模型最近24小时每小时的健康度。

  • 口径与数据结构(后端聚合“最小单位=5分钟切片”)

  • 写入路径与性能设计(异步队列 + UPSERT)

  • 小时聚合查询(按小时 bucket 计算 success_slices/total_slices/成功率)

  • 公共页面数据源(无需登录,展示所有模型最近 24h 每小时健康度)

    • 公共 API:controller.GetPublicModelsHealthHourlyLast24hAPI() 计算 start_hour/end_hour(对齐到整点)后调用 model.GetAllModelsHealthHourlyStats(),并按“每模型 × 24 小时”补齐缺失小时(补齐见 controller.GetPublicModelsHealthHourlyLast24hAPI())。
    • 成功请求 token 展示(不修改成功率口径):
      • 数据来源:复用小时聚合表 quota_datatoken_used(写入点见 model.LogQuotaData()),按 model_name + created_at(整点) 聚合得到每小时成功 token。
      • 合并位置:在公共 API 内额外查询 quota_data 并把结果合并进返回行字段 success_tokens(实现见 controller.GetPublicModelsHealthHourlyLast24hAPI())。
      • 说明:成功率仍完全来自健康度切片表 model_health_slice_5msuccess_slices/total_slices/success_rate 逻辑不变),token 统计仅用于展示与排序。
    • 前端展示与“低流量视作无数据”策略(公共页面 /model-health):
      • 文案提示:页面“最近 24 小时各模型运行状态一览”后追加“监测所有请求(包括格式不正确导致的错误)”。
      • 报表过滤:按每模型最近 24 小时的每小时 success_tokens 计算 P10(仅对 >0 token 的小时取分位数),若某小时 success_tokens < P10 则该小时视作“无数据”(UI 会使用该模型的平均成功率进行填充渲染)。
      • 平均值/总体成功率:模型平均成功率与整体成功率的分子分母(success_slices/total_slices)均忽略这些被判定为“无数据”的低流量小时。
    • 路由与鉴权:
      • 管理员接口 /api/model_health/hourly:在 router.SetApiRouter() 中挂载并强制 middleware.AdminAuth()(满足“非管理员隐藏可自定义模型和时间的查询(在控制台)”)。
      • 公共接口 /api/public/model_health/hourly_last24h:在 router.SetApiRouter() 中挂载且无鉴权(满足“新页面所有用户即使非登录也可查看”所需的数据源)。
    • 缓存:公共接口仍使用 Redis + 内存双层缓存,缓存内容包含新增字段 success_tokens;key/TTL 定义见 publicModelHealthCacheKeypublicModelHealthCacheTTL;读取见 getPublicModelHealthCache(),写入见 setPublicModelHealthCache()
  1. 【已实现】管理员豁免“用户请求限速 RPM”(在无法按标签精确解除某类限速时的兜底)
  1. 【已实现】最近 100 次 API 调用请求/响应缓存(含错误与上游流式原始 chunk)+ 管理员 UI 查阅
  1. 【已实现】生成随机兑换码(支持前缀、数量、随机额度区间、并下载 txt 文件)
  1. 【已实现】每渠道“模型角色映射”(将特定 role 转换为另一种 role,不是全局模型映射)
  1. 【已实现】强制在日志记录 IP(即使用户关闭 IP 记录开关)
  1. 【已实现】web/public/oauth-redirect-linuxdo.html 多站点 OAuth 重定向回调页(回调需先跳到该页)
  1. 【已实现】接入 FingerprintJS:记录用户最近 5 次去重 visitor id + ip(按 ip+visitor_id 去重)+ 管理员查询同 visitor id 用户(工作台面板“关联追踪”)
  • 原始需求(保留):实现接入 fingerprintjs/fingerprintjs 库,记录用户的历史5次visitor id + ip(按 ip+visitor_id 去重后的5次),并实现管理员查询相同visitor id的用户,也就是说创建一个管理员可见的在工作台的面板,面板名称4个字

  • 前端:采集 visitor id + 1 小时节流上报

  • 后端:记录到表 user_fingerprints,同用户最多保留 5 个不同 ip + visitor_id 组合(按 ip+visitor_id 去重)

    • 写入入口:controller.RecordFingerprint() 读取 visitor_idRecordFingerprintRequest),并取 User-Agentc.ClientIP() 一起入库。
    • 数据表:model.UserFingerprint 映射表名 user_fingerprintsTableName())。
    • 去重逻辑:后端使用 upsert,按 (user_id, visitor_id, ip) 组合去重;命中则更新 user_agent/updated_at(实现见 model.RecordFingerprint())。
    • 保留 5 条:超过 5 个 ip + visitor_id 组合记录时,先取第 5 条的 (updated_at,id) 作为阈值,再删除更旧的记录(避免 MySQL 下出现仅 OFFSETLIMIT 的非法 SQL;实现见 model.RecordFingerprint())。
    • MySQL 迁移:项目启动时会执行 DB.AutoMigrate(),并会尝试创建复合唯一索引 ux_user_fingerprints_user_visitor_ip(见 model.UserFingerprintuniqueIndex tag),以保证并发下按 (user_id, visitor_id, ip) 组合去重正确。
    • 搜索兼容:管理员搜索 visitor id / username / email 时,后端统一使用 LOWER(column) LIKE LOWER(?) 做大小写不敏感匹配,避免 PostgreSQL 默认 LIKE 大小写敏感而与 MySQL 行为不一致。
    • 若你是“存量库迁移”且线上账号无建索引权限/或 AutoMigrate 未生效,请手动补该索引(否则可能产生重复行,导致“保留 5 条”失真):
      ALTER TABLE user_fingerprints
        ADD UNIQUE KEY ux_user_fingerprints_user_visitor_ip (user_id, visitor_id, ip);
  • 管理员查询:重复指纹列表 + 点进查看关联用户

  1. 【已实现】活跃任务槽追踪系统(全局 1000 / 单用户 50,SimHash 相似匹配,LRU 淘汰)+ 600s 高活跃扫描入库 + 24h token 消耗查询
  1. 【已实现】合并上游签到更新 + 增加“是否开启随机额度”开关
  1. 【已实现】OpenAI 文本 token 统计 CPU 优化:抽样真实计数校准 + 字符倍率估算(CPU 优先)
  • 目标:降低 CountTextToken 触发 tiktoken-go/regexp2 的频率,在允许小幅 usage 波动前提下优先降低 CPU。

  • 修改位置:service/token_counter.go(仅改 CountTextToken 的 OpenAI 文本模型分支;非 OpenAI 分支保持 EstimateTokenByModel 不变)。

  • 实现细节:

    • 新增按 model 维度的内存校准器(sync.Map + 每模型 mutex),样本池固定容量 10。
    • 样本项为 (chars,tokens)chars 使用 utf8.RuneCountInString
    • 比率采用稳健口径:ratio = sum(tokens_i) / sum(chars_i)
    • 短文本噪声过滤:chars < 64 不入池。
    • 池未满时:每次真实计数并入池,返回真实 token。
    • 池已满时:默认估算 int(chars * ratio) 返回;并进行 1% 抽样真实计数更新池(环形 FIFO 替换)。
    • 低流量兜底:距离上次真实校准超过 300s 强制一次真实计数更新池。
    • 估算结果夹逼:下限 0,上限 chars*4,避免异常倍率放大。
  • 并发与性能:

    • 校准器结构线程安全:sync.Map 保存 *tokenCalibrator,每实例内部 sync.Mutex
    • 抽样计数使用轻量计数器(atomic)实现,估算路径仅 RuneCount + 算术 + 少量状态读取
  • 行为说明(代码注释已加):

    • 本地快速路径为近似计数,会轻微影响内部预扣与部分回填给客户端的 usage.prompt_tokens
    • 该波动是有意设计,目标为 CPU 优先,并通过持续真实抽样自适应校准。
  • 可选环境变量:

    • ENABLE_FAST_TIKTOKEN(默认 true
    • FAST_TIKTOKEN_SAMPLE_RATE(默认 0.01
    • FAST_TIKTOKEN_FORCE_REAL_SECONDS(默认 300
  1. 【已实现】应用层 gin gzip 增加环境开关(默认关闭,启用时 BestSpeed)
  • 目标:避免在上游 nginx 已压缩时,应用层 gin-contrib/gzip 继续占用 CPU(compress/flate 热点)。

  • 修改文件:

    • router/web-router.go
    • router/api-router.go
    • router/dashboard.go
  • 行为变更:

    • 默认不启用应用层 gzip(ENABLE_GIN_GZIP 默认 false)。
    • ENABLE_GIN_GZIP=true 时才注册 gzip middleware。
    • 启用时压缩级别固定为 gzip.BestSpeed(避免误用高压缩等级导致 CPU 升高)。
  • 实现说明:

    • 复用现有 common.GetEnvOrDefaultBool(定义在 common/env.go),未新增重复 env helper。
    • 三处原先的 gzip.DefaultCompression 已改为条件注册 + gzip.BestSpeed
  • 验证建议:

    • 默认关闭:不设置 ENABLE_GIN_GZIP(或设为 false),直连应用端口 curl -I,应看不到应用层添加的 Content-Encoding: gzip
    • 开启:设置 ENABLE_GIN_GZIP=true 重启后,直连应用端口 curl -I 应出现 Content-Encoding: gzip
    • pprof:默认关闭后,compress/flate.(*compressor).deflate/findMatch 热点应显著下降。
  1. 【已实现】CPU 驱动的自适应重试延时(env 开关,0~1s 动态调节)
  • 目标:在上游/自身高负载时自动“变慢”重试,降低瞬时并发与 CPU/IO 压力,避免重试风暴。
  • 行为:当启用后,系统监控每次采样 CPU 使用率时:
    • cpu > threshold:重试间隔 +10ms
    • cpu <= threshold:重试间隔 -10ms
    • 间隔范围限制为 [0, 1s];仅在 发生重试 时,才会在两次尝试之间 sleep。
  • 配置(环境变量):
    • RETRY_DELAY_ADAPTIVE_ENABLED(默认 false
    • RETRY_DELAY_CPU_THRESHOLD(默认 50,取值 0~100)
    • RETRY_DELAY_STEP_MS(默认 10,每次调整的步进毫秒数)
    • RETRY_DELAY_MAX_MS(默认 1000,最大重试间隔毫秒数)
  1. 【已实现】基于 pprof 证据的 Claude/OpenAI 兼容转换热路径最小性能修复
  • 背景与目标(保留外部行为)

    • 已根据线上 pprof 热点,对 Claude/OpenAI 兼容转换中的高频 JSON 往返和重复扫描做最小改动优化,目标是降低 dto.(*ClaudeRequest).SearchToolNameByToolCallIddto.(*ClaudeMessage).ParseContentdto.(*ClaudeMediaMessage).ParseMediaContentdto.(*ClaudeRequest).ParseSystem 所在热路径 CPU 与不必要的 Marshal/Unmarshal 开销。
    • 约束保持不变:未修改对外 API、JSON tag、导出字段语义;新增缓存字段使用 json:"-",不影响序列化。
  • 请求级 tool name 索引缓存

    • dto.ClaudeRequest 内新增非序列化字段 toolNameByCallID map[string]string,按 tool_use.id -> tool_use.name 懒加载建立索引。
    • (*dto.ClaudeRequest).SearchToolNameByToolCallId() 现已改为:
      • toolCallId 直接返回空字符串;
      • 首次查询时调用内部 ensureToolNameIndex() 扫描一次消息;
      • 后续查询直接走 map,避免每次重新全量扫描并重复解析 messages[*].content
    • 索引构建仅收集 type == "tool_use"id/name 非空的条目;解析失败时跳过该消息,保持原有兼容回退风格。
  • Claude 内容解析快路径

    • dto/claude.go 新增内部 helper parseClaudeMediaMessagesFast(data any)parseClaudeMediaMessageItemFast(item any)
    • 快路径优先覆盖常见输入形态:
      • []ClaudeMediaMessage
      • []*ClaudeMediaMessage
      • []any
      • []map[string]any
      • nil
    • []any / []map[string]any
      • 若元素已是 ClaudeMediaMessage*ClaudeMediaMessage,直接复用;
      • 其他单项仍允许回退到 common.Any2Type,保留兼容性;
      • 如果快路径过程中遇到无法按单项处理的内容,则整体回退到原有 Any2Type[[]ClaudeMediaMessage] 逻辑。
    • 以下函数已切换到快路径实现,降低整块 content/systemMarshal+Unmarshal 次数:
  • convert 路径的实际收益点

    • service.ClaudeToOpenAIRequest()tool_result 分支仍保持原有行为:当 mediaMsg.Name 为空时,调用 SearchToolNameByToolCallId 回查名称。
    • 由于该查询现在已变为“首次建索引 + 后续 O(1) map 查找”,因此无需在 convert 层做更大范围缓存改造,即可消除原来的“每个 tool_result 都重新扫描全部 messages 并重复解析”的热点开销。
  • 验证

    • 新增 dto/claude_test.go
      • 覆盖多条 messages / 多个 tool_use / tool_result 下的 SearchToolNameByToolCallId 正确性;
      • 覆盖重复调用结果稳定;
      • 覆盖 ParseContent / ParseSystem / ParseMediaContent[]ClaudeMediaMessage[]any{map[string]any{...}}nil、字符串内容路径的兼容性;
      • 增加基准,对比旧式全量扫描与新索引查找。
  1. 【已实现】流式 Flush 节流 + recent calls stream chunk 批量落盘(最小补丁)
  • 目标与约束

    • 仅处理两处已确认热点:
      • 流式 SSE 输出不再每个 chunk 无条件 Flush
      • recentCallsCache 的 stream chunk 不再每片同步 OpenFile/Write/Close
    • 保持 SSE 语义不变,不改协议,不做额外重构。
  • 流式 Flush 节流

  • recent calls stream chunk 批量落盘

    • 修改文件:
    • 实现:
      • recentCallEntry 增加 entry 级 streamChunkBuf bytes.Buffer
      • AppendStreamChunkByContext 仍保留:
        • 单 chunk 截断逻辑
        • 总量上限判断
      • 但不再每片直接写文件,而是:
        1. 将 chunk 编码为一行 JSONL
        2. 追加到内存缓冲
        3. 缓冲达到 16KiB 时一次性 append 到 stream_chunks.jsonl
      • 新增底层批量写接口:
        • marshalJSONLStringLine
        • appendRaw
        • flushStreamChunkBuffer
      • 收尾刷盘位置:
  • 验证

    • 新增 relay/helper/common_test.go
      • 验证普通小 chunk 不会立刻 flush
      • 验证 PingData 必定即时 flush
      • 验证时间阈值到达后会触发 flush
    • 新增 service/recent_calls_cache_test.go
      • 验证 stream chunk 会先留在 entry 内存缓冲
      • 验证 FinalizeStreamAggregatedTextByContext 会把 pending buffer 刷到 stream_chunks.jsonl
      • 验证刷盘后 Get() 仍能正确读回 chunk 与 aggregated text
  1. 【已实现】修复流式 SSE 渲染 panic 与 pprof 监控自杀式异常退出
  • 修改文件:

  • 背景:

    • 线上容器重启时抓到堆栈落在 common.CustomEvent.Render -> writeData
    • 旧实现对 Data 做了 data.(string) 强制断言,只要流式路径传入的不是 string,就会直接 panic,触发进程重启
    • 同时 common.Monitor 在 CPU 采样失败时会 panic(err),这会把原本只用于诊断的 pprof 监控变成新的退出源
    • 进一步抓到 fatal error: concurrent map iteration and map write,堆栈落在 gin responseWriter.Flush(),说明流式 flush 节流收尾时和其他写协程并发操作了同一个 HTTP response header
  • 实现:

    • writeData 改为使用 fmt.Sprint(data) 序列化任意类型,不再依赖字符串类型断言
    • 保留原有 SSE 数据写入与 data: 前缀补 \n\n 的行为
    • writeData 现在会把底层 writer 错误向上返回,避免静默吞错
    • Monitor 在 CPU 采样失败时改为记录系统日志并继续下一轮,不再 panic 杀进程
    • StreamScannerHandler 的 defer 收尾顺序改为:
      • 先发停止信号并停止 ticker
      • 等待 ping / scanner / data handler 协程退出
      • 最后在 writeMutex 保护下串行执行 FlushPendingWriter
    • 这样最终 flush 不会再和并发中的 Render/WriteHeader 交错执行,避免触发 net/http.Header 的并发 map fatal
  • 验证:

    • 新增 common/custom-event_test.go
    • 覆盖:
      • 字符串 SSE payload 仍保留 \n\n 结束符
      • map[string]any 等非字符串 payload 不再 panic
      • nil payload 不再 panic