Skip to content

feat(routing): 会话首请求上游竞速——等权组内择快并绑定亲和 #234

Description

@g1331

背景

当前上游选择完全由人工配置驱动:候选集经能力匹配、密钥授权、健康/熔断过滤、并发准入后,按 priority 分层、层内按 weight 加权随机选一个。系统不感知上游的实时快慢,个别请求卡在慢上游时只能等超时后 failover。

请求日志中已有完整的耗时数据,但仅用于事后报表,未参与选路决策。

方案:首请求竞速 + 亲和绑定

利用现有会话亲和性机制(session-affinity.ts,绑定键为 (apiKeyId, capability, sessionId))的 miss/hit 分叉作为竞速触发点:

请求进来 → 查亲和性
   ├─ 命中 → 直连绑定上游(会话内绝大多数请求,零额外成本)
   └─ 未命中 → N 路并发竞速 → 胜者响应并写入绑定 → 本会话后续直连胜者

胜者是实测选出的“此刻对该会话最快的上游”,等于每个会话开头免费做了一次真实探测,且后续请求命中胜者的 prompt cache。

设计决策

决策点 结论
触发时机 亲和性 miss 且能提取出 sessionId(提不出的请求走原有选路,防止一次性调用每次都竞速)
参赛资格 先按现有逻辑加权随机选出“种子”,与种子同优先级且同权重的候选构成竞速组
参赛人数 默认 2,上限 3,组内随机抽取;组内只有种子自身时不竞速,直连
胜负判定 首字节到达且状态码非 5xx;输家立即 abort 流
绑定 胜者写入现有亲和性绑定(TTL 语义不变:滑动 5 分钟 / 绝对 30 分钟)
开关 全局开关,默认开启
记录 全部参赛路计入请求日志与计费快照(复用 failoverHistory 结构,竞速≈并行版 failover 首跳)

参赛资格的语义依据

priority 表达“我更想用谁”,weight 表达“我要谁多承担”——竞速若跨层或跨权重,会用“谁快用谁”推翻这些人工意志。同优先级 + 同权重是配置中唯一“毫无偏好声明”的等价组,现有实现对它们用纯随机;把纯随机换成实测竞速,是不覆盖任何人工配置的升级。

由此天然获得细粒度控制面:想让哪些上游竞速就配成等权同级;不想让某个贵上游参赛,挪 1 点权重即可退出。无需逐上游开关与新增 UI。

种子选组机制保证跨组流量分配比例完全不变(weight 语义原样保留),竞速只发生在等权组内部。

成本与已知取舍

  • 竞速不是严格“每会话一次”:亲和绑定 30 分钟到期或空闲超 5 分钟失效后会重新竞速,频率仍然很低。
  • 输家上游照付首轮输入 token 费用(或消耗订阅账号一次请求额度),故 N 默认 2、上限 3。
  • 等权组内 weight 的分流作用会部分失效(谁快谁赢)。存在一定自平衡:胜者负载升高变慢后开始输,流量自然溢出。额度型限制(如订阅 5 小时窗)不体现在延迟上,耗尽报错后由现有 failover 兜住。
  • 已把多个按量付费上游配成等权的存量用户,升级后首轮 token 费会随竞速翻倍,需在发版说明中告知(默认开启的告知义务)。

边界与注意点

  • 竞速期间并发槽位短暂占用 N 个,需与队列准入机制正确交互(并发满的候选本就已被过滤出候选集)。
  • 胜负判定后输家 abort 必须及时,避免继续产生输出 token。
  • 下游响应与错误体不得携带上游身份信息(沿用现有约束)。

不做的事

  • 全量竞速(每请求 N 路):成本 ×N,不做;本方案阈值语义上是其安全子集。
  • 对冲请求(hedged requests,绑定上游中途卡住时补发备胎):与竞速互补,另行考虑。
  • 逐上游“参与竞速”开关与 UI:等权同级配置已是控制面,暂不需要。

Metadata

Metadata

Assignees

No one assigned

    Labels

    feature新功能请求

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions