Skip to content

feat: 新增 key-affinity 路由策略与可配置亲和时长(默认 60m) - #97

Open
xyonium wants to merge 7 commits into
Mag1cFall:mainfrom
xyonium:feat/key-affinity
Open

xyonium wants to merge 7 commits into
Mag1cFall:mainfrom
xyonium:feat/key-affinity

Conversation

@xyonium

@xyonium xyonium commented Oct 31, 2026

Copy link
Copy Markdown

关联 Issue: #96

动机

现有两种策略都解决不了"多会话并发 + 上游缓存命中"的组合需求:

  • round-robin 按请求轮转,同一客户端会话的连续请求散落到不同账户,上游 Playground 的隐式前缀缓存(cachedContentTokenCount)几乎打不中;
  • fill-first 把所有流量压到第一个可用账户,单账户 RPM 更早触顶,且日配额集中烧在一个号上。

key-affinity 用请求命中的 API key 作为会话标记,并在有效期内(KEY_AFFINITY_TTL,默认 1h / 60 分钟)记忆该 key 上一次实际命中的账户:

  • 1 小时内持续复用上一次命中的账户(保证多轮对话隐式前缀缓存命中);
  • 首次分配或过期后按轮询分配空闲热账户(不同 key 天然均匀分散到不同空闲 Warm 账户,不会像静态哈希那样碰撞扎堆);
  • 故障转移后锁定新账户(不回跳):当原粘性账户因满载或冷却不可用时,自动从已预热账户(无热账户则唤醒冷备)轮询切换到新账户,并立即将该 key 的粘性目标更新为新账户——后续即使原账户解除冷却/占用也不会跳回,避免来回横跳导致上下文缓存二次断档。

行为

  • ROUTING_STRATEGY=key-affinity 启用,默认仍为 round-robin。
  • KEY_AFFINITY_TTL=1h 配置同一 API key 保持使用上一次命中账户的有效时长(默认 60m/1h,要求正数时长,设置页修改后立即热生效)。
  • 亲和种子 = 请求命中的 API key 的 SHA-256(只存哈希,原始 key 不进上下文、不落盘、不记日志)。
  • 记忆粘性 + 轮询兜底:
    • 若 StickySeed 在 stickyBySeed 表中有未过期的绑定记录,将该账户排在候选首位,并在成功获取租约时刷新 updatedAt;
    • 若首次出现、超过 KEY_AFFINITY_TTL 未活跃、或原绑定账户当前满载/冷却,按 round-robin 顺序从当前可用 WarmReady/WarmAvailable 账户(热池全满时按序唤醒 StandbyReady)选取下一个账户,并将 stickyBySeed[seed] 更新为本次实际命中的账户。
  • 无种子安全回退:未配置 PROXY_API_KEY 或请求未命中 key(StickySeed 为空)时,自动按 round-robin 轮询分配。
  • round-robin / fill-first 的账户选择逻辑完全不变;等待队列粒度变化见下方"评审确认的行为变化"。
  • 当前仅聊天生成请求(generateWithRetry)生效(其余请求类型的亲和接线留作后续 PR)。

实现

  • internal/config/config.go:Validate 接受 key-affinity;新增 KeyAffinityTTL(环境变量 KEY_AFFINITY_TTL,默认 60m)。
  • internal/api/middleware.go:认证中间件把命中 key 的 SHA-256 写入请求上下文,导出 APIKeyAffinitySeed(ctx)。
  • internal/aistudio/accounts.go + channel.go:AccountSelection 新增 StickySeed;AccountPool 新增 stickyBySeed 映射与 SetKeyAffinityTTL;selectionIndicesLocked 先按 round-robin 旋转候选,再把有效期内绑定的粘性账户提到首位;tryAcquireLocked 在成功获取租约时记录/更新 stickyBySeed 并惰性清理过期项。
  • internal/app/runtime.go + admin.go + internal/api/admin.go:启动与管理端热更新同步设置 SetRoutingStrategy 与 SetKeyAffinityTTL;生成调度重试循环注入种子;internal/app/dispatch.go:dispatchKey 纳入种子,不同种子的等待队列分开。
  • 设置页新增策略选项与 key_affinity_ttl 输入框(选 key-affinity 时显示,立即生效)+ 7 种语言文案 + 后端错误文案国际化 + .env.example / README.md / README_en.md。

