Skip to content

multi2api v1.9.12

Choose a tag to compare

@github-actions github-actions released this 20 Oct 04:08
· 118 commits to master since this release

会话粘性:四个层面全部修复

会话粘性自 v1.8.0 起从未生效过。这个版本把它修完了 —— 而且修的不止报告里那一个。

层面一:ExtractKey 只认客户端不发的字段

ExtractKey 只看 metadata.conversation_id / conversation_id / metadata.user_id,而 OpenAI 兼容客户端(DSH / Cline / Continue 等绝大多数)从不发送这些字段 —— sessionId 只在 baseUrl 含 api.openai.com 时才写进 prompt_cache_key,x-session-affinity 头只对 OpenRouter 启用。

修法:加第 4 级兜底「对话前缀指纹」—— model + messages 前两条(system 截 512 字符)的 FNV-1a。多轮对话的 messages 是前缀增长的,开头两条永远不变 ⇒ 指纹在整段对话里恒定,这正是缓存亲和需要的不变量,且不需要客户端配合。

三个刻意的取舍:只取前两条(全量会随轮次漂移)、system 截 512(防时间戳)、形态不认识时返回空(宁可轮换也不算出会漂移的键)。

层面二:分配时从全池挑号,把会话绑到别的上游

修完层面一,真机隔离实例实测仍然不粘 —— 每轮换一个号,decision 全是 sticky_unavailable:session_rule。

诊断日志给出确切原因:

[DIAG pickByUID] provider 不匹配: uid=0234c548-... want=loomy got=workbuddy

Router.Resolve 的慢路径自己就分配并绑定账号,而候选池来自 Config.Available,main.go 里赋的是 pool.AvailableUIDs() —— 它等价于 AvailableUIDsFor(DefaultProvider),只返回默认上游的号。

⇒ loomy 的会话被绑到 workbuddy 的号 ⇒ PickByUIDFor("loomy", …) 必然返回 nil ⇒ Unbind ⇒ 下一轮重复 ⇒ 死循环。

修法:Resolve(key, provider) 带上游;Config.AvailableFor(provider) 按上游取候选(pool.AvailableUIDsFor 早就存在,只是没接上)。

层面三:绑定表纯内存,重启即丢

redis_mode: noop 时 Store 是全空实现,而代码注释里写着"防重启丢粘性" —— 设计意图与默认行为背道而驰。

实测代价:msgs=1146 花 5.59 credit、msgs=753 花 1.76 credit,仅仅因为重启(正常请求 0.1~0.3)。

修法:新增 redisstore.LocalFile(data/sticky-bindings.json):

  • 原子替换(写 .tmp 再 rename;Windows 先 Remove 再 Rename,避免半截 JSON)
  • 节流 500ms(SetBind 每轮都调,每次落盘会打满盘)
  • 只存 key→uid,TTL 不落盘(过期仍由 lastActive 判定)
  • 带 version 字段(将来改结构能识别旧文件,而不是静默读成空表)
  • 加载失败一律当空表 —— 粘性是优化不是契约

层面四:loomy 上下文超限判成"只换号不罚"

workbuddy 侧的分类器早就覆盖了这些形态,真实缺陷在 loomy:它的分类器只有 7 个 marker,完全没有上下文超限判定,落到 DefaultErrorKind(400) ⇒ ErrKindClient(只换号不罚)。

⇒ 一次"这次请求太长"会把 loomy 的 46 个号全轮一遍(每个号收到的 body 一样长,换到哪都必然超限),最后报成笼统的 503。

修法:加 8 条 context marker(中英双形态)+ isContextExceeded(),判 ErrKindFatalClient —— 不换号、直接结束、透传上游原文。


实测验证

层面一 + 二(隔离实例,78 个真账号:loomy 46 / workbuddy 6 / workbuddy-intl 17 / 其他 9):

修复前(同一段对话 6 轮)
11:42:09  uid=260913075224031074  decision=sticky_unavailable:session_rule
11:42:13  uid=260825100642697603  decision=sticky_unavailable:session_rule
11:42:17  uid=260825101823357254  decision=sticky_unavailable:session_rule
→ 每轮换一个号

修复后(同一段对话 6 轮)
11:46:36  uid=260913075224031074  decision=(空)
11:46:40  uid=260913075224031074  decision=
11:46:43  uid=260913075224031074  decision=
11:46:46  uid=260913075224031074  decision=
11:46:49  uid=260913075224031074  decision=
11:46:53  uid=260913075224031074  decision=
→ 100% 粘在同一账号

层面三(杀进程模拟重启):

第 1 次启动
  sticky_sessions = 1
  绑定文件 {"version":1,"binds":{"pfx:b1c5ab74":"260924011154958976"}}

杀进程 → 第 2 次启动
  sticky_sessions = 1                      ← 恢复成功
  重启前 uid = 260924011154958976
  重启后 uid = 260924011154958976           ← 同一账号

层面四:只有单测。端到端实测没做成 —— 440 KB 请求(275,531 tokens)在 deepseek-v4.1-flash 上直接成功,说明它的窗口远大于报告里的 100,000,没能触发超限。


守卫测试(11 条)

StableAcrossTurns                ★ 1/2/3 轮必须同键
DiffersByFirstUser               不同首条 user 不同键
DiffersByModel                   不同模型不同键
ExplicitKeyWins                  显式键优先
ContentBlocksFlattened           内容块数组折平后同键
UnknownShapeReturnsEmpty         形态不认识返回空
SystemTimestampTailDoesNotDrift  system 尾巴时间戳不影响指纹
ResolveRespectsProvider          ★ loomy 只拿到 loomy 的号(20 次随机 key)
ResolveStickyAcrossTurns         同一 key 10 轮不换号
ResolveEmptyProviderPoolReturnsFalse  该上游无号时返回 false,不跨界兜底
AvailableForFallsBackToAvailable 没配 AvailableFor 时退回 Available
ContextExceededIsFatalClient     ★ 8 种超限形态必须判 FatalClient
ContextExceededNotClient         反证:不能漏判成 Client
FatalDoesNotPunishAccount        防过度修复:不能落进任何"罚账号"类别
InvalidRequestStillFatal         回归:原有 invalid_request 判定不被挤掉
OtherMarkersUnaffected           回归:其余 5 个 marker 不受影响

升级注意

前端 embed 在二进制里,必须重新 build:

git pull
go build -o bin\wb2api-server.exe ./cmd/server

重启后验证:/status 的 sticky_sessions 不再是 0;同一会话连发多轮,request-log 里的 uid 应稳定;data/sticky-bindings.json 会出现并在重启后保留绑定。