尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Flutter + MNN 端侧大模型实战:手机离线跑 Qwen3.5 生成儿童故事

Flutter + MNN 端侧大模型实战:手机离线跑 Qwen3.5 生成儿童故事 端侧 AI 儿童故事 App 的技术拆解。一等奖 最佳技术奖2026 通义 ARM 手机创意 AI 挑战赛。项目已开源github.com/qhvssonic/mommy-story-on-device一、项目简介「mommy story」是一个完全运行在手机本地的 AI 睡前故事 App。录制 3 秒妈妈的声音AI 即可用妈妈的声音为宝宝讲故事。全部推理在端侧完成无需联网无需账号无需服务器。核心技术栈LLMQwen3.5-0.8B / 2BMNN 推理引擎ARM SME2 加速TTSZipVoice-Distillsherpa-onnx 推理端侧声音克隆框架Flutter 3.8Platform Channel 桥接 Native天玑 9500 实测数据指标数据LLM Prefill452 token/sLLM Decode56 token/sTTS 合成速率0.14 秒/字本文聚焦 LLM 端侧集成的完整技术方案。二、技术架构全景整个系统分四层从上到下┌──────────────────────────────────────────┐ │ Flutter UI Layer (Dart) │ │ ├─ StoryCreatePage故事创作界面 │ │ ├─ StreamController流式文本接收 │ │ └─ MnnLlmServiceDart 服务层 │ └──────────────────┬───────────────────────┘ │ Platform Channel │ (MethodChannel EventChannel) ▼ ┌──────────────────────────────────────────┐ │ Android Native Layer (Kotlin) │ │ ├─ MnnLlmPluginMethodChannel 注册 │ │ ├─ MnnLlmEngine模型生命周期管理 │ │ └─ GenerateListener流式回调 │ └──────────────────┬───────────────────────┘ │ JNI ▼ ┌──────────────────────────────────────────┐ │ C / MNN Layer │ │ ├─ mnn_llm_bridge.cppJNI 桥接 │ │ ├─ libMNN.soMNN 推理引擎 │ │ └─ Qwen3.5 INT4 模型文件 │ └──────────────────────────────────────────┘链路是Dart 调 MethodChannel → Kotlin Plugin 收到 → 调 JNI → C 调 MNN API → 模型推理 → token 通过 EventChannel 流式回传给 Dart UI。三、MNN 集成方案C → JNI → Kotlin → Dart3.1 C JNI 层参考 MNN 官方的 MnnLlmChat 示例精简为文本生成功能。核心是mnn_llm_bridge.cpp暴露以下 JNI 方法// 加载模型传入 config.json 路径externCJNIEXPORT jlong JNICALLJava_com_yuqingteck_mommy_1story_mnn_MnnLlmEngine_nativeLoadModel(JNIEnv*env,jobject thiz,jstring configPath);// 流式生成步进模式循环调 generate(1) 直到 EOSexternCJNIEXPORT jobject JNICALLJava_com_yuqingteck_mommy_1story_mnn_MnnLlmEngine_nativeGenerate(JNIEnv*env,jobject thiz,jstring systemPrompt,jstring userPrompt,jint maxTokens,jobject listener);关键设计决策步进式生成而非一次性response(maxTokens)。参考 MnnLlmChat 的实现先调response(messages, ..., 0)初始化再循环generate(1)逐 token 生成模型按 EOS 自然停止。这样可以在 Dart 侧实时收到每个 token 做流式显示。每次生成销毁重建实例。比赛中发现连续多次生成会出现 KV cache 残留导致输出|endoftext|等特殊 token。最终方案是每次nativeGenerate开头先reset()如果仍然异常就销毁重建整个 LLM 实例。暴力但有效。3.2 Kotlin Plugin 层classMnnLlmPlugin:FlutterPlugin,MethodCallHandler,EventChannel.StreamHandler{// MethodChannel 处理loadModel, generate, stopGenerate, releaseModel// EventChannel 处理流式 token 回传}MnnLlmEngine 封装了 JNI 调用GenerateListener 接口把每个 token 通过 EventChannel sink 推给 Dart。3.3 Dart 服务层classMnnLlmService{staticconst_methodChannelMethodChannel(com.yuqingteck.mommy_story/mnn_llm);staticconst_eventChannelEventChannel(com.yuqingteck.mommy_story/mnn_llm_stream);FutureLlmLoadResultloadModel(StringmodelPath)async{...}Futureboolgenerate({requiredStringsystemPrompt,requiredStringuserPrompt,...})async{...}StreamLlmStreamEventgettokenStream{...}}EventChannel 返回的事件用 sealed class 区分LlmTokenEvent新 token、LlmDoneEvent完成 性能指标、LlmErrorEvent错误。四、踩坑记录坑 1Vivo X300 native fopen 权限问题天玑 9500 的设备Android 15上native 层无法 fopen App 的 external storage 路径errno13。但 Dart/Java IO 层可以正常读取。解决方案模型文件统一放在 App internal storagegetApplicationSupportDirectory通过ModelManagerService管理。开源版本的模型下载直接写入 internal storage绕过了这个问题。坑 2UTF-8 多字节流式安全MNN 的流式回调是按 byte buffer 返回的不保证在 UTF-8 字符边界切割。在天玑 9500 的高速推理下56 token/s多字节中文字符3 字节经常被拆到两次回调中JNI 层的NewStringUTF遇到不完整的 UTF-8 序列会直接 SIGABRT。解决方案在 C 层加pending_bytes缓冲区收到 bytes 后检查最后一个字符是否完整不完整就暂存等下一次回调凑齐再转 String。坑 3n-gram 重复惩罚Qwen3.5-0.8B 的 0.8B 参数量在生成较长文本时容易陷入重复循环比如小兔子小兔子小兔子…。两层防御Native 层加 n-gram 重复惩罚ngram8, factor1.05Dart 层加重复检测兜底4-12 字子串连续出现 3 次立即停止生成坑 4R8 混淆把 JNI native methods 优化掉Release 构建时 R8 会把它认为未使用的 native 方法删掉导致运行时UnsatisfiedLinkError。解决方案proguard-rules.pro 中添加-keep class com.yuqingteck.mommy_story.mnn.** { *; }坑 5LLM config 中 is_visual 字段MNN 的 Qwen 模型配置里有is_visual: true的默认值会尝试加载visual.mnn文件。端侧故事生成不需要视觉能力把llm_config.json中的is_visual改为false即可。五、ARM SME2 加速天玑 9500 是全大核设计1× C1-Ultra 4.21GHz 3× C1-Premium 3.5GHz 4× C1-Pro 2.7GHz且支持 ARM SME2 指令集。MNN 在检测到 SME2 支持时会自动启用优化的矩阵运算路径。对比数据指标麒麟710无 SME2天玑9500SME2提升模型加载7.1s0.93s7.6xPrefill40.3 t/s452 t/s11.2xDecode11.3 t/s56.2 t/s5.0xPrefill 的提升最为显著11 倍因为 Prefill 阶段是大矩阵乘法密集计算正好是 SME2 的优势场景。MNN 对 SME2 的支持分编译期和运行期两步编译期构建libMNN.so时需开启 SME2 编译选项MNN 默认已开启cmake..-DMNN_ARM82ON-DMNN_SME2ON-DMNN_BUILD_LLMON-DMNN_LOW_MEMORYON运行期无需任何额外配置。MNN 初始化时自动检测当前 CPU 是否支持 SME2 指令支持则自动调度到 SME2 内核执行 GEMM/GEMV 算子不支持则回退到 Armv8.6i8mm/ Armv8.2dotprod等内核。开发者无需修改业务代码同一份 so 可跨设备兼容。logcat 中sme2: 1可确认已生效。六、模型管理与自动下载开源版本实现了 App 内模型自动下载用户安装后零门槛使用。模型托管在 ModelScope国内 CDN支持断点续传和多源 fallbackModelScope → HF 镜像 → HF 原站。首次下载约 660MBQwen3.5-0.8B 470MB ZipVoice TTS 186MB下载完即可完全离线使用。用户也可以在设置页按需下载更大的 Qwen3.5-2B1.2GB以获得更好的故事质量。七、内存管理策略端侧 LLM 和 TTS 不同时加载交替使用故事生成阶段加载 LLM~1.5GB→ 生成文本 → 释放 LLM ↓ 语音合成阶段加载 TTS~500MB→ 合成语音 → 保持 TTS峰值内存 2GB适配 6GB RAM 的手机。TTS 模型通过持久化 Worker Isolate 管理首次合成时加载约 2 秒后续合成零冷启动。sherpa-onnx 的 FFI 在 Flutter 主 Isolate 中加载时会与 raster 线程产生 mutex 冲突因此强制在独立 Isolate 中运行。八、边合成边朗读为了减少用户等待实现了流水线式的边合成边朗读LLM 流式生成故事文本 ↓ 按句号切分每段 ≥15 字 第一段文本就绪 → TTS 合成第一段~2-3s→ 立即播放 ↓ 播放期间后台合成第二段 第一段播完 → 第二段已就绪 → 无缝衔接 ↓ ... 最后一段播完 → 合并为完整 WAV九、总结在 Flutter 中集成 MNN 端侧大模型的关键点四层架构Dart → Kotlin → JNI → C虽然链路长但每层职责清晰调试方便步进式生成比一次性生成更灵活支持流式显示和随时停止UTF-8 安全在高速推理下是必须处理的问题内存管理需要 LLM/TTS 交替加载避免 OOMARM SME2带来的加速效果显著是端侧大模型可用的关键项目完整代码已开源包含 MNN JNI 桥接、Platform Channel、模型下载等全部实现 github.com/qhvssonic/mommy-story-on-device模型托管在 ModelScopeApp 内一键下载 modelscope.cn/models/yuqingteck/mommy-story-on-device-models附相关资源MNN 推理框架alibaba/MNNQwen3.5 模型Qwen/Qwen3.5sherpa-onnxk2-fsa/sherpa-onnxZipVoice 论文arXiv:2506.13053
返回列表