◉ Pulse
nnkiirue99-maker avatar
#31 today

nnkiirue99-maker/llm-memory-postmortem

Python AI

把 530M 的 ML 栈请出 Flask 主进程,顺便让语义检索快了 32 倍。一次真实排查的完整记录:怎么证伪内存泄漏、为什么拆进程而不换 ONNX、以及 profile 如何打脸我的直觉。

Created Jul 27, 2026
Stars
7
Today
+7
Forks
1
Quality
90/100
Momentum
19

README

Fetched from GitHub on Oct 30, 2026, 11:02 PM UTC · README.md

View original on GitHub

Third-party content — Open Source Pulse is not the owner of this repository.

This README belongs to nnkiirue99-maker/llm-memory-postmortem and its contributors. We fetch it directly from the project’s public GitHub repository and display it for reference only. All text, code, images and trademarks remain the property of their respective authors. See the repository for its license terms. Copyright / takedown policy.

把 530M 的 ML 栈请出 Flask 主进程,顺便让检索快了 32 倍

一次真实的排查记录。起因是一个跑在 3.6G 内存小机器上的 Flask 应用,进程内存三天涨到 888M。

结论先放这里,省得你翻到最后:

之前 之后
Flask 主进程 RSS 888M(三天) 68M(稳定)
一次语义检索 10.99s 0.34s
检索结果 — 逐位一致,没变

两件事其实没什么关系,是排查过程中前后脚撞上的。但它们合起来说明一个道理:动手优化之前先测量,你以为的瓶颈通常不是瓶颈。我在这篇里两次猜错,都写出来了。


一、现象

一个 Flask 应用,功能是笔记 + 对话,带语义检索(bge 中文小模型做 embedding,BM25 做关键词兜底)。机器 3.6G 内存。

现象是主进程 RSS 一路涨:

重启后          79M
17 分钟后      662M
三天后         888M

看起来像内存泄漏。但"看起来像"不等于是。

二、第一步:先证伪,别急着抓泄漏

如果真是泄漏,那一定有某个代码路径反复执行、每次留下一点。最省事的验证方式是按真实流量比例打请求,看 RSS 和对象数怎么走。

我没用 py-spy、没上 tracemalloc,就用 Flask 自带的 test_client 在独立进程里跑:

import gc, collections, time

def rss_kb():
    for line in open('/proc/self/status'):
        if line.startswith('VmRSS:'):
            return int(line.split()[1])

def obj_profile():
    c = collections.Counter()
    for o in gc.get_objects():
        c[type(o).__name__] += 1
    return c

import app as A
client = A.app.test_client()

gc.collect()
base_rss, base_obj = rss_kb(), obj_profile()

# 1) 先测后台线程空转:什么都不请求,只等
time.sleep(45)
gc.collect()
print('空转 45 秒:', (rss_kb() - base_rss) / 1024, 'M')

# 2) 再按真实流量比例逐个端点压
for method, url in ENDPOINTS:
    gc.collect(); before = rss_kb()
    for _ in range(150):
        client.get(url)
    gc.collect(); after = rss_kb()
    print(f'{url:42} {(after-before)/1024:+.1f}M')

# 3) 最后看对象类型的净增长
gc.collect()
for name, n in (obj_profile() - base_obj).most_common(12):
    print(f'{name:28} +{n}')

完整脚本在 code/memprobe.py。

结果:

空转 45 秒后                    +0.3M
/api/notes_by_date              +0.3M
/api/profile                    -0.3M
/api/feed                       +0.8M
/api/tags/list                  +0.0M
/api/messages                   +2.0M
/api/shelf                      +0.0M
...
1200 个请求总计                 +3M

对象数增长 top:
  function                     +23
  ReferenceType                +19
  builtin_function_or_method   +19
  SplitResult                  +18
  list                          +4

读接口是干净的。 1200 个请求只涨 3M,对象增长几十个而且全是模块导入的正常副产物(function、ModuleSpec、SourceFileLoader 这些是首次走到某分支时 import 带进来的,一次性)。后台线程空转也干净。

那 888M 从哪来的?

三、分层测:不是泄漏,是一次性加载

既然不是反复累积,那就是某个东西一次性吃掉一大块然后赖着不走。分层测最直接:

