我第一次接触 MLC LLM 是在一个特别尴尬的场合。当时客户拿着手机问我,能不能让这个 App 在没网的时候也跑个大模型,我脑子里第一反应是"这不现实吧"。半年后我回头再看这个判断,只能说那时候我对端侧推理的认知还停在很浅的层面。MLC LLM 这类框架改变的恰恰是"端上跑不动大模型"这个前提本身。
这一章我想把整件事从头捋一遍。不是列大纲,而是把我踩过的坑、理解上的几次转变、以及最后形成的选型逻辑摊开来讲。如果你正在犹豫端侧推理该不该上、怎么上、上了之后边界在哪,这些内容应该能帮你省掉不少试错的时间。
1.1 从云端推理到端侧推理的需求变迁
云端推理这套模式我用了好几年,它的好处很直观。模型放服务器上,想换就换,算力按需扩,客户端只管发请求收结果。那几年做产品基本不用考虑设备型号这回事,反正所有脏活累活都在机房解决了。
事情开始变味是从几个具体的客户抱怨开始的。一个做户外作业设备的团队跟我说,他们的工人经常在没信号的山里干活,App 里那点智能功能全成了摆设。另一个做车载场景的,网络延迟波动大到让语音交互体验直接崩掉。还有一类需求更敏感,用户明确表示数据不能出设备,哪怕加密传输也不行。
这些问题单看是孤立的,放在一起看指向同一个方向:当智能功能从"锦上添花"变成"业务必需"的时候,云端的几个固有短板就被放大了。网络可用性、往返延迟、数据出域,每一条都能成为硬约束。端侧推理不是要取代云端,它解决的是云端覆盖不到或者覆盖成本太高的那部分场景。
我现在的判断方式很朴素。先看这个功能断网之后还能不能用,如果不能,用户会不会因此骂娘。再看这个功能对延迟的容忍度,凡是要求亚秒级响应的交互式场景,云端往返本身就是瓶颈。最后看数据敏感度,凡是涉及个人隐私、商业机密、合规红线的内容,数据不出设备是最省心的方案。三条里中一条,端侧就值得认真评估;中两条以上,基本可以立项了。
1.2 MLC LLM 技术栈与端侧运行时架构
MLC LLM 这个名字我第一次看到的时候以为又是一个套壳方案,后来读了它的设计文档才明白它要做的事情有多底层。它是一套编译器和运行时的组合,核心思路是把模型计算图编译成目标设备能高效执行的形式,中间经过算子融合、内存规划、目标后端代码生成这些环节。
我自己理解这套架构的关键在于两层。上层是模型编译,你拿一个 HuggingFace 上的模型,经过量化、图优化、目标平台适配,最后产出一个能直接加载的产物包。下层是运行时,这个运行时不是简单的推理引擎,它带着自己的内存管理器、KV Cache 管理逻辑和线程调度策略。两层的边界划得比较清楚,你在编译期决定的东西运行时基本不再改动,运行时关心的是怎么把已经规划好的计算高效地喂给硬件。
设备上的执行路径我画过好几次图才理清楚。App 通过语言绑定调用运行时 API,运行时把请求转成内部的 token 流处理循环,每一轮 decode 都涉及 embedding 查表、注意力计算、前馈网络、采样这几个阶段。注意力那块是最吃内存的,KV Cache 的增长曲线直接决定了你能跑多长的上下文。我第一次在手机上看到内存曲线随着对话轮次往上爬的时候,才真正理解为什么端侧对上下文长度这么敏感。
这里有个容易被忽略的细节。MLC LLM 的运行时对每种后端有专门的实现,Metal、Vulkan、CUDA、WebGPU 各有各的路径。这意味着同一个模型在不同设备上的性能表现可能差异很大,你在一台 iPhone 上测出来的数字不能直接套到安卓旗舰上。我吃过这个亏,拿 iOS 的基准数据去给安卓客户做预期管理,结果实测差了将近一倍。
1.3 端侧推理的典型场景、能力边界与选型判断
跑过十几个端侧项目之后,我大致把场景归成了几类。第一类是离线可用的智能助手,典型代表是户外、航空、工业巡检这些网络条件不可靠的领域。第二类是隐私敏感的本地处理,比如医疗问诊记录、法律文书草稿、个人日程管理。第三类是低延迟交互,语音唤醒后的意图理解、输入法里的实时补全、相机里的场景描述都属于这一类。
能力边界这块我想说得直白一些。端侧能跑的模型规模受设备内存硬约束,一台 8GB 内存的手机,留给模型的预算撑死也就 4GB 出头,这还得看系统和其他 App 的占用情况。量化之后 3B 到 7B 参数的模型是比较现实的选择,再往上就要靠更激进的量化或者更小的上下文窗口来换。我第一次试图在手机上跑 13B 模型的时候,App 直接被系统干掉,那个教训挺深刻的。
选型判断我总结了一个不太严谨但好用的框架。先问这个任务需不需要世界知识,需要大量事实性知识的任务端侧模型容易胡编,这类交给云端更稳。再问任务对输出格式的要求有多严,结构化输出在端侧是能做的,后面章节会细讲,但它对提示词和约束解码的配合要求比较高。最后问更新频率,模型如果每个月都要迭代,端侧的分发和更新成本要提前算进去。
我见过一些团队一上来就想把所有功能都搬到端侧,这个思路我不太认同。端侧和云端更像是分工而不是替代,把离线可用、隐私敏感、低延迟这三类需求留在端侧,把知识密集、算力密集、频繁更新的部分放云端,这个组合在大多数产品里是最务实的。
1.4 相关搜索意图梳理:部署教程、手机端性能优化与 json_object 输出
写这个系列之前我翻了大量搜索记录和社区提问,发现大家关心的东西集中得有点出乎意料。排在前面的是部署教程,尤其是 Android 和 iOS 真机跑通那一步,很多人卡在编译产物和设备环境的匹配上。这类问题我在后面第二章会用完整的流程走一遍,包括那些文档里没写但实际会绊住你的细节。
性能优化是第二个高频方向。问得最多的不是"怎么更快",而是"为什么我的手机比别人的慢这么多"。这个问题背后涉及量化策略、线程配置、GPU 后端选择、KV Cache 管理好几个变量,单一因素很难解释清楚。第四章我会把参数调优的清单整理出来,每个参数影响什么、调整的优先级怎么排,都讲明白。
json_object 输出这块的搜索量增长得很快,我猜是越来越多的人在端侧做结构化数据抽取或者工具调用。端侧做 JSON 输出和云端不太一样,约束解码的实现路径、解析容错的设计、失败回退的策略都要重新考虑。第三章专门讲这个,包括提示词和 Schema 怎么配合才能让输出稳定下来。
还有一个隐性的需求我觉得值得提一下。很多人搜的是具体问题,真正想解决的是"端侧这套东西到底靠不靠谱"。这个判断没法靠一篇教程给出来,它需要你把概念、架构、场景、边界、优化、评估这几块拼在一起看。这也是我把第一章写成全景而不是直接上教程的原因。
部署这件事我一开始想得太简单了。看完官方 Quick Start,觉得不就是装个包、跑个命令、把产物推到设备上嘛。真正在真机上把模型跑起来,花了我差不多两周,中间踩的坑有一半文档里没写。这一章我把从零到真机运行的完整路径走一遍,尽量把那些会绊住你的细节说透。
写之前我回忆了一下这几个项目里最费时间的环节,排在前面的不是模型编译本身,而是环境版本对不上、产物和设备架构不匹配、真机日志看不懂这几类问题。这类问题有一个共同点,每一步单独看都挺简单,串起来就到处漏风。
2.1 环境准备、工具链安装与依赖检查
我现在的习惯是先拿一台干净的机器,把整个流程从零走一遍,这样每一步依赖是什么、版本冲突在哪都清清楚楚。MLC LLM 的编译链依赖 Python、CMake、TVM 那一套,还要额外的 NDK 来做安卓产物。第一次装的时候我在一台已经用了两年的开发机上折腾,各种 Python 包版本打架,折腾到半夜没搞定,换了个 conda 环境十分钟就跑通了。这件事教我的教训很实在:别在存量环境上赌。
具体到版本选择,Python 我用 3.10 比较稳,3.11 和 3.12 有些依赖包还在跟进。CMake 建议 3.28 以上,低版本在编译某些算子时会报找不到特性。安卓侧要装 NDK,版本跟 MLCChat 仓库里声明的对齐,我用 r26 和 r27 都跑过,r25 有几次链接报错。这些版本号看着琐碎,错一个可能就要花半天定位。
依赖检查我写了个小脚本,每次装完跑一遍,检查 Python 版本、cmake --version、ndk 路径、git submodule 状态、还有 pip 里 mlc-llm 和 tvm 的版本是否匹配。这个脚本后来成了我每个项目的标配,看着土,能省很多排查时间。
还有一个容易忽略的点,磁盘空间。MLC LLM 编译过程中会生成大量中间文件,一个模型的编译产物加缓存,十几 G 是常有的事。我第一次在磁盘快满的机器上编译,跑到一半报了个毫无关联的错误,后来才发现是磁盘满了导致写文件失败。
2.2 模型选择、量化编译与打包产物说明
模型选择这块我踩过的最大的坑是贪大。第一次上手我挑了个 7B 的模型想直接塞进手机,编译没问题,推到设备上一加载就被系统 OOM 干掉。后来老老实实从 1B 到 3B 开始试,把流程跑顺了再往上加参数。
MLC LLM 的模型生态现在覆盖得挺广了,Llama、Qwen、Phi、Gemma 这些主流架构都有支持。我的建议是优先选社区里有人跑过、有预编译产物的模型,尤其是 Qwen 系列在中文场景下的表现和社区支持都比较成熟。选模型的时候我看三件事:参数的规模、支持的量化档位、以及 tokenizer 是不是标准实现。第三点被很多人忽略,非标准 tokenizer 在端侧解码时会出现奇怪的乱码,排查起来很费劲。
量化是编译阶段最重要的决策。MLC LLM 支持 q4f16、q4f32、q3f16 这几档常见的组合,数字是权重位宽,后缀是激活和 KV Cache 的精度。我自己测下来的经验是,q4f16 在绝大多数手机上是性价比最高的选择,模型体积压到原来的四分之一左右,推理质量下降在可接受范围内。q3 系列体积更小,代价是复杂推理任务上质量掉得比较明显,做简单分类或者抽取任务还好,做多轮对话就有点勉强。
编译命令本身不长,mlc_llm compile 加上模型路径、目标设备和量化参数。真正需要理解的是产物结构,编译出来会得到一个目录,里面有模型权重分片、tokenizer 配置、聊天模板、还有一份 mlc-chat-config.json。这份配置文件决定了运行时怎么加载模型、用什么上下文长度、采样参数默认值是什么。我第一次改配置的时候不知道聊天模板在哪,结果模型输出把用户消息和系统提示词都复读了一遍,后来翻了 runtime 源码才搞明白模板的拼接逻辑。
打包产物有两种形态。一种是给 MLCChat 这类现成 App 用的,把产物目录推到设备的指定路径就行。另一种是把产物打进自己的 App 包里,这种要在构建阶段把模型文件作为 assets 或者资源包集成进去,注意安卓对 assets 单文件大小有限制,大模型要分片。这两种方式我在项目里都用过,开发调试阶段用第一种快,正式发版用第二种。
2.3 Android 端部署流程与真机调试要点
安卓端我一直用 MLCChat 的官方 demo 做第一轮验证,这个仓库把 JNI 层、native 库、UI 都打包好了,你只要把编译产物推到手机上的对应目录,App 启动就能加载。第一次跑通的时候我盯着手机上那个慢慢吐字的对话框看了半天,那种"真跑起来了"的感觉挺难忘的。
真机调试这块有几个点我想单独说。第一个是 adb 推送大文件的速度,模型动辄几个 G,adb push 有时候会卡住或者中途断开,我一般会拆成几个小文件分批推,或者干脆推到 SD 卡再移动。第二个是 logcat 过滤,MLC LLM 的运行时日志打得很详细,加载模型、分配内存、每轮推理的耗时都有,我习惯用 adb logcat -s mlc 只看相关标签,不然日志混在一起根本没法看。
最容易出问题的是内存。安卓系统对单个 App 的内存限制因厂商而异,同一份模型在小米上跑得好好的,换到某国产 ROM 上直接被 kill。第一次遇到这种情况我以为是模型太大,后来用 adb shell dumpsys meminfo 一看,是系统把可用内存算得很保守。解决思路有两种,一是降低上下文长度减少 KV Cache 占用,二是把模型再压一档量化。我现在的做法是在 App 启动时先做一个内存预估,超阈值就提示用户或者自动降级配置。
还有一个细节,安卓上的 GPU 后端选择。MLC LLM 在安卓上主要走 Vulkan 和 OpenCL 两条路径,前者在支持 Vulkan 1.1 以上的设备上表现更稳。我做适配的时候遇到过某款机型 Vulkan 驱动有 bug,加载就崩,切到 CPU 后端才跑起来,性能掉了一半多。这种设备兼容性问题只能靠真机测,模拟器测不出来。
2.4 iOS 端部署流程与签名、权限注意事项
iOS 端整体体验比安卓顺一些,苹果的 Metal 后端成熟度高,性能和稳定性都更可预期。第一次在 iPhone 上跑起来的时候,速度让我有点意外,比同价位的安卓旗舰快了不少,这件事也让我理解了为什么很多团队做端侧 demo 优先选 iOS。
Xcode 工程这块,MLCChat 的 iOS 版本用 SwiftUI 写的,依赖通过 Swift Package Manager 管理。签名是最容易卡住新人的地方。免费开发者账号每七天要重新签一次,做长期测试的话这个频率挺折磨人,我一般是让客户用自己的开发者账号签,省得反复折腾。真机调试还需要信任开发者证书,这一步在设置里的"设备管理"里操作,第一次接触的人往往会漏掉。
权限方面主要是两个。一个是本地网络权限,如果你的 App 需要通过局域网和设备通信或者下载模型,iOS 会弹窗请求这个权限,Info.plist 里要加对应声明,不然请求会静默失败。另一个是文件访问权限,模型文件如果放在 App 沙盒外的位置,需要配置相应的访问能力。这两个权限问题我第一次都碰到了,表现是 App 行为跟预期完全不符,日志里也看不出明显线索,排查方向全错。
模型文件的集成方式跟安卓类似,可以走 App 包内置,也可以首次启动时从网络下载到沙盒 Documents 目录。内置的好处是离线可用,坏处是 App 体积直接膨胀,App Store 上传对包大小有约束。我现在倾向的做法是内置一个小的基础模型,大的可选模型走下载,下载后做完整性校验再加载。
还有 TestFlight 做内测分发这个环节值得提一句。模型文件大,走 TestFlight 每次上传等待时间都很长,我一般会让模型走独立的下载通道而不打进包里。这个做法对测试效率提升很明显。
2.5 桌面端、边缘设备与手机端部署差异对比
跑过这三类平台之后,我发现它们的差异比想象中大。桌面端是最省心的,CUDA 后端在 Linux 和 Windows 上成熟得很,模型规模不受太多限制,7B、13B 都能跑,调试工具齐全,profiling 数据也细。我一般把桌面端当成模型验证的第一站,先在桌面上确认模型质量和基本性能,再往移动端迁移。
边缘设备这块我主要接触的是 Jetson 系列和几款带 NPU 的国产开发板。Jetson 走 CUDA 路径,编译方式和桌面基本一致,性能也不错,代价是功耗和散热都有要求。NPU 设备的情况复杂得多,MLC LLM 对非 CUDA、非 Metal、非 Vulkan 的后端支持还在演进中,能不能跑通,得看具体芯片的软件栈成熟度。我做过一次 RK3588 上的尝试,量化产物能加载,推理速度一般,优化空间主要在算子层,后面第四章会细讲。
手机端的差异我体会最深的是碎片化。安卓阵营的芯片、驱动、系统版本差异巨大,同一份产物在不同机器上的性能可以差三到五倍。iOS 相对统一,主要是不同代芯片之间的算力差距。这个差异直接影响了部署策略,安卓上我会准备多档量化配置根据设备能力动态选择,iOS 上基本一档通吃就行。
调试体验也完全不同。桌面端可以挂 profiler,可以看每个算子的执行时间。手机端基本只能靠日志和埋点,最多抓个 systrace。这个限制让手机端的性能优化更依赖经验判断和大量的对照实验,而不是精细的算子级分析。这也是我后面第四章会把调优清单写得很细的原因,移动端优化靠的是系统性的参数组合,而不是单点突破。
2.6 部署验收清单与常见问题排查
每个项目部署阶段我都会走一遍自己攒的验收清单,这个清单迭代了十几个项目之后基本稳定了。第一项是环境可复现,从干净机器按文档走一遍能装出一样的版本组合。第二项是编译产物完整,产物目录里权重、配置、tokenizer 文件齐全,md5 校验通过。第三项是桌面端先跑通,模型能加载、能正常生成、输出质量符合预期。
真机验收这块我列的更细。模型加载时间是否有明确阈值、首 token 延迟是多少、连续推理十轮之后内存曲线有没有失控、设备温度上升是否在可接受范围、断网状态下功能是否正常、前后台切换后模型状态是否正确恢复。这六项每一项我都遇到过翻车案例,尤其是前后台切换这一项,安卓上后台被系统回收之后重新回来加载模型会卡好几秒,用户体验很差,需要提前做预热和状态恢复设计。
常见问题排查我整理了几类典型症状和对应方向。App 启动黑屏或者闪退,先看是不是模型文件路径不对或者权限问题,安卓上多用 adb logcat 抓崩溃栈。模型加载成功但输出乱码,八成是 tokenizer 或者聊天模板配置不对,检查 mlc-chat-config.json 里的模板字段。推理速度慢到没法用,先确认是不是走了 CPU 后端而不是 GPU 后端,这个错误我在安卓上犯过两次,日志里会有后端初始化信息。内存 OOM,先降上下文长度,再考虑降量化档位,两招都不行就换更小的模型。
还有一类问题很难归到具体类别里,表现是"能跑,但感觉不对"。这种情况我一般会做对照实验,用同一份模型在桌面端跑一遍,对比输出质量是不是一致,不一致就是移动端配置有问题,一致就是模型本身在这个任务上的能力边界。这个方法虽然笨,定位效率比瞎猜高得多。
部署这一章写下来,我最大的体会是端侧部署真正消耗时间的从来不是编译那几条命令,而是环境对齐、产物匹配、真机调试这些看起来边边角角的环节。把这些环节摸熟了,后面的性能优化和结构化输出才有稳定的基础。
做端侧应用做到一定程度,你一定会撞上这个问题:模型能说话了,但它说的话没法直接用。我最早做的一个手机端信息抽取项目,模型每次返回一段自然语言,我还得在客户端写正则去抠字段。抠得不准,格式一飘就全废。那段时间我每天都在想,能不能让模型直接吐一个 JSON 出来,我拿去 JSON.parse 就能用。这个念头把我带进了 json_object 模式和约束解码这一片,踩的坑比部署阶段还碎。
3.1 json_object 在端侧推理中的价值、限制与适用任务
端侧跑模型,最大的优势是数据不出设备。结构化输出这件事在端侧的价值,比在云端大得多。我做过一个健康记录的 App,用户口述一段身体情况,端侧模型直接抽成 {"symptom": "...", "duration": "...", "severity": "..."} 这种结构,存进本地数据库。整个过程没有一次网络请求。用户看到的是自己说的话变成了一张填好的表单,这种体验在纯文本对话里是给不了的。医疗、金融、法律这几类场景对隐私敏感,端侧结构化输出的吸引力就在这里。
限制也得说清楚。端侧模型规模摆在那里,1B 到 3B 的模型,你让它生成自由文本还算流畅,让它严格按 schema 输出,难度完全不是一个量级。我试过用 1B 模型跑一个带五六层嵌套的 JSON schema,结果它在中途就开始编字段名,输出到一半卡在括号里出不来。小模型对嵌套结构和长字段名的记忆能力很弱,这是硬约束。我的经验是端侧 schema 尽量扁平,字段数控制在五到八个以内,嵌套最多一层。超出这个范围,模型质量会断崖式下滑。
适用的任务类型我摸索下来有这么几类比较稳。单轮信息抽取,比如从一段文本里拿人名、时间、地点、金额。工具调用的参数生成,端侧 Agent 要调本地 API,参数是固定的几个字段。分类加解释,输出一个枚举值加上一句简短理由。这几类任务的共同点是输出空间小、结构固定、不需要复杂推理。反过来,多步推理链、长文档摘要带引用、动态结构生成,这几类我都不建议在端侧用 json_object 模式硬上,失败率太高,后处理成本会把端侧推理省下来的延迟吃掉。
还有一个容易被忽略的点,结构化输出对端侧解码速度有影响。约束解码需要在每一步对词表做过滤,这个开销在桌面端可以忽略,在手机上会实实在在地拖慢出字速度。我做过的对照实验里,同一个模型开约束解码比不开,首 token 延迟高出一截,整体生成速度掉百分之二三十。这个代价值不值,取决于你的任务对延迟的敏感程度。
3.2 MLC LLM 中 JSON 输出的实现路径与调用方式
MLC LLM 里让模型输出 JSON,我知道的路径有两条,用起来差别挺大。一条是走 response_format 参数,把类型设成 json_object,运行时会在解码层面加约束,保证输出是合法 JSON。另一条是在请求里直接传一个 grammar 或者 schema,走语法约束解码。两条路径底层机制相通,调用方式不一样。
先说 json_object 这条路。在 MLC LLM 的 Python API 里,请求对象上挂一个 response_format,值传 {"type": "json_object"},运行时就会启用 JSON 约束。我一开始以为设了这个参数就万事大吉,结果模型返回了一个 {},空的。翻文档才明白,这个模式下运行时只保证语法合法,不保证内容符合你的预期。你的提示词里必须明确写清楚要哪些字段、什么类型,模型才会照着填。这个参数像是给输出套了个模具,模具是空的,你得自己往里灌东西。
Android 和 iOS 的端侧集成里,这个参数走的是各自的 binding 层。安卓侧我是在 MLCChat 的 JNI 调用里加了对应字段,iOS 侧在 Swift 的请求结构体里补上。两边的字段名和层级不一样,我对着源码调了半天才对齐。这里有个坑,不同版本的 MLC LLM 对 response_format 的支持程度不一样,老版本可能直接忽略这个参数,输出照旧是自由文本。我在一个项目里用了半年前的编译产物,参数传了没反应,排查了很久才发现是版本问题。升级运行时之后立刻就好了。
语法约束那条路更适合复杂场景。MLC LLM 底层用的是 TVM 的语法约束解码能力,你可以传一个 EBNF 语法或者从 JSON Schema 转换过来的约束。我做过一个需要输出特定日期格式的场景,用 json_object 模式模型老是把日期写成各种花样,换成语法约束把日期模式写死之后,输出立刻就规整了。这条路的代价是写约束本身有学习成本,语法写错了解码会直接卡死或者报错。
调用方式的细节上,我建议先用 Python 桌面端把参数调通,确认输出符合预期,再把同样的参数搬到移动端 binding。桌面端调试方便,能打印每一步的 logits 和约束状态,移动端只能看最终输出,盲调效率极低。这个顺序我建议别反着来。
3.3 提示词、JSON Schema 与约束解码的协同设计
这三样东西得一起设计,分开调怎么调都不顺。我最初的思路是先写好 schema,再随便写句提示词,最后开约束解码。结果模型输出的字段顺序乱七八糟,有些字段干脆不出现。后来我把这三者的关系想明白了,提示词负责告诉模型"要填什么",schema 负责定义"长什么样",约束解码负责"不许长歪"。三者各管一段,缺一段就出问题。
提示词这块,我的写法是把 schema 直接嵌进提示词里,用一段示例 JSON 展示期望的输出。小模型对示例的模仿能力比抽象描述强得多。我试过两种写法,一种是用文字描述字段含义,一种是给一个填好的例子,后者的字段命中率明显更高。提示词里我还会明确写一句"只输出 JSON,不要任何解释文字",这句话能挡掉大部分模型想加开场白的冲动。约束解码虽然能拦住非法字符,拦不住模型在 JSON 前后加 markdown 代码块标记,这些标记在解析时会让 JSON.parse 直接报错。
Schema 的设计我踩过的坑集中在字段命名上。用驼峰还是下划线,用缩写还是全拼,模型的表现差别不小。我的经验是字段名用常见的英文单词,别用生僻缩写,模型在预训练里见过的高频词识别率高。字段类型尽量用 string 和 integer 这两类,boolean 和 enum 次之,array 和嵌套 object 要谨慎。我做过一个字段是字符串数组的场景,模型有时候返回单个字符串,有时候返回数组里套数组,格式飘得厉害。后来把数组拆成固定几个字段才稳定下来。
约束解码和 schema 的配合有一个反直觉的点。约束越严格,模型越容易在约束里"绕圈"。我遇到过 schema 要求某个字段是枚举值,模型在几个枚举值之间反复横跳,生成了一长串非法输出之后被截断。这种情况的根因是约束解码在每一步都把非法 token 的概率压到零,模型在剩余空间里找不到出路时会乱走。缓解办法是给 schema 留一点弹性,比如枚举里加一个 "other" 兜底值,或者把必填字段减少一两个。约束是用来兜底的,不是用来把模型逼到墙角的。
这三者的协同我现在的做法是先在桌面端用一批真实输入跑一轮,看模型在哪个字段上最容易出错,针对性地改提示词里的示例,改 schema 的字段设计,最后才动约束配置。改的顺序很重要,先改约束往往掩盖了提示词和 schema 的问题,表面上输出合法了,内容还是错的。
3.4 结构化输出稳定性测试、解析容错与失败回退
结构化输出最怕的是"大部分时候对"。我做过一个端侧表单填写功能,测试阶段百分之九十的输入都能正确解析,上线之后用户的实际输入五花八门,失败率一下子上去了。那次教训让我把稳定性测试当成一个独立环节来做,不能靠开发阶段的随手试几下。
测试集的构建我是这么做的。从真实用户输入里采样,覆盖长短文本、中英混杂、带表情符号、带数字和日期、口语化和书面化这几种类型。每类至少准备几十条,跑的时候记录三个指标:输出是否为合法 JSON、字段是否齐全、字段值是否正确。这三个指标要分开看,合法 JSON 但字段缺失和字段齐全但值是错的,对应的问题和修法完全不同。我一般会跑三轮,第一轮看整体通过率,第二轮针对失败样本改提示词和 schema,第三轮做回归确认没有把之前对的搞坏。
解析容错这块,客户端代码不能假设模型输出一定是干净的 JSON。我的做法是解析前先做一轮清洗,去掉 markdown 代码块标记、去掉 JSON 前后的多余文字、处理常见的转义问题。清洗逻辑我用正则实现,规则不多但覆盖了八成以上的脏输出。清洗之后还解析失败,就走兜底逻辑。兜底逻辑分几档,第一档是尝试用宽松解析器,比如允许尾逗号、允许单引号;第二档是把原始输出整段存下来上报,同时给用户一个可编辑的文本框手动修正;第三档是直接回退到非结构化模式,让模型用自然语言回答,客户端再走关键词匹配。这三档我在不同项目里都用过,具体用哪档看业务对准确率的要求。
失败回退还有一个策略层面的问题。端侧推理失败之后要不要重试,重试几次,重试的时候要不要换参数。我的经验是重试一次就够,第二次还失败基本就是模型能力边界的问题,再试也是浪费电。重试的时候我会把温度调低,约束开得更严,有时候能把边缘情况救回来。连续失败的情况我会记一个本地计数器,同一个 session 里失败超过阈值就切到兜底模式,不再让用户等。
监控这块在端侧有个特殊之处,数据不上云,失败样本怎么收集是个问题。我的做法是只上报脱敏后的统计信息,比如失败次数、失败类型、设备型号、模型版本,不上报原始输入输出。这样既能定位问题又不违反隐私承诺。有些项目里我会加一个用户主动反馈的入口,用户觉得结果不对可以一键上报,这种样本质量比自动采集的高得多。
稳定性这件事没有终点。模型版本升级、schema 调整、提示词改动,任何一个变动都可能让之前的通过率掉下来。我现在每个项目都会维护一个回归测试集,改动之后跑一遍,通过率掉超过五个点就不发版。这个习惯是被坑出来的,之前有一次改了一句提示词,线上解析失败率翻了三倍,回滚花了大半天。
上一章聊完结构化输出,回过头看手机端的性能,我才发现自己之前一直是在“能跑”这条线上打转。模型在手机上跑起来是一回事,跑得让人愿意用是另一回事。我第一个端侧项目交付的时候,老板拿了一台三年前的中端安卓机试了试,问了一句“它怎么打字比我奶奶还慢”,那一刻我意识到性能优化不是可选项,是端侧落地的生死线。这一章我把自己在手机上折腾推理性能的整个过程摊开讲讲,从怎么看指标到怎么省电,踩过的坑比代码行数多。
4.1 性能指标定义与基准测试方法
聊优化之前得先搞清楚优化什么。我最初做性能测试只会看一个数,就是生成速度,单位 token 每秒。后来发现这一个数根本不够用,用户感知到的“卡”和“慢”是两回事。我现在必看的指标有四个:首 token 延迟、解码速度、内存峰值、还有单次推理的总能耗。首 token 延迟决定用户按下按钮之后等多久看到第一个字,这个指标在交互式场景里比整体速度还重要。解码速度决定后面的字吐得多快,内存峰值决定应用会不会被系统杀掉,能耗决定手机烫不烫。四个指标得分开测,混在一起看不出问题在哪。
基准测试的方法我走过弯路。一开始我在桌面端跑分,觉得数据挺漂亮,搬到手机上全崩了。桌面端的 CPU 和手机上的 ARM 核心完全不是一个物种,缓存大小、内存带宽、调度策略全不一样。我现在只信真机数据,而且至少准备三台设备,一台旗舰、一台中端、一台老机器,测出来的区间才有参考价值。测试的时候我会固定输入长度,固定输出长度,跑二十次取后十次的平均值,前几次受冷启动和温控策略影响太大,算进去会失真。
还有一件事值得单独说,就是测试环境的一致性。我做过一次对比实验,两次测试隔了一天,数据差了百分之十五,排查半天发现是第一次测的时候手机插着充电,第二次是电池供电。充电状态下机器会放开功耗限制,性能数据会好看很多,但不代表用户实际体验。我现在测性能前会把设备重启,关掉所有后台应用,电量保持在中间区间,屏幕亮度调到固定值。这些细节听起来琐碎,不做的话测试数据就是自欺欺人。
4.2 模型量化、权重压缩与内存占用优化
手机端最硬的约束是内存,这个约束比算力还残酷。我遇到过最尴尬的一次,模型文件大小明明在预算内,加载到一半应用被杀掉了,查了半天是权重解压的时候瞬间内存翻倍,超过了系统给单个应用的上限。量化这件事就是在这种压力下被逼着去深挖的。
MLC LLM 支持的量化格式我基本都用过一轮。q4f16 系列是我现在默认选的,四比特权重量化配上十六比特的激活值,模型体积能压到原来的四分之一左右,精度损失在大多数对话任务里感知不到。q3 系列我试过,体积更小,但在一些需要精确数字或者逻辑推理的任务上,输出会明显变差,从能用的表单抽取直接退化成瞎猜。q4 和 q8 之间的取舍我现在的判断标准很简单,看任务对精度的敏感度,信息抽取类任务用 q4 就够,需要长文本理解或者代码生成的任务我倾向于抬到 q8 或者 q4f16 加更大的模型。
除了量化本身,权重加载的策略也能省不少内存。我做过一个实验,把模型加载和推理初始化分开,在用户真正触发推理之前不分配 KV Cache,启动内存能降下来一大截。这个改动对用户体验的感知虽然不直接,但降低了应用在低内存设备上被系统回收的概率。还有一个细节是模型文件的读取方式,用内存映射比一次性读进内存要友好,延迟加载页面的代价比直接 OOM 崩溃要划算得多。安卓侧的 low memory killer 不太讲道理,你得主动给系统让出空间。
4.3 KV Cache、上下文长度与批处理策略调优
KV Cache 是我觉得最被低估的一个优化点。刚上手的时候我根本没在意这个东西,只知道它是推理时缓存历史的一个结构。直到有一次我发现模型生成到后面越来越慢,几百个 token 之后速度掉了一半,才意识到缓存管理出了问题。KV Cache 的大小和上下文长度是线性关系,上下文开到四千 token,缓存占的内存能到几百兆,在手机上这是个很吓人的数字。
上下文长度这块我的经验是别贪心。很多应用场景其实用不到那么长的上下文,把上限设到自己真实需要的位置,缓存能省下来一大块。我做过一个语音助手类的应用,大部分对话在十轮以内,上下文人设到两千 token 足够用。之前默认开到八千,内存占用高出一大截,推理速度也慢。把上限调下来之后,同一个模型在低端机上的可用性明显改善。需要更长上下文的任务,我会考虑滑动窗口或者摘要压缩,把老的内容压成一句话塞回去,比原样堆着要经济得多。
批处理在手机上是个比较微妙的话题。云端推理靠批处理吞吐量拉满,手机端几乎没有并发请求的场景,批处理的意义不大。我试过把多个小请求攒在一起跑,结果首 token 延迟反而变高了,用户等得更久,得不偿失。端侧我现在的策略是单请求优先,把资源集中给一次推理,让这一次跑得尽可能快。批量能力留给后台任务,比如离线做的数据整理或者日志分析,那些场景对延迟不敏感。
4.4 GPU 后端、线程配置与算子层优化
手机上的 GPU 推理是个双刃剑,用好了提升明显,用不好适得其反。MLC LLM 在不同平台上的 GPU 后端支持不太一样,安卓侧主要走 OpenCL 和 Vulkan,iOS 侧是 Metal。我最早在安卓上开 GPU 加速,跑了几个型号,旗舰机提升明显,中端机反而不如 CPU。查下来是中端 GPU 的驱动和内存带宽拖了后腿,数据搬运的时间比计算本身还长。这件事让我明白 GPU 加速不是无脑开,得看设备档位。
线程配置是我最近才认真对待的。CPU 推理的时候线程数开多少合适,这个答案不是固定的。我一开始跟网上教程学,把线程数设成 CPU 核心数,结果在老机器上性能不升反降,因为大核小核混着调度,互相抢资源。后来改成只绑定大核,线程数取大核数量,小核留给系统和后台任务,速度立刻就稳了。这个配置我按机型做了分级,旗舰机八核,中端机四到六核,老机器的线程数压到两三个反而更稳。我现在会读设备型号和核心架构,动态决定线程预算。
算子层面的优化对普通开发者来说接触得不多,但有几个地方可以间接影响。量化方案的选择本身就决定了一部分算子走的是高效路径,选主流量化格式的时候能蹭到社区已经调优过的 kernel。另外一点是输入形状的稳定性,模型如果每次都处理不同长度的输入,算子没法走固定形状的优化路径。我会在客户端把短输入补齐到固定长度,虽然计算量稍微多一点,但延迟的波动小了很多,这种用小代价换稳定性的思路在端侧很实用。
4.5 功耗、发热与长时间运行稳定性优化
手机端做性能优化绕不开热这个问题。我印象最深的一次,测试的时候一切正常,用户反馈说用十分钟手机就烫得拿不住。我拿测温枪一测,背板温度上到了四十五度。芯片到这个温度会主动降频,性能数据断崖式下滑,用户看到的就是用了会儿应用突然变卡。散热这件事在端侧不是可选项,长时间运行的稳定性才是真正的体验。
降功耗的手段我试过不少。最直接的是控制推理频率,把连续推理拆成有间隔的任务,让芯片有时间喘口气。我做过一个连续对话的应用,原本是一次性跑完整段生成,背板温度一路上涨。改成每生成二十个 token 稍微让出一下,虽然总时间拉长了一点,但温度控制住了,后半程的速度反而比一次性跑完更稳。另一个手段是动态降级,检测到设备温度升高之后,自动从 GPU 切回 CPU 或者把上下文压缩一下,牺牲一点效果换持续的可用性。
长时间运行的稳定性我还会关注内存泄漏和缓存膨胀。有个项目跑了半小时之后应用闪退,查下来是每次推理都在 KV Cache 上叠加新的历史,没有清理机制,内存一点点被吃掉。加上会话结束时的缓存释放之后问题就没了。我的做法是在客户端加一个推理轮次计数器,超过阈值就主动重建一次推理会话,把积累的碎片清掉。这个操作用户根本感知不到,但对稳定性帮助很大。我还设置了低内存回调,系统发出内存告警的时候主动收缩缓存,宁可损失一点上下文也不让应用被强杀。
4.6 端到端延迟优化案例与参数调优清单
说一个我印象比较深的案例。一个做现场巡检的 App,用户拍照之后,端侧模型要识别设备并输出一段检查意见,用户站在设备旁边等结果。最初的版本从按下快门到出结果要八秒多,用户抱怨得像是在等火车。我拆了一下延迟的构成,模型加载占了将近两秒,预处理占了一秒多,首 token 延迟两秒,剩下的解码速度慢。每一段都不算爆炸,合起来就让人受不了。
改法是一段一段啃。模型加载改成启动时预加载,进入拍照界面前就把模型准备好,那两秒挪到了用户看不见的地方。预处理简化了图像缩放流程,去掉了几次不必要的格式转换。首 token 延迟通过缩短提示词、精简上下文压下来一截。解码速度上把量化从 q4 调到 q4f16,速度提了一些,精度没掉。最后端到端降到了三秒出头,用户反馈从抱怨变成了勉强接受。这个案例让我记住一件事,端到端延迟是个拼盘,单独优化一个指标收益有限,得把整个链路摊开看。
调优清单我整理成了一套自己每次都会过一遍的点。模型选择上先确认档位,能用小模型不用大模型,能用量化不用原始权重。上下文长度按业务真实需求设,别留太多余量。线程配置按设备档位分级,GPU 开关做机型和温度双重判断。首 token 延迟优先,因为它最影响感知。KV Cache 设上限并加清理,长会话定期重建。测试至少覆盖三档设备,测试环境固定,数据取稳定态。发热场景单独测半小时以上,观察性能衰减曲线。这个清单不复杂,但每次新项目过一遍,能省掉很多上线之后才炸的雷。
优化这件事做到后面,我发现心态会变。一开始总想找到一个大招,一个参数改完性能翻倍。做得多了才清楚,端侧性能是十几个小决策堆出来的,每一个都只值百分之几,叠在一起才让人用着舒服。我现在的习惯是每加一个功能,先在低端机上跑一轮,把性能预算当成一种日常约束,而不是等出了投诉再回头救火。这种习惯一旦养成,端侧应用的可维护性会上一个台阶。
性能调优的那一阵子,我满脑子都是毫秒和兆字节,觉得只要把数字压下去,端侧这件事就算成了。真把应用推到用户手里才发现,指标好看和用户满意之间隔着一条河。有人拿着机型反馈说语气怪,有人截图说输出一半乱码,还有人安装完第二天应用就没了,因为系统扛不住内存直接把它清掉了。这一章我想聊的是从“跑得快”到“用得稳”这一段路,评估怎么做、问题怎么找、坑怎么填,以及我后来形成的一套落地打法。
5.1 精度、速度、内存与用户体验的多维评估
评估这件事,我吃过只看速度的亏。有个项目为了跑得快,把量化压到了 q3,速度确实提了一截,上线之后用户开始抱怨输出内容变得敷衍,原本能答对的条款解释开始东拉西扯。我回头补了一轮精度评估,用一个几百条的业务问答集做对比,发现准确率掉了将近十四个点。速度指标漂亮得很,用户留不住。从那以后我的评估表里精度和速度是并列的,谁也不能单独决定一个版本能不能发。
精度评估在端侧比云端要麻烦。云端可以用一整套离线集跑分,端侧的真实输入五花八门,用户对着屏幕输入的口语、方言、不完整句子,跟测试集差距很大。我的做法是分两层评估,一层是标准测试集,保证模型本身的能力没有断崖下滑,另一层是真实场景采样,用户实际输入经过脱敏之后攒一批,定期跑一次。第二层那批数据是最能说明问题的,我见过好几次标准集还过得去、真实采样惨不忍睹的情况。
内存的评估我一开始也做得粗。只看加载完成之后的内存占用,没看运行过程中的峰值和波动。有一次测试跑完漂亮得很,用户实际使用十分钟之后崩溃。后来我加了内存曲线的采集,整个推理过程是涨是平是漏,必须画出趋势来看。特别留意 GC 触发之后有没有回落到基线,回不去的大概率就是泄漏。用户体验这一维最难量化,我的手段就笨一点,拉几个种子用户做小范围灰度,让他们按真实场景用一周,收集主观感受加上崩溃率、后台存活率这些硬指标,两边一起看。
速度和体验之间的关系也不是简单的越快越好。我做过一个输入法的联想功能,解码速度调到极限之后,候选词刷得飞起,用户反而觉得眼睛跟不上。后来我加了节流,让输出有一个平滑的节奏,主观感受比极限速度舒服得多。评估的维度不能只算机器视角,得把人的感知也放进去。
5.2 日志、监控、埋点与问题定位体系
端侧出了问题的第一反应,以前是“我这边复现不了”。这种话对着用户说是没有意义的,人家只知道自己手机上出事了。最初的版本我埋点做得极简,只在崩溃的时候上报一段堆栈,信息量严重不足。有一个用户反馈说生成为空,日志里什么都没有,我折腾了三天才靠猜测定位到是他手机的存储空间只剩几十兆,模型加载失败了。那次之后我把日志体系当基础设施来做,前置规划,不再是事后补。
日志的分层我现在会分三类。一类是贯穿流程的关键节点,模型加载、推理开始、首 token 产出、生成结束、异常退出,每个节点打时间戳和设备上下文。第二类是告警级别的严重事件,内存逼近阈值、解码超时、解析失败这些,单独一份细粒度日志。第三类是常规的调试日志,只在测试版本打开,正式版关掉或者做采样,不然日志本身就会变成一个性能负担。三类的开关和采样率都要能远程配置,出问题的时候临时把某个用户的日志等级拉高,比让人家重装一个带调试信息的包要现实得多。
埋点设计上我后来学会了一件事,不要只埋“成功路径”。生成了两百个字,中间有没有回退过、有没有重试过、哪个步骤花的时间最长,这些信息比最终结果更有价值。我会在推理链路的每个阶段打点,生成一份延迟分解的统计,用户报慢的时候能回答慢在哪一步。还有个小心思,把模型版本、量化格式、线程配置这些参数随埋点一起上报,出问题的时候能快速圈定是不是某个配置批次的问题,而不是大海捞针。
线上监控面板我现在会盯着几个数,崩溃率、后台存活率、首 token 延迟的 P90、输出解析失败率。P90 比平均值更能反映真实用户的感受,平均值会被一堆快速的请求摊平,真正让人难受的是尾部那一段。发现异常之后,按设备型号和系统版本拆开看,问题往往集中在某几类机型上。这套体系搭起来花了不少精力,可它让端侧的问题从“玄学”变成了“可以坐下来分析的事”。
5.3 隐私安全、合规要求与模型更新策略
端侧推理最被用户看重的一点就是数据不上云,这个卖点一旦被破坏,整个产品定位就塌了。我见过有团队为了做效果分析,把用户的所有输入原样上传到服务器,美其名曰“匿名化”,其实只是去了个名字。这种做法在合规审查面前站不住脚。我现在的原则是,除非用户明确授权并且只上传必要的统计字段,任何涉及内容的原始数据都不出设备。
本地数据的处理也有讲究。模型文件本身、缓存、用户输入的历史,落盘的时候我倾向于都做加密,密钥用系统的安全存储来管理。日志尤其容易出问题,很多人会把输入内容打到日志里方便排查,结果日志被导出之后就成了泄露渠道。我在项目里定了一条硬规矩,日志只记录长度、耗时、错误码这类元数据,内容一律脱敏或者干脆不打。这条规矩在开发阶段会让人觉得不方便,但上线之后能省掉大麻烦。
模型更新的策略在端侧是个独立课题。模型文件动辄几百兆,全量下发对用户流量和体验都不友好。我现在的做法是版本分通道,小版本走增量更新,只下发有变化的权重分片,大版本才走全量。更新时机放在用户连接 WiFi 并且设备空闲的时候,不在前台推进下载,避免卡住交互。更新完成之后保留上一版作为回滚点,新版本上线之后先在小流量灰度里观察崩溃率和对齐指标,没问题再逐步放量。我吃过一次没有回滚准备的亏,新模型上线之后某类机型大面积报错,只能紧急发版本撤回,那个过程非常被动。
合规这一块我没有专业背景,只能把握底线。数据不出设备、有明确的隐私说明、用户可以清除本地数据、敏感权限按需申请,这几条是我每次上线前都会对着过一遍的。有些场景涉及到的行业要求更高,那种时候我会拉法务一起看,不自己拍脑袋。
5.4 典型应用方案与工程最佳实践
做了几个项目之后,我慢慢有了一套自己的套路。小模型加窄任务是端侧最稳的组合。一个专门的意图识别模型配一个摘要模型,两个都跑在手机上,效果比硬塞一个大模型进去要稳得多。大模型在端侧天生受内存和算力约束,把任务切细、每个环节用最合适的模型,比追求一个全能选手更实际。
冷启动这件事我现在已经形成条件反射。用户第一次打开应用等模型加载的那几秒,是最容易流失的环节。能预加载的预加载,能异步初始化的异步初始化,启动流程上把模型准备挪到用户真正需要它之前。我还会做一个降级路径,模型没准备好或者加载失败的时候,走一个轻量的规则版本先顶上,让用户有东西用,不至于白屏。这套降级逻辑在低端机上救过好几次。
工程上还有几个习惯我觉得挺重要。模型文件打进安装包还是运行时下载,取决于包体和首次体验的取舍,我现在倾向把最小的一个模型打进包里保证开箱可用,更大的模型按需下载。版本号要贯穿模型、配置、客户端三方,任何一个变化都要能追溯。测试环境要覆盖三档机型加上至少两个系统版本,最好再配一台真机做长时间稳定性测试,放一晚上跑循环推理,看内存曲线和平稳度。这些都是打磨出来的经验,写进流程里之后团队里新人也少踩坑。
5.5 技术趋势、能力演进与落地行动清单
端侧这块变化很快。芯片的 NPU 越来越强,厂商开始提供自己的推理运行时和量化工具,模型侧也有越做越小、效果越做越好的趋势。我明显感受到的变化是,两年前还需要精打细算才能塞进手机的模型,现在跑起来轻松了不少。云端模型能力的下放也很快,小参数模型在特定任务上的表现越来越接近大模型。这种趋势对端侧开发是好事,能力边界在持续外扩。
不过我对趋势保持一点冷静。新硬件、新格式、新运行时的出现意味着适配成本,不是所有设备都能跟上。我的态度是关注,但不追首发。等某个技术方案在主流机型上稳定下来,社区有了成熟的案例,再考虑接入。追新在端侧往往是用用户的稳定性做赌注,不划算。
如果现在让我给一个准备做端侧项目的团队列一份行动清单,我会写这么几条。先想清楚任务是否真的需要端侧,隐私、离线、延迟这三条里至少占一条再做,不然云端更省心。模型选型从最小可用开始,效果不够再加档,不要一上来就上大块头。评估体系在第一个版本就搭起来,精度、速度、内存、体验四个维度都留好数据入口。日志和埋点前置设计,不要等出事再补。隐私数据默认不出设备。更新机制留回滚。测试覆盖三档真机加长时间稳定性。清单不长,每一条都是被现实教过之后留下来的。
端侧推理这件事,做久了会发现它的难点不在模型本身,而在整个工程闭环。模型只是链路里的一环,评估、监控、合规、更新、降级这些看起来不性感的部分,才决定了产品能不能长期站在用户手机上。我现在的习惯是每做完一个功能,回头问自己一句,出问题的时候我能不能立刻知道、能不能快速定位、能不能安全回退。这三个问题回答得了,端侧这件事才算真正落了地。