Skip to content

fix(chat): DEVIN_CONNECT 路径补上 sticky 绑定 — 缓存亲和此前完全失效#230

Merged
dwgx merged 1 commit into
dwgx:masterfrom
wangergou777:fix/connect-sticky-cache-affinity
Jul 26, 2026
Merged

fix(chat): DEVIN_CONNECT 路径补上 sticky 绑定 — 缓存亲和此前完全失效#230
dwgx merged 1 commit into
dwgx:masterfrom
wangergou777:fix/connect-sticky-cache-affinity

Conversation

@wangergou777

@wangergou777 wangergou777 commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

问题

setStickyBinding 目前只在两处被调用,都在 Cascade 成功路径上(chat.jspoolCtxcascadeResult 两个分支),注释也写明了意图是让 cascade_id 在下一轮仍然有效。

DEVIN_CONNECT 路径从不写入绑定。 所以在 connect 部署上设 STICKY_SESSION_ENABLED=1 实际是空转:

[sticky] CHECK  ... enabled=true
[sticky] ENTER  ...
[sticky] MISS   ...

开启后连续 23 次请求,23 次 CHECK / 23 次 MISS / 0 次 SET。查询一直在跑,但没有任何代码写入过绑定。

getApiKey 的候选排序以 lastUsed 升序收尾(最久未使用优先),所以在没有绑定的情况下,系统会主动把同一会话的每一轮派给不同账号。

为什么这很贵

上游 prompt cache 按账号隔离。用付费账号实测(gpt-5-6-sol-max,同一段 payload,直接钉死 token 调用以排除池子干扰):

步骤 metadata #7 判读
账号 A 首发 {2:3, 3:5, 4:1991, 6:66} tag4 = cache write
账号 B 同内容 {2:3, 3:5, 4:1991, 6:66} 仍是 write,read 为 0
账号 A 再发 {2:3, 3:5, 5:1991, 6:66} tag5 = cache read
账号 C 同内容 {2:3, 3:5, 4:1991, 6:66} 又是 write

换账号后整段上下文必须重新全量写入。写入与读取的配额单价实测相差约一个量级,所以每轮换号 = 每轮重付整个累积上下文的成本。

对增长型对话(Codex / Claude Code 的工具循环)影响最大,因为上下文每轮都在变长。

修复后

同一段长对话(每轮新增约 8K token,走 /v1/chat/completions,带稳定的 user 字段):

轮次 |   write  |   read
   1 |     8138 |        0     首轮建缓存
   2 |     8130 |     8138
  10 |     8130 |    73178
  22 |     8130 |   170738     write 恒为本轮增量
  23 |   186998 |        0     绑定账号额度耗尽 → 换号,全量重写
  24 |     8130 |   186998     新账号上重新建立后继续复用

修复前每一轮都是第 23 行那种全量重写。日志也从 0 命中变成 41/44 命中。

第 23 轮说明回退路径工作正常:绑定账号不可用时清除绑定、正常选号、重新绑定,不会拒绝服务(stickyNoFallback=false 时)。

一个容易踩的细节

连接路径取号时 modelKey 恒为 null

acquireConnectAccount   waitForAccount(tried, signal, QUEUE_MAX_WAIT_MS, null, callerKey, selector)
acquireConnectFailover  getApiKey(triedKeys, null, callerKey, selector)

bindingKey()callerKey + '\0' + (modelKey || '*')。所以绑定必须也以 null 写入,否则查询永远落在 callerKey\0* 而绑定落在 callerKey\0<model>,静默不命中 —— 退化成修复前一模一样的空转。测试里对这个失败模式加了回归守卫。

#226 pair-chain 的关系

两者互补,都需要:

  • pair-chain(commitConnectSession)让上游 session_id 在多轮之间稳定。
  • 本 PR 让账号在多轮之间稳定。

prompt cache 存在账号上,所以只有 session_id 稳定是不够的:下一轮仍会被 lastUsed 升序派到别的账号,缓存照样落空、整段上下文重写。反过来只有账号稳定也拿不到 session 续接的收益。

流式路径里这次把绑定放在 commitConnectSession 之前(同一个 r.kind === 'ok' 分支内),两者都是尽力而为、互不影响。

