在 Python 中调用 llama-cpp-python 时带 context 的连续对话内存泄漏定位

文章导读
连续对话场景下内存持续上升,首先要区分“对话历史变长导致的内存占用上升”和“真正的内存泄漏”。两种问题处理方式完全不一样,混在一起排查会浪费大量时间。建议先做一轮可测量的观察,再决定改代码还是改调用方式。
📋 目录
  1. A 先量化:token 数在涨,还是内存单独在涨
  2. B 检查 Llama 实例:有没有被反复创建
  3. C 带 context 的连续对话:问题多半在上下文管理
  4. D 参数层面:n_ctx、n_threads 和 batch 的影响
  5. E 验证清单与收尾动作
A A

连续对话场景下内存持续上升,首先要区分“对话历史变长导致的内存占用上升”和“真正的内存泄漏”。两种问题处理方式完全不一样,混在一起排查会浪费大量时间。建议先做一轮可测量的观察,再决定改代码还是改调用方式。

连续对话内存增长通常有两个来源:对话上下文长度增长,或底层 llama.cpp 上下文对象没有被正确释放。排查先把两者分开:记录 token 数和 RSS 变化,再检查每个请求是否新建 Llama 实例。token 数同步增长是提示词管理问题;token 数不变而 RSS 持续上升才是真泄漏。

先量化:token 数在涨,还是内存单独在涨

在代码里同时输出“本次请求的 token 数”和“进程 RSS”。llama-cpp-python 的 Llama 类在返回结果里通常会带 usage 字段,包含 prompt_tokens 和 completion_tokens。把 usage 中的 prompt_tokens 之和当作对话上下文长度;用 psutil 读取进程内存。连续多轮后,如果 prompt_tokens 和 RSS 同步上升,说明是提示词越积越长,不是泄漏;如果 prompt_tokens 稳定,RSS 却一直升高,才需要继续往下查。

import os, psutil, time
from llama_cpp import Llama

llm = Llama(model_path="...", n_ctx=4096)

def process_memory_mb():
    return psutil.Process(os.getpid()).memory_info().rss / 1024 / 1024

messages = []
for turn in range(10):
    user_input = f"turn {turn}: " + "x" * 500
    messages.append({"role": "user", "content": user_input})
    resp = llm.create_chat_completion(messages=messages)
    messages.append(resp["choices"][0]["message"])
    print(turn, resp["usage"]["prompt_tokens"], process_memory_mb())

运行后用输出画一条线。prompt_tokens 线性增长,RSS 也线性增长,二者趋势一致,就先不要怀疑底层绑定泄漏,优先处理上下文截断。

检查 Llama 实例:有没有被反复创建

内存泄漏最容易出现在“每个会话新建 Llama 对象,旧对象却没有显式释放”的写法里。llama-cpp-python 的 Llama 对象在初始化时会加载模型并分配内存;如果放在请求处理函数内新建,函数退出后对象应该被销毁,但线程、回调或异常处理可能让引用仍然存在。建议在模块加载时只创建一次实例,并在所有会话中复用。

# 不要这样做:每个请求都新建实例
def on_request(messages):
    llm = Llama(model_path="...")
    return llm.create_chat_completion(messages=messages)

# 建议这样做:进程级单例
_llm = None
def get_llm():
    global _llm
    if _llm is None:
        _llm = Llama(model_path="...", n_ctx=4096)
    return _llm

如果必须每次请求新建实例,一定要在结束后调用 del llm,必要时再调用 gc.collect(),并确认没有其他变量还引用这个对象。可以用 weakrefgc.get_objects() 帮助找引用链,但不要指望垃圾回收能解决所有问题,先把实例生命周期改对。

带 context 的连续对话:问题多半在上下文管理

llama-cpp-python 的 create_chat_completion(messages=messages) 会把你传入的整段消息列表作为输入。如果你把 history 一直追加,prompt_tokens 会一直增长。超过 n_ctx 后,行为取决于后端:有的直接报错,有的会截断,有的可能保留部分消息但内存仍然在涨。即使模型支持长文本,内存占用也会随输入长度增加,这不叫泄漏,需要按“上下文窗口管理”来处理。

常见的保守处理方式有三种:第一,保留最近 N 轮消息,更早的压缩成摘要;第二,每次只传最近 K 条消息,让历史由外部存储维护;第三,限定最大 token 数,超了就触发重置或裁剪。你可以先用最简单的方式验证:每轮只传最后 4 条消息,观察内存是否还在持续上升。

在 Python 中调用 llama-cpp-python 时带 context 的连续对话内存泄漏定位
history = []
def add_and_chat(user_text):
    history.append({"role": "user", "content": user_text})
    # 只保留最近 6 条消息,验证内存变化
    recent = history[-6:]
    resp = get_llm().create_chat_completion(messages=recent)
    history.append(resp["choices"][0]["message"])
    return resp

改成限制消息条数后,如果 RSS 曲线趋于水平,说明原来就是消息列表无限增长造成的。此时不要继续在内存泄漏上找原因,而是设计对话历史的存储和裁剪策略。

参数层面:n_ctx、n_threads 和 batch 的影响

除了对话历史,n_ctx 本身也会影响内存占用。llama.cpp 在初始化时会按 n_ctx 分配 KV cache,n_ctx 设置得越大,基础内存就越高。如果进程已经存在持续增长,先把 n_ctx 调小观察,比如从 8192 改成 4096,确认基线内存是否下降。这不能解决泄漏,但能帮助判断是不是上下文窗口设置过大。

另外要注意 n_threadsn_batch 等参数不会导致无上限增长,但会影响整体资源占用。排查时尽量保持这些参数不变,一次只改一个变量。如果使用 OpenAI 兼容接口里的 max_tokens,它限制的是生成长度,不影响 prompt 长度,不要混淆。

验证清单与收尾动作

排查到这一步,至少可以给出一个保守结论:先用“单例 Llama + 固定消息条数 + 记录 prompt_tokens”跑 20 轮对话,如果内存不再持续上升,就说明问题出在调用方式,而不是绑定库本身。如果已经采用这些做法仍持续上升,需要进一步检查系统层的内存分配、llama.cpp 版本行为,以及是否有其他线程任务堆积。

最后提醒:不要只依赖任务管理器观察“内存变高了”。任何长期运行的服务都应有基础监控,至少在内存翻倍时能触发日志。对这种场景,合理的目标是先保持内存曲线可预测,再追求更低占用。