fix(chat): DEVIN_CONNECT 路径补上 sticky 绑定 — 缓存亲和此前完全失效#230
Merged
Conversation
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
force-pushed
the
fix/connect-sticky-cache-affinity
branch
from
July 25, 2026 12:50
efe3890 to
9b24f98
Compare
Owner
|
深度评审通过,合并。评审跑了 6 个独立维度 + 对每个候选问题做了对抗性复核,结论: 诊断链逐条核实成立。 随合并在 master 上补的几件事(不影响本 PR 结论):
已知的后续项(不阻塞):首轮并发散射(bind-after-success 固有)、RPM 满清绑定(rebind-on-success 使其影响有限)、 感谢两连高质量根因修复(#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
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 小节
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
问题
setStickyBinding目前只在两处被调用,都在 Cascade 成功路径上(chat.js里poolCtx与cascadeResult两个分支),注释也写明了意图是让cascade_id在下一轮仍然有效。DEVIN_CONNECT 路径从不写入绑定。 所以在 connect 部署上设
STICKY_SESSION_ENABLED=1实际是空转:开启后连续 23 次请求,23 次 CHECK / 23 次 MISS / 0 次 SET。查询一直在跑,但没有任何代码写入过绑定。
而
getApiKey的候选排序以lastUsed升序收尾(最久未使用优先),所以在没有绑定的情况下,系统会主动把同一会话的每一轮派给不同账号。为什么这很贵
上游 prompt cache 按账号隔离。用付费账号实测(
gpt-5-6-sol-max,同一段 payload,直接钉死 token 调用以排除池子干扰):{2:3, 3:5, 4:1991, 6:66}{2:3, 3:5, 4:1991, 6:66}{2:3, 3:5, 5:1991, 6:66}{2:3, 3:5, 4:1991, 6:66}换账号后整段上下文必须重新全量写入。写入与读取的配额单价实测相差约一个量级,所以每轮换号 = 每轮重付整个累积上下文的成本。
对增长型对话(Codex / Claude Code 的工具循环)影响最大,因为上下文每轮都在变长。
修复后
同一段长对话(每轮新增约 8K token,走
/v1/chat/completions,带稳定的user字段):修复前每一轮都是第 23 行那种全量重写。日志也从 0 命中变成 41/44 命中。
第 23 轮说明回退路径工作正常:绑定账号不可用时清除绑定、正常选号、重新绑定,不会拒绝服务(
stickyNoFallback=false时)。一个容易踩的细节
连接路径取号时
modelKey恒为null:而
bindingKey()是callerKey + '\0' + (modelKey || '*')。所以绑定必须也以null写入,否则查询永远落在callerKey\0*而绑定落在callerKey\0<model>,静默不命中 —— 退化成修复前一模一样的空转。测试里对这个失败模式加了回归守卫。与 #226 pair-chain 的关系
两者互补,都需要:
commitConnectSession)让上游 session_id 在多轮之间稳定。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 test→ 2751 passed / 0 failed。