1. 为什么要在手机端折腾大模型推理把Qwen这类模型塞进手机里跑很多人第一反应是图啥。云端API调用便宜又省事手机SoC那点功耗和散热余量跑个7B模型听起来就像用家用轿车拉集装箱。但实际做过端侧落地的同行都清楚有些场景就是不能把数据往外传——比如离线会议纪要、本地知识库问答、工业巡检的实时缺陷描述这些场景要么网络不稳定要么数据敏感度极高要么延迟要求卡在几十毫秒级别。这时候手机NPU的价值就出来了它不像GPU那样抢显示资源也不像CPU那样一跑大模型就烫得拿不住功耗比通常能做到CPU方案的十分之一左右。高通从骁龙8 Gen 1开始Hexagon NPU的AI算力就进入了可用区间到8 Gen 3和8 Elite这一代INT4量化下的稠密算力已经能支撑7B级别模型做流式推理。关键是你得把模型喂对——PyTorch训练出来的权重直接扔给NPU是跑不起来的中间要经过量化、图转换、算子映射这一整套流程。我见过太多人卡在QNN转换那一步报错信息看得一头雾水最后放弃转回CPU推理结果速度只有NPU方案的五分之一。这篇内容面向的是已经有一定模型部署基础、想在骁龙平台上把Qwen跑通的开发者。我会从LoRA微调开始讲一直走到Hexagon NPU上的QNN部署中间涉及量化校准、算子兼容性排查、上下文长度与内存的权衡这些实际会卡住人的环节。如果你手上有一台骁龙8 Gen 3或8 Elite的机器或者正在用QCS8550这类开发板这套流程可以直接参考。2. Qwen选型与LoRA微调的实操细节2.1 为什么选Qwen而不是其他开源模型端侧部署选模型参数规模不是唯一指标词表大小、层数、注意力头配置都会影响NPU上的内存占用和算子映射效率。Qwen2.5系列在7B这个档位上GQA分组查询注意力的配置比较友好KV Cache的膨胀速度比同参数量的MHA模型慢不少。我实测过Qwen2.5-7B-Instruct和另一个同尺寸模型在骁龙8 Gen 3上的表现前者在4K上下文时KV Cache占用大约少30%这对手机那点内存来说很关键。另外Qwen的分词器对中文的压缩率不错同样一段中文文本token数通常比Llama系少15%到20%。这意味着在相同的上下文窗口下Qwen能塞进更多实际内容。如果你做的是中文场景的端侧应用这个优势会直接体现在用户体验上。选型时还要注意一点不要一上来就盯着最大的模型。7B在INT4量化后大约占3.5GB到4GB内存加上KV Cache和运行时开销8GB内存的手机勉强能跑但后台一切换就可能被系统杀掉。如果你要做的是常驻后台的助手类应用3B甚至1.8B可能是更务实的选择。我个人的经验是先拿1.8B把整条链路跑通再往上换7B这样排查问题时变量少很多。2.2 LoRA微调的数据准备与参数设置LoRA微调本身不复杂但数据格式和target module的选择会直接影响后面NPU转换的顺利程度。先说数据Qwen2.5的chat template是固定的你的训练数据最好直接构造成messages格式不要自己拼字符串。我见过有人手动拼|im_start|user\n...结果tokenizer处理时多加了特殊token训练出来的模型在NPU上推理时输出乱码。数据量方面端侧场景通常不需要大规模微调。500到2000条高质量样本覆盖你的目标场景就够了。关键是样本的多样性——如果你的应用要处理用户的各种口语化提问训练数据里就得多放一些不规范的表达否则模型在NPU上跑起来后对真实输入的鲁棒性会很差。LoRA的target module选择有个坑很多人习惯性地把q_proj、k_proj、v_proj、o_proj全加上再加上gate_proj、up_proj、down_proj。这样训练出来的adapter合并后模型权重的分布变化比较大量化时更容易出现精度掉点。我的建议是先从q_proj和v_proj开始这两个对注意力模式的调整最直接合并后的权重变化也相对温和。如果效果不够再加o_proj和gate_proj。from peft import LoraConfig, get_peft_model lora_config LoraConfig( r16, lora_alpha32, target_modules[q_proj, v_proj, o_proj], lora_dropout0.05, biasnone, task_typeCAUSAL_LM )rank选16是个比较稳的起点。rank太高比如64会让adapter参数量膨胀合并后权重的扰动范围变大量化时更难受。rank太低比如4又可能学不到足够的任务特征。16到32之间是比较安全的区间。学习率方面LoRA微调通常用1e-4到2e-4配合cosine调度。epoch数不要太多3到5轮就够了多了容易过拟合。我一般会在训练时留10%的验证集盯着验证loss一旦开始回升就停。2.3 微调后的权重合并与精度检查LoRA训练完adapter权重和base model是分开的。部署前必须合并成一个完整的权重文件。合并本身一行代码的事但合并后的精度检查不能省。from peft import PeftModel from transformers import AutoModelForCausalLM base_model AutoModelForCausalLM.from_pretrained( Qwen/Qwen2.5-7B-Instruct, torch_dtypetorch.float16 ) model PeftModel.from_pretrained(base_model, ./lora_adapter) merged_model model.merge_and_unload() merged_model.save_pretrained(./merged_model)合并后一定要做两件事第一用同样的验证集跑一遍推理确认输出和合并前一致第二检查权重里有没有NaN或Inf。LoRA合并时如果base model是fp16而adapter是fp32某些情况下会出现数值溢出。我遇到过合并后某几层权重变成NaN的情况原因是训练时用了fp16混合精度adapter里存了一些极小的值合并时被放大成了异常值。检查方法很简单for name, param in merged_model.named_parameters(): if torch.isnan(param).any() or torch.isinf(param).any(): print(f异常权重: {name})如果发现异常回退到fp32合并或者调整LoRA的alpha值重新训练。这一步花十分钟能省掉后面量化时几个小时的排查时间。3. 从PyTorch到QNN量化与图转换的核心环节3.1 量化方案的选择INT4还是INT8骁龙Hexagon NPU对INT4和INT8都有硬件支持但两者的取舍不是简单的INT4更快。INT4的权重压缩率更高7B模型大约占3.5GBINT8则要7GB左右。手机内存有限INT4通常是唯一可行的选择。但INT4的精度损失也更明显尤其是对注意力层的KV Cache做量化时长上下文下的输出质量下降会比较明显。我的做法是混合精度权重用INT4激活值用INT8KV Cache保持FP16。QNN的量化工具链支持这种混合配置但需要在转换脚本里显式指定。这样做的代价是内存占用比全INT4高一些但比全INT8低不少精度上也能接受。量化校准集的选择很关键。不要随便拿几百条通用语料去校准最好用你的目标场景数据。比如你做的是法律问答校准集里就应该有法律相关的文本。校准集的数量不用多100到200条就够了但覆盖面要广尽量包含不同的输入长度和内容类型。3.2 QNN转换流程中的算子兼容性排查QNN转换的核心工具是qnn-onnx-converter和qnn-model-lib-generator。流程大致是PyTorch导出ONNXONNX转QNNQNN编译成模型库。听起来简单但ONNX导出那一步就会遇到各种算子不支持的问题。Qwen2.5里用到的Rotary Embedding、RMSNorm、SwiGLU这些算子ONNX的opset版本不同导出结果差异很大。我建议用opset 14或更高版本低版本的opset对动态shape的支持不好而大模型推理时序列长度是动态的。导出ONNX时有个细节attention mask的处理。HuggingFace的模型默认会用attention_mask但ONNX导出时如果直接把mask作为输入QNN转换时可能会因为mask的shape变化导致图优化失败。我的做法是在导出前把mask逻辑固化到模型内部或者用固定长度的mask推理时再根据实际长度截断。torch.onnx.export( model, (input_ids, attention_mask, position_ids), qwen2.5_7b.onnx, opset_version14, input_names[input_ids, attention_mask, position_ids], output_names[logits], dynamic_axes{ input_ids: {0: batch, 1: sequence}, attention_mask: {0: batch, 1: sequence}, position_ids: {0: batch, 1: sequence}, logits: {0: batch, 1: sequence} } )导出后先用ONNX Runtime跑一遍确认输出和PyTorch一致。这一步能过滤掉大部分导出问题别跳过。3.3 量化校准中的精度掉点定位量化后精度掉点是最让人头疼的问题。表现可能是输出重复、逻辑混乱、或者直接变成乱码。定位方法是从后往前查先看最后一层的输出和FP16的差异再逐层往前推。QNN工具链提供了逐层对比的功能但用起来比较繁琐。我的土办法是把量化后的模型和FP16模型对同一批输入的输出拿出来算余弦相似度。如果某一层的相似度突然掉到0.9以下那这一层就是问题所在。常见的掉点原因有几个一是某些层的权重分布特别集中INT4的量化区间覆盖不住二是激活值里有极端值校准集没覆盖到三是LayerNorm或Softmax这类对数值敏感的算子被量化了。对于第三种情况可以在转换配置里把这些算子标记为FP16执行。{ quantization_config: { activation_bitwidth: 8, weight_bitwidth: 4, bias_bitwidth: 8, exclude_layers: [lm_head, embed_tokens], keep_fp16_ops: [LayerNorm, Softmax] } }lm_head和embed_tokens通常建议排除在量化之外这两层对精度的影响很大但参数量占比不高保持FP16的代价可以接受。4. Hexagon NPU上的部署与性能调优4.1 QNN上下文创建与内存配置模型编译成QNN模型库后在手机上运行时需要创建QNN context。这一步的内存配置直接决定了能不能跑起来。骁龙8 Gen 3的NPU有专用的内存区域但大小有限7B模型INT4量化后加上KV Cache很容易超过默认限制。创建context时需要指定qnn_context_priority和内存分配策略。我一般会把context优先级设为高避免被系统降级。内存方面QNN支持从ION内存池分配也支持从系统内存分配。ION内存的访问速度更快但容量有限适合放权重KV Cache这种动态增长的部分放系统内存更稳妥。QnnContext_Config_t contextConfigs[] { {QNN_CONTEXT_CONFIG_OPTION_PRIORITY, QNN_CONTEXT_PRIORITY_HIGH}, {QNN_CONTEXT_CONFIG_OPTION_MEMORY_TYPE, QNN_CONTEXT_MEMORY_TYPE_ION} };实际部署时我建议先用小模型1.8B验证整条链路确认context创建、内存分配、推理调用都没问题再换7B。这样出问题时排查范围小很多。4.2 推理时的KV Cache管理与上下文长度权衡KV Cache是端侧推理的内存大户。Qwen2.5-7B有28层每层KV Cache的大小是2 * num_kv_heads * head_dim * seq_len * dtype_size。以GQA配置为例num_kv_heads是4head_dim是128seq_len为2048时FP16的KV Cache大约占2 * 4 * 128 * 2048 * 2 * 28字节算下来约1.1GB。如果上下文拉到8K这个数字就变成4.4GB加上模型本身的3.5GB8GB内存的手机直接爆掉。所以端侧部署必须对上下文长度做限制。我的经验是7B模型在8GB内存手机上上下文控制在2K到3K比较安全。如果应用场景确实需要长上下文可以考虑几个方案一是用滑动窗口注意力只保留最近N个token的KV Cache二是对KV Cache做INT8量化内存减半但精度有损失三是用分页KV Cache把不活跃的页换出到闪存但延迟会增加。滑动窗口是最实用的方案。Qwen2.5本身支持配置sliding_window参数但需要在模型导出时就固定下来。我一般设2048超出部分用注意力掩码截断。这样内存占用可控对大多数对话场景也够用。4.3 实测性能数据与瓶颈分析在骁龙8 Gen 3上Qwen2.5-7B INT4量化后的实测数据大致如下指标数值备注首token延迟180-250ms取决于输入长度解码速度12-18 tokens/s上下文2K以内内存占用4.2GB含KV Cache功耗3.5-4.5W持续推理时温升8-12°C10分钟连续推理解码速度在12到18 tokens/s之间波动主要受内存带宽影响。Hexagon NPU的算力是够的但权重从内存搬到NPU的带宽是瓶颈。INT4量化已经把权重压缩到3.5GB但每次解码都要遍历全部权重带宽压力依然很大。首token延迟主要花在prefill阶段输入越长延迟越高。如果输入超过1K token首token延迟可能到400ms以上。优化方法是把prefill和decode分开处理prefill用更大的batchdecode用流式输出。功耗方面持续推理时整机功耗在3.5W到4.5W之间比CPU方案低很多但比GPU方案略高。温升在10分钟连续推理后大约8到12度手感上就是温热不至于烫手。如果做长时间运行的应用建议加个温度监控超过阈值就降频或暂停。5. 踩坑记录那些文档里不会写的细节5.1 ONNX导出时的动态shape陷阱动态shape在ONNX里是个老大难问题。Qwen2.5的attention计算里有不少reshape和transpose操作这些操作在动态shape下容易触发ONNX的shape推断错误。表现是导出成功但ONNX Runtime加载时报shape不匹配。我的解决办法是在导出前把模型里的view操作全部换成reshape并且在关键位置插入torch.onnx.export能识别的shape断言。另外position_ids的生成逻辑最好固化到模型里不要作为外部输入否则动态shape下容易出错。还有一个坑是batch维度。端侧推理通常batch1但ONNX导出时如果保留了动态batch维度QNN转换时可能会因为batch维度的不确定性导致图优化失败。建议导出时直接把batch固定为1。5.2 QNN转换时的算子版本冲突QNN的算子集和ONNX的算子集不是一一对应的。有些ONNX算子QNN不支持需要用QNN的等价算子替换。比如ONNX的LayerNormalization在QNN里对应的是LayerNorm但两者的参数顺序不一样直接转换会报错。更麻烦的是版本冲突。QNN SDK不同版本对同一算子的支持程度不同。我遇到过在QNN 2.20上能正常转换的模型升级到2.24后报算子不支持。原因是新版本对某个算子的输入类型做了更严格的检查。应对方法是锁定QNN SDK版本不要随意升级。如果必须升级先在开发板上跑一遍完整的转换和推理流程确认没问题再更新生产环境。5.3 手机端内存不足导致的静默失败手机端推理最隐蔽的坑是内存不足导致的静默失败。表现是推理请求发出去后没有响应也没有报错就是卡住。原因是QNN context创建时申请的内存超过了系统限制但系统没有返回明确的错误码而是直接杀掉了推理进程。排查方法是看系统日志里的low memory killer记录。如果发现推理进程被kill那就是内存不够。解决办法有几个降低上下文长度、减少KV Cache的精度、或者把模型换成更小的版本。还有一个细节是Android的后台限制。如果应用切到后台系统可能会回收NPU资源。需要在AndroidManifest里声明foregroundServiceType并且用前台服务保持推理进程的优先级。5.4 温度墙与持续推理的降频策略骁龙8 Gen 3的NPU在持续高负载下会触发温度墙频率从最高档降到中档解码速度可能从18 tokens/s掉到10 tokens/s左右。这个降频是硬件保护机制软件层面没法绕过但可以优化。我的做法是在推理循环里加入温度检测当温度超过阈值时主动降低推理频率比如每解码一个token后sleep几毫秒。这样虽然单次推理变慢了但整体持续时间更长用户体验反而更平滑。另外把prefill和decode分开调度prefill阶段可以短时间跑高频decode阶段用低频维持这样温度上升更平缓。6. 从开发板到手机跨平台部署的注意事项6.1 QCS8550开发板与手机端的差异很多人在QCS8550开发板上调通了模型直接搬到手机上就出问题。两者的NPU硬件架构相同但软件栈和内存配置有差异。开发板的QNN驱动版本通常比手机新算子支持更全。手机上的QNN驱动是随系统更新的版本可能落后好几个小版本。另外开发板的内存通常比手机大散热也更好。在开发板上能跑7B的配置到手机上可能因为内存或温度限制跑不起来。我的建议是在开发板上调通后先在手机上用1.8B验证确认整条链路没问题再逐步换大模型。6.2 Android NNAPI与QNN的直接调用选择Android提供了NNAPI作为NPU的抽象层但NNAPI对QNN的封装有性能损耗而且支持的算子集比QNN原生少。如果你追求极致性能直接调QNN是更好的选择。但直接调QNN需要自己管理context、内存和线程开发成本更高。我的经验是如果模型结构比较标准NNAPI够用如果用了自定义算子或者需要精细控制内存和功耗直接上QNN。另外NNAPI的版本兼容性也是个问题不同Android版本对NNAPI的支持程度不同直接调QNN反而更稳定。6.3 模型更新与热切换的实现思路端侧应用经常需要更新模型比如换了微调数据或者调整了量化配置。如果每次更新都让用户重新下载整个模型体验很差。我的做法是把模型拆成base和adapter两部分base模型不变只更新adapter。QNN支持多context可以把base模型和adapter分别加载到不同的context里推理时动态组合。不过QNN对adapter的支持有限LoRA的合并需要在转换前完成。所以实际做法是base模型用QNN编译好adapter在服务器端合并到base后重新量化编译然后以增量更新的方式下发。这样每次更新的数据量只有几百MB比全量下载小很多。热切换方面QNN context的创建和销毁需要时间不适合频繁切换。我的做法是同时保留两个context新模型加载到备用context切换时只改推理调用的指针旧context在后台异步销毁。这样切换延迟可以控制在毫秒级。7. 一些实测后的个人体会这套流程走下来最大的感受是端侧部署的瓶颈往往不在NPU算力而在内存带宽和散热。Hexagon NPU的算力足够跑7B模型但权重搬运的带宽限制了实际解码速度。INT4量化是必须的但量化带来的精度损失需要在校准集和混合精度配置上花心思弥补。另一个体会是不要追求一步到位。先用小模型把整条链路跑通再逐步换大模型、加长上下文、优化性能。我见过太多人一上来就搞7B结果卡在某个环节几天都过不去最后放弃。端侧部署是个系统工程每个环节都有坑小步快跑比大步跨越靠谱得多。最后分享一个实用技巧在开发阶段把QNN的日志级别调到最高所有算子映射、内存分配、推理调用的细节都打出来。这些日志在排查问题时非常有用尤其是算子不支持或者内存不足这类错误日志里通常有明确的线索。生产环境再把日志级别降下来避免性能损耗。