测试

  • TestValidateRoutingStrategyKeyAffinity:校验 key-affinity、默认 60m TTL、非法值拒绝与持久化读写。
  • TestAdminConfig:验证管理端保存与热更新 key_affinity_ttl。
  • TestAuthMiddlewareAPIKeyAffinitySeed:种子为命中 key 的 SHA-256,未配 key 为空。
  • TestKeyAffinityDeterministic / SpreadsSeeds / FailoverSticksToNewAccount / TTLExpiration / WithinAllowedSubset / EmptySeedRotates / IgnoredUnderRoundRobin:覆盖有效期内粘性保持、新种子轮询分散、故障转移后留在新账户不回跳、TTL 过期后重新轮询、子集过滤、空种子轮转与策略隔离。
  • TestDispatchKeyIncludesStickySeed:种子参与排队分组。
  • 全量 go test -count=1 ./internal/... 与前端 vue-tsc --noEmit && vp build 通过。

评审确认的行为变化(显式接受)

  • dispatchKey 纳入种子后对所有策略生效:round-robin/fill-first 下不同 API key 的等待请求也分成独立队列。账户轮转逻辑不变、wakeHeads 逐队列唤醒队首、无死锁/饥饿;跨 key 严格 FIFO 变为按 key 分组 FIFO(各 key 内仍严格 FIFO)。评审确认良性且更合理,显式接受。

已知范围边界(后续 PR)

  • 本 PR 仅在聊天生成主路径(generateWithRetry + acquireWarmLease)注入 StickySeed,因为上游隐式前缀缓存在多轮对话生成中收益最大:
    • embeddings / transcriptions / bidi 虽然在 internal/app 层通过 ClassifyCandidates + OrderCandidates 构造候选列表,但其下游在 internal/aistudio 包内调用 resolveAccountLease / pool.AcquireFor 时会用不带 StickySeed 的新 AccountSelection 重新排序,需要改动 OrderCandidates 签名并在 aistudio 层的重试循环中透传种子才能端到端生效;
    • CountTokens / GenerateTitle / GenerateImage / GenerateVideo 则直接在 internal/aistudio 包内构造 AccountSelection(受 api → aistudio 单向依赖限制无法直接读 api 包上下文)。
    • 以上非聊天路径留作后续 PR 统一扩展。
  • 更完整的会话亲和(X-Session-ID、previous_response_id、prompt_cache_key、metadata.user_id 等多种子来源)可在 APIKeyAffinitySeed 处扩展,见关联 FR。

本地验证说明

  • Go 侧:全量构建 + go test -count=1 ./internal/... 全部通过。
  • 前端:npm run build(含 vue-tsc --noEmit 与 vp build)通过。

@Mag1cFall Mag1cFall left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CPA是按客户端自带的会话id或者对话开头内容的哈希来区分会话,并不是API key,这里逻辑有点诡谲

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

多个会话共用一个key怎么办呢?

Comment thread internal/config/config.go
return fmt.Errorf("ROUTING_STRATEGY 必须是 round-robin、fill-first 或 key-affinity")
}
if c.KeyAffinityTTL <= 0 {
return fmt.Errorf("KEY_AFFINITY_TTL 必须是正数时长")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

配置校验已经保证是正数了

@Mag1cFall

Copy link
Copy Markdown
Owner

说实话逻辑并不是很直观,且从根本上由于缓存是跨账号的,这个PR很可能并不需要;我暂时不会合入,除非官网的策略变动

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants