Nanbeige4.2-3B 这类 3B 量级模型在本地跑 Agent,显存不是由参数量单独决定的:参数量只决定权重那一块,真正让进程崩掉或明显掉速的通常是 KV cache,而 KV cache 随上下文长度和并发数同步放大。稳妥的做法是把显存拆成权重、KV cache、框架开销三部分,先分别估出量级,再用同一段多轮对话做上下文阶梯采样,最后在可接受速度下反推并发上限。本文的三组记录(阶梯、并发、量化对比)都应该落在你自己的机器上,任何具体数字都要以本机日志和 nvidia-smi 读数为准。
跑本地 Agent 时,先按「权重 + KV cache × 并发 + 框架开销」列式子,再逐项用命令验证。适用场景是单机单卡或小卡多卡的本地推理;操作动作是空载测框架开销、固定 prompt 拉长上下文测 KV 增长、再调并发找边界;验证方式是 nvidia-smi 采样加请求侧时间戳;风险边界是框架预分配显存会让读数偏高,量化档位还可能牺牲结构化输出稳定性,两者都要单独确认。
把权重、KV cache 与框架开销分开列
先把三块的估算式子写出来,变量含义固定下来,后面每次采样就只需填变量。下面是通用骨架,不同推理框架的参数名可能不同,但物理含义一致:
- 权重显存 ≈ 参数量 N × 每参数字节 b。b 由精度决定:FP16/BF16 约 2 字节,INT8 约 1 字节,INT4 约 0.5 字节;量化还会额外带 scale、zero-point、分组元数据等开销,所以要留一点余量,最终以模型加载完成后、未发请求时的显存读数为准。
- KV cache ≈ 2 × L × H_kv × d × S × B × b_kv。L 是层数,H_kv 是 KV 头数(用了 GQA 就填 KV 头数,不是注意力头数),d 是每头维度,S 是上下文 token 数(输入加已生成),B 是并发数或批大小,b_kv 是 KV 每元素字节数,前面的 2 表示 K 和 V 两份。结果除以 1024³ 换算成 GiB。
- 框架开销:CUDA context、kernel workspace、CUDA graph、tokenizer、运行时自身占用的内存。这部分没有可靠公式,只能测。
框架开销用两段差值法量:先只启动服务、不发任何请求,记一次 nvidia-smi 的 used;再发一条极短请求(比如 8 个 token 输入、16 个 token 输出),再记一次。第一次读数减去权重估算值就是框架固定开销,第二次与第一次的差值主要落在 KV cache 和临时激活上。需要注意:不少框架在启动时就按百分比预分配显存池(常见参数名如 gpu_memory_utilization、gpu_memory_fraction),此时 used 会直接顶到上限,看不出真实占用。遇到这种情况先把预分配比例调低到刚能加载模型,再看差值,或者改看框架自己暴露的 KV cache 使用率指标和日志。
# 空载基线:启动服务后、不发请求时执行
nvidia-smi `--query-gpu`=timestamp,memory.used,memory.total,utilization.gpu \
`--format`=csv,noheader
# 进程级视角,用来确认是哪块卡、哪个 pid 在吃显存
nvidia-smi `--query-compute-apps`=pid,process_name,used_memory \
`--format`=csv,noheader
用不同上下文长度跑同一段多轮对话
这一步的目的不是测出某个固定数字,而是看显存随上下文增长在哪一段开始明显抬头,以及抬头后是否出现 OOM 或明显掉速。做法是准备一段固定的多轮 Agent 对话(含系统提示、工具定义、两到三轮历史、最后一次用户提问),只改动历史长度,其余全部保持不变。可以按 2K、4K、8K、16K 这样的阶梯往上加,上限取决于模型支持的最大上下文和显卡实际容量;每上一档重启一次推理进程,避免显存碎片和上一轮残留干扰读数。
# 采样前清掉估值,采样期间每秒落一行到文件
nvidia-smi `--query-gpu`=timestamp,memory.used,memory.total,utilization.gpu \
`--format`=csv,noheader -l 1 > mem_watch.csv
# 跑完这一档后停掉采样
kill %1
每次采样至少记录这些字段,缺一项后面就没法归因:
- 档位标识与输入 token 数、输出 token 数(以服务端日志或 tokenizer 计数为准,不要用字符数估算)
- 发起前显存 used、请求峰值显存 used、结束后显存 used(判断是否释放)
- 是否 OOM、是否被框架主动换出到内存
- 首 token 延迟、总耗时、输出 token 数,据此算每秒生成 token 数
- 该档位的并发数、批大小、量化档位、启动参数快照
连成曲线后一般能看到两个转折:一个是 KV cache 开始主导显存增量的位置,另一个是速度开始明显下降的位置。Agent 场景里多轮历史会不断变长,两个转折中更早出现的那个,才是实际能用的上下文上限。
调并发数与批大小找到可接受点
并发和上下文是一起吃 KV cache 的,所以固定上下文长度再调并发,才有可比性。先把上下文压到上一步确认的安全值,再逐个放大并发。下面是一份通用启动参数骨架,字段名按你所用框架替换,重点是理解每个参数动的是哪一块:
# 通用骨架,参数名请替换为所用框架的实际命名
serve `--model` <本地模型路径> \
`--max-model-len` 8192 \ # 单请求上下文上限,直接决定单条 KV 大小
`--max-num-seqs` 4 \ # 同时处理的请求数,乘进 KV cache 总量
`--max-num-batched-tokens` 8192 \ # 单次前向的 token 预算,影响峰值激活
`--gpu-memory-utilization` 0.80 \ # 预分配上限,先压低再逐步放开
`--dtype` bfloat16 \
`--enforce-eager` # 排查期先关图捕获,减少一类额外开销
| 参数 | 作用 | 调大后的直接影响 | 观察点 |
|---|---|---|---|
| max-model-len | 单请求上下文上限 | 单条 KV cache 线性变大 | 长对话档位是否 OOM |
| max-num-seqs | 并发请求数 | KV cache 总量按倍数放大 | 峰值显存与尾延迟 |
| max-num-batched-tokens | 单次前向 token 预算 | 峰值激活和临时显存上升 | 短请求密集时的响应抖动 |
| gpu-memory-utilization | 预分配比例 | 预留越多,越早报 OOM 前先挡住 | 空闲显存是否被白白占住 |
速度与显存的记录方式建议统一在请求侧做:给每个请求打三个时间戳——发起 t0、收到首个 token t1、流式结束 t2。首 token 延迟 = t1 − t0,生成速度 = 输出 token 数 ÷ (t2 − t1)。并发下不要只看平均,要同时记中位数和最慢那条,Agent 一次任务里如果有一轮卡住,整体体验就坏在这一轮。显存侧同步记峰值 used 和请求结束后是否回落到基线。
定位顺序建议是:先降 max_num_seqs,再降 max_model_len,最后才考虑换更低的量化档位。把请求换出到内存或开 CPU offload 只能避免进程直接崩,会带来明显变慢,不宜当成提升吞吐的手段。
比较不同量化档位下同一任务的输出
量化省的是权重那一块显存,KV cache 基本不受影响,所以「量化后能跑更长上下文」这个预期通常要打折。更该关心的是量化后 Agent 的流程还成不成立——工具调用参数能不能解析、多轮里约束会不会丢。比较时把变量控死:同一段多轮对话、同一系统提示、同一 temperature(建议先设 0 或固定 seed)、同一 max_tokens、同一并发、同一上下文长度,只换量化档位重启服务。
| 记录项 | FP16/BF16 | INT8 | INT4 |
|---|---|---|---|
| 加载后显存 used | |||
| 峰值显存 used | |||
| 首 token 延迟 / 生成速度 | |||
| 工具调用是否可解析为 JSON | |||
| 参数名与取值是否落在允许集合 | |||
| 多轮后是否保持前文约束 | |||
| 是否出现重复循环或提前截断 |
判定标准要写成可检查的断言,而不是「读起来差不多」:结构化输出能被 JSON 解析器吃下;工具名和必填参数不缺失、类型正确;参数值在你自己定义的允许集合内;在第三轮追问后仍然遵守第一轮给出的约束;输出没有陷入重复短语或在中途被截断。任何一条不满足,就该把这一档位判为不适合当前流程,而不是靠调提示词硬补。同时把显存与速度两列填上,才能判断为了省下的那点权重显存,付出的是否值得。
整页做下来,你手里应该有四份东西:一份三块显存的估算式子、一份上下文阶梯的显存与速度曲线、一份并发参数对照记录、一份量化档位的输出与格式检查表。之后换机器、换上下文上限或换模型版本,照这套流程重跑一遍即可,不必重新推导。