def rss_mb():
    for line in open('/proc/self/status'):
        if line.startswith('VmRSS:'):
            return int(line.split()[1]) / 1024

print(f'空进程              {rss_mb():.0f}M')
import vector_search
print(f'import 检索模块      {rss_mb():.0f}M')
vector_search.search('随便查点什么', limit=3)
print(f'首次 search 之后     {rss_mb():.0f}M')

输出:

空进程              11M
import 检索模块      42M
首次 search 之后    571M     ← 一次 +529M

再往下拆一层,import torch 这一句就吃 163M:

import vector_search   42M
import torch          205M

真相:检索模块里的模型是 lazy load 的,

_model = None

def get_model():
    global _model
    if _model is None:
        from sentence_transformers import SentenceTransformer
        _model = SentenceTransformer(MODEL_NAME)
    return _model

第一次有人调 search(),torch 运行时 + 模型权重 + 分词器词典整套被拉进 Flask 主进程,然后就永远待在那儿了。

这不是 bug,_model 缓存住是对的,重复加载才有问题。问题在于它待错了地方:一个要频繁重启、要保持轻快的 Web 进程里。

顺带解释了那个"三天 888M"的曲线:571M 是一次性的台阶,之后每次检索还留 0.4M 左右(torch 的 CPU 分配器和 glibc 堆碎片,会趋于平台期,不是无限涨)。50 次检索之后 gc 干净了是 593M。台阶 + 缓慢累积,三天正好凑到 888M。

教训一:RSS 曲线像泄漏,不代表是泄漏。先分层测一次性成本,再谈累积。

四、两个方案,为什么选了看起来省得更少的那个

摆在面前两条路:

A. 换 ONNX Runtime,把 torch 那 163M 省掉。sentence-transformers 支持 onnx 后端,推理还更快。

B. 把检索拆成独立进程,主进程通过 HTTP 调。

直觉上 A 更"根治",B 只是把 530M 从这个进程挪到那个进程,总量没变。

我选了 B。理由:

A 动的是 embedding 计算本身。 数据库里已经存着一批用 torch 算出来的向量。换成 ONNX 之后新算的向量哪怕只有极小偏差,新旧向量就不在同一个空间里了,检索质量会悄悄变差。这种问题不报错,只是结果慢慢变得不相关,等你察觉可能已经过去很久。要保险就得全库重新 embed。

A 省得也没听起来那么多。 torch 走了,onnxruntime 自己要占几十 M,模型权重照样得加载。净省估计 150 到 200M,不是 529M。

B 的风险是零。 计算逻辑一行不改,向量一个比特不变。而且它能给一个 A 给不了的东西:独立进程可以在空闲时整个退掉,那才是真省内存,而不是搬家。

两者不冲突。先做 B,将来想再压,在检索服务内部换 ONNX。那时候即使向量出问题,影响面也只在那一个服务里。

教训二:选方案要看风险面,不要只看收益数字。能回滚的小收益胜过不能回滚的大收益。

五、实现

服务端

一个几十行的 Flask 服务,把需要模型的操作暴露成三个接口。完整代码见 code/vecsrv.py:

@app.route('/search', methods=['POST'])
def api_search():
    _touch()                       # 记一下最后请求时间,给空闲判断用
    d = request.get_json(force=True) or {}
    import vector_search
    with _lock:
        hits = vector_search.search(d['query'], limit=d.get('limit', 8), ...)
    return jsonify({'ok': True, 'results': [_jsonable(h) for h in hits or []]})

要注意 _jsonable():检索结果里混着 sqlite3.Row 和 numpy 标量,直接 jsonify 会炸。而且 embedding 的 bytes 字段不该回传:

def _jsonable(row):
    import numpy as np
    out = {}
    for k, v in dict(row).items():
        if isinstance(v, (np.floating, np.integer)):
            out[k] = v.item()
        elif isinstance(v, (bytes, bytearray)):
            continue                 # embedding blob 不回传
        else:
            out[k] = v
    return out

瘦客户端

这是整个改动里最关键的设计。客户端提供和原模块同名的函数,需要模型的走 HTTP,纯 numpy 的本地算:

# 走 HTTP
def search(query, limit=8, **kw): ...
def encode_text(text): ...
def embed_note(note_id, content): ...

# 本地算,不引入 torch
def vec_to_blob(vec):  return np.asarray(vec, dtype=np.float32).tobytes()
def blob_to_vec(blob): return np.frombuffer(blob, dtype=np.float32)
def cosine_sim(a, b):  return float(np.dot(a, b))

这样调用点只改 import 那一行:

- import vector_search
+ import vector_client as vector_search

其余代码一个字不用动。 五个调用点全部是这样改的,改动量小到可以一眼看完。

再加一个防呆,免得有人误调:

def get_model():
    raise RuntimeError('get_model() 在瘦客户端里不可用:模型只住在检索服务进程里。'
                       '需要向量请改用 encode_text(text)。')

降级策略要分清

三个接口失败时的处理必须不一样,这点值得单独说:

def search(query, **kw):
    try:
        return _post('/search', {...})['results']
    except Exception as e:
        _log(f'search degraded ({e})')
        return []          # 少一段相关记忆,功能照常,不该让整个请求失败

def embed_note(note_id, content):
    try:
        return _post('/embed_note', {...})['ok']
    except Exception as e:
        _log(f'embed_note({note_id}) failed: {e}')
        return False       # 后台补偿任务会重跑,静默失败可以接受

def encode_text(text):
    d = _post('/encode', {'text': text})
    if not d.get('ok'):
        raise RuntimeError(...)   # 调用方拿它的结果写库,静默返回 None 会写坏数据

search 静默降级,encode_text 必须抛。判断标准是失败的后果写不写进持久化状态。

哪些调用点不用改

短命的定时任务(cron 里跑完就退的那些)保持直连。它们各自加载一次模型然后进程就结束了,走 HTTP 反而多此一举,还多一个失败点。

只有常驻进程需要瘦身。

六、空闲卸载:一个失败的尝试和一个成功的

拆完之后,检索服务空闲时占着 578M 明显浪费。第一版我写了软卸载:

def _unload():
    import vector_search
    vector_search._model = None
    gc.collect()
    ctypes.CDLL('libc.so.6').malloc_trim(0)   # 让 glibc 把堆还给系统

实测:

卸载前 578M → 卸载后 492M

只吐出来 86M。

原因是 libtorch_cpu.so 那 400 多 M 是 mmap 进来的共享库映射,把 _model 置空只释放了模型权重和推理时的缓存,那个 .so 的映射还在。malloc_trim 对 mmap 区域也无能为力。

改成让进程整个退掉,交给 systemd 拉起:

def _janitor():
    while True:
        time.sleep(60)
        if time.time() - _last_req > IDLE_UNLOAD_SEC and _model_loaded():
            log(f'空闲 {IDLE_UNLOAD_SEC}s,退出让 systemd 回收 (RSS {_rss_mb():.0f}M)')
            os._exit(0)
[Service]
Restart=always
RestartSec=5

这样空闲后回到 52M。实测走了一遍:120 秒空闲触发退出,重新请求能正常拉起并返回结果。

代价是有一个 5 秒的重启窗口。客户端加重试兜住:

def _post(path, payload, timeout, retries=2):
    last = None
    for i in range(retries + 1):
        try:
            r = requests.post(BASE + path, json=payload, timeout=timeout)
            r.raise_for_status()
            return r.json()
        except Exception as e:
            last = e
            if i < retries:
                time.sleep(3)      # 覆盖 RestartSec=5 的窗口
    raise last

教训三:卸载一个大对象和释放它占的内存是两回事。共享库映射不归 Python 的 gc 管,也不归 malloc 管。

七、验证:怎么证明"结果没变"

这是整件事里最该花时间的地方。拆服务的全部价值建立在"计算结果不变"上,得拿数据说话,不能靠"应该没问题"。

import numpy as np
import vector_client as VC
import vector_search as VS

Q = '一个测试查询'

hits   = VC.search(Q, limit=5, warm=False, track=False)   # 走 HTTP
direct = VS.search(Q, limit=5, warm=False, track=False)   # 同进程直接调

