先说说我最近的折腾吧。有个做边端设备的项目,需要把七B参数级别的对话模型塞进一台可用内存只有4GB左右的设备里,还要保证每秒能流畅吐出十几个token。这件事听起来就像“把大象塞进冰箱”——冰箱确实能装东西,但得先把大象拆成能过门的形状。做端侧大模型部署,本质上就是在干“拆大象”的活:量化、剪枝、蒸馏、算子融合、内存规划,每一步都在回答同一个问题——一个本来为服务器设计的模型,凭什么能在手机和开发板上活下来。
这篇内容我不打算讲概念浮云,直接把我实测过的方案、遇到的坑、算过的账、最后跑出来的效果都摆出来。适合两类人看:一类是正准备把手头模型往设备端部署的开发者,另一类是只想搞明白端侧模型为什么慢、为什么难、应该从哪下手的产品或算法同学。
1. 先算一笔账:7B模型为什么装不进手机
大多数人对端侧部署的第一反应是“模型太大,内存不够”。这个直觉对,但不够精确。我们先做一道简单的算术题。
一个7B参数的模型,如果以FP16精度存储,每个参数占2字节,那么光权重就要14GB。就算把精度压到INT8,也要7GB;压到INT4,大概是3.5GB到4GB。问题是,端侧设备不是只有你这一个模型在跑,系统、App、摄像头、其他服务都要占内存,App进程能拿到的可用内存往往只有几个GB。所以第一步不是“优化模型”,而是让模型本身先能住进这个空间。
但内存只是第一层墙。真正让端侧部署难上天的,是“内存带宽墙”。
我直接用数据说话。假设一台设备的有效内存带宽是50GB/s,这个数字在手机SoC里已经算不错的了。推理分两个阶段:prefill(处理输入阶段)和decode(逐token生成阶段)。decode阶段每生成一个新token,都要把模型的全部权重从内存搬到计算单元里过一遍。如果模型还是FP16的14GB权重,那么每生成一个token,光搬运权重的理论下限就是14GB除以50GB/s,约280毫秒。这个速度换算下来,每秒只能吐三四个token,产品上根本没法用。
你可以把内存带宽理解成一条超市收银通道,模型权重就是货架上的商品。生成token就像结账——每个token都要把所有商品扫一遍。商品越多、通道越窄,结账就越慢。所以端侧推理的瓶颈,绝大多数情况下不是芯片算得不够快,而是内存吃得不够快。
这就是为什么量化在端侧不是“可选项”,而是“必选项”:它把商品体积直接减少了一半甚至四分之三。把权重压到INT4后,理论搬运时间从280毫秒降到80毫秒左右,配合一些运行时优化,跑出十来个token每秒是可行的。这张账算清楚之后,后面的所有工程手段就都有方向了。
我把三种精度的方案做个对照:
| 精度 | 7B权重体积 | 理论带宽下限/秒 | 实际可用性 |
|---|---|---|---|
| FP16 | 14GB | 约3-4 token/s | 无法装入常规终端 |
| INT8 | 7GB | 约7 token/s | 勉强可用,内存紧 |
| INT4 | 3.5-4GB | 约12 token/s | 端侧主流选择 |
做端侧部署,第一课就是别一上来就调代码,先算算内存和带宽这两笔账。目标设备是什么性能、能承受多大的模型、多大的KV Cache,全在这两张表里。
2. 量化是第一刀:FP16到INT4的精度代价与阈值控制
量化这事看起来很简单:把权重从高精度变成低精度。但真正动手时会发现,量化方式的每一层选择,都直接影响最终效果和速度。我说的每一层,不是修辞,是字面意思——per-layer、per-channel、per-token。
先看量化公式。最常见的对称量化是:
scale = max(abs(weight)) / 127 q_weight = clamp(round(weight / scale), -127, 127)反量化时用dequant = q_weight * scale。非对称量化会额外加一个zero point,公式会多一项:
q_weight = clamp(round(weight / scale) + zero_point, 0, 255)理论上很简单,难点在scale怎么选。同样一个张量,如果整层共用一个scale,那些数值范围特别大的outlier通道会把其他通道“压扁”,精度损失明显;如果每个通道一个scale,精度保住了,但推理引擎要处理更多元数据。Per-channel量化是目前INT4方案的主流,因为它对精度的影响比per-tensor小很多,而且硬件侧基本都能支持。
另一个容易被忽略的点是per-token量化。激活值(activation)的分布跟权重不一样,它受输入内容影响,波动很大,所以很多引擎会对激活做per-token的动态量化。在实际效果上,权重量化是“一锤子买卖”,而激活量化是“每句话都要重新算”。这里踩过坑的朋友应该明白,权重量化完了模型还能说话,激活量化没做好,模型可能直接闭麦。
量化不是无脑压精度。我实测过同型号7B模型的三个版本:FP16版、INT8版、INT4版。单独看PPL(困惑度)和benchmark分数,INT4版只比FP16差零点几个点,看起来完全没问题。但一放到真实对话场景,问题就出来了:生成长回答时逻辑开始飘,格式输出偶尔错位,复杂中文指令的遵循能力明显弱一截。为什么?
因为量化误差是逐层放大的。浅层一点小误差,传到底层可能变成语义偏移。benchmark测的是短文本和受限任务,掩盖了这种累积效应。这也是为什么我不建议只看PPL一个指标,至少要同时看:真实对话质量、指令遵循率、生成长度稳定性。
我的实操建议是:保留少量关键层为FP16,其它层用INT4。哪几层关键?通常是最靠近输入和输出的那几层,还有Norm层和Embedding层。原因是这些层对精度变化最敏感,Embedding层直接决定词向量空间,码乱一点整个语义都会歪。这种混合精度方案在常见推理引擎里都能配置,成本是体积增加几百MB,换来的是输出质量接近INT8水平。
校准集,这是另一个必须讲透的问题。量化本身需要一个校准过程来确定scale范围,校准集选不好,模型在训练域之外的表现会直线下降。后面专门写坑的时候我会展开说,这里先给一个可执行的标准:
- 校准样本量不用多,几十到上百条就够;
- 但覆盖面必须广:通用对话、领域术语、特殊格式都要有;
- 最好混合一些带有极端token分布的样本,防止scale被普通文本带偏。
量产一个模型的量化版,标准动作是先切出20%样本做校准,剩下80%样本做对比验证,逐层看量化前后的输出logits差异。哪层差异大,就把哪层拎出来单独用更高精度。这套方法虽然土,但很稳。
3. 剪枝和蒸馏:当量化不够时如何继续“瘦身”
有些场景,光靠量化还是不够。比如目标设备只有2GB可用内存,或者模型本身就不是7B而是13B、34B。这时候就得动第二刀:剪枝和蒸馏。
先说剪枝。剪枝本质上是从模型里删掉不重要的参数。业内最常见的做法是权重剪枝:把绝对值很小的权重直接设成0,相当于网络里有一堆“死神经元”。但问题在于,这种非结构化稀疏对端侧硬件很不友好。GPU有专门针对稀疏度加速的算子,但CPU、NPU、DSP上没有,反而会因为稀疏矩阵的存储格式和索引开销变得更慢。
我在端侧项目里几乎只用结构化剪枝:直接剪掉整个Channel、整个Head、甚至整个Transformer层。举个例子,MLP层中间维度有几千个隐藏单元,剪掉其中1/4,参数直接少1/4,推理引擎完全感知不到变化,因为矩阵维度变了而已。Attention的Head也可以剪,但要注意保留的Head数量至少得满足模型本身的组数要求,剪过头会严重影响长距离依赖。
剪枝之后的精度恢复,需要做微调训练,这一步必须有训练数据。如果项目里没有训练团队,剪枝方案基本可以跳过,直接上量化就好。我自己用过的经验是:垂直领域任务的模型,结构化剪掉20%-30%参数,微调后效果几乎不掉;通用对话模型不要轻易剪,知识容量压缩后,各种边缘问题会大量冒出来。
再说蒸馏。蒸馏的逻辑是:训练一个大而强的教师模型,让它生成答案和soft label,再把这些知识“教给”一个小学生模型。学生模型的架构、参数量都比教师小很多,但通过模仿教师的输出分布,尽量把能力压缩到小模型里。
蒸馏对端侧部署的价值在于:它可以把一个大模型的知识,压缩进一个原本就能跑得动的小模型。比如教师是13B,学生是1.5B,学生学完后走量化路线,最终体积可能只有几百MB。但蒸馏不是免费的,它要求你有完整的训练链路、充足的数据集,以及足够的训练算力。一个常见误区是:直接用教师模型的输出去train学生模型就行。实际上,蒸馏损失里教师softmax的温度调整、学生模型自带的训练损失如何配比,这些细节直接决定学生是“学到了”还是“背下来了”。
我的建议是:如果目标是垂直场景(比如车载语音助手、客服问答、某类设备控制),蒸馏这条路性价比非常高;如果目标是通用对话,建议别蒸馏,直接放弃把13B塞进端侧的想法,换成已经在较小体积下设计好的架构,或者考虑端云协同。
最后补一句路线组合。我常用的落地顺序是:先量化看精度,精度达标就上线;不达标,先结构化剪枝+微调;再不行,用蒸馏换个小架构。每走一步做一次评估,而不是一上来就全套上。
4. 把推理引擎调顺:算子融合、缓存与线程调度
模型压缩完了,接下来就是运行时层面的工程。同一份INT4模型,用同一个引擎,不同配置下跑出来的速度能差两三倍。这一节讲我调过的东西。
4.1 算子融合:减少“过秤”次数
前面说过,内存带宽是主要瓶颈。所以任何能减少“读数据次数”的操作都值得做。算子融合就是干这个的。
举个最常见的例子:很多模型里有GELU激活函数,紧跟着就是矩阵乘法。如果不融合,GELU先把中间结果写回内存,矩阵乘法再去内存读出来算,一来一回多了两次访存。融合后,GELU算出的结果直接留在寄存器或缓存里给矩阵乘,省掉了中间写入和读取。单个算子省得不多,但一个Transformer里几十层,每层省几轮,累计下来非常可观。
还有一类融合和量化相关:反量化融合。INT4权重不能直接参与矩阵乘,往往要反量化为FP16再算。聪明的引擎会把“反量化+矩阵乘”融合成一个算子,在矩阵乘内部按需反量化,而不是先反量化全部权重放到内存里。这种方式叫做weight-on-the-fly,实测可以省下大量中间存储。
4.2 KV Cache:被很多人低估的内存黑洞
推理生成时,每个token都要跟前面所有token的Key和Value做注意力计算。这些K/V会一直缓存着,这就是KV Cache。它的增长速度很多人没概念。
以一个32层Transformer为例,粗略估算:每生成一个token,新增的K/V缓存大概是128KB(FP16)到64KB(INT8)左右。听着不多,但上下文到8K token时,KV Cache就是0.5GB到1GB。这就是为什么很多端侧模型跑着跑着,上下文一长就内存暴涨,甚至直接被系统杀掉。
应对方案有三个:
- 对KV Cache做INT8量化,内存直接减半;
- 限制模型最大上下文长度,产品上不允许无边界增长;
- 使用分页KV Cache或环形缓冲区的思路,避免预留一整块巨大内存,按页分配。
我在一个长对话场景里做过实测:不限制上下文长度,连续聊二十分钟后内存占用从1.8GB涨到3GB以上;限制上下文和KV量化后,内存稳定在2GB左右。对端侧App来说,稳定比什么都重要。
4.3 线程数和调度的粗粒度规则
推理引擎跑在CPU上时,线程数不是越多越好。prefill阶段算力密集,可以用满所有核心;decode阶段是带宽密集,开太多线程反而会因为核间竞争、锁开销把有效带宽拉低。我实测下来,decode阶段4到6个线程往往比8个线程更快,但不同SoC结果不一样,最好直接在目标设备上做一组线程梯度测试。
另外,注意线程绑核。端侧处理器通常是大核小核混合架构,小核算力弱,跑大模型矩阵乘会严重拖后腿。如果引擎允许配置绑核策略,要把推理线程绑在大核上,避免系统把小核调度进去。这一点在车载、工控设备上特别明显,因为这类设备后台任务复杂,系统调度很容易把推理线程踢到小核上。
4.4 预热与重复计算消除
首次推理往往比后续推理慢很多,因为模型文件要从磁盘读进内存、内存分配、算子初始化都要时间。大部分端侧应用的做法是在启动阶段先做一次“预热推理”,把该加载的资源都加载完,用户真正开始对话时才不会卡住。
预热时选一个短一点的prompt,跑一两遍就行,目的是把权重从磁盘映射进内存、把算子缓存建好。实测下来,预热后的首次回答延迟能从两三秒降低到一秒以内。这个优化毫无技术含量,但几乎每个端侧项目都能用到。
5. 从模型到App的完整落地路径:五步走框架
很多朋友拿到模型后第一反应是“直接量化,转格式,塞进去跑”。但这样往往会在某个环节卡住很久。我自己总结了一套五步走的落地路径,每一步都有明确的验收标准,适合作为项目启动的框架。
5.1 第一步:定义目标资源与精度基线
动手之前,先在设备上量清楚三件事:可用内存上限、目标带宽(或根据芯片手册估算)、可接受的生成速度。再拿一个达标设备跑一下FP16版本的模型,记录它的回答质量作为精度基线。这一步不用做任何优化,就是为了后续对比时有个参照。
验收标准:明确写出内存上限、目标token/s、精度容忍度。
5.2 第二步:模型压缩与量化方案确定
根据资源目标选择压缩方案:直接INT4量化?混合精度?剪枝+微调?蒸馏?把压缩方案跑通后,用第一步记录的基线作为对照,评估真实对话质量。这里一定要做“盲评”,不要只盯着PPL看。
验收标准:量化后模型在目标任务上,损失在接受范围内;权重体积低于内存预算。
5.3 第三步:转换到推理引擎并优化运行时
把量化后的模型转换到推理引擎支持的格式,完成算子融合配置、线程调优、KV Cache量化、预热逻辑。每一步改动后都跑一份基准数据,记录最终数字。这里建议准备一份简单的脚本,自动跑一个固定长度prompt并统计prefill耗时、decode耗时、内存峰值。
验收标准:达到预想的token/s,内存峰值不超预算。
5.4 第四步:端侧集成并做压力验证
模型在引擎里跑得动,不代表在App里跑得动。要写一个最小可运行的壳,把文本交互、上下文增长、中断、异常重启都测一遍。重点压测长对话和连续多轮场景。
验收标准:连续对话30分钟以上不崩溃,上下文增长后速度不骤降。
5.5 第五步:灰度与监控
正式发布前,先小范围灰度,并在端上采集推理耗时、内存、生成质量等数据。不要嫌麻烦,端侧模型最怕的就是“本地没问题,线上集体翻车”。灰度期至少覆盖目标设备Top 3的机型。
我做的验收指标大概长这样:
| 指标 | 目标 | 说明 |
|---|---|---|
| 首个token延迟 | < 1s | 用户可以感知的响应速度 |
| 稳定生成速度 | >= 10 token/s | 保证阅读体验 |
| 峰值内存 | <= 可用内存的70% | 留出系统余量 |
| 连续对话稳定性 | 30分钟不崩溃 | 长会话内存可控 |
这套框架看起来繁琐,但能挡住大多数“上线即事故”的情况。
6. 实测踩坑记录:四个曾经让我返工的问题
每个做端侧部署的人,都会在同样几个地方栽跟头。这里把我踩过最深的四个坑完整记录下来,包括排查链路,而不是只给结论。
6.1 校准集只覆盖通用域,目标场景直接崩掉
现象:模型量化后,日常对话挺正常,但一测领域测试集,回答质量明显下降,甚至出现大量重复词。
排查:拉出量化前后每个token的logits差异,发现差异最大的token里,领域专属词占了相当比例。问题很清楚:校准集基本都是通用百科类文本,模型输出空间的领域部分没有被充分校准,导致量化后这些词的决策边界被错误压缩。
修复:重新构建校准集,混入领域数据,也加上一些“奇怪”的短文本,重跑量化。修复后领域测试恢复到了可接受水平。
经验:校准集质量比数量重要,领域覆盖比总样本量重要。
6.2 内存被杀进程,日志里找不到OOM
现象:模型跑得挺快,但会话一长,App闪退。查看系统日志,没有明显的OutOfMemory报错,只有一句“进程被系统策略回收”。
排查:我一度以为是KV Cache增长导致,但限制上下文后问题依旧。后来逐段排查内存占用,发现罪魁祸首是推理引擎默认预分配了一大块连续内存,加上量化权重、运行时中间缓冲,几块内存加在一起,超过了系统的温和阈值,被系统判定为“过大进程”直接回收。
修复:调整引擎的arena内存策略,改成按需增长;把权重文件改成mmap方式映射读取,而不是一次性读进堆内存;内存峰值降了将近800MB。
经验:端侧内存问题,看峰值内存往往不够,还要看“内存分配方式”和“系统对进程内存的评价机制”。
6.3 只测首token延迟,上线后每秒token数崩溃
现象:验收时首token延迟1秒内,速度稳定在12 token/s;真机上线后,聊天超过十轮,速度掉到6 token/s,用户明显感受到变慢。
排查:一开始怀疑是温度降频,测了功耗排除了。继续压测,发现随着上下文长度增长,KV Cache变大,attention计算量也在涨,decode速度自然变慢。这在长对话场景下不可避免。首token延迟只是“起跑速度”,不是“稳定配速”。
修复:一个是限制对话轮次,超长对话主动清理早期KV Cache;另一个是对KV Cache做量化,减小访存压力。修复后,长对话末尾的速度只下降了20%左右,而不是50%。
经验:验收一定要测“最长允许上下文末尾的速度”,不要只测开头。
6.4 量化成了,速度却没快多少
现象:把FP16模型换成INT4,内存确实降了,但token/s只提升了20%左右,跟理论提升差太多。
排查:做算子级profile,发现时间大头并不全在矩阵乘法上。模型里有一些非量化算子,比如LayerNorm、Softmax、SiLU、注意力分数计算,它们在中低精度下运行反而更慢。又因为算子内存布局不连续,访存效率很差。相当于把“大块商品”变小了,但结账通道排队的人反而更多了。
修复:手动调整图优化,把能融合的算子全部融合;对非量化算子改用优化过的手写实现;调小了推理引擎的缓存池避免频繁内存分配。最终速度提升到了接近理论预期。
经验:量化只是减负,算子融合和内存布局才是把理论收益变现的关键。
好了,这四个坑我基本都在项目周期内遇到过,每次返工都在2到3天以上。写出来是希望大家少走点弯路。端侧大模型部署这件事,本质上没有银弹,每一步都是权衡:精度和体积的权衡,速度和稳定性的权衡,通用能力和资源占用的权衡。但好在衡量标准非常清楚——模型能在目标设备上流畅运行,能在用户手里不崩不卡地完成真实任务。能做到这一点,前面折腾的一切就都值了。