Repository navigation
multi2api v1.9.12
会话粘性:四个层面全部修复
会话粘性自 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 会出现并在重启后保留绑定。