print('命中 id 序列一致:', [h['id'] for h in hits] == [d['id'] for d in direct])
print('score 完全一致:  ', all(abs(h['score'] - d['score']) < 1e-9
                               for h, d in zip(hits, direct)))

v_http  = VC.encode_text('一句用来测试的话')
v_local = VS.encode_text('一句用来测试的话')
print('最大逐元素误差:', np.abs(v_http - v_local).max())
print('余弦相似度:    ', float(np.dot(v_http, v_local)))
命中 id 序列一致: True
score 完全一致:   True
最大逐元素误差: 0.000e+00
余弦相似度:     1.0000000000

顺便量一下 HTTP 的代价(同一批查询,各跑一轮取中位数):

同进程直接调用   中位数 10.74s
走 HTTP          中位数 11.05s
HTTP 净开销      约 +0.31s

本地回环加 JSON 序列化,三百毫秒,可以接受。

……等一下。10.74 秒?

八、意外收获:profile 打了我的脸

拆服务是为了内存,性能本来不在计划里。但上面那个 benchmark 顺手暴露了一件事:一次检索要 10 秒多。

我当时的猜测是:逐条把 embedding 从数据库读出来算余弦,几千条 Python 循环肯定慢,堆成矩阵做一次乘法就能解决。

猜错了。 老老实实分段计时:

t = time.time(); query_vec = encode_text(Q);        t_enc = time.time() - t
t = time.time(); rows = conn.execute(SQL1).fetchall(); t_q1 = time.time() - t
t = time.time()
for r in rows:
    cosine_sim(query_vec, blob_to_vec(r['embedding']))
t_vec = time.time() - t
t = time.time(); bm25 = SimpleBM25(texts);          t_build = time.time() - t
t = time.time(); scores = bm25.score(Q);            t_score = time.time() - t

完整脚本见 code/profile_search.py。结果:

encode_text(查询向量)      0.03s
SQL 查笔记                 0.01s   (497 行)
笔记逐条余弦               0.00s
SQL 查对话块               0.06s   (5266 行)
对话块逐条余弦             0.01s
BM25 建索引(全库分词)     11.48s   <<<
BM25 打分                  0.19s
------------------------------------
合计                      11.78s
BM25 两段占比               99%

向量部分 0.01 秒。我准备优化的地方,占总耗时的千分之一。

真凶是这段:

class SimpleBM25:
    def __init__(self, docs, k1=1.5, b=0.75):
        self.doc_tokens = [list(jieba.cut(d)) for d in docs]   # ← 这里
        ...

# search() 里
texts = [r['text'] for r in rows]
bm25 = SimpleBM25(texts)             # 每次查询都重建
bm25_scores = bm25.score(query)

5266 条文档、184 万字,每次查询都重新跑一遍中文分词。jieba 是纯 Python,这个量级就是十几秒。

教训四:优化前必须 profile。凭直觉找瓶颈,大概率找错。我这次的直觉偏了三个数量级。

九、修法:缓存索引

BM25 索引只依赖文档内容。文档没变,索引就没必要重建:

_BM25_CACHE = {'key': None, 'bm25': None}

def _get_bm25(rows):
    """按行集合缓存 BM25 索引。

    key 用全部 id 的 hash:新增、删除、顺序变化都会失效,
    不会和 zip(rows, bm25_scores) 的对应关系错位。
    """
    key = hash(tuple(r['id'] for r in rows))
    c = _BM25_CACHE
    if c['key'] != key or c['bm25'] is None:
        c['bm25'] = SimpleBM25([r['text'] for r in rows])
        c['key'] = key
    return c['bm25']

调用处:

- bm25 = SimpleBM25([r['text'] for r in rows])
+ bm25 = _get_bm25(rows)

缓存 key 为什么用 id 的 hash

最容易想到的 key 是 len(rows),或者 (len(rows), max_id)。都不够。

因为 bm25_scores 是靠 zip(rows, bm25_scores) 和行按位置对应的。只要行的顺序或组成变了而 key 没变,分数就会张冠李戴,而且不会报错,只是检索结果悄悄变得不相关。

hash(tuple(r['id'] for r in rows)) 对新增、删除、顺序变化全都敏感。遍历 5266 个 id 取 hash 大约 1 毫秒,为这个安全性完全值得。

