最近在做一个简单的ReAct Agent,基于PyTorch和HuggingFace的LLM,每次调用模型生成回复后,我把历史对话拼到prompt里继续下一轮。但跑了十几轮之后,显存占用直线上升,从4G涨到12G,最后直接OOM。我试过清空缓存(torch.cuda.empty_cache())和调低max_new_tokens,都没什么效果。是不是每次拼接prompt时,历史输出被重复计算了梯度?还是说推理时也需要detach?我看一些Agent框架(比如LangChain)好像没提这个问题,是我写法有问题吗?希望大佬指点一下,最好能贴一段简单的处理逻辑,谢谢!
用PyTorch写Agent时,多轮对话的显存越堆越高怎么处理?
全部回复
共 8 条我也遇到类似的问题,跑几轮还正常,到后面直接炸显存,真的很头疼。我后来试了两种方法,稍微好一点但没完全解决:一个是在每次推理前显式把输入里的历史tensor都detach一下,比如prompt_ids.detach(),然后with torch.no_grad()包住生成过程;另一个是每次只保留最近几轮对话,把太早的历史直接丢掉,不过这样Agent就记不住太久远的信息了。
我感觉你说的“历史输出被重复计算梯度”可能是对的,虽然推理时按理说梯度不更新,但PyTorch默认还是会构建计算图,历史tensor的梯度信息可能一直累积在那里,empty_cache只清理缓存不释放计算图。我试过在每轮结束后调用torch.cuda.empty_cache(),确实没用,得手动把中间变量删掉或者用del显式释放。
但我不太确定的是,像LangChain那些框架是怎么做的,它们是不是内部已经做了类似的处理?还是说它们默认只用单轮推理,所以根本不会遇到这个问题?另外,你用的HuggingFace模型是直接model.generate()吗?这个接口会不会自动管理历史计算图?我看文档说generate里有个use_cache参数,默认是True,可能跟这个也有关系,但我不太清楚改成False会不会影响速度。
如果你找到更好的办法,比如怎么优雅地管理多轮对话的显存,可以贴出来一起交流,我真的被这个卡了好久,感觉是写Agent时很容易忽略但很致命的一个坑。
我也遇到过类似问题,试过torch.no_grad()包住推理部分,显存确实能降一点,但长期对话还是会涨。感觉可能是历史tokens的key-value cache没释放?另外好奇你prompt拼接时是不是把整个对话历史都塞进去了,有没有试过只保留最近几轮?
这个问题的根因其实挺典型的,你猜的方向基本都沾边了,但最核心的问题不是梯度,而是历史tokens的KV Cache没有被释放。
你用PyTorch推理LLM时,每次调用model.generate(),内部会为输入序列的每个token计算key和value,并缓存在一个动态增长的张量里。你手动拼接历史对话后,每次的新prompt长度都在变长,旧一轮的KV Cache虽然被覆盖了,但PyTorch的显存分配器不会立刻把那块显存还给系统——它只是标记为可复用,而你的新序列长度更大,分配器又会去申请新的显存块,导致碎片化和持续上涨。torch.cuda.empty_cache()只是清空缓存池,治标不治本。
你提到的detach其实不是这里的关键,推理时本来就不需要梯度,但如果你用的是model.generate(),它默认会开启torch.no_grad(),梯度问题不存在。真正需要优化的方向是手动管理KV Cache,或者用现成的方案。最简单的写法是:不要让每次对话都重新编码全部历史,而是用past_key_values这个参数把上一轮的缓存传进去。HuggingFace的模型在generate()里都支持return_dict_in_generate=True配合use_cache=True,你可以在每次生成后拿到past_key_values,然后下一轮只对新输入的tokens做推理。
如果你不想自己写这个逻辑,可以看看vLLM或者Text Generation Inference的continuous batching思路,或者直接把历史对话截断到固定窗口长度——比如只保留最近5轮,用滑动窗口控制序列上限。LangChain没提这个问题是因为它底层默认用transformers的pipeline,那个接口会自动做past_key_values的传递,但你没用pipeline的话就得自己处理。
建议你先试一下把每次拼接后的prompt通过tokenizer编码,然后手动传给model的past_key_values参数,显存应该就稳住了。如果还涨,检查下是否在循环里重复创建了optimizer或模型副本,那个也会吃显存。
这个问题我踩过一样的坑。核心原因是每次拼接历史对话后,整个输入序列都会进入模型计算图,PyTorch默认会保留所有中间变量的梯度用于反向传播,哪怕你只是推理。解决方案:在每次生成前加torch.no_grad(),并且对生成好的输出调用.detach()后再拼回prompt,避免计算图不断累积。另外建议每轮手动释放一次显存,调一下torch.cuda.empty_cache()配合gc.collect()。
这个我踩过一样的坑,核心问题就是推理时默认开了grad,历史token的梯度一直在累积。你只要在with torch.no_grad()下跑生成,或者对logits调一下.detach()就行。另外建议每轮对话后手动把history里旧的token_ids从显存里删掉,只保留必要的prompt拼接,这样十几轮下来显存增长能控制在2G以内。
这个问题我踩过一样的坑,甚至一度怀疑是HuggingFace的模型有内存泄漏。后来仔细排查才发现,核心原因其实是每次拼接历史对话时,整个prompt的token序列都在变长,而PyTorch默认会保留所有中间变量的计算图,你虽然调了inference模式,但如果没显式用torch.no_grad()包裹生成过程,或者模型内部的某些layer(比如past_key_values)没有妥善清理,梯度还是会累积。
你提到的detach确实是个关键点,但更准确的说是:生成完成后,对输出的logits或者hidden_states做.detach()并不够,因为你每次拼接prompt时,旧的输出文本已经被转成token ids拼进新的输入序列了,这些token ids本身不携带梯度,但如果你在生成时用了return_dict_in_generate=True之类保留中间状态,那些past_key_values会一直留在显存里。我后来用的办法是:每次生成前,手动把模型切换成`.eva
l()
模式,再加上with torch.no_grad(),生成完立刻调用del outputs和torch.cuda.empty_cache(),但最有效的其实是**限制past_key_values的长度**——很多模型支持use_cache=True但你可以设置max_cache_length`或者直接每轮生成后重置past_key_values。
另外LangChain不提这个问题是因为它底层大多走的是pipeline或者generate的批量接口,每次调用都是独立的计算图,不会像我们自己手写循环那样把历史状态一直挂在显存里。你可以在每次生成前强制清空一下模型内部的cache:model.clean_cache()(如果有的话),或者更粗暴地,每次生成完把model.past_key_values赋值为None。还有一个trick:如果历史对话太长,可以只保留最后几轮,或者用滑动窗口截断prompt,既能省显存,效果也不一定差太多。你试试看,应该能稳住。
这问题我之前也遇到过,确实挺坑的。你猜得没错,问题就出在历史输出没有detach,PyTorch默认会保留计算图,导致每轮对话的梯度都累积在显存里。推理时只要在生成后加一句with torch.no_grad()或者手动对历史tensor调用.detach()就能释放掉。另外拼prompt时建议用纯字符串拼接,别把每轮输出都转成tensor存着,我后来改成只存文本列表、推理时再tokenize,显存就稳住了。
我也碰到过一模一样的问题,十几轮对话后显存直接爆掉,当时还以为是自己代码写崩了。你提到的梯度问题我后来确认过,确实是个关键点——PyTorch默认会保留计算图,所以即使你只是推理,如果没显式detach,历史输出的梯度信息会一直累积,尤其是把整个对话历史拼进prompt重新过一遍模型的时候,每次都会在前面的计算图上叠加新的节点,显存自然越堆越高。
我当时的处理方式是在每次生成完把输出的token ids detach一下,或者干脆用torch.no_grad()包住整个生成过程,这样就不会保留梯度了。另外,如果用了past_key_values缓存(比如HuggingFace的模型支持),记得每轮对话要重新初始化,不然旧的key-value也会占着显存不释放。还有个小技巧是定期用del显式删除没用的变量,再调torch.cuda.empty_cache(),虽然不能完全解决问题,但能缓解一些。
不过我也挺好奇,像LangChain那种框架到底是怎么处理的?我试过它们的一些例子,同样跑多轮对话,显存增长似乎没那么夸张,是不是内部做了类似截断或者分段处理?你用的HuggingFace模型是哪个?有些模型对长文本的显存优化不一样。如果你找到了更干净的解决方案,记得分享下,这问题困扰我好久了。