今天终于解决一个困扰了我三年的内存泄漏问题,原来全是依赖项辣鸡gpt-3-encoder的锅
事实上3年前就有人发现这个问题并提了issue: https://github.com/latitudegames/GPT-3-Encoder/issues/35 可是到今天都没修。。甚至没有维护者回复😅那你发个毛的npm 包给人用呢。。
我让AI通读了我的项目代码,没找出根因,然后它写了一份内存监测脚本,我在服务器跑了6天,前后分别dump了份堆快照,然后让codex分析才终于找出来罪魁祸首。
AI的总结如下:
已定位的真实泄漏:gpt-3-encoder 的全局 BPE 缓存
结论先说:gpt-3-encoder 在 Encoder.js 里用一个模块级、无上限、永不淘汰的 Map
缓存 BPE 结果,长跑进程里随唯一 token 数持续增长。已改为 js-tiktoken 的 r50k_base
(计数逐条一致)并移除该依赖。见 utils/count.js。
现象
dump/ 下三份线上 .heapsnapshot(同一 PID 1761309,抓取前 v8 会强制 GC,故为存活内存):
| 快照 |
时间 |
文件 |
节点数 |
边数 |
存活自持内存 |
string 节点 |
| A |
10/06 01:52 |
54MB |
499,604 |
2,148,238 |
47.7MB |
176,389 |
| B |
10/06 |
57MB |
520,723 |
2,288,522 |
50.8MB |
184,507 |
| C |
10/12 10:47 |
157MB |
1,096,590 |
3,499,224 |
102.1MB |
747,182 |
A→B 间隔约 2.5h 只涨 ~6%;B→C 约 6 天翻倍。单调增长 = 泄漏,而非高水位。
定位
-
node scripts/memtest/analyze-snapshot.js <a.heapsnapshot> <c.heapsnapshot> 按 type + name
汇总 self size 做差,增长集中在 string(+36MB)和 array(+14.6MB)。
-
按字符串内容前缀聚合:顶部前缀(各种 require 源码串)三份快照几乎不变,说明增量不是
某些定长串,而是大量内容各异的小串(<64 字符桶 20 万 → 75 万)。
-
从 GC root 做 BFS 找保留路径,最大单节点是一个 14MB 的空名数组:
(GC roots)
→ Module ./node_modules/gpt-3-encoder/Encoder.js
→ closure "encode"
→ system / Context
→ Map "cache"
→ array "" .table (14 MB)
-
按「字符串被谁持有」聚合,array(空名,即上面这张 Map 的 table)随时间爆炸:
|
A |
B |
C |
array(空名) 持有的字符串 |
0.55MB × 5,897 |
0.97MB × 13,153 |
35.04MB × 573,400 |
对照:encoder / bpe_ranks 的两张 3MB 静态表三份快照大小恒定,.decoder 恒为 0.575MB——
只有 cache 在涨。14MB(table) + 35MB(缓存串) 基本就是 B→C 那 +51MB 的全部。
根因
node_modules/gpt-3-encoder/Encoder.js(v1.1.4):
const bpe_ranks = dictZip(bpe_merges, range(0, bpe_merges.length))
const cache = new Map; // 模块级:进程内共享,永不回收
function bpe(token) {
if (cache.has(token)) return cache.get(token)
...
cache.set(token, word) // 只写不删,没有上限/淘汰
return word
}
每次分词遇到新的 token 就永久写进 cache。服务端输入无穷无尽 → 无界增长,等价内存泄漏。