改动

  • src/handlers/chat.js:新增 bindConnectSticky(callerKey, acct),在流式与非流式两条 connect 成功路径各调用一次。尽力而为:入参残缺或 sticky 未开时惰性返回,内部 try/catch 保证绝不因绑定失败影响已服务的请求。
  • test/connect-sticky-affinity.test.js:键形状契约、模型域写入的回归守卫、残缺入参惰性、不抛异常。

行为在 STICKY_SESSION_ENABLED 未开时完全不变isStickyEnabled() 直接返回)。

npm test2751 passed / 0 failed

setStickyBinding 只在两处 Cascade 成功路径被调用(为 cascade_id 续接而设),
DEVIN_CONNECT 路径从不写入绑定。所以在 connect 部署上 STICKY_SESSION_ENABLED=1
实际是空转:getApiKey 每轮都查、每轮都 MISS,随后候选排序以 `lastUsed` 升序收尾,
把同一会话的每一轮主动派给不同账号。

上游 prompt cache 按账号隔离。付费账号实测(gpt-5-6-sol-max,同一 payload):

  账号A 首发   → metadata dwgx#7 tag4(write)=1991
  账号B 同内容 → 仍是 tag4(write)=1991,read 为 0
  账号A 再发   → tag5(read)=1991
  账号C 同内容 → 又是 tag4(write)=1991

即换号后整段上下文必须重新全量写入。写入与读取的配额单价实测相差约一个量级,
因此轮换会让每一轮都重付整个累积上下文的成本。

修复后同一长对话可连续命中缓存:write 恒为本轮增量 8,130,read 随上下文涨到
170,738;此前每一轮都是全量重写。

关键细节:连接路径取号时 modelKey 恒为 null(acquireConnectAccount 与
acquireConnectFailover 都传 null),而 bindingKey 是 `callerKey\0(modelKey||'*')`,
所以绑定也必须以 null 写入 —— 否则查询静默不命中,退化成修复前同样的空转。

+ test/connect-sticky-affinity.test.js:键形状契约、模型域写入的回归守卫、
  残缺入参下的惰性、以及「绝不抛异常」(亲和是尽力而为,不能拖垮已服务的请求)

2751/0 绿。
@wangergou777
wangergou777 force-pushed the fix/connect-sticky-cache-affinity branch from efe3890 to 9b24f98 Compare July 25, 2026 12:50
@wangergou777 wangergou777 changed the title fix(chat): DEVIN_CONNECT 璺緞琛ヤ笂 sticky 缁戝畾 鈥?缂撳瓨浜插拰姝ゅ墠瀹屽叏澶辨晥 fix(chat): DEVIN_CONNECT 路径补上 sticky 绑定 — 缓存亲和此前完全失效 Jul 25, 2026
@dwgx

dwgx commented Jul 26, 2026

Copy link
Copy Markdown
Owner

深度评审通过,合并。评审跑了 6 个独立维度 + 对每个候选问题做了对抗性复核,结论:

诊断链逐条核实成立。 setStickyBinding 全仓确实只有两处 Cascade 成功路径调用;connect 路径只查不写;lastUsed 升序收尾确实主动轮换;modelKey=null 键域分析正确且回归守卫到位;写读单价一个量级与上游定价一致。四个协议面(chat/messages/responses/gemini)一次覆盖。质量与 #224 同级。

随合并在 master 上补的几件事(不影响本 PR 结论):

  1. auth.js sticky 快路径此前不检查 excludeKeys —— 本 PR 写入绑定后,dead-token failover 会被困死在绑定账号上(实测:健康池 + 401,attempts=[B,B,B])。已修 + 回归测试。
  2. bindConnectSticky 补上 cascade 侧写绑定都有的 per-user-scope 门禁,避免共享 key 多租户部署把全部流量漏斗到单账号(单租户用 WINDSURFAPI_SINGLE_TENANT_CACHE=1,与 cascade reuse 同语义)。
  3. call-site 级 e2e 回归(整个修复被 revert 时测试套件此前仍然全绿)。
  4. sticky 统计暴露到 /connect-metrics;STICKY_SESSION_* 补文档。

