最近在做一个多工具调用的Agent项目,用PyTorch 2.1跑Qwen-2.5-7B。推理时我把历史对话、工具返回结果都拼进上下文,用generate()流式输出。现在发现每跑完一个工具调用,显存就涨几百MB,连续跑十几个工具后直接OOM。我已经试过torch.cuda.empty_cache(),也把梯度关了(with torch.no_grad()),甚至把历史序列截断到4096,但占用还是只增不减。奇怪的是,单次推理后显存会回落一点,但基线就是比刚开始高。有没有大佬遇到过类似情况?是KV cache没释放,还是streamer对象持有张量?或者单纯就是PyTorch的显存碎片化,需要定时重置torch.cuda.memory._record_memory_history()?希望有实战经验的朋友指点一下,谢谢。
PyTorch写Agent循环时显存越跑越高,是代码问题还是框架特性?
全部回复
共 11 条大概率是cache对象没清干净,试试每个循环重建model和tokenizer,或手动释放旧streamer。
建议给generate()传past_key_values=None强制重置,不然基线肯定一路涨。
多半是generate里cache_updated没清干净,试试手动重置past_key_values,顺便检查下streamer是不是存了整段输出。
PyTorch的缓存分配器确实会保留显存块,empty_cache()只是把空闲块还给缓存池,不会真正释放给系统。你试试在循环外先warmup一次,然后监控torch.cuda.memory_reserved()和memory_allocated(),如果reserved持续上涨那就是分配器碎片化问题,可以定期用torch.cuda.memory_allocated对比看看。另外streamer如果持有上个step的logits张量,也会造成引用计数不归零,建议在generate后显式del掉streamer对象再gc.collect()。我之前遇到过类似情况,最后发现是history列表里存了太多张量,改成只存token id就稳定了。
八成是streamer没清干净,试下结束流式后del掉对象再empty_cache,基线应该能降回来。
八成是cache_utils里没清干净,试试generation_config里把use_cache设False,或者手动重置一下streamer的token缓存。
八成是streamer或cache对象没清干净,我之前也这样,手动del加gc.collect能好点。
我之前也踩过类似的坑,最后发现多半不是PyTorch的锅,而是HuggingFace的generate内部在维护past_key_values时没释放干净。你试试把每次流式输出后的streamer对象显式del掉,再配合empty_cache,有时候streamer里会缓存logits或hidden state。另外你截断到4096但KV cache是按层数乘以头数动态分配的,如果模型内部没重新初始化past_key_values,旧的长度可能还占着块,建议每次工具调用后重新build一个新的model instance或者用model.reset()之类的方法。还有个隐蔽点是Qwen的tokenizer在拼接工具返回时可能会生成特殊张量,比如tool_call_ids,这些在batch维度上不会自动清理。我后来直接用paged attention的库比如vLLM跑agent循环,显存控制就稳多了,但要牺牲一点灵活性。你也可以看看是不是数据加载器里保留了上一轮的input_ids,那个引用也会让显存基线抬高。总之先排除代码里的引用泄漏,再怀疑框架特性,毕竟PyTorch本身不会无故吞显存。
我之前也踩过类似的坑,最后发现真凶往往不是PyTorch本身,而是generate()内部的KV cache实现。你试过把use_cache=False传进去吗?虽然会慢点,但能立刻验证是不是缓存问题。另外那个streamer对象如果没在循环结束后显式关闭,确实会持有最后一轮的张量,建议每次迭代后del掉再GC。还有个容易被忽略的点:Qwen这类模型在generate()里默认会为每个工具调用重新计算完整前缀,如果历史拼接方式不当,会导致缓存索引错乱,显存碎片化。你可以试试把多轮对话打包成一个batch同时推理,而不是循环调用,这样能大幅降低峰值占用。如果实在不行,就手动在每次工具调用后重置模型状态,比如model.clear_cache()(部分架构支持),或者干脆用vLLM这类专门做推理优化的框架,省心很多。基线不回落这个现象,我怀疑是PyTorch的缓存分配器没把碎片合并回去,多调几次empty_cache并配合gc.collect()可能会有效果。
我最近也在折腾类似的多轮agent,遇到过一模一样的显存爬坡问题。后来排查下来,发现多半不是框架特性,而是你自己代码里某个环节在偷偷累积计算图或者缓存。你虽然用了no_grad,但generate()内部的past_key_values如果没处理好,每次循环都会把旧的KV cache带进新的forward,尤其是你手动拼历史的时候,很容易把整个历史的KV都重新算一遍,那显存当然只涨不跌。建议你检查一下是不是每次调用generate都传了past_key_values,或者模型内部有没有开启use_cache但没清干净。另外streamer那边确实有坑,有些实现会把所有生成token的张量存下来等结束才释放,你可以试试换成简单回调函数自己收token,或者干脆每轮结束就把streamer置空并del一下。还有个土办法,就是每完成一个工具调用就手动把模型切到eval模式再切回,强制触发缓存清理,虽然治标不治本但至少能确认是不是动态图残留。我最后是改成每轮只传最近两轮对话截断,配合显存监控打印nvidia-smi,才找到具体是哪个环节在漏。你要是方便,可以在循环前后打印一下torch.cuda.memory_allocated()对比,八成能看出来是推理本身涨的还是tokenizer或者数据处理那边涨的。
大概率是streamer在作怪。TextIteratorStreamer配合多线程时,如果没等生成线程真正join完,下一轮又新建一个,旧的引用没断,KV cache就一直挂着。我之前也踩过,改成复用同一个streamer或者用generate的return_dict_in_generate手动管理输出就正常了。另外工具返回结果拼进去后序列变长,KV cache本来就线性涨,建议每轮工具调用完重建一次上下文,别在原对象上反复append。
我也踩过这个坑,基本可以确定是streamer和KV cache没被及时回收。你每次generate传入新的streamer对象,它内部持有的output_ids和对应cache在流式结束后不会自动释放,累积起来就是几百MB地涨。建议试试手动del streamer和outputs,再配合gc.collect(),光靠empty_cache没用因为它只回收未引用的缓存块。另外确认下你是不是每次都在拼完整历史,那KV cache会随轮次线性增长,截断到4096只是限制了单次输入长度而已。