另外注意:这个表的文本字段只在 INSERT 时写入,之后只更新别的列。如果你的文本字段会被原地修改,key 里还得带上内容的指纹。

效果

                    优化前      优化后
第 1 次查询         10.85s     11.96s   ← 冷缓存,要建一次索引
第 2 次             11.12s      0.35s
第 3 次             10.86s      0.32s
第 4 次             11.14s      0.33s

平均 10.99s  →  0.34s     快 32 倍

结果比对(4 个查询,比 source / id / score 到小数点后 10 位):

一致  查询 1
一致  查询 2
一致  查询 3
一致  查询 4
结果全部一致: True

代价是常驻内存从 586M 涨到 650M,索引占 60M。

一个连带关系

这个缓存只在常驻进程里有意义。原来的架构下,检索散落在各处按需加载,缓存了也活不长。

是先拆了服务,才吃得到这个收益。 两件事看起来无关,实际上第二件依赖第一件。架构调整的收益经常是这样迟到的。

十、还剩什么没做

诚实交代:

  • 冷启动 20 秒(加载模型 8s + 建索引 11.5s)。空闲退出之后的第一个请求会撞上。想去掉得把索引 pickle 到磁盘,还没做。
  • BM25 打分那 0.19 秒没优化。score() 里为每个文档重建 tf_map,预计算或者上倒排索引能压到接近零。当前不是瓶颈,没动。
  • 60M 索引内存没优化。存 token 列表比较费,改倒排索引结构会省一些。

三件都是想清楚了故意不做,不是忘了。够用就停手也是一种选择。

十一、四条教训

  1. RSS 曲线像泄漏不代表是泄漏。 先分层测一次性成本,用固定流量压读接口证伪。
  2. 选方案看风险面,不只看收益数字。 不改变计算结果的小优化,胜过可能悄悄污染数据的大优化。
  3. 卸载对象 ≠ 释放内存。 mmap 进来的共享库不归 gc 管也不归 malloc 管,要真还给系统只能让进程退掉。
  4. 优化前必须 profile。 我这次凭直觉找的瓶颈偏了三个数量级,profile 一跑十秒钟就水落石出。

代码

代码是从实际项目里摘出来的,命名做了通用化处理。直接抄大概率跑不起来,看思路就好。

MIT License。

Rendered by GitHub from the repository’s README. Scroll to read the full file.
First trending

Jul 28, 2026

first spotted on the list

Best rank

#31

highest position reached

Appearances

1

times on the trending list

Quality score

90

out of 100

Momentum

19

trend strength indicator

Star velocity

0.07

stars per day since creation

Activity — last 60 days

daily counts, collected every 6 hours
  • Stars
  • Forks
  • Merged PRs
  • Issues
  • Closed issues

Collecting activity data since launch — check back in a few days.

Star history

total stars per snapshot

Not enough snapshots yet — check back soon.

Rank history

position on trending · lower is better

Not enough snapshots yet — check back soon.

3D card for your website

Live HTML card with a glass surface and pointer tilt. Paste the iframe on any website.

<iframe src="https://www.notionpulse.cc/embed/nnkiirue99-maker/llm-memory-postmortem" width="420" height="240" style="border:0;max-width:100%;background:transparent" loading="lazy" title="nnkiirue99-maker/llm-memory-postmortem — trending on Open Source Pulse"></iframe>

Stars badge

Show current stars in your README or profile. Auto-updates every 6 hours.

nnkiirue99-maker/llm-memory-postmortem stars badge
Markdown
[![nnkiirue99-maker/llm-memory-postmortem](https://www.notionpulse.cc/api/badge/nnkiirue99-maker/llm-memory-postmortem.svg)](https://www.notionpulse.cc/repo/nnkiirue99-maker/llm-memory-postmortem)
HTML
<a href="https://www.notionpulse.cc/repo/nnkiirue99-maker/llm-memory-postmortem"><img src="https://www.notionpulse.cc/api/badge/nnkiirue99-maker/llm-memory-postmortem.svg" alt="nnkiirue99-maker/llm-memory-postmortem stars badge" /></a>

Repository of the Day

#31 Repository of the Day

Repository of the Week

#31 Repository of the Week

Repository of the Month

#31 Repository of the Month