问题描述
服务长时间运行后,Node 进程的 RSS 持续上涨,GC 无法回收,最终触发 OOM 或被容器/守护进程杀掉重启。
定位到主要原因是 src/auth/session-affinity.ts 里的 SessionAffinityMap:每次请求成功完成都会往这个进程级单例 Map 中写入一条记录,并把整段 instructions(也就是 system prompt)原样保存。
触发位置
写入侧(每次成功响应都会触发一次):
src/routes/shared/streaming-handler.ts:80
src/routes/shared/non-streaming-affinity.ts:32
affinityMap.record(
capturedResponseId,
capturedEntryId,
conversationId,
turnState,
req.codexRequest.instructions ?? undefined, // ← 整段 system prompt
usageInfo?.input_tokens,
Array.from(metadataCollector.responseFunctionCallIds),
variantHash,
);
记录在内存里保留 DEFAULT_TTL_MS = 4 * 60 * 60 * 1000(4 小时),10 分钟扫一次过期。
量级估算
Codex 的 system prompt 通常在 10–50 KB 之间(包含工具描述时更大)。以一个比较常见的负载估算:
- 50 req/min × 60 × 4h = 12,000 条 / 4 小时
- 单条按 30 KB 估算:≈ 360 MB 常驻内存
数据全在 V8 老生代里,触发 Full GC 也无法回收(仍在 TTL 内),实际表现就是 RSS 单调上涨直到进程被杀。
关键观察
instructions 字段的所有真实使用场景都只是做字符串相等比较:
src/routes/shared/proxy-session-context.ts:79:
normalizeInstructions(currentInstructions) === normalizeInstructions(implicitStoredInstructions)
lookupInstructions / lookupLatestInstructionsByConversationId 的下游消费者也只用于比较
也就是说,完全没有必要保存原文,保存一个稳定 hash 就足够。
影响
- 长跑实例必须靠定时重启兜底
lookupLatestResponseIdByConversationId 是 O(N) 全表扫描,Map 越大每次代理请求越慢
- 小内存机器(512 MB / 1 GB 容器)几乎不可用
建议修法
把 instructions 字段改存 SHA-256 截断后的短 hash(约 22 字节),lookup 端同样做 hash 再比较。语义等价,体积压缩 99% 以上:
import { createHash } from "crypto";
function hashInstructions(s: string | undefined | null): string | undefined {
if (!s) return undefined;
return createHash("sha256").update(s).digest("base64url").slice(0, 22);
}
// record() 内:
instructions: hashInstructions(instructions),
// 比较侧(proxy-session-context.ts):
hashInstructions(normalizeInstructions(currentInstructions)) === entry.instructions
可选的额外加固:
DEFAULT_TTL_MS 从 4h 收紧到 1h(上游 prompt cache 实际寿命远短于 4h,超过 1h 的 affinity 命中率极低,留 1h 已经足够)
- 给 Map 加 LRU 硬顶(例如 5000 条),用
Map 自带的插入顺序淘汰最早项,防止极端流量下失控
lookupLatestResponseIdByConversationId 建一个 Map<conversationId, Set<responseId>> 二级索引,去掉 O(N) 全表扫
环境
- 版本:v2.0.75
- Node:18+
- 部署:Docker / Linux
如果维护者方便,我可以提 PR。
问题描述
服务长时间运行后,Node 进程的 RSS 持续上涨,GC 无法回收,最终触发 OOM 或被容器/守护进程杀掉重启。
定位到主要原因是
src/auth/session-affinity.ts里的SessionAffinityMap:每次请求成功完成都会往这个进程级单例 Map 中写入一条记录,并把整段instructions(也就是 system prompt)原样保存。触发位置
写入侧(每次成功响应都会触发一次):
src/routes/shared/streaming-handler.ts:80src/routes/shared/non-streaming-affinity.ts:32记录在内存里保留
DEFAULT_TTL_MS = 4 * 60 * 60 * 1000(4 小时),10 分钟扫一次过期。量级估算
Codex 的 system prompt 通常在 10–50 KB 之间(包含工具描述时更大)。以一个比较常见的负载估算:
数据全在 V8 老生代里,触发 Full GC 也无法回收(仍在 TTL 内),实际表现就是 RSS 单调上涨直到进程被杀。
关键观察
instructions字段的所有真实使用场景都只是做字符串相等比较:src/routes/shared/proxy-session-context.ts:79:lookupInstructions/lookupLatestInstructionsByConversationId的下游消费者也只用于比较也就是说,完全没有必要保存原文,保存一个稳定 hash 就足够。
影响
lookupLatestResponseIdByConversationId是 O(N) 全表扫描,Map 越大每次代理请求越慢建议修法
把
instructions字段改存 SHA-256 截断后的短 hash(约 22 字节),lookup 端同样做 hash 再比较。语义等价,体积压缩 99% 以上:可选的额外加固:
DEFAULT_TTL_MS从 4h 收紧到 1h(上游 prompt cache 实际寿命远短于 4h,超过 1h 的 affinity 命中率极低,留 1h 已经足够)Map自带的插入顺序淘汰最早项,防止极端流量下失控lookupLatestResponseIdByConversationId建一个Map<conversationId, Set<responseId>>二级索引,去掉 O(N) 全表扫环境
如果维护者方便,我可以提 PR。