做 Flutter 的开发者应该都有同感:产品想靠 AI 加分,但把能力接进来之后,最先撞上的往往不是模型效果,而是网络依赖、密钥管理、API 账单和飘忽不定的首字节延迟。我最近把一个模拟项目完整跑通了:在 Flutter 应用里集成 EmbeddingGemma 2,做完全端侧的文本向量化与语义检索,也就是 AI Edge Foresight 的核心——让设备在本地完成语义理解与预判,不需要把用户数据发到任何远程服务。这篇文章我会从方案选型、模型转换、Flutter 集成到问题排查一步步复盘,适合正在做移动端 AI 功能、又不想被云端推理成本绑住手脚的开发者;哪怕你只是刚接触端侧 AI 的新手,也能从里面找到可以直接抄作业的代码和配置。
1. 先搞清楚:端侧嵌入模型解决的是哪类问题
1.1 这三类场景必须走端侧
先给不了解背景的读者定位一下:EmbeddingGemma 2 不是聊天大模型,它是嵌入模型,做的是把一段文本变成固定维度的向量,让语义接近的文本在向量空间里靠得近。基于这个能力可以搭语义搜索、内容去重、推荐召回、聚类分析,还有现在很火的 RAG 里的前置检索。
那为什么不直接调云端向量化 API?答案很简单,三类场景绕不开端侧。
第一类,隐私敏感内容。笔记、聊天记录、医疗档案、企业内部文档,这些数据哪怕走加密通道发到云端,产品侧也很难跟客户解释清楚"数据去了哪里、被谁处理过"。端侧推理从物理层面保证数据不出设备,隐私合规压力瞬间小很多。
第二类,离线与弱网环境。用户在地铁、飞机或者偏远地区,网络随时可能断,云端接口一旦不可用,功能就跟着没了。把嵌入计算放在本地,等于把检索能力也一块本地化了,弱网下功能不降级。
第三类,成本和延迟。日活过百万的产品,每次 embedding 都走 API,账单是一笔持续放大的固定开销;而且多一跳网络就多一分延迟抖动。端侧推理一旦预热完成,单次向量化的耗时在几十到两百毫秒这个区间,比跨网络的接口调用稳得多。
所以我的判断标准一直很直接:你的数据允不允许出设备?你的用户等不等得起网络?只要这两个问题里有一个是否定的,端侧嵌入模型就是你的主场。当然端侧也不是银弹,参数量受限、效果上限不如云端大模型、包体积和内存都要付代价,这些约束后面我会一起算账。
1.2 为什么偏偏是 Flutter 加 EmbeddingGemma 2
先说模型侧。EmbeddingGemma 2 在端侧嵌入这个赛道上,有几个先发优势值得讲清楚。
第一,它是双向训练设计,不像因果语言模型那样只能往前看,所以句子级和段落级的语义表征质量更扎实,尤其适合做检索而不是做生成。第二,发布方直接给了量化好的权重和转换工具链,对移动端部署做了大量铺垫,而不是丢一个原始权重让开发者自己从零折腾。第三,多语言覆盖到位,中文短句、口语化表达、中英混排的语义召回我实测都在可用水平以上。
Flutter 这边选它没有悬念。我的目标平台是 Android、iOS 双端,后面还想在桌面端快速做验证,一套 Dart 逻辑全覆盖是很大的效率优势。tflite_flutter 插件对本地推理的支持已经相当成熟,资产加载、常驻 isolate、多线程配置都有现成路线。之前我也调研过基于 BERT 蒸馏出来的小嵌入模型,体积确实诱人,但维护活跃度和二次开发空间都不如 Gemma 这个生态;纯闭源 API 就更不用比了。
最终选择 EmbeddingGemma 2,核心原因是在"移动端可行的体积、语义质量、官方支持力度"这三项上,它同时拿到了及格以上的分数。
2. EmbeddingGemma 2 的特性与端侧推理的硬约束
2.1 模型结构上的几个关键点
在动手集成之前,有几个结构层面的特性必须先理解,否则后面返工成本很高。
第一个是输出维度。EmbeddingGemma 2 分 2B 和 9B 两档,9B 那档做量化在移动端也还是太重,我主攻 2B。2B 档输出向量维度在 768 上下,这个规模做点积检索的成本很低:一万条文本暴力扫描也就几十毫秒,完全不需要一上来就上向量索引。
第二个是上下文长度。官方推荐单次输入一般不超过 2048 token,中文大概对应一千多个字。短文本场景(标题、摘要、用户 query)完全够用,但长文档不能整篇塞进去,需要自己设计分块策略:要么重叠切片,要么先分段取向量再聚合。我第一版偷懒直接截断,结果长文档尾部信息全部丢失,检索出来的片段经常是"半句话",这个教训值得记下来。
第三个是向量归一化。官方建议对输出向量做 L2 归一化后再用于相似度计算,归一化之后余弦相似度就等于点积,检索实现可以省掉除法。这个细节看似不起眼,但很多人训练完模型直接拿原始向量去比较,分数看起来正常,一旦跨模型、跨版本对向量库做比对,偏差就出来了。
还有一点容易被忽略:嵌入模型对文本预处理很敏感。大小写、标点、特殊符号的处理方式,直接影响 tokenizer 的输出,最终影响向量质量。这块我在 tokenizer 小节会展开细说。
2.2 从原始权重到 TFLite 的完整转换链路
把模型跑进 Flutter 比想象中绕。发布方给的是 safetensors 格式,Flutter 侧没法直接用,必须转成 TFLite。
我的链路是:先下载量化好的 int8 权重,转 ONNX,再经 TensorFlow 转换工具链落成 TFLite。2B int8 转换后的文件尺寸在 2GB 出头,int4 版本大概 1GB 出头,对移动端来说都是"大件",所以后面的加载策略和内存策略都围绕这个大文件来设计。
转换过程有两个地方必须盯紧。
一是量化校准数据集。PTQ 量化不是丢进工具自动跑就完事,校准集必须贴近真实业务文本分布。我第一次直接用公开通用语料做校准,结果中文短文本检索的相似度分数普遍偏低,后来换成业务相关的问答对和笔记样本,效果立刻回升。校准集这件事,越早用真实语料,后面返工越少。
二是动态输入形状。TFLite 默认固定输入形状,如果输入的 token 长度不固定,需要在转换时打开动态 shape 支持,但这会牺牲一部分性能。我的做法是统一 padding 到固定长度,短文本用 padding 掩码处理,实测性能和稳定性都更好。代价是长文本最多只能吃满 2048 token,超出的部分走分块。
2.3 比模型更麻烦的 Tokenizer 移植
如果让我说这个项目里最坑的部分,不是模型转换,而是 tokenizer,没有之一。
EmbeddingGemma 2 用的是 SentencePiece 分词器,属于 unigram 模型,带一套词表文件和文本归一化规则。Dart 生态里没有现成的、能和官方实现完全对齐的 SentencePiece 封装。你要是图省事从网上随便拉一个 BPE 分词器来切文本,token ID 和模型期望的对不上,向量质量会莫名其妙地劣化,而且这种劣化在指标上很难一眼看出来,排查起来极其痛苦。
我最后用 dart:ffi 封装了一个 C++ 版本的 SentencePiece 实现,通过动态库在 Android 和 iOS 上调用。桌面端验证阶段更简单,直接用平台通道调本地的原生服务,先把链路跑通再说。
如果你不想碰 FFI,还有一个取巧路径:因为词表是公开的,可以把词表离线导出成一个纯数据文件,在 Dart 里自己实现 unigram 解码逻辑。这套方案的代码量不小,但能绕开不同平台动态库的兼容性问题。具体怎么选,要看团队对原生构建的熟悉程度。我的建议是:如果这个功能是长期核心能力,趁早投入 FFI;如果只是原型验证,用 Dart 纯实现更省事。
3. Flutter 集成实操:加载、推理、检索一整套
3.1 工程准备与依赖配置
依赖其实不多,核心就三个:tflite_flutter 负责推理,path_provider 负责把模型从 assets 搬到应用目录,sqflite 负责本地向量存储。代码里加上这几行就够了:
dependencies: flutter: sdk: flutter tflite_flutter: ^0.11.0 path_provider: ^2.1.0 sqflite: ^2.3.0模型文件放 assets。这里有一个关键坑:tflite_flutter 加载 asset 时走的是 Flutter 的 asset 机制,但推理库底层需要 mmap 映射文件。如果模型文件在打包时被压缩了,运行时就会出现文件读取失败或者内存映射错乱,而且报错信息很不直观。
我的做法是:首次启动时把模型从 assets 复制到应用私有目录,再从私有目录加载。虽然多了一步 IO,但能避开 AAB 打包场景下压缩策略不一致导致的诡异问题。
Future<void> _copyModelToLocal() async { final dir = await getApplicationSupportDirectory(); final file = File('${dir.path}/embeddinggemma2b_int8.tflite'); if (file.existsSync()) return; final data = await rootBundle.load('assets/models/embeddinggemma2b_int8.tflite'); await file.writeAsBytes(data.buffer.asUint8List()); }Android 和 iOS 还有一些平台细节。Android 侧如果 minSdk 偏低,要留意 TFLite 依赖的 C++ 运行库版本是否满足;iOS 侧模拟器和真机的底层推理能力差异很大,我在兼容性小节会详细说。在这里先记住一句话:性能测试只信真机数据,模拟器数字一律仅供参考。
3.2 模型加载与推理封装
模型加载必须封装成单例,全局只初始化一次。1GB 出头的文件冷加载要一两秒,用户等不起,这个等待必须发生在启动阶段或首次使用前。
class EmbeddingEngine { EmbeddingEngine._(); static final EmbeddingEngine instance = EmbeddingEngine._(); Interpreter? _interpreter; int _outputDim = 768; List<List<double>>? _outputBuffer; Future<void> init(String modelPath) async { final options = InterpreterOptions() ..threads = 4 ..useXNNPACK = true; _interpreter = await Interpreter.fromFile(modelPath, options: options); final shape = _interpreter!.getOutputTensor(0).shape; _outputDim = shape[shape.length - 1]; _outputBuffer = List.generate(1, (_) => List.filled(_outputDim, 0.0)); } List<double> embed(List<int> tokenIds) { _interpreter!.run([tokenIds], _outputBuffer); final vector = List<double>.from(_outputBuffer![0]); _normalize(vector); return vector; } void _normalize(List<double> vector) { var sum = 0.0; for (final v in vector) { sum += v * v; } final norm = sqrt(sum); for (var i = 0; i < vector.length; i++) { vector[i] = vector[i] / norm; } } }这里两个细节值得强调。一是输出 buffer 要复用,不要每次推理都 new 一个数组。int8 模型推理的中间结果很大,频繁创建对象会让 GC 压力剧增,我实测过不复用 buffer 时页面滑动帧率有明显掉帧。二是线程数配置要克制。线程开太多反而会因为同步开销劣化性能。4 到 6 个线程是当前主流移动处理器上比较稳的区间。
推理要放后台 isolate。Flutter 的 compute 每次都会新建 isolate,但模型初始化开销极大,不能每次算 embedding 都重新加载一遍。正确做法是 Spawn 一个常驻 isolate,模型只初始化一次,之后用 port 收发任务:
Future<void> _spawnWorker() async { final receivePort = ReceivePort(); await Isolate.spawn(_workerEntry, receivePort.sendPort); _sender = await receivePort.first as SendPort; } void _workerEntry(SendPort sendPort) async { await engine.init(modelPath); final port = ReceivePort(); sendPort.send(port.sendPort); await for (final msg in port) { // 解析任务,调用 engine.embed,回传结果 } }这套常驻 isolate 方案的难点在于生命周期管理,包括模型加载完成的信号、任务超时兜底、isolate 崩溃后的重启策略。建议在工程里封装一个任务管理器,统一处理这些边界情况,不要散落在业务代码里。
3.3 归一化、相似度计算与本地向量存储
推理出来的向量已经被引擎归一化,检索就退化成点积比较,代码非常干净:
double dotProduct(List<double> a, List<double> b) { var result = 0.0; for (var i = 0; i < a.length; i++) { result += a[i] * b[i]; } return result; }向量存储我用了 sqlite 的 BLOB 字段,一万条以内的数据做暴力扫描完全够用,避免一开始就引入向量数据库的运维复杂度。表结构大概是:id、文本摘要、向量 BLOB、业务标签。检索时按条件过滤后逐条点积,TopK 排序即出结果。
这里有一个很重要的性能教训:不要把向量存成 JSON 字符串。我最早图省事把 List 直接序列化成 JSON 存进 sqlite,结果序列化和反序列化的开销在中低端机上能占到检索总耗时的 40%。正确做法是用 ByteData 把 Float32List 的底层字节写进 BLOB,读取时用 Float32List.view 包一层,速度能快五倍以上。
以我做的模拟项目为例,核心场景是本地笔记语义搜索。用户离线输入"上次那个关于权限设计的会议结论",系统在本地完成向量化、和笔记库里的几千条向量点积排序、返回最相关的三条笔记。整个流程从输入到出结果,在旗舰设备上不到 100ms,体验和在线搜索几乎没有差别。这就是 AI Edge Foresight 的落地形态:设备提前理解内容语义,用户找它的时候直接在本地给答案。
3.4 性能实测数据与优化方向
我在模拟项目里记录了一组参考数据,环境是 2B int8 量化模型,Flutter release 模式:
| 设备类型 | 冷启动加载耗时 | warm 后单次推理 | 峰值内存增量 | 是否可接受 |
|---|---|---|---|---|
| 旗舰 Android 设备 | 约 1.5s | 50ms 左右 | 约 1.7GB | 可用 |
| 中端 Android 设备 | 约 3s | 200ms 徘徊 | 约 1.7GB | 勉强,需配合降级 |
| 新款 iOS 设备 | 约 1.2s | 60ms 左右 | 约 1.5GB | 可用 |
| 低端 Android 设备 | 5s 以上 | 400ms+ | 易触发系统杀进程 | 不建议 |
这组数据说明一个现实:2B 模型在移动端属于"高配玩具"。旗舰机可以玩得很流畅,中低端机要砍量化和批处理。两个优化方向值得探索:一是 int4 量化再压一档体积,代价是语义效果的损失需要业务侧用检索指标验证;二是蒸馏一个更小的专用嵌入模型,只保留若干 Transformer 层,专攻业务语料。建议先把端到端链路跑通、把效果基线定下来,再动这两块,不然你连"优化后是否变差"都无法判断。
4. 实战中的坑:问题排查与避坑手册
4.1 模型加载与打包阶段的典型报错
最频繁遇到的错误是模型加载成功但 Tensor 维度对不上,报错信息往往是一串 shape 不匹配的提示。这类问题几乎都出在转换环节和代码环节的信息不一致:转换时改了动态输入策略、padding 方式,但 Dart 侧还在按旧 shape 拼输入。
我的排查习惯是,进入联调前先打印所有输入输出 tensor 的 shape 和类型,跟转换脚本里的参数逐项比对。听起来是废话,但只要团队里有第二个人维护过这条链路,迟早会踩雷。
另一个高发问题是 Android 打包后模型文件找不到。AAB 和 APK 对 asset 的打包处理策略不同,Flutter 默认可能对大文件做压缩,而 TFLite 底层希望文件是没有压缩的。前面说的"复制到应用私有目录"土办法,实测在 APK、AAB 各种分发渠道下都能稳定运行,建议直接抄这个方案,不要自己去调压缩白名单。
4.2 推理性能与内存的排查思路
性能问题出现时,先别急着怀疑模型参数量。我调优时发现,真正耗时的瓶颈经常藏在 tokenizer 的文本归一化、Dart 层对象拷贝、调用开销这些"看不见"的地方。
有一个屡试不爽的技巧:把完整推理链路拆成三段,分别统计 tokenizer 耗时、模型推理耗时、后处理耗时。完整跑一遍链路,看看水分在哪一段。我遇到过推理引擎本身只花 80ms,但前后处理加起来却有 150ms 的案例,优化前后处理比折腾量化参数见效快得多。
内存问题则要盯峰值。int8 模型加载后,模型权重、中间激活、输入输出 buffer 同时在内存里,低端机上很容易触发系统杀进程。排查方法是分阶段记录当前内存占用量,先定位是权重占大头还是中间激活占大头。中间激活占大头就调小 batch 和序列长度;权重占大头就只能换量化方案或蒸馏小模型,没有第三条捷径。
4.3 跨平台兼容性差异
Flutter 引以为傲的跨平台,在本地推理场景会露出不少"平台裂缝"。最典型的是 iOS 模拟器和真机的差异:模拟器上 GPU 加速支持不完整,推理会回退到 CPU 路径,你在模拟器上测出的性能数字对真机毫无参考价值。所以性能优化全部以真机为准。
老式 Android 设备的问题更隐蔽。int8 推理依赖 XNNPACK 做算子优化,部分老款 32 位 ARM 设备拿不到优化算子,直接回退到通用实现,单次推理时间可能翻好几倍。我的工程决策是不硬扛:检测到设备不满足要求时,不加载模型,功能入口直接隐藏,让用户面对一个"此功能当前设备暂不支持"的状态,而不是一个傻等十几秒的转圈。
桌面端也有坑。Flutter 跑 Windows 和 macOS 时,推理插件需要对应的原生动态库,需要额外维护构建流程。我在验证阶段用桌面端跑通整条链路省了不少反复真机部署的时间,但这个成本不是每个团队都付得起。如果人力紧张,直接以 Android 真机为主战场更现实。
5. 一些后来才想明白的经验
如果重来一次,我会更早接受一个事实:端侧嵌入模型的工程复杂度,大头从来不在模型本身,而在它周围的那一圈基础设施。tokenizer 移植、量化校准集选择、静态资源打包策略、常驻 isolate 方案,每个单独看都不难,但串成一个链路后,任何一环出错都会让最终效果莫名其妙地缩水。
尤其是校准集。我第一版模型做出来的相似度分数整体偏低,排查了两天才意识到是校准语料和真实业务分布差异太大。换上真实业务文本重新校准后,指标立刻恢复正常。这个教训让我养成了习惯:做量化之前,先花半天把生产环境里的真实文本抽样出来,作为校准集的第一候选。
还有一个小技巧值得分享:在模型转换阶段就把 L2 归一化"焊死"进计算图,而不是在 Dart 层手动做。这样所有调用方拿到的都是规范化向量,不会出现某个业务方忘了归一化、检索结果莫名其妙差一截的隐性 bug。
这个方向后续还有不少扩展空间。我计划把暴力扫描换成层级近邻索引,让十万级文本的检索也能稳定跑在百毫秒内;再往后,考虑把 EmbeddingGemma 2 和端侧的小型生成模型串起来,做成一个真正的本地语义问答闭环。到那时,AI Edge Foresight 就不再只是一个检索组件,而是设备上真正具备"预判与理解"能力的语义引擎了。这大概也是我一直觉得端侧 AI 最让人兴奋的地方:那些原本依赖云端的能力,正在一点一点回到用户的设备上。