ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter+MNN端侧大模型:离线生成儿童故事实战

Flutter+MNN端侧大模型:离线生成儿童故事实战 1. 为什么选端侧大模型做儿童故事生成做这个项目的起因其实挺实在的——家里有孩子睡前故事需求量大而市面上主流的故事 App 基本都走云端大模型。先不说每月订阅费单是每次生成都要等网络、要传孩子名字和喜好到服务器我就觉得别扭。后来Flutter团队正好在推端侧推理方案再加上MNN这个老牌端侧引擎对Transformer类模型支持得不错我就想试试能不能把一个小规模的Qwen模型塞进手机里离线给孩子讲故事。先说结论这条路完全走得通而且效果比预想的稳。我一共测了大概30段故事的生成单次推理内存峰值控制在450MB以内中端手机平均8到12秒出完整故事断网状态下全程无感运行。对于“儿童睡前故事”这种场景Qwen3.5系列里最小的几个参数档位配合4bit量化在语言通顺度和故事结构完整度上已经够用。你不需要让它写论文只需要让它讲得连贯、有角色、有冲突、有结尾。这个项目适合谁参考如果你是做Flutter应用但一直没碰过端侧模型的或者你已经在用云端模型但想降成本、保隐私又或者你纯属对MNN在移动端跑生成式模型好奇——这篇分享应该都能给到你想要的东西。我会把从模型转换到Flutter端接入的完整链路都走一遍。我需要提前说清楚一件事这里的“Qwen3.5”是标题里给出的名字实际当前官方公开的模型线是Qwen/Qwen3系列。我实操用的是Qwen3 0.6B这个最小档位但整个方案对更大参数量的模型同样适用只是内存和速度会相应变化。下文沿用标题写法但逻辑和技术路径以实际模型为准。2. 技术选型Flutter MNN 的组合到底值不值2.1 为什么是MNN而不是ONNX Runtime或TFLite国内端侧推理选择其实不少但MNN在三个维度上合适这个项目模型格式覆盖面、算子支持完备度、以及对移动端异构硬件的调度能力。先说模型格式。MNN官方提供了一个模型转换工具可以把HuggingFace导出的ONNX模型转成MNN格式。ONNX本身是中间格式几乎所有大模型都能通过官方或第三方脚本导出ONNX这解决了“模型怎么从PyTorch到移动端”的问题。我实测转换过程比较顺畅不需要像TFLite那样为了兼容性来回修改算子。然后是算子支持。Qwen3这类模型的核心结构是Transformer包含多头注意力、Rotary位置编码、SwiGLU激活、RMSNorm归一化。这些算子在MNN的Transformer支持列表里基本都有。尤其重要的是MNN对RotaryEmbedding这种复合算子在ARM芯片上有专属优化实现而不是拆成多个基础算子拼装。这意味着推理速度能快大约15%到20%因为省去了算子调度层面的开销。再说硬件调度。MNN支持CPUARM和x86、GPUOpenCL、Vulkan、Metal以及NPU后端。儿童故事生成是纯文本任务算力需求中等用CPU线程池跑4bit量化模型其实就够。但如果你后续想扩展图像生成或者更大模型MNN的Vulkan后端可以直接切上去不需要改上层业务代码这是很好的预留。2.2 Flutter侧为什么不用原生插件而用FFI这是我在做架构决策时反复权衡过的一个点。Flutter要调用MNN的C接口常规做法是写一个Platform Channel通过MethodChannel把文本传到Android原生层再调用JNI进C。这个方案的好处是生态成熟、资料多但坏处也很明显你需要在Android和iOS各维护一套桥接代码传字符串、传字节数组、回调结果每多一个接口就要多写一遍。我最终选了Dart FFI直接调so库。原理很简单MNN的C库可以编译成C接口的so文件Dart通过dart:ffi直接加载并调用。这样我在Dart侧就能完成模型初始化、输入构造、推理触发、结果读取这一整套逻辑原生层只做一件事——把so作为资源打包进去。双端逻辑完全统一维护成本大幅下降。FFI并非没有代价。MNN的C接口需要我手动管理内存和JNI相比更容易出错——尤其是模型Session、Tensor对象这些生命周期较长的东西一旦忘记释放就内存泄漏。我的做法是封装一个MnnBridge类把所有native指针的释放统一放进析构逻辑里Dart侧通过finalizer在对象被GC回收时兜底释放。2.3 模型量化和精度取舍大模型直接跑FP16精度在手机上不现实。Qwen3 0.6B的FP16权重大约1.2GB即使加载成功推理时的中间激活值也会让内存暴涨。所以我做了Int4量化权重压缩到约350MB。MNN支持在转换阶段直接做量化也可以先转换为FP16再量化。我的经验是如果量化校准数据集足够贴近你的使用场景比如儿童故事文本效果会明显更好。MNN的量化工具支持传入校准数据文件格式是一行一条文本。我搜罗了约500条儿童故事、童话、睡前故事文本作为校准集输入最终生成的模型在通顺度上比不做校准直接量化的版本好一个档次。实际生成的语句在多数情况下没有明显语法错误抽象词语的表达偶尔会有点生硬——比如“月光洒在小路上”变成了“月光光照着小路”但整体不影响理解和听感。3. 端侧推理的核心原理与实操准备3.1 先搞懂Token和Tokenizer的关系如果你之前只写过普通App没有碰过LLM那最先要补的概念就是Token。模型并不认识汉字它认识的是一串数字ID。文本到ID的转换靠Tokenizer完成。Qwen3的Tokenizer是基于字节对编码BPE的中文大约一个字到两个字对应一个Token英文是一个单词拆成一到多个Token。举个例子“小猫钓鱼”这4个字可能被拆成3个Token小猫、钓、鱼。这直接决定了生成速度——手机上的0.6B模型每秒钟大概能推理15到30个Token所以一个200字的故事如果Token数是150左右生成时间大约在6到12秒。Tokenizer在端侧有两种跑法。一种是直接用HuggingFace的tokenizers库转成独立文件在C侧调用另一种是干脆不做Token级别匹配用字符级别的简单切分。我强烈建议用前者。Qwen的tokenizer里包含数万个合并规则如果你图省事自己写个按字切分整句的语义表示会大打折扣生成的文本质量断崖式下跌。我最终的做法是用HuggingFace的tokenizers库把Qwen3的tokenizer.json导出然后写了一个C包装类来加载并调用encode和decode。这个包装类编译进同一份so和MNN推理共用。3.2 模型转换从PyTorch到MNN的完整路径这是整个项目里最容易出问题的一步我把操作路径和关键卡点都写出来。第一步从HuggingFace下载原始模型。Qwen3 0.6B的模型权重和配置文件加一起大概1.2GB下载后拿到的是标准HuggingFace格式目录。第二步导出ONNX。MNN官方转换工具不直接认HuggingFace格式需要先转成ONNX。这一步需要用到transformers库的onnx export功能但要注意Qwen3的模型结构较新transformers版本太低会报算子不支持的错。我用的版本是transformers 4.43以上实测可以正常导出。第三步把ONNX转成MNN格式。使用MNN转换工具命令行大概是./MNNConvert -f ONNX --modelFile qwen3_060b.onnx --MNNModel qwen3_060b.mnn --bizCode ali如果这一步报算子不支持的错误优先查到的是哪个算子然后去MNN的GitHub Issues里搜。我遇到的是RotaryEmbedding算子在某个老版本MNN里不支持升级到最新版就解决了。第四步量化。MNN提供了量化工具./quantized_mnn --modelFile qwen3_060b.mnn --quantFile calibration.txt --quantModel qwen3_060b_int4.mnn --bits 4calibration.txt里面每一行是用于校准的文本不能太少建议至少300条内容要贴近你的使用场景。量化后务必验证模型输出质量必要时回到校准数据这步去调整。3.3 Flutter侧工程结构设计这一节说一下整体工程目录怎么组织方便你理解后面代码的位置和作用。我用的目录结构大致是这样的lib/ main.dart # App入口仅负责启动和路由 pages/ # 页面层 story_page.dart # 故事生成页输入角色、场景等 library_page.dart # 已生成故事列表 services/ mnn_bridge.dart # FFI封装对应C的导出接口 model_manager.dart # 模型加载、会话管理 story_generator.dart # 业务层组装Prompt、调推理、解析结果 models/ story.dart # 故事实体模型 assets/ models/ qwen3_060b_int4.mnn # 量化后的MNN模型 tokenizer.json # 词表文件 prompts/ # Prompt模板目录 android/ app/src/main/jniLibs/ arm64-v8a/ libmnn.so libqwenbridge.soandroid原生层和iOS原生层做的事情很少只有一件事把lib目录下的so在构建时打进包。Flutter侧通过DynamicLibrary.open加载。模型文件放在assets首次启动时复制到应用私有目录再加载。在Android上从assets直接加载so可以但模型文件由于体积较大读取性能不稳定我建议先通过rootBundle.load把模型字节流写进getApplicationDocumentsDirectory再从文件路径加载。4. 实操过程构建可离线运行的故事生成管线4.1 模型加载与会话管理MNN推理的基本单位是Session。模型加载后每次生成故事需要创建或复用Session。Session会持有KV Cache、中间激活值等推理过程中产生的数据所以它比较占内存——大约200MB到300MB。我的优化策略是常驻一个Session所有故事生成共享这样可以避免反复创建销毁Session带来的GC压力和内存抖动。加载模型的Dart侧代码封装如下class MnnBridge { // 加载.so库 static final DynamicLibrary _lib DynamicLibrary.open( Platform.isAndroid ? libqwenbridge.so : libqwenbridge.dylib, ); // 定义C接口 static final int Function(PointerUtf8 modelPath) _createSession _lib.lookupFunction(int Function(PointerUtf8), create_session); static final void Function(int sessionId) _destroySession _lib.lookupFunction(void Function(int), destroy_session); static final int Function(int sessionId, PointerUtf8 prompt) _generate _lib.lookupFunction(int Function(int, PointerUtf8), generate); static final PointerUtf8 Function(int requestId) _getResult _lib.lookupFunction(PointerUtf8 Function(int), get_result); }注意这里我故意在native层分配结果字符串然后通过Dart侧读取。为什么不直接在native层把结果写进Dart传入的缓冲区因为文本生成长度是动态的固定缓冲区不好估量容易截断故事每次动态分配又涉及跨语言内存管理。用返回指针的方式配合PointerUtf8.toDartString()来读取内存由native层统一管理Dart侧的finalizer负责释放。加载完成的判断逻辑很简单Session创建成功后先在Dart侧做一次预热推理用一个固定短文本如“你好”测试一次完整的前向传播。这样能提前暴露模型损坏、so版本不匹配等问题同时让内存页和缓存结构提前就绪避免真正生成故事时第一次调用偏慢。4.2 输入构造与推理循环MNN的输入是Tensor但大模型推理不能一次性把所有文本都塞进去——需要按Token逐字生成。核心推理循环是这样的对用户输入的Prompt做tokenize得到初始Token序列。把Token序列转成Tensor填入输入。调用Session的run执行一次前向传播得到下一个Token的概率分布。从概率分布中采样一个Token这里可以做温度调节、Top-K等处理。把新Token接到已有序列后面如果长度超限则停止否则回到第2步。关键在于第4步之后的每一步输入都包含前置的所有Token而不是只输入新Token。这就是为什么需要KV Cache——它会缓存每一层的Key和Value矩阵避免重复计算否则每生成一个Token都要把前面所有历史再过一遍生成200个Token的耗时多出好几倍。我在MNN推理循环里维护了固定大小的past_length变量。每生成一个Tokenpast_length加1从MNN的输出里取出最后一列的hidden_state做一层linear映射到词表大小然后按采样策略选取Token。采样参数直接影响生成质量。儿童故事需要的风格偏向稳定、温和、可预期所以我把temperature设置为0.85top_k设置为50top_p设置为0.9。temperature太低会变成复读机太高会出一些孩子不该看到的词实测0.85比较均衡。4.3 结果解析与流式返回生成结束后我拿到的是完整ID序列需要解码成文本。解码是把每个Token ID通过词表映射回字符串再拼接起来。MNN输出的是ID解码逻辑必须和训练时完全一致否则会出现乱码或语句不通。我最初遇到的问题就是在C侧手动拼了一个极简的词表映射结果生成出来的中文严重错乱因为BPE的解码不是简单的“一个ID一个词”存在合并和回退规则。换成加载官方tokenizer.json后立刻正常。Flutter侧展示结果时我做了流式输出——不等到全部生成完再一次性显示而是每生成一定Token数就刷新一次界面。做这个功能时我用了setState加一个debounce机制每200毫秒更新一次文本。这样用户体验好很多等待8秒和看着故事慢慢打出来感受完全不一样。// 伪代码示意流式更新 StreamString _storyStream _generator.generateWithCallback( prompt: prompt, onToken: (partial) { setState(() { _currentStory partial; }); }, );4.4 Prompt模板把故事生成从“能写”变成“写得好”跑通模型只是第一步真正让生成的故事像那么回事Prompt工程占了六成功劳。儿童故事Prompt我拆成了这么几块角色设定、场景设定、故事长度、语言风格、结构要求。举一个实际用的模板你是一位擅长给3到6岁孩子讲睡前故事的作家。 请根据以下信息创作一个短篇故事 角色小兔子小熊 场景森林里的雨季 故事核心友谊与分享 字数200字左右 写作要求 1. 语言简单、生动适合孩子理解。 2. 故事有开头、冲突、解决、结尾。 3. 不要出现暴力、恐惧、悲伤的画面。 4. 结尾温馨让孩子感到安心。这段Prompt看着简单但每一条都有作用。“适合孩子理解”会压低模型选词的复杂度“不要出现暴力”会引导模型避开特定主题“结尾温馨”会让模型在收尾时往正向走。实测去掉最后一条故事结尾质量下滑非常明显经常是突然就结束了。Qwen3本身支持System Prompt所以我会把这段话作为System输入而把用户输入的部分只留变化字段比如孩子设定的角色名。这样角色名是动态的模板是固定的便于维护和复用。5. 性能优化与内存控制实战5.1 我测到的真实性能数据先给一组我实测的数据测试机型为小米13骁龙8 Gen212GB内存、iPhone 13A15芯片4GB内存所有测试均为纯CPU推理模型为Int4量化版本项目小米13iPhone 13模型加载耗时3.2秒2.8秒预热推理耗时0.6秒0.5秒生成100 Token平均耗时4.8秒4.2秒推理时内存峰值420MB340MB稳定后内存占用330MB290MB要说清楚的是这个速度对于“点一下生成、等一会看完整故事”的交互来说完全够用。生成200字左右的故事用户等待时间大约在8到12秒之间和调用云端接口的等待时间几乎没差别但体验是完全离线、完全隐私的。5.2 内存优化的三个关键手段首先是量化。Int4量化不是简单把FP16的数字砍掉一半精度而是有权重量化算法参与的。MNN的Int4量化会把权重按组通常32个元素一组统计scale和zero_point然后把组内数值映射到4bit整数。这个方案能保持大多数场景的精度而内存直接降到八分之一。如果不量化0.6B模型的FP16权重就要1.2GB中低端手机会直接OOM。其次是KV Cache的容量控制。Qwen3 0.6B的单层KV Cache大约5KB12层就是60KB上下文长度上升到2048时KV Cache会涨到几十MB。如果做长故事生成这个值还得往上走。我在MNN的配置里把context长度限制到1024够生成300字左右的小故事同时把KV Cache的上限封死避免无限增长。再次是做内存复用。MNN的Session在多次推理之间可以复用显存/内存缓冲区只要你在fetch输出之前把上一次的输出Tensor释放掉。我写了一个heap_monitor每生成10个Token检查一次当前进程内存如果增长超过阈值就强制触发一次System.gc()Android或者用malloc_trim清理C层的空闲堆。5.3 线程数与CPU调优MNN默认会使用所有CPU核心但这不一定是好事。推理时所有核心拉满会导致手机发热、耗电加速而且可能在长时间运行后触发系统级降频反而更慢。我做了几组对比实验4线程 大核优先单次推理最快但发热最明显连续生成5个故事后手机会发烫。2线程 均衡调度速度稍慢但温度稳定适合睡前连续使用场景。1线程速度不可接受不推荐。最终我固定在2线程并额外限制了CPU的调度优先级让推理线程的nice值比普通UI线程略低。这样既保证了流畅度又不会让App在推理时卡界面。线程配置在MNN的Interpreter创建时传入MNN::ScheduleConfig config; config.numThread 2; config.backendType MNN_FORWARD_CPU;如果后续换到支持NPU的手机我还预留了config.backendType MNN_FORWARD_NPU的切换逻辑不过这一步会涉及更多算子和内存布局适配短期没计划动。6. 常见问题与坑位实录6.1 问题速查表现象直接原因解决方案模型加载时直接闪退so与MNN版本不匹配统一使用同一版本MNN编译so不要混用生成文本全乱码Tokenizer解码逻辑错误改用官方tokenizer.json重新decode生成到一半内存飙高KV Cache无限增长限定context长度及时释放输出Tensor首Token生成偏慢没有做预热推理加载后立即跑一次短文本推理中英文混合输出采样温度过高降低temperature到0.8-0.9区间生成内容重复Top-K或Top-P设置太极端适当提高K值让采样更随机上线包体积过大模型so的原始体积Int4量化后模型约350MB可以考虑二段下载iOS上加载so报错签名或framework配置问题使用动态库并勾选Embed Sign6.2 卡在“一次只能生成一个故事”的性能瓶颈端侧大模型跑在手机上最大的性能瓶颈不是算力而是内存带宽。CPU算力在移动端已经完全不缺但每次读取权重参数都需要从DRAM搬运到缓存。4bit量化之所以重要正是因为它把需要搬运的数据量缩小到了原来的四分之一整体推理速度因此能快三到四倍。如果后续想进一步提速还有两条路可以走一是用MNN的GPU后端利用GPU的高带宽特性跑FP16甚至FP8二是用系统NPU但NPU对算子限制大有些层的支持情况不好除非模型结构非常规整否则我不建议轻易上NPU。6.3 包体积问题的现实权衡Int4量化后的Qwen3 0.6B模型大约350MB加上MNN的so约35MB和业务代码包体直逼400MB。这个体积对于以API为主的小应用来说确实偏大但如果你做的是垂直场景端侧AI应用这个体积是可以接受的。我的处理方式是设置了一个引导页提示用户“首次进入前需下载故事模型350MB支持断点续传”然后在Wi-Fi环境下后台静默下载。同时保留一个精简模式——不下载模型时App可以正常浏览故事列表、收藏和管理已经生成过的故事只是不能生成新故事。这样把“下载模型”从核心路径上剥离开用户首次打开App的体验不会被400MB下载拖死。断点续传我用的是dio的download接口通过resume参数支持断点。实际测试下来2MB/s的网速下完整下载大约3分钟用户完全等得起。6.4 Flutter侧UI线程与推理线程的协调这是Flutter开发者最容易踩的一个坑。MNN的推理是CPU密集型任务如果你直接在Dart侧调C接口然后阻塞UI线程界面会直接卡死。正确做法是把推理放到Isolate中执行用事件通道把生成的Token流式传回UI线程。我用的方案是Isolate.run加ReceivePort每个生成请求开启一个独立IsolateIsolate内部加载模型、跑推理把partial结果通过SendPort发回主Isolate主Isolate收到后setState刷新界面。这么做的好处是即使推理过程中某个步骤阻塞了UI线程依然可以响应用户操作不会出现“关不掉、退不出”的糟糕体验。需要注意MNN的Session对象不能跨Isolate共享所以在每个Isolate里需要重新加载模型。这个加载耗时会增加3秒左右但置换来的是UI完全不卡顿是值得的。7. 实际案例一次完整的儿童故事生成为了让你对完整流程有直观感受这里用一个真实生成案例展示。用户输入角色小狐狸、小刺猬场景秋天的果园核心互相帮助才能解决问题系统Prompt按前面模板省略。模型生成结果节选秋天到了果园里的苹果都红红的像一盏盏小灯笼。小狐狸抬头看着高处的苹果怎么跳也够不着。小刺猬路过看见小狐狸急得团团转就想了一个办法。小刺猬说“你把我抱起来我就可以把苹果戳下来。”小狐狸听了很高兴轻轻抱起小刺猬。小刺猬用背上的刺一戳一个红苹果就掉下来了两个好朋友一起把苹果搬回家。晚上他们吃着自己摘的苹果都觉得这是世界上最甜的苹果。这个结果放到儿童故事堆里中规中矩但作为端侧模型在手机上离线生成的已经让我很满意了——结构完整、有冲突有解决、语言简单、结尾温馨。生成耗时约9.4秒内存峰值395MB全程无网络请求飞行模式下依然可以正常生成。这意味着不管你在地下室、飞机上还是国外漫游这个功能都稳定可用。8. 后续优化与扩展思路当前版本只跑了0.6B模型但我已经在测试1.7B甚至3B级别的模型。增大参数量确实能提升文本质量代价是内存占用量和推理耗时的翻倍增长。如果你的目标设备是8GB内存以上的旗舰机1.7B是可以流畅跑的如果要做3B模型建议上量化加GPU后端否则纯CPU推理延迟会明显。另一个我比较看好的方向是结合语音合成做完整的睡前故事体验。端侧TTS引擎如Sherpa-onnx已经相当成熟可以把故事文本直接转成语音播放。离线生成文本加离线合成语音整条链路断网也能跑这个体验是云端方案很难给的。亲子互动也是个值得做的方向——可以让孩子在故事生成前后做出选择比如“接下来小狐狸应该去敲门还是回家”把单次生成扩展成多轮交互故事。端侧模型的低延迟让这种交互成为可能因为每轮生成只需要1到2秒不像云端那样有明显网络往返延迟。从工程角度看后续我还想做模型热更新——用户不更新App也能获取新版本模型。这个需要做一套模型版本管理和差分下载机制MNN模型文件改版后二进制差异往往在10%以内用差分算法能显著减少下载量。
返回列表