前阵子改造一个笔记类App碰到了一个特别实际的问题用户笔记里全是私人内容为了一个“相似笔记推荐”的功能让文本走云端别说用户不放心我自己心里都过不去。再加上线上API按调用次数计费成本跟着日活走量稍微上来就肉疼。于是我干脆把目光转向了完全端侧的方案——在手机本地跑一个轻量级文本嵌入模型把输入内容转成向量然后在设备上完成相似度检索和意图联想。折腾了一段时间做了个Demo代号叫AI Edge Foresight技术栈就是 Flutter 加一款轻量级嵌入模型。这篇把选型、集成、实测和踩坑过程完整梳理一遍适合想在 Flutter 里跑端侧AI、又不想被云端API绑住手脚的开发者参考。1. 为什么非要“完全端侧”云API的四个劝退理由1.1 隐私、延迟、成本、断网一个都躲不掉先说隐私。笔记、聊天记录、健康数据这类东西一旦出设备就变味。用户嘴上不说心里清楚得很。你在应用描述里写“数据将上传服务器进行分析”本来想下载的也犹豫了。这还不算各国合规审查、隐私协议更新、用户投诉这些隐形工作量。再说延迟。线上一个embedding接口正常网络下往返200到400毫秒遇到弱网直接一秒往上。如果是做“边输入边联想”的交互这个延迟根本没法用。我最初在笔记列表页做一个“相关笔记推荐”每次进入页面都触发云端调用用户滑两下就发现卡片半天才刷出来体验掉得很厉害。成本更现实。embedding类的API虽然单次不贵但架不住调用量大。日活一万、每天每人触发二十次一个月下来账单很可观。而且这种功能往往不能直接收费属于“做了帮留存、不做没感觉”的隐性功能成本只能自己扛。最后一个问题断网。地铁、电梯、山区信号说没就没。云端方案一断网整个功能直接瘫痪但端侧模型不会。所以从产品角度说端侧不是“炫技”是很多场景下的必需品。1.2 端侧方案不等于“效果缩水”很多人一听到端侧AI就摇头觉得手机性能不够、模型效果差。这个印象主要来自于把生成式大模型硬往手机上塞的尝试——那确实跑不动。但AI不止有生成这一条路。嵌入模型这种结构专攻“把文本变成向量”体积可以压到几十MB手机上推理一次只要几十毫秒效果和云端中等模型差距没想象中大。我最后跑通的这套方案模型文件约50MBAndroid中端机冷启动加载模型约300毫秒单次推理约45毫秒一次返回768维向量。再加上向量检索几千条笔记在本地做语义联想整体延迟50毫秒内。这已经足够支撑“输入一句话立刻推荐相关笔记”的交互了。所以我把项目定位成离线语义检索 本地联想推荐。不碰生成不做复杂对话把“理解语义”这一件事做透。2. 模型选型为什么选一款几十MB的嵌入模型2.1 嵌入模型和生成式模型分工完全不同要理解这个选型先分清两类模型。生成式模型干的是“续写”你给它一句话它一个字一个字往外蹦参数动辄几十亿上百亿部署在手机上暂时不现实。嵌入模型干的是“编码”它把一整段文本压缩成一个固定长度的向量比如768维。这个向量能保留语义信息——意思相近的两句话它们向量的距离就近意思无关的向量距离就远。有了向量就能做很多事相似度检索、聚类、意图分类、去重、推荐。这些恰恰是移动App里最常见的AI需求。所以并不是端侧做不了AI而是得选对模型类型。2.2 我在候选模型里是怎么取舍的当时找了三款开源端侧嵌入模型做对比为了方便起见叫它们A、B、C。三款模型的核心参数如下模型文件体积向量维度手机端平均推理耗时语义效果个人评测A约 50MB768约 45ms优秀B约 120MB1024约 120ms优秀但体积偏大C约 25MB384约 30ms一般我最后选了A理由很直接在体积、推理速度、语义质量三者之间最平衡。768维向量足够表达复杂语义45ms延迟不会让交互卡顿50MB体积塞进APK也还能接受。B模型效果虽好但120MB体积对安装包影响太大C模型太小实测对长句和抽象语义的理解明显偏弱遇到“最近睡眠不好”这种句子召回结果的语义相关性不够。这里有个容易被忽视的点向量维度也不是越大越好。768维对端侧已经是偏高的维度越高检索计算量越大内存占用越高。如果场景比较简单384维甚至256维也能用优先响应速度。我选768维是为了让“联想推荐”的质量更高后面实测2000条数据暴力检索也只要几毫秒才决定保留这个维度。2.3 模型转换ONNX这条路绕不开原始模型通常是PyTorch格式不能直接在手机端跑得先转成移动端推理框架认得的格式。我的选择是转成ONNX然后配合ONNX Runtime Mobile在Android和iOS两端做推理。转换过程用Python完成核心代码大概长这样import torch from transformers import AutoTokenizer, AutoModel model AutoModel.from_pretrained(your-embedding-model) model.eval() dummy_input torch.randint(0, 1000, (1, 128)) # 固定序列长度128 torch.onnx.export( model, dummy_input, embedding_model.onnx, input_names[input_ids], output_names[embedding], dynamic_axesNone, # 固定shape便于端侧优化 opset_version17 )转换时碰到两个麻烦。第一个是动态shape。模型默认接受任意长度输入ONNX导出时会把seq_len设成动态维度但移动端推理框架对动态shape支持差转换后运行报错。解决办法是固定输入序列长度128不足补零超出截断。这里要强调固定长度后训练和服务时的预处理必须完全一致。我之前有一版预处理结尾没做padding导致转换成功但推理结果全乱了排查了很久。第二个是算子兼容。某个归一化算子导出后在ONNX Runtime Mobile上直接报“unsupported operator”。这类问题我建议不要硬扛直接改写成等价操作比如把LayerNorm里的部分计算拆成矩阵乘和加法兼容性立刻就好了。这类排查很费时间建议转换后先用官方工具跑一遍算子检查提前暴露问题。转换完还可以进一步量化。FP32转成FP16体积能砍半速度也有提升对效果影响很小。如果要更激进可以上INT8量化但对部分CPU设备会有精度损失需要实测。3. Flutter集成端侧推理引擎从原生层到Dart层3.1 为什么用MethodChannel而不是FFIFlutter本身不能直接调用C/C要跑推理引擎必须经过原生层。实现桥接的方式有三种MethodChannel、FFI、PlatformView。我这里选的是MethodChannel。原因很简单ONNX Runtime直接提供了Android的Java API 和 iOS的C API用MethodChannel包一层逻辑最少代码也最好维护。FFI虽然在性能上有优势但需要自己管理C层的内存、生命周期调试成本高不少。对于“调用一次返回一个float数组”这个场景MethodChannel的序列化开销可以忽略不计。3.2 Android端Kotlin插件的关键代码Flutter插件部分我用的是FlutterPlugin接口加MethodChannel。核心逻辑分成四步加载模型、构造Session、预处理输入、跑推理。Kotlin代码关键片段如下class EdgeEmbedderPlugin : FlutterPlugin, MethodCallHandler { private lateinit var channel: MethodChannel private lateinit var session: OrtSession private lateinit var environment: OrtEnvironment override fun onAttachedToEngine(binding: FlutterPluginBinding) { channel MethodChannel(binding.binaryMessenger, edge_embedder) channel.setMethodCallHandler(this) environment OrtEnvironment.getEnvironment() // 从assets拷贝模型到缓存目录后再加载 val modelPath copyModelToCache(binding.applicationContext) val options OrtSession.SessionOptions() session environment.createSession(modelPath, options) } override fun onMethodCall(call: MethodCall, result: Result) { when (call.method) { embed - { val text call.argumentString(text) ?: val ids tokenize(text) // 返回LongArray val shape longArrayOf(1, ids.size.toLong()) val tensor OnnxTensor.createTensor(environment, ids, shape) val outputs session.run(mapOf(input_ids to tensor)) val embedding outputs[0].value as ArrayFloatArray result.success(embedding[0].toFloatArray()) } else - result.notImplemented() } } }有几个细节要说明。模型文件默认放在assets里但Android打包时assets里的二进制文件会被压缩运行时要先拷贝到应用缓存目录再加载否则ONNX Runtime读取文件会失败。我在Kotlin里写了个copyModelToCache第一次启动时拷贝后面直接用缓存副本。tokenize这一步也需要和训练时保持一致。我用的是和模型配套的tokenizer在端侧实现了一个精简版先做文本归一化再按词表映射成id序列截断/填充到128。这个部分属于“自己做容易出错”的环节我建议直接看一下tokenizer源码把核心逻辑搬到Kotlin不要凭感觉写。我自己就吃过亏简单按空格切分然后查表结果和Python侧完全对不上。3.3 iOS端Swift封装C推理的思路iOS这边我用Swift调ONNX Runtime的C API。Swift直接调C头文件在模块里做一层封装流程和Android类似加载模型、创建Session、跑推理。iOS比Android多一个坑ONNX Runtime的C库集成到Xcode时链接参数、bitcode、架构支持都要单独配置。我第一次集成的时候漏了-force_load参数导致运行时符号找不到直接崩溃。建议官方示例工程怎么配就怎么抄别自己发挥。Swift封装的核心接口就一个输入文本输出[Float]。内部把Swift的[Int]转成C的std::vectorint64_t推理完成后把std::vectorfloat转回NSArray或[Float]。桥接代码不复杂但类型转换很容易写错建议写单元测试覆盖。3.4 Dart侧封装异步调用与对象管理Dart侧我封装了一个EdgeEmbedder类负责和原生层通信对外暴露一个embed方法class EdgeEmbedder { static const _channel MethodChannel(edge_embedder); FutureListdouble embed(String text) async { final result await _channel.invokeMethodList(embed, {text: text}); return result!.castdouble(); } }MethodChannel默认是异步的不会卡住UI线程。这里要注意并发问题同时调用多次embed时原生层需要做好线程同步否则Session会被并发访问。实现在Android端用一个单线程Executor串行处理所有推理请求避免了并发冲突。另外一个重要细节不要让Dart侧反复创建大的Listdouble。一次返回768个float如果用List包装会产生大量对象频繁调用会触发GC卡顿。优化做法是原生层返回Float32List或者直接走ByteDataDart侧用Float32List.view读取能显著减少内存分配。4. 端侧向量检索与“Foresight”联想推荐4.1 语义相似度计算模型跑通了向量也有了接下来就是怎么用。最基础的操作是计算两段文本的语义相似度通常用cosine similarity[ \text{similarity} \frac{\vec{a} \cdot \vec{b}}{|\vec{a}| |\vec{b}|} ]Dart实现很简单double cosineSimilarity(Listdouble a, Listdouble b) { double dot 0, normA 0, normB 0; for (int i 0; i a.length; i) { dot a[i] * b[i]; normA a[i] * a[i]; normB b[i] * b[i]; } return dot / (sqrt(normA) * sqrt(normB)); }用户输入一句话先对它做向量化再和库里已有的笔记向量逐一比对取Top-K返回这就是最朴素的端侧语义检索。我在Demo里把笔记的向量直接存在内存List里2000条笔记暴力计算一遍也就几毫秒体验很好。数据量一大比如到了几万条这招就扛不住了需要上向量索引或者SQLite向量搜索这部分后面说。4.2 “Foresight”到底是怎么做出来的项目代号里有个“Foresight”听起来玄乎其实就是“联想式推荐”。不预测未来而是根据用户当前输入推断他大概率在找什么方向的内容。我做了两层。第一层是Top-K相似检索。输入一句话直接返回语义最接近的历史笔记这个最简单也最直观。第二层是聚类导向的意图预测。把所有历史笔记向量用K-Means聚成几簇每簇代表一个“内容方向”。用户输入新文本后先计算它离哪个簇中心最近然后把该簇内Top-K笔记推荐出来。这么做的好处是即使当前输入和某条笔记的表面措辞相差很远也能因为同属一个语义簇而召回。实测举一个例子。模拟的数据集里有一条笔记叫“咖啡因摄入记录”另一条叫“睡前刷手机时长”这两条文本里都没有“睡”这个字。当我输入“最近睡眠不太好”时系统把这两条笔记都推到了前排就是因为它们的向量位置离“睡眠困扰”这个语义簇很近。换成关键词匹配方案这两条永远不可能被召回。这就是嵌入模型的价值也正是“Foresight”这个名字的由来。4.3 本地检索的性能实测我在中端Android机器上测试检索耗时2000条768维向量暴力cosine计算加排序单次约5毫秒。加上推理耗时45毫秒端到端在50毫秒左右。对交互反馈来说这个延迟毫无压力。顺便说一句如果再往上扩数据量不要直接上服务端可以优先考虑端侧向量索引。ONNX Runtime不管检索需要另配方案。常见选择是写一个简化版HNSW索引或者把向量存进SQLite自己在SQL里做距离计算。如果想省事可以直接引入一个支持向量索引的端侧数据库具体选型看项目规模。5. 真机实测数据与踩坑记录5.1 两代设备上的实测数据我把Demo跑在一台中端主流Android手机和一台旧款iPhone上结果差距比较大具体数据如下指标中端Android旧款iPhone模型加载耗时约 300ms约 800ms平均单次推理耗时约 45ms约 120ms推理时峰值内存增量约 80MB约 150MB2000条笔记检索耗时约 5ms约 15ms旧款iPhone明显吃力原因是芯片缺少专用的NPU加速ONNX Runtime只能走CPU加上FP32模型对内存不友好。后来我针对旧设备换了INT8量化版模型单次推理降到了70ms左右内存增量也降到了90MB旧设备终于能流畅跑起来。这一步说明端侧AI的适配不看Android还是iOS而是看具体设备算力量化是解决旧设备卡顿最直接的手段。5.2 五个绕不开的坑我从现象到解决一次说清坑一模型文件放assets后被压缩导致加载失败。现象模型加载时报FileNotFound但文件明明在。原因Flutter构建时默认压缩assets里的二进制文件。解决在pubspec.yaml里给模型路径配置noCompress。flutter: assets: - assets/models/embedding_model.onnx这里的重点是要确保Android构建时资源不压缩否则模型文件路径会变成压缩流ONNX Runtime读不到。iOS受这个影响小但Android必须处理。坑二首次启动加载模型慢到怀疑人生。现象冷启动后第一次调用embed耗时超过800毫秒用户点一下功能按钮界面卡在那里转圈。原因模型加载和Session初始化都是重操作。解决App启动后立刻在后台线程做一次预加载把Session初始化放前面真正用时只做推理。实测预加载后首次推理耗时直接降到50毫秒内。坑三Dart侧频繁接收float数组导致卡顿。现象连续做检索时UI偶发掉帧。原因invokeMethod返回Listdynamic会产生大量临时对象GC压力大。解决改为返回Float32List或ByteDataDart侧用view直接读取对象分配少了很多掉帧消失。坑四iOS旧设备内存暴涨。现象旧款iPhone连续推理几次后App内存涨到300MB以上甚至被系统杀掉。原因FP32模型在CPU推理时内存开销大且Session未做释放。解决换INT8量化版控制同时推理的并发数为1推理结束后及时关闭Tensor和输出对象。处理后内存增量稳定在90MB左右。坑五Flutter热重载后通道重复注册。现象开发时热重载几次再调用embed直接报“channel already registered”。原因onAttachedToEngine反复调用之前的通道没有释放。解决在onDetachedFromEngine里调用channel.setMethodCallHandler(null)确保重新挂载时干净。这几个坑都经历过一遍之后整个链路才算真正稳定下来。目前这个Demo在两端设备上都能流畅运行弱网和离线情况下功能完全不受影响。6. 能力边界、适合场景和后续扩展方向6.1 这套方案能做什么不能做什么先说能做。离线语义搜索、相似内容推荐、意向分类、内容去重、个性化标签推荐这些都行。本质上是把所有以“理解语义相似度”为核心的任务本地化。不能做的事也很明确不能文本生成不能多轮对话不能做复杂逻辑推理。嵌入模型天生不具备这些能力它只负责“编码”不负责“产出”。如果硬要在端侧做生成那得换生成式模型体积和算力需求完全是另一回事。所以这套方案的边界就是只做理解不做创作。6.2 哪些场景最适合根据这段时间的实践我觉得这几类场景最合适笔记类App的内容检索和联想推荐数据全程不出设备隐私友好。健康、金融等隐私敏感行业应用合规压力小很多。车载、野外作业等弱网或离线环境功能不受网络影响。企业内部工具不用把内部文档传到外部服务。反过来如果产品本身依赖云端账号体系或者需要跨设备同步语义索引那端侧方案只能作为云端的补充而不是替代。6.3 后续还能往哪些方向扩这个Demo距离一个完整产品还很远。我列的扩展方向有三个。第一引入端侧向量索引。当笔记量到几万条时暴力检索就不够了。可以考虑给SQLite加向量扩展或者实现一个简化版HNSW把检索耗时控制在可接受范围内。第二做更细的量化压缩。当前INT8量化还有精度损失后续可以尝试量化感知训练让模型在压缩的同时保持语义效果。如果团队有精力知识蒸馏也是一个方向用大模型蒸馏出更小的端侧模型。第三和本地推荐系统结合。现在只是“根据当前输入找相似”下一步可以做用户画像向量结合历史行为做个性化排序。这需要记录用户点击行为但在端侧做这些问题不大。最后再分享一个小技巧。如果你也要做类似功能记得在App启动后预热ONNX Runtime Session不要等用户实际操作时才加载。这个预热动作能直接把首查延迟从几百毫秒压到几十毫秒对体验的帮助立竿见影。做完这个Demo我最大的感受是端侧AI的关键从来不是模型参数堆得有多大而是模型够小、跑得稳、体验顺。想清楚这一点很多选型决策都会变得简单。