Flutter+MNN+Qwen3.5:端侧离线儿童故事生成App

Flutter+MNN+Qwen3.5:端侧离线儿童故事生成App 做儿童故事 App 这个想法源自我某天晚上被女儿缠着讲故事手机刚好没网自己临时编又编不出新花样。当时脑子里就冒出一个念头如果手机本地就有一个能写故事的大模型断网也能用那该多好。于是就有了这个项目——用 Flutter 搭 App 壳用阿里开源的 MNN 做端侧推理引擎把 Qwen3.5 的小参数模型跑进手机里完全离线生成儿童故事。整个过程踩坑不少尤其模型转换、Flutter 到原生推理引擎的桥接、生成质量和性能的平衡这几块每一样都让我折腾了好几个晚上。这篇文章把完整复现这套方案的步骤、参数和心得写下来适合想入门端侧大模型的 Flutter 开发者也适合正在纠结选型的朋友。我把话说在前头这套组合能跑通而且最终体验出乎我意料地好。1. 项目方案选型为什么是 Flutter MNN Qwen3.5在动手之前我对手机离线跑大模型这件事是持怀疑态度的。哪怕只是 0.5B 参数的模型计算量摆在那里手机 CPU 再怎么优化也不可能和云端 GPU 比。但我这个 App 的使用场景恰恰是端侧最需要覆盖的地方给小孩讲故事一般在卧室没有 WiFi也不合适把孩子的输入内容传到云端。1.1 端侧大模型的关键优势离线、隐私、低延迟云端大模型在大多数场合是更好的选择但儿童睡前故事这个场景里端侧的优势非常明确。第一断网能用而且响应稳定不存在网络抖动导致的等待。第二孩子的输入、模型生成的故事全部留在本地设备上没有隐私上传的顾虑。第三模型初始化好之后点击生成几乎是瞬时响应的这种流畅感对孩子和家长来说都非常重要。端侧的代价也很直观模型能力上限摆在那里0.5B 的模型写不出惊艳长文但写一个 200 字、语言简单、主题明确的睡前故事绰绰有余。我之前拿云端的 7B 模型对比过生成的故事质感的确定更高但断网这个红线直接决定了端侧方案才是这个产品形态的正确解。1.2 Flutter 做客户端壳子的三个理由我选 Flutter 做客户端核心理由有三点。第一一套代码同时覆盖 iOS 和 Android模型推理逻辑放在 Native 层Flutter 只需要负责 UI、状态管理和本地存储。第二Flutter 做讲故事这类 App 的 UI 非常顺手自定义字体、淡入淡出动画、故事卡片布局都能快速实现。第三本地数据库生态够用sqflite、Hive 都能满足故事记录的存取需求。我也对比过 React Native 和 Kotlin Multiplatform。RN 在 JavaScript 和原生 ML 库之间大批量交互时多一层桥接损耗做流式 token 刷新会觉得别扭。KMP 的原生能力很好但 UI 层仍要各写一遍开发量接近写两个 App。Flutter 在这个项目里是平衡点虽然涉及原生推理时要自己写桥接但整体工程量可控。1.3 推理引擎选型MNN 凭什么胜出移动端推理引擎不少TFLite、ncnn、ONNX Runtime Mobile 都是成熟方案。我最终选 MNN核心是三点轻量。MNN 的 .so 只有几 MB加上量化工具链也保持精简后期包体压力小。量化支持成熟。MNN 做 int8/int4 量化在移动端框架里属于先行者带校准数据的量化工具能有效控制精度损失。对大模型有专门适配。MNN 内置了 transformer/LLM 相关推理模块支持 KV cache 和采样循环不是只能跑普通 CNN 网络。我拿候选框架做了个简单对比各位可以根据自己项目情况参考。框架包体量化支持高性能 LLM 支持中文资料TFLite小好中中ncnn小好中好ONNX Runtime Mobile中好中一般MNN小很好好好实际体验下来MNN 的问题在文档相对分散。很多能力在源码里能翻到但官网上没写清楚需要有点耐心去读源码和示例。不过只要链路跑通稳定性还是让人放心的。1.4 Qwen3.5 小参数体积和能力的权衡Qwen 系列一直有适合端侧的小尺寸模型Qwen3.5 也提供了 0.5B 和 1.5B 的 Instruct 版本。我选择 0.5B 作为默认模型理由很实际500MB 左右的 int8 体积手机完全装得下。中文创作能力在同类小模型里属于第一梯队生成的故事句子通顺、语法稳定。指令遵循能力够用给它限制每句话不超过 20 个字基本能遵守。1.5B 我也实际测过故事质量确实更好情节更丰富但 int8 体积约 1.6GB加载和生成速度都有明显下滑。权衡之后决定以 0.5B 为默认1.5B 放在设置页让用户在合适网络环境下自行选择。2. 环境准备与工程搭建先把地基打牢环境准备阶段我走了不少弯路尤其是 Flutter 的 Gradle 插件和 NDK 版本组合问题很容易在项目初期就劝退一批人。2.1 开发环境清单我的开发环境如下仅供参考Flutter SDKstable 3.24 以上版本Android Studio最新稳定版NDK 25MNN2.9 版本 releaseQwen3.5-0.5B-Instruct 模型权重Android 端建议把 minSdk 设为 23Android 6.0。理论上 minSdk 21 也能跑但低版本机型的 CPU 跑 MNN 模型性能太差生成一个故事要等五六分钟体验跟不上。iOS 端最低版本我设到了 iOS 14。2.2 Flutter 工程初始化与依赖配置创建工程flutter create story_qwenpubspec.yaml 里先添加两个核心依赖dependencies: flutter: sdk: flutter sqflite: ^2.3.3 path_provider: ^2.1.3sqflite 用来存故事历史path_provider 用来定位 App 私有目录模型文件后续也放在这个目录下。状态管理先用 setState项目初期不需要引入额外库避免增加复杂度。Android 构建有一个关键配置需要在 app/build.gradle 里指定 ABI 过滤否则打包会把所有架构的 MNN so 都打进去包体直接膨胀。android { defaultConfig { ndk { abiFilters arm64-v8a, x86_64 } } }arm64-v8a 覆盖绝大多数真机x86_64 留给模拟器调试。armeabi-v7a 我没有加32 位老机器跑这种模型体验太差而且高版本 MNN 对 32 位预编译包的支持也在弱化。2.3 本地数据库设计故事记录的存取很多朋友在问 Flutter 内嵌数据库怎么做在这个项目里我用的方案是 sqflite。故事记录有明确字段结构后续需要考虑按主角、按主题检索SQLite 最合适。表结构如下CREATE TABLE stories ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, protagonist TEXT NOT NULL, scene TEXT NOT NULL, theme TEXT NOT NULL, content TEXT NOT NULL, created_at INTEGER NOT NULL );为什么不选 Hive故事记录是结构化数据不是纯 KV 缓存。Hive 更适合简单键值对场景比如缓存某个角色的头像配置、App 设置项。如果你的项目只是把故事原样丢进去不需要查询过滤Hive 也能胜任但我这里明显需要按条件筛选SQLite 更得体。顺带说一句本地数据库加后端同步的演进路径。MVP 阶段我没做云同步但表里预留了 created_at 时间戳。后续真要同步加一个 sync_at 字段配合记录级修改时间就能做增量同步。这样设计让我后续无论是接后端还是做本地备份迁移都不用改表结构。3. 模型转换与量化把 2GB 压到 540MB模型转换是端侧大模型项目里最容易让人崩溃的一步。原始 PyTorch safetensors 权重不能直接塞进 MNN 推理需要经过下载权重 → 导出 ONNX → MNNConverter 转 MNN → 量化这条完整链路。3.1 下载 Qwen3.5 权重Qwen 系列的权重可以从魔搭社区ModelScope获取这是国内主流的开源模型托管平台下载速度稳定。用 git lfs 拉取git lfs install git clone https://www.modelscope.cn/Qwen/Qwen3.5-0.5B-Instruct.git如果魔搭上模型路径有调整直接在平台搜索 Qwen3.5找到对应仓库即可。拉下来之后目录里会有 model.safetensors 或分片文件、tokenizer 相关文件、模型配置文件。3.2 导出 ONNX 中间格式MNNConverter 直接读取 ONNX 模型所以需要先把 PyTorch 权重转成 ONNX。我用 transformers 加载模型然后通过 torch.onnx.export 导出from transformers import AutoModelForCausalLM, AutoTokenizer import torch model AutoModelForCausalLM.from_pretrained(./Qwen3.5-0.5B-Instruct, torch_dtypefloat32) tokenizer AutoTokenizer.from_pretrained(./Qwen3.5-0.5B-Instruct) prompt 你好 inputs tokenizer(prompt, return_tensorspt) torch.onnx.export( model, (inputs[input_ids], inputs[attention_mask]), qwen3.5-0.5b.onnx, input_names[input_ids, attention_mask], output_names[logits], dynamic_axes{ input_ids: {0: batch, 1: seq}, attention_mask: {0: batch, 1: seq}, logits: {0: batch, 1: seq} }, opset_version14 )导出 LLM 时有两个关键点必须设置 dynamic_axes。生成时每步序列长度都在变固定长度会导致性能浪费甚至运行失败。opset_version 建议控制在 14 左右。太新的 opset 会用到 MNNConverter 尚未支持的算子你会在转换阶段遇到各种 Unknown Op。3.3 MNNConverter 转 .mnn 文件MNN 各版本的转换工具命令略有不同我用的版本是这样./converter --modelFile qwen3.5-0.5b.onnx --MNNNet qwen3.5-0.5b-fp32.mnn执行后如果没有报错会生成 fp32 精度的 MNN 模型。首次转换不建议直接加量化参数先把 fp32 版本跑通再量化。这样定位问题范围小遇到报错也不需要从头排查。3.4 量化int8 是甜点位fp32 的 0.5B 模型体积约 2.1GB手机根本装不下。MNN 量化工具可以把模型转成 int8 甚至更低精度。./quantized.out qwen3.5-0.5b-fp32.mnn qwen3.5-0.5b-int8.mnnMNN 的量化工具支持带校准数据的量化校准数据尽量贴近业务场景。我测试时放了几十条儿童故事 prompt 做校准转换之后生成结果和 fp32 几乎没有肉眼可见的差异。三个版本实际对比模型文件体积生成 200 字故事耗时骁龙 8Gen2文本质量fp322.1GB约 12 秒基准int8540MB约 8 秒几乎无差异int4280MB约 7 秒偶有漏字、重复词int4 在低端机能用但生成质量不稳定我最终默认 int8。需要提醒的是MNN 量化工具在不同 release 版本里目录和参数名可能调整执行前先跑一遍--help别照抄命令就闷头跑。实操建议fp32 模型确认量化完成、测试通过后直接删掉。一个文件 2.1GB 太占磁盘留在工作目录里没有意义还影响后续开发。4. Flutter 与 MNN 桥接让 Dart 调起原生推理模型转换搞定后最硬的部分来了Flutter 怎么把文本 prompt 传给 MNN再把生成的 token 流接回 UI。4.1 MethodChannel EventChannel 组合方案平台通道方案写起来直观调试也方便。我打算快速跑通验证所以选了 MethodChannel 做初始化EventChannel 做生成结果的流式回传。class MnnEngine { static const MethodChannel _methodChannel MethodChannel(story_qwen/engine); static const EventChannel _eventChannel EventChannel(story_qwen/events); Futurevoid init({required String modelPath}) async { await _methodChannel.invokeMethod(init, {modelPath: modelPath}); } StreamString generate(String prompt) { return _eventChannel .receiveBroadcastStream({prompt: prompt}) .map((event) event.toString()); } }这个 API 设计成生成时返回一个 StreamFlutter 侧直接用 StreamBuilder 接住每个 token 实时刷新界面视觉上就是打字机效果。4.2 Kotlin 侧封装 MNN 推理引擎Android 端我直接在 MainActivity 里注册 MethodChannel没有单独拆 plugin。项目只有一个平台要调拆 plugin 增加工程复杂度收益不高。class MainActivity : FlutterActivity() { private val engine MnnEngineWrapper() override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) MethodChannel( flutterEngine.dartExecutor.binaryMessenger, story_qwen/engine ).setMethodCallHandler { call, result - when (call.method) { init - { val path call.argumentString(modelPath) engine.init(path) result.success(true) } else - result.notImplemented() } } EventChannel( flutterEngine.dartExecutor.binaryMessenger, story_qwen/events ).setStreamHandler(StoryStreamHandler(engine)) } }MNN 推理核心逻辑在 MnnEngineWrapper 里大致流程Interpreter 加载 .mnn 文件创建 Session设置线程数、后端、精度生成循环喂入当前 token id跑一次推理拿 logits 做采样得到下一个 token id遇到 EOS 或达到 max_tokens 停止循环放在子线程跑每得到一个新 token通过 Handler 切回主线程再发给 EventChannel。如果不在主线程发事件Android 上的 EventChannel 不会帮你做线程切换Flutter 侧 UI 刷新会有风险。4.3 iOS 端思路iOS 端原理一致Swift 里写 MNN 桥接通过 CocoaPods 或手动引入 framework 都可以。生成 token 的回调同样要切主线程再发给 Flutter。iOS 上 MNN 后端支持 Metal理论性能比 Android CPU 方案更好但桥接逻辑完全对称Kotlin 侧怎么拆Swift 侧就怎么拆没有额外的理解成本。4.4 模型文件打包与首次加载540MB 的模型不能打进 APK包体会失控。我的处理方案首次启动时把模型文件从 assets 拷贝到应用私有目录。发布版本可以让用户在外置存储里选择一个已下载的 .mnn 文件。FutureString prepareModel() async { final dir await getApplicationSupportDirectory(); final modelFile File(${dir.path}/qwen3.5-0.5b-int8.mnn); if (await modelFile.exists()) return modelFile.path; final data await rootBundle.load(assets/models/qwen3.5-0.5b-int8.mnn); await modelFile.writeAsBytes(data.buffer.asUint8List()); return modelFile.path; }模型加载初始化放在 App 启动阶段的后台任务里做避免卡 UI。加载完成后首 Token 延迟在旗舰机上实测能控制在 1 秒以内。注意EventChannel 的回调默认在平台线程不要在回调里直接处理耗时任务。我在实际项目里把 token 解码放到子线程只把最终解码后的字符串抛回主线程能明显降低主线程压力。5. 儿童故事生成Prompt、采样参数与 UI 呈现模型能跑通只是第一步真正让用户觉得好用关键在于 prompt 模板、解码参数和生成内容的呈现。这一节直接给出我可复用的完整配置。5.1 Prompt 模板设计思路儿童故事生成质量一半取决于模型另一半取决于 prompt。我的模板长这样你是一位会讲温暖故事的儿童作家。请根据下面的要求创作一个睡前故事 主角小狐狸 场景雪夜的森林 主题帮助别人 要求 - 使用简单中文适合3到6岁孩子理解 - 每句话不超过20个字 - 故事总长度约200字 - 结尾积极温暖不能有暴力内容这个模板有几个精心设计的地方以身份设定开头模型从一开始就进入作家角色。变量区只保留主角、场景、主题三个避免约束条件太多导致模型混乱。硬性要求放在最后模型对末尾的指令遵循度往往比前面的普通描述更高。Flutter 侧把模板做成数据类用户输入三个字段后拼接成完整 prompt再用一个随机填充按钮提升趣味性。5.2 解码参数让故事不那么发癫解码参数直接决定故事是流畅还是胡言乱语。我测试后确定的一组默认参数temperature 0.85top_p 0.9repetition_penalty 1.1max_new_tokens 600temperature 太高容易写跑题太低故事平淡无味。0.85 是我试了几十条 prompt 后最稳的取值。repetition_penalty 必须加小模型在长文本里很容易重复同一句话1.1 能把复读现象控制住。这些参数在 Native 侧采样函数里生效。如果 MNN 的采样接口不直接支持某个参数就自己写一个轻量采样函数从 logits 取概率分布后按 temperature 缩放再用 top_p 截断实现成本不高。5.3 流式输出与 UI 渲染技巧生成过程用 StreamBuilder 接收StreamBuilderString( stream: _engine.generate(prompt), builder: (context, snapshot) { if (snapshot.hasData) { _storyContent snapshot.data!; } return StoryTextView(text: _storyContent); }, )这里踩过一个性能坑每个 token 都 setState 刷新整个页面中端机会明显掉帧。优化方式是把正文区域拆成独立 StatefulWidget让它局部刷新不动 AppBar 和输入区。另一个优化是按批次刷新每 3 个 token 刷一次 UI用户体验几乎没有差别渲染压力小很多。5.4 面向儿童的内容安全策略面向儿童的内容再谨慎都不过分。我的防线有两道模型侧prompt 里明确写不能有暴力内容。应用侧生成结束后对全文跑一个规则过滤器命中不适合儿童阅读的词就把该篇标记为需家长查看并提示重新生成。规则过滤器不用做太复杂一个关键词列表加正则就够。小模型的幻觉比例比大模型高意外输出不是可能不会发生而是迟早会发生多一层过滤少一分风险。6. 性能优化与机型适配中低端手机也能跑端侧大模型和普通 App 最大的区别是性能敏感。优化集中在首 Token 延迟、内存占用和低端机适配三个方向上。6.1 首 Token 延迟优化首 Token 延迟指用户点击生成到屏幕出现第一个字的时间。影响因素主要有三个模型加载是否提前完成。我把模型预加载放到 App 启动后台任务用户点生成时模型已经 ready。线程数设置。Android 中高端机用 4 线程效果最好继续往上会因为多核竞争反而变慢。低端机用 2 线程避免发热降频。Session 复用。生成完一个故事不销毁 Session下一个故事继续使用省掉重新初始化的时间。实测数据骁龙 8Gen2 上模型预加载完成后的首 Token 延迟在 400-800ms中端机在 1-2 秒。这个速度对点击后看到打字机效果来说完全够用。6.2 内存控制与 KV Cache生成长故事时 KV Cache 会持续增长。以 0.5B 模型为例生成 512 个 token 时 KV Cache 大约占几十 MB中端机可以接受。但如果同时打开多个页面或者手机本身内存紧张容易被系统回收。我的策略App 启动时读取系统内存等级低于等于 4GB 的设备强制使用 max_new_tokens 300故事控制在 200 字以内。生成结束后主动释放 KV Cache将 Session 重置为初始状态。监听系统低内存回调内存紧张时只保留当前页面不预加载其他页面。6.3 降级策略与机型适配端侧大模型跑的是真实算力无法靠纯软件跨越硬件差距。我做了自动降级策略设备等级模型量化线程最大生成 token旗舰机1.5Bint84800中端机0.5Bint84512低端机0.5Bint42320降级策略在设置页做成自动检测模式大部分用户不需要手动选择。中低端机上宁可减少生成长度也要保住流式响应的流畅感。7. 常见问题排查掉坑记录与解决方案这一节是我真实的踩坑记录按出现频率从高到低排列基本都是能直接套用的排查路径。7.1 模型加载崩溃闪退现象App 启动加载模型时直接闪退。排查路径看日志里有没有 Incompatible ABI 或 dlopen failed。有的话基本是 .so 架构问题检查 abiFilters 配置。确认模型文件完整性。用本地命令行先跑一遍 MNN demo验证 .mnn 文件本身能正常加载。大模型加载时内存峰值很高低内存设备容易直接崩。解决办法是换 int4 模型或者在加载前做低内存检测提示用户关闭其他应用后再进入生成页。7.2 生成内容重复、死循环小模型生成长文本时容易陷入复读机状态。如果 temperature 和 repetition_penalty 都调过还在复读可以尝试降低 max_new_tokens故事短一些复读概率指数级下降。检查 prompt 是否要求每句话过于简短。过短的句子配合高 temperature 更容易产生重复。采样侧如果支持 no_repeat_ngram打开这个选项直接把连续重复的 n-gram 禁掉。7.3 Gradle 插件警告与依赖下载失败不少新手会看到这个提示You are applying Flutters main Gradle plugin imperatively using the apply script。这是老模板在 android/app/build.gradle 里直接 apply flutter.gradle 导致的新版本建议改用声明式插件方式。我的处理方式是按 Flutter 新模板迁移在 settings.gradle 里声明插件移除 android/app/build.gradle 里手动 apply 的代码。同时把 Gradle 版本升级到 8.x。这个操作顺带解决了一大半依赖包下不下来的问题——多数版本冲突都是 Gradle 插件版本和 AGP 版本不匹配引起的。7.4 生成结果中文乱码生成的中文乱码大概率不是模型问题而是编码处理问题。重点检查三处prompt 字符串从 Dart 到 Kotlin 是否按 UTF-8 传递。MethodChannel 默认是 UTF-8只要不手动转码一般没问题。Native 采样得到 token id 后decode 成文本必须使用 Qwen 配套的 tokenizer不能自己按 BPE 硬解。Flutter 侧拼接字符串时确保没有按字节截断导致半个字符。7.5 生成界面卡顿掉帧UI 卡顿通常不是推理线程抢资源而是 Flutter 侧刷新太频繁。按批次刷新是第一个方案。另一个方案是把正文渲染从 Text 换成 TextPainter大文本场景下性能更好但实现成本稍高。我的建议是先做批次刷新中端机实测已经能保证不满帧但顺滑的效果。写在最后整个项目跑通之后我最深的体会是端侧大模型并不是把模型丢进手机就跑而是一条需要把模型转换、原生推理、Flutter 桥接、产品体验全部串起来的链路每一环都有细坑。但链条一旦通了体验就非常稳——断网、低延迟、隐私可控这些优势是云端方案替代不了的。最后分享一个让我家里人惊喜的小功能。我在生成页面加了一个随机主题按钮每次点击会随机组合角色、场景、主题生成一个完全不同的故事。女儿现在每天晚上都点这个按钮然后等着看今天小狐狸又去了哪里。实现上只是 prompt 拼接前用 Dart 的 Random 从几个数组里各取一个值却意外成了整个 App 使用频率最高的入口。如果你也想做类似的端侧大模型应用我的建议是别一上来就上大参数模型先把 0.5B 全链路跑通再按需换更大的模型。坑先趟一遍后面的路会顺畅很多。