#1456 | 2026-10-12 11:37:54

今天终于解决一个困扰了我三年的内存泄漏问题,原来全是依赖项辣鸡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 天翻倍。单调增长 = 泄漏,而非高水位。

定位

  1. node scripts/memtest/analyze-snapshot.js <a.heapsnapshot> <c.heapsnapshot> 按 type + name 汇总 self size 做差,增长集中在 string(+36MB)和 array(+14.6MB)。

  2. 按字符串内容前缀聚合:顶部前缀(各种 require 源码串)三份快照几乎不变,说明增量不是 某些定长串,而是大量内容各异的小串(<64 字符桶 20 万 → 75 万)。

  3. 从 GC root 做 BFS 找保留路径,最大单节点是一个 14MB 的空名数组:

    (GC roots)
      → Module ./node_modules/gpt-3-encoder/Encoder.js
        → closure "encode"
          → system / Context
            → Map "cache"
              → array "" .table        (14 MB)
    
  4. 按「字符串被谁持有」聚合,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。服务端输入无穷无尽 → 无界增长,等价内存泄漏。