已知的后续项(不阻塞):首轮并发散射(bind-after-success 固有)、RPM 满清绑定(rebind-on-success 使其影响有限)、prompt_cache_key 作为 Responses 侧稳定亲和信号。

感谢两连高质量根因修复(#217 也补进贡献名单了)。

dwgx added a commit that referenced this pull request Jul 26, 2026
…rapped on a dead bound account

getApiKey 的 sticky 快路径此前不检查 excludeKeys。connect 路径没有绑定时
这是死代码;一旦有绑定(#230 补上写入后),dead-token failover 每一跳都
会被快路径把同一个死账号原样交回:triedKeys 形同虚设,循环在一个账号上
烧完 maxHops 后 401,健康账号全程闲置,且重复 reportError 把绑定账号
连带打成 status=error。

dead token 是唯一中招的错误类别:QUOTA_EXHAUSTED 写 quotaResetAt、
RATE_LIMITED 写 rateLimitedUntil,快路径的健康检查都拦得住;而首个
UNAUTHORIZED 只记 health event,账号仍 active 零冷却,只有 excludeKeys
能把它排除。

- src/auth.js: 快路径增加 !excludeKeys.includes(acct.apiKey);被排除时
  落入既有 no-longer-usable 分支(noFallback 保留绑定返回 null,否则
  clearStickyBinding 后正常选号)
- test/sticky-exclude-keys.test.js: failover 不得重发已排除的绑定账号 +
  排除后照常重绑定,两条回归
@dwgx
dwgx merged commit d231d44 into dwgx:master Jul 26, 2026
5 checks passed
dwgx added a commit that referenced this pull request Jul 26, 2026
…metrics

getStickyStats() 此前全仓零消费者 —— 运营上无法确认
STICKY_SESSION_ENABLED 是否真的在绑定(#230 之前 connect 部署
永远 0 SET,靠数日志行才发现)。现在挂到已有的 /connect-metrics:

- dashboard/api.js: 响应新增 sticky: { enabled, hits, misses, creates,
  expires, evictions, fallbacks, size }
- sticky-session.js: 新增 noteStickyFallback();auth.js 在绑定账号不可用
  清除绑定处调用,fallbacks 计数从常量 0 变为可观测信号
- test/dashboard-api.test.js: 断言 sticky 字段形状
dwgx added a commit that referenced this pull request Jul 26, 2026
…stable scope signals

Responses 链式客户端(codex 等)用 previous_response_id 串轮次,而它每轮
都变 —— 落进 candidates join 后 callerKey 每轮重铸,sticky 绑定和 cascade
亲和永远攒不起来(#230 的收益在这类客户端上直接归零)。

OpenAI 对 user 的两个正式后继字段本来就是为此设计的:safety_identifier
是稳定终端用户标识,prompt_cache_key 是客户端显式的缓存亲和路由键。
两者按 user 同样的方式短路取值(优先级 user > safety_identifier >
prompt_cache_key > 原 candidates join),空值照旧 fail-closed 不铸 scope。

+ test/caller-key.test.js: 跨轮稳定性(previous_response_id 变动下 scope
  不变)、优先级、空值回退
dwgx added a commit that referenced this pull request Jul 26, 2026
…#133 but was never documented

dashboard 上两个依赖它的实验开关(stickyBindByUserOnly/stickyNoFallback)
早有完整 UI 文案,主开关本身却在 README 和 .env.example 里零文档 ——
#230 让它在 connect 部署上第一次真正生效,补上:

- README.md / README.en.md: 环境变量表新增三行,含 DEVIN_CONNECT 上的
  缓存经济学动机(按账号隔离、写≈10x读)、per-user-scope 前置条件、
  SINGLE_TENANT_CACHE 单租户 opt-in、connect-metrics 观测入口
- .env.example: 新增 Sticky session 小节
dwgx added a commit that referenced this pull request Jul 26, 2026
#217 responses-lite additional_tools 兼容当时漏进台账(台账从 #216 直接
跳到 #219),连同本次 #230 sticky 绑定一起补上。githubId=142420084。
npm run sync:contributors 已同步 docs 侧。
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