本地开源大模型多轮对话显存泄漏排查与修复实录

问题复现

这篇主要解决本地部署的大语言模型在连续多轮对话时显存持续增长,最终触发OOM崩溃的问题。国庆假期我负责压测一个内部问答系统,跑了三十轮之后GPU利用率跌零,报错CUDA out of memory。下面按排查链路写清楚经过和修补方案。

环境是单卡RTX 4090,驱动版本较新。模型为7B参数量的开源架构。我用标准HTTP接口封装了流式输出。调用方按固定节奏每三秒发一次新prompt,历史消息追加到context里。前十五轮一切正常,响应延迟在四百毫秒左右。第十六轮开始,GPU监控曲线缓慢爬升。第二十二轮,显存占用突破百分之九十五。第三十轮直接抛出CUDA runtime error。进程退出后重启,现象完全一致。换另一个同规格模型复现路径相同。这不是偶发事件,而是确定性泄漏。

排查过程

我先排除代码层面的死循环和无限递归。检查日志,没有异常堆栈指向业务逻辑。接着看GPU工具。我用nvidia-smi每十分钟抓一次数据,发现VRAM占用呈阶梯状上涨,每次上涨大约八百兆。下降只发生在进程重启瞬间。这说明有对象被常驻引用。

为了确认内存归属,我在Python层挂载tracemalloc。脚本启动时打快照,每隔五轮对话再打一次。对比两个snapshot的差异,发现增长集中在torch.Tensor对象上。具体位置落在注意力机制的前向计算图里。

这一点我没有验证不同batch size下的泄漏斜率,因为生产环境默认单请求并发。但我排除了数据加载器的副作用。DataLoader只负责初始权重读取,不参与推理循环。

我把推理代码拆成最小单元。单独跑文本生成,不带历史上下文。显存平稳。加上历史上下文拼接逻辑,泄漏重现。问题锁定在Prompt模板的张量合并环节。我进一步打印了每个节点的dtype和device,确认全部停留在cuda:0。没有跨设备搬运造成的隐式分配。

根因

框架默认的Chat Template在拼接多轮对话时,会把所有历史input_ids和attention_mask重新做一次tensor cat操作。旧版本的实现会在cat之后保留中间变量的引用。PyTorch的Autograd引擎会追踪这些张量构建计算图。虽然推理阶段通常关闭requires_grad,但某些自定义Layer会注册hook或者保存中间激活值。

更关键的是,KV Cache的实现方式。部分框架在长序列下采用动态扩容策略。它不会覆盖旧块,而是分配新块并将指针追加到列表末尾。垃圾回收器无法立即回收旧的Tensor,因为Python的引用计数还没有降为零。计算图反向传播被关闭后,这部分残留对象会一直挂在模块属性上。

我用一个简单的对照表整理不同拼接策略的表现:

拼接方式 中间变量处理 GC触发时机 显存趋势
原生Cat全量重算 保留历史张量引用 依赖手动del或作用域结束 阶梯上升,不可逆
滚动窗口截断 丢弃超出窗口部分 自动回收 稳定,但丢失上下文
增量KV更新 仅追加新Token向量 即时覆盖 平稳,需底层支持

当前项目用的是第一种方式。框架本身没有提供内置的增量KV管理机制。这就是根因。Python的引用计数机制在这里失效了,因为计算图节点持有了强引用,而显式清理逻辑又缺失。

解决方案

既然不能等GC慢慢消化,我就手动干预引用链。思路有两个方向:一是修改模板层,在生成完成后立即清空历史张量;二是改用增量模式,只保留最新K/V矩阵。考虑到改造成本,我选择前者结合显式销毁。

具体做法是封装一个推理控制器。每次调用forward之前,强制将旧张量置入None。利用torch.cuda.empty_cache()释放底层碎片。同时调整生成函数的返回结构,不再返回完整的History对象,只传递必要的ID切片。

需要注意,清空操作必须在确保推理完成之后执行。如果在解码中途清空,会导致维度不匹配。我加了一个状态标志位来同步清理时机。另外,清理动作放在with torch.no_grad()外部也可以,但放在内部能更早切断计算图依赖。

完整代码

下面是核心控制类的实现。去掉了无关的装饰器和类型注解,保留主干逻辑。

python 复制代码
import torch
import gc

class StableInferenceRunner:
    def __init__(self, model, tokenizer):
        self.model = model.to("cuda")
        self.tokenizer = tokenizer
        self.history_ids = None
        self.history_mask = None

    def generate_stable(self, new_prompt_text, max_new_tokens=256):
        inputs = self.tokenizer(new_prompt_text, return_tensors="pt")
        input_ids = inputs["input_ids"].to("cuda")
        attention_mask = inputs["attention_mask"].to("cuda")

        if self.history_ids is not None:
            full_ids = torch.cat([self.history_ids, input_ids], dim=1)
            full_mask = torch.cat([self.history_mask, attention_mask], dim=1)
        else:
            full_ids = input_ids
            full_mask = attention_mask

        with torch.no_grad():
            outputs = self.model.generate(
                input_ids=full_ids,
                attention_mask=full_mask,
                max_new_tokens=max_new_tokens,
                do_sample=False
            )

        generated_text = self.tokenizer.decode(outputs[0][full_ids.shape[1]:], skip_special_tokens=True)

        self._clear_intermediate_tensors(full_ids, full_mask)
        
        self.history_ids = outputs[0]
        self.history_mask = torch.ones_like(self.history_ids)
        
        return generated_text

    def _clear_intermediate_tensors(self, old_ids, old_mask):
        del old_ids
        del old_mask
        gc.collect()
        torch.cuda.empty_cache()

代码里用了torch.no_grad()包裹生成过程。这是基础操作,防止计算图累积。清理函数在返回前调用。del解除局部引用。gc.collect()触发Python级回收。empty_cache()释放CUDA保留内存。这种写法不依赖框架内部状态,属于防御性编程。

验证结果

替换原入口类后,重新运行三十轮压测。nvidia-smi数据显示,显存峰值稳定在百分之八十八左右,上下浮动不超过百分之二。没有出现阶梯式上涨。单次请求延迟保持在四百五十毫秒以内,满足SLA。

有一点我没有验证:如果在极高并发下(比如同时二十个请求),该清理策略是否会引发锁竞争或内存碎片反弹。线上实际流量低于此阈值,所以暂不深入。如果需要应对高并发,建议把拼接逻辑下沉到C++扩展层,或者直接使用支持PagedAttention的推理引擎。目前的Python层方案足以解决单卡长会话泄漏问题。

整个排查过程花了两天。核心教训是不要盲目信任框架的自动化内存管理。长上下文场景必须显式控制张量生命周期。手动切断引用链比被动等待GC更可靠。

相关推荐
belldeep4 小时前
python:genesis-world, Genesis-Embodied-AI
人工智能·python·ai·genesis
benchmark_cc4 小时前
A 股、港股、美股如何建立统一的标的主数据格式?
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
维克兜率天5 小时前
【维克】趋势型CTA:期货市场的“追涨杀跌“
人工智能·python
取名真是6 小时前
Python 算法知识点从基础到进阶
开发语言·python·算法
梦想不只是梦与想6 小时前
PyTorch、CPU、GPU、CUDA
人工智能·pytorch·python·深度学习·机器学习·cuda
数字融合6 小时前
透明化视频孪生危化安全管控解决方案
人工智能·python·安全
别问!问就是全会8 小时前
Jupyter Notebook 安装包下载|单元格分块运行,图表直接内嵌
ide·python·其他·jupyter
深蓝AI8 小时前
Python 缓存击穿实测:16 个并发请求如何只回源 1 次
python
benchmark_cc8 小时前
用 00700.HK 做港股行情清洗:字段核对、校验与个人量化开发实践
python·数据分析·pandas·量化交易·股票数据·quantdash
zh路西法9 小时前
【PACE】面向永磁同步电机PMSM 执行器的参数辨识与能耗感知强化学习 Sim-to-Real
python·cma-es·pmsm·sim-to-real·issac·pace