跑模型的时候,注意力基本上都放在模型本身上:参数量多大、精度多高、单次推理快不快。等真把模型丢到生产环境,你会发现压垮系统的往往不是模型推理本身,而是另一件事——每个请求、每轮对话、每次并发切换时,那些看不见摸不着的执行上下文。
模型执行上下文这个词,听起来很像操作系统里的概念,实际上在模型服务领域里随处可见。它承载了一次模型调用从进入到返回的全部中间状态:token序列、注意力缓存、临时张量、显存分配器状态、CUDA流和句柄,甚至包括随机数种子和会话历史。上下文管理得好,系统在并发和长会话下能稳稳当当;管理不好,轻则首token延迟暴涨,重则整个进程直接OOM被杀,连崩溃日志都来不及记。
这篇文章不讨论模型怎么训练,也不讨论怎么调prompt,而是把"模型执行上下文的管理与切换机制"拆开来讲:上下文到底由什么组成、生命周期怎么管理、多模型多会话之间如何切换、大模型场景里KV Cache为什么是核心战场,以及我实测中遇到的典型故障和完整排查链路。适合正在做模型部署、推理平台、Agent/会话系统的工程师参考,也适合刚接触推理性能优化的新手建立全局认知。
1. "执行上下文"是什么:一次模型调用背后的临时工作台
1.1 从一次模型调用说起
你给模型发了一句话,服务端实际发生的事情远比"把文本喂进去、吐出结果"复杂。以一次大语言模型推理为例,完整链路大概是这样的:
- 把用户文本tokenize成token id序列;
- 在显存里分配输入张量、attention mask、position id;
- 加载或复用模型权重;
- 执行prefill阶段,逐层计算,生成当前序列的KV Cache;
- 进入decode阶段,每一步只推理一个新token,并把新的K、V追加到缓存里;
- 每一步都要同步当前采样状态,比如temperature、top_p、随机种子;
- 直到生成结束符或达到max tokens上限。
这中间所有动态产生的中间状态,合在一起就是一次请求的"执行上下文"。它和模型权重最大的区别在于:权重是静态的、只读的,而上下文是动态的、和具体请求强绑定的。
我用一个比喻来理解这件事:模型权重像车间里固定的机床,而执行上下文更像是师傅手头那份图纸、半成品和工具摆放状态。机床再快,每次换订单都要重新整理工作台,整理得好不好,直接决定下一个订单的产出速度。
1.2 上下文不是单层概念:进程级和请求级得分开看
很多人聊上下文时会把几样东西混在一起,导致排查问题的时候思路不清。我习惯把执行上下文拆成四个层次,按生命周期和归属来区分:
| 上下文成分 | 典型内容 | 生命周期 | 典型开销 |
|---|---|---|---|
| 计算状态 | token序列、KV Cache、中间激活值 | 请求级 | 随序列长度和并发数线性增长 |
| 运行环境 | CUDA context、cuBLAS/cuDNN handle、CUDA stream | 进程级 | 创建一次约数百毫秒到数秒 |
| 资源句柄 | 显存分配器、内存池、事件、engine/session句柄 | 进程级/请求级 | 不显式释放会持续累积 |
| 业务状态 | 会话历史、工具调用记录、状态机数据 | 会话级 | 常被忽略,最容易被截断误伤 |
在PyTorch这类深度学习框架里,经常听到"CUDA context",那是进程级的运行环境;而在TensorRT里你有ExecutionContext,在ONNX Runtime里有SessionState,在各类推理引擎里又会有RequestState。这些名词虽然不同,本质上都属于上面表格里的某一层。排查问题时先分清是进程级的东西没初始化好,还是请求级的状态没释放干净,方向就对了。
1.3 为什么上下文往往比模型权重更吃资源
很多刚接触推理优化的人有个直觉:模型越大越吃显存,所以7B模型权重14GB,那给它预留16GB总够了吧?但一到线上就发现根本不是这么回事。
问题出在上下文是"按请求累计"的。以LLaMA-7B为例,fp16权重大约14GB。单请求的KV Cache大约0.5MB/每token,2048个token就是1GB左右,看起来还可以接受。可一旦32个请求并发,KV Cache总量就要32GB,已经比权重翻倍了,这还没算激活值、临时缓冲和显存碎片。
所以你会看到这样一个反直觉的现象:单卡单模型本地推理毫无压力,一旦放到服务端做并发,显存反倒先爆掉。这背后的核心变量就是上下文,而不是模型权重。理解了这一点,你就会明白为什么所有正经推理框架都在绞尽脑汁管理上下文,而不是把精力都花在让算子跑得更快上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 上下文生命周期:从冷启动创建到销毁的完整链路
2.1 冷启动的"暗时间":创建上下文为什么这么慢
你第一次调用模型时特别慢,第二次就快了,这是几乎所有推理框架的共同现象。第一次慢的原因不是模型推理本身,而是在创建上下文。
进程级运行环境需要初始化CUDA context,这一步通常要几十到几百毫秒;接着要创建cuBLAS、cuDNN的handle和workspace,再加载首次用到的kernel,动态shape场景还可能触发JIT编译,这一串下来花上几秒到十几秒都不意外。我第一次用TensorRT跑动态batch时,第一次推理等了将近20秒,日志显示编译kernel占了绝大部分时间,当时以为程序卡死了。
这里有个实操建议:服务启动后主动做一次warmup请求,把CUDA context、算子缓存、显存分配器全部"预热"一遍,再开始对外提供服务。别让第一个真实用户承受冷启动的代价。warmup的请求最好覆盖线上常见的shape组合,因为不同shape触发的kernel和plan cache可能不一样。
2.2 上下文池化:把创建成本摊到每一次调用上
既然冷创建那么贵,自然想到复用。工程上最常见的做法是上下文池化,思路和数据库连接池完全一样:提前创建一批上下文对象放在池子里,请求来了从池里取,请求结束归还而不是销毁。
池化的粒度要分场景。线程级复用一般是thread-local,每个工作线程持有自己的上下文实例,好处是不用加锁,坏处是线程数和上下文数强绑定;请求级复用是请求结束后把上下文reset后放回池子,适合异步并发调度模型。做多模型服务时,池子最好按模型分开,别让不同模型的上下文混用,否则上下文加载和切换的成本会重新出现。
这里容易踩的坑是:复用时没有彻底reset状态。比如上一轮请求残留的KV Cache、采样器状态、临时buffer没有清干净,下一轮请求拿到的就是脏上下文,轻则结果变差,重则直接报shape不匹配。我见过的一次线上事故,就是因为复用的上下文里留下了上一段对话的attention mask,导致后续请求的生成质量断崖式下降。所以池化不仅要管"取和还",还要在归还时做完整状态清理。
2.3 销毁不彻底:显存泄漏的隐形来源
说到上下文销毁,大部分人都不太在意,觉得对象不引用了自然会被回收。但在这个领域,垃圾回收的直觉往往会坑你。
我自己的经历:一个Python服务反复加载和卸载模型,del model之后显存并没有回到基线。跑了几千个请求后,显存被占得越来越多,最后OOM。排查时发现,模型本身确实释放了,但底层的CUDA context、cuBLAS workspace、显存分配器缓存还赖在显存里。
PyTorch里调用torch.cuda.empty_cache()只能清掉显存缓存池里的空闲块,并不等于释放了整个CUDA context。真正要释放,需要让所有指向该context的tensor引用归零,再销毁对应的engine或session句柄。像TensorRT的engine、ONNX Runtime的session,都要显式调用release方法,不能只靠析构函数兜底。
排查显存泄漏时,我的固定动作是:
- nvidia-smi看哪个进程占着显存不松口;
- 连续多次调用显存清理函数,观察显存是否逐级回落到基线;
- 用推理框架自带的内存分析工具,比如PyTorch的torch.cuda.memory_summary(),看缓存分配器里到底有什么;
- 反复创建销毁上下文,记录显存峰值,看是不是呈阶梯式上升。
值得一提的是,很多容器化部署还会叠加一层"进程退出但显存没释放"的问题,这时候用fuser -v /dev/nvidia*看谁还持有GPU设备文件,往往能找到"僵尸"进程。
3. 多模型多会话下的上下文切换:换入换出的成本账
3.1 切换的本质是把状态搬进搬出
单张GPU上跑多个模型,或者一个推理服务要服务多租户时,上下文之间是互斥的,尤其是多个模型并存但显存不够的场景。这时候就必须做上下文切换。
切换过程大概是:把当前上下文的计算状态保存下来(如果需要持久化),回收当前占用的显存,加载目标模型和对应上下文,再做一次预热。听着有点像CPU的线程上下文切换,但代价完全不是一个量级。CPU上下文切换是微秒级,GPU显存换入换出是以秒来计的。
另一种轻量级切换不需要搬显存:多个上下文常驻显存,切换时只切换CUDA stream或计算流。它的速度非常快,但前提是显存装得下所有常驻上下文。所以工程上真正的权衡,就是"显存换时间"还是"时间换显存"。
3.2 三种工程上可落地的切换策略
我见过的上下文切换方案基本可以归成三类:
| 策略 | 做法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 串行直切 | 同一时刻只保留一个上下文,完整换入换出 | 实现简单,显存占用最低 | 切换耗时最长,秒级起步 | 模型数量少、对延迟不敏感的离线批处理 |
| 常驻并行 | 多个上下文同时驻留显存,用stream分时执行 | 切换几乎无额外开销 | 显存占用为所有上下文叠加 | 显存充足、需要低延迟响应的在线服务 |
| 调度换入换出 | 按优先级调度上下文,低优先级上下文可换出到CPU内存甚至磁盘 | 吞吐高,延迟可控 | 实现复杂,需要预emption机制 | 多租户、负载波动大的生产环境 |
大多数严肃的推理平台不会只用一种策略,而是混合:热门模型的上下文常驻显存,冷门模型走调度换入换出;显存富余时自动把更多上下文升级为常驻,显存紧张时先换出低优先级。
我不建议一上来就自己造调度器,除非你真的需要精细化控制。市面上的推理引擎、模型服务框架都已经把调度机制做进去了,你要做的是理解它们的配置项,设置好显存上限和优先级策略,而不是重复摸一遍轮子。
3.3 切换开销实测与量化方法
这里给个我自己实测的量级参考,方便你有个直觉。实验环境是PCIe Gen4的GPU,两个LLM上下文各约3GB:
- 常驻并行切换:只切换CUDA stream,相邻请求间隔的额外开销基本可以忽略,微秒到毫秒级;
- 串行直切:一个3GB上下文搬到CPU内存,再换入另一个3GB上下文,实测3到5秒;
- 调度换入换出:高优请求插队时,低优上下文被抢占后,恢复时间取决于换出的上下文大小和持久化方式,可能再叠加数秒。
量化切换耗时,别用time.perf_counter直接包一层就完事。GPU是异步执行的,你在CPU侧量到的时间根本不准。更可靠的做法是用CUDA event标记起止点,或者直接用NVIDIA Nsight Systems抓端到端timeline,能看清楚时间到底花在数据传输、kernel launch还是显存分配上。
还有一个容易被忽视的指标:切换后第一次推理的延迟。因为上下文换入后,算子缓存、plan cache可能都失效了,首token延迟会比稳态高一大截。上一节讲冷启动的时候就提到过,这个效应在切换场景里同样存在。定位时看首token延迟,远比看平均token延迟更能发现切换问题。
4. LLM推理中的上下文主战场:KV Cache 的管理与释放
4.1 KV Cache:用显存换算力的一次换购
如果说章节1到3聊的是通用模型执行上下文,那在LLM推理这个具体场景里,KV Cache就是绝对的主战场。
Transformer做自回归生成时,每一步都要计算当前token对所有历史token的注意力。如果不缓存历史token的Key和Value,生成第N个token时就要从头重算N-1个token的注意力,复杂度直接从O(N)退化成O(N²),长序列根本跑不动。KV Cache就是把这些历史K、V暂存在显存里,每次生成新token只需要计算新token的K、V并追加。这是典型的"用显存换算力"。
它的显存开销很直观,计算公式可以背下来:
单个token的KV Cache大小 = 2 × num_layers × num_heads × head_dim × dtype_bytes
以LLaMA-7B为例,32层、32头、每头128维、fp16两个字节,算下来单个token约0.5MB。2048个token的上下文就是1GB,32并发就是32GB。这也是为什么在长上下文、高并发的场景里,KV Cache经常成为显存的第一个瓶颈,而不是权重。
4.2 预留与分页:PagedAttention 解决了什么
早期实现LLM推理服务时,KV Cache是按"最大序列长度"预分配的。比如max_len设成4096,一个请求就预留2GB;实际可能只生成512个token,预留的87.5%全浪费了。高并发下这种浪费会迅速吃掉显存。
PagedAttention的思路是:不按最大长度预分配,而是把KV Cache切成固定大小的block,运行时按需分配。原理类似操作系统里的虚拟内存分页:需要多少页就分配多少页,随时可以扩展。vLLM就是靠这个特性把KV Cache的显存利用率大幅拉高,让同样一张卡能装下更多并发请求。
我自己实践下来的体验是:启用分页式KV Cache是对显存最立竿见影的优化,尤其在高并发短请求的场景。想确认收益,可以看推理引擎暴露的显存利用率指标,比如gpu_cache_usage_perc,低于80%说明缓存池多数在空转。
更进一层,如果多个请求共享相同的前缀,比如同一个system prompt、同一段工具说明,那么这部分前缀的KV Cache按理可以复用,不必每个请求重新算一遍。现在一些推理引擎已经在做prefix caching,把前缀的KV Cache按内容做索引,命中后直接从缓存里组装上下文,省掉的既是显存也是算力。做Agent场景、固定system prompt很长的服务,这个优化特别值得关注。
4.3 长会话与上下文窗口冲突时的降级策略
上下文窗口再大也有上限,长会话很快就会撞墙。我在Agent系统里看到最多的情况就是:对话轮数一多,prompt长度超过max_len,系统直接截断,然后模型就开始"失忆",前面的关键信息全丢了。
常用的降级策略我整理成一张选型表:
| 策略 | 做法 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 硬截断 | 只保留最近N轮对话 | 实现最简单,无额外延迟 | 早期关键信息直接丢失 | 短会话、弱依赖历史的场景 |
| 摘要压缩 | 把早期对话压成一段摘要再注入 | 保留核心信息,上下文可控 | 摘要本身有信息损耗,生成摘要要花时间 | 长会话、任务有明确主线 |
| RAG注入 | 历史信息存向量库,检索相关片段后组装 | 信息密度高,可扩展知识量 | 引入检索延迟和组装成本 | 知识密集、事实性要求高的场景 |
| 分层上下文 | 把系统提示、固定背景、近期对话分层管理,超限优先丢弃最不重要的 | 灵活,可裁剪粒度细 | 管理复杂度上升 | 生产级Agent系统 |
实际操作中不要只用一个策略。我的习惯是,系统提示和关键约束永远单独存放、不允许被截断;近期对话用滑动窗口保留;更早的内容走摘要或向量检索。这种分层设计本质上是"上下文的分级管理",也是长会话场景里最可靠的做法。
还要特别说一句:Agent从一个子任务切到另一个子任务时,很多人直接清空上下文重来,这其实是个陷阱。用户的意图、关键约束这类持久状态必须保留,该清的是中间过程产生的噪音。所以上下文管理器要能区分"可丢弃上下文"和"持久上下文",切换的时候只丢该丢的。
5. 实测中的三类故障与完整排查链路
5.1 并发一高就OOM:从显存快照到准入控制
典型现象:单个请求跑得很稳,并发从8加到16,进程直接OOM被杀。
排查链路我按顺序走:
- nvidia-smi看显存快照,先确认是不是当前这个进程在涨,排除其他进程占显存;
- 用推理框架自带的分析工具看哪些张量占了大头,确认是KV Cache还是上下文池没回收;
- 检查max_seq_len设置,确认是不是按最大长度预分配了显存;
- 检查batch size放大后KV Cache是否线性增长,以及上下文池是否设置了上限。
解决的思路分两层。第一层是减小浪费:开启分页式KV Cache,按实际长度分配而不是按max_len预分配;限制上下文池大小,让池子在显存可控的范围内周转。第二层是准入控制:请求进来之前,先估算它可能占用的峰值显存,显存不足时让请求排队等待,而不是直接接收进来挤爆进程。
估算公式不复杂,增量显存约等于 预估序列长度 × 单token KV Cache大小 × 并发增量,再乘一个1.2到1.5的安全系数。这个值要和当前显存余量比较,余量不足就触发背压。这招看着笨,但能挡掉大部分OOM事故。
5.2 切换模型后首token变慢:冷上下文与算子缓存失效
典型现象:同时部署模型A和模型B,第一次从A切到B时,首token延迟从150ms飙到2s,切回A之后又慢一次。
我见过不少人把这归因于"模型加载慢",其实不完全是。加载权重只是一部分,更隐蔽的是每个模型都有自己的算子缓存和plan cache。切换后第一个请求要重新触发kernel加载、cuDNN/cuBLAS plan选择,动态shape还会触发autotune,这些都算进首token延迟里。
定位方法是用Nsight Systems抓一次切换后的请求,看时间分布是花在kernel编译、显存分配还是权重加载上。如果确认是算子缓存失效,解决方案是让热门模型常驻显存,切换只切计算流;或者限定shape集合,提前给每个组合做warmup,把plan cache预热好。
如果冷切换实在无法避免,就在切换动作上做异步化:后台先把模型B的上下文加载好,加载完成后再把流量切过去,让用户请求永远不落在加载过程中。这个思路对线上服务很实用。
5.3 长对话"失忆":上下文被截断或重建的隐蔽场景
典型现象:Agent执行几步工具调用之后,模型突然不记得之前用户说过的关键信息,就像切换上下文时把状态弄丢了。
排查链路要从应用层到推理层逐层看:
- 先看message list或prompt长度,确认是不是触发了滑动窗口截断;
- 再看KV Cache是否在请求间被清空。很多推理引擎默认generate结束就释放KV Cache,多轮对话必须显式保留或重新传入历史;
- 检查后端是否挂了多个worker,负载均衡把同一会话的请求分到了不同worker,导致上下文分裂。这种情况要用一致性哈希或session sticky做会话保持;
- 检查异步场景里复制了上下文对象,但KV Cache没有同步复制,导致恢复出来的会话缺了关键中间状态。
我的经验是:不要把上下文安全寄托在推理引擎的隐式缓存上。多轮会话的message history必须显式持久化,存在Redis、数据库或向量库里,每次请求都重新组装上下文。如果启用KV Cache复用,就得保证前缀完全一致,否则命中不了,缓存等于摆设。
还有一点容易被忽略:上下文截断时要记录"截断了什么"。这样即使模型失忆,也还能从日志里判断是截断导致的,还是别的bug,而不是瞎猜。
5.4 建议长期盯住的五个监控指标
上下文管理做得怎么样,最终要靠指标说话。我建议至少长期盯住这些:
| 指标 | 建议工具 | 关注点 |
|---|---|---|
| GPU显存占用 | nvidia-smi、dcgm-exporter | 预留空间 vs 实际峰值 |
| KV Cache使用率 | 推理引擎metrics(如vLLM的gpu_cache_usage_perc) | 是否长期接近上限 |
| 上下文切换耗时 | CUDA event、Nsight Systems | 占总时延的比例 |
| 首token延迟 | 网关或应用metrics | 冷切换和缓存失效的信号 |
| 工作线程与会话数 | 应用自定义metrics | 与上下文池上限的关系 |
压测也别只跑一种固定场景。我的习惯是做一个"并发 × 序列长度 × 会话轮数"的矩阵压测,提前把上下文开销算清楚,再做性能优化。每个组合都记一组基线数据,线上出问题的时候拿来做对比,定位速度会快很多。
上下文管理这个事,最难的往往不是机制本身,而是"你以为它没有状态"的时候最危险。KV Cache、CUDA context、会话历史这些看起来像"缓存"的东西,本质上都是状态,该显式管理的时候就显式管理,该持久化的时候就持久化。把上下文当成一等公民对待,很多线上疑难杂症的根,其实早就埋在那了。
