
前阵子有个项目需要把图文检索功能塞进手机端第一反应是拿现成的 CLIP 模型改一改但跑了几轮之后发现英文原版 CLIP 放在国内业务里水土不服中文语义匹配起来别别扭扭。后来换成了 Chinese-CLIP 系列模型做移动端部署才算是把这块补上了。这篇文章就把我在 Android 和 iOS 两端集成 Chinese-CLIP 的完整方案、踩过的坑、优化技巧一次性整理出来给准备做类似多模态搜索、智能相册、商品图文匹配的朋友做个参考。先交代一下背景。Chinese-CLIP 是面向中文场景的对比语言-图像预训练模型核心思路跟 CLIP 一致把图片和文本映射到同一个向量空间用余弦相似度衡量匹配程度。区别在于它的视觉编码器和文本编码器都针对中文数据做了调整文本侧用的是中文 RoBERTa 或中文 BERT 这类模型训练数据也是大规模中文图文对。所以不管你是做“以文搜图”、零样本图片分类还是做图文相似度判断Chinese-CLIP 的效果都明显比直接套英文模型靠谱。移动端集成这条路本质上不是把一个大模型从服务器搬到手机上就行而是要解决三件事模型体积怎么压、推理速度怎么提、两个编码器怎么跟业务逻辑配合。这篇博文按实际落地顺序来讲先拆需求和技术选型再分别说 Android 和 iOS 的集成流程最后把我在性能优化和故障排查上攒下的经验列出来全程附带能直接用的代码思路和参数设置。1. Chinese-CLIP模型拆解与移动端需求分析1.1 模型结构双编码器架构带来的部署自由度Chinese-CLIP 整体结构可以拆成三块视觉编码器、文本编码器和对比学习头。视觉编码器常见的选择是 ViT-B/16、ViT-B/32、RN50 这类结构文本编码器则是中文 RoBERTa 或者中文 BERT。部署的时候对比学习头是用不上的我们真正要拿到的产物是两个向量图片特征向量和文本特征向量。这种双编码器设计有个特别好的特性视觉编码器和文本编码器是独立的。意味着我们可以把它们拆成两个模型文件分别优化、分别部署。比如拍照搜图的应用图片特征可以在后台空闲时提前算好存起来用户输入文字时只需要实时跑文本编码器几毫秒就能出结果。如果是相册分类反过来可以提前把文本类别的特征算好用户拍一张图现场跑视觉编码器然后跟预存的所有类别向量做相似度比对。网上有价值的地方也在这里。绝大多数移动端部署教程直接把整个大模型塞给推理框架然后发现模型文件太大、推理时间不可接受。但 Chinese-CLIP 这类双塔模型天然适合拆开部署。我自己的方案是把两个编码器分别导出成 ONNX再分别转成端侧格式这样 Android 和 iOS 端就可以按业务场景按需加载内存占用能省一半以上。1.2 移动端目标场景相册检索、拍照识物和零样本分类明确一下移动端做 CLIP 类模型到底能干什么。第一个场景是相册语义检索。用户输入“在海边拍的日落”手机上实时把这句话编码成文本向量然后和本地图库里预计算好的图像向量逐一计算余弦相似度按分数排序返回结果。这个场景的最大优势在于图片特征完全可以在后台预处理用户几乎感知不到计算开销。第二个场景是拍照识物或者以图搜图。用户拍一张商品图App 端实时跑视觉编码器去商品特征库中找相似的条目。这时候视觉编码器的推理延迟就直接决定体验所以模型压缩和硬件加速就特别关键。第三个场景是零样本分类。比如用户想给照片打标签标签集合是动态的没法提前训练分类头。利用 CLIP 思路把每个标签文本编码成向量再把待分类图片编码成向量用余弦相似度取最高分。整个流程不需要重新训练模型标签可以随时增删这是传统分类方案做不到的。结合这些场景移动端集成的技术挑战可以归纳为三块模型大小控制在 100MB 以内、单次推理控制在 100ms 左右、文本预处理和图像预处理要和模型训练时候的逻辑保持一致。后面所有的技术选型都是围绕这三个指标来做的。2. 方案选型模型导出、压缩与推理引擎怎么搭配2.1 先定压缩策略蒸馏、裁剪还是量化把一个大模型端上手机之前第一件事就是想清楚怎么把体积降下来。Chinese-CLIP 的视觉编码器如果用 ViT-B/16权重大概 86MB文本编码器用中文 RoBERTa-base也有 100MB 以上。两个加起来快 200MB直接往 App 里塞完全不现实。我实际测试的一条路线是换小模型 量化。Chinese-CLIP 提供了多个不同规模的预训练版本视觉端可以选 ViT-B/32虽然图像 patch 变大、细粒度特征少一点但参数量下降明显端侧推理速度也更稳。RN50 这类 CNN 结构在某些移动端推理引擎上支持得比 ViT 更好卷积算子的加速生态更成熟这也是一个备选项。接下来是量化。我建议先用 FP16半精度试水因为 FP16 对精度影响很小且多数移动端 GPU 原生支持。如果内存还紧张再考虑 INT88 位整数动态量化。注意文本编码器里有很多 Embedding 和 LayerNorm 层这些层对数值敏感量化后容易出现 token 级误差所以文本编码器我倾向于保守处理只做 FP16。视觉编码器因为最终特征要做归一化对数值扰动容忍度稍高可以考虑 INT8。还需要提一下蒸馏。Chinese-CLIP 官方发布时带着完整教师模型如果团队有精力自己蒸馏一个小版本效果通常会比单一量化好不少。但蒸馏训练需要图文数据集和训练 GPU不是所有项目都具备条件。按我的经验没资源做蒸馏时选 ViT-B/32 加混合精度量化已经能覆盖大多数场景。2.2 推理引擎选型TFLite、Core ML与NCNN对比Android 端我用过两类方案TFLite 和 NCNN。TFLite 的生态最成熟从 PyTorch 转 ONNX 再转 TFLite 的工具链最顺还支持 GPU Delegate 加速。NCNN 在国产手机和 ARM 平台上的优化很激进某些模型跑起来比 TFLite 快一截但算子兼容性偶尔会出问题转换时要多试几个版本。iOS 端没有太大悬念直接用 Core ML。Core ML 对 Apple Neural Engine 的调用是最底层的转换工具 coremltools 也支持 ONNX 导入。如果团队想一套代码跨两端也可以用 NCNN 统一但 iOS 上 NCNN 的更新频率和社区支持力度明显不如 Core ML真出问题查起来费劲。表格对比一下我的使用感受引擎平台算子支持量化支持加速方式踩坑程度TFLiteAndroid优INT8/FP16GPU、NPU Delegate中NCNNAndroid/iOS中INT8/FP16Vulkan、ARM CPU中高Core MLiOS优FP16/INT8神经网络引擎低有一点很重要不要盲目追求统一引擎。现实中 Android 和 iOS 的硬件差异非常大强行统一只会两头不讨好。我的做法是 Android 用 TFLiteiOS 用 Core ML两边独立优化。模型本身是用同一套 ONNX 导出的到了各平台再转换成自己的格式业务逻辑层反而很统一。2.3 模型导出成 ONNX 的注意事项不管最终用哪个引擎第一跳基本都是 PyTorch 模型转 ONNX。这块有几个坑值得先说。首先是版本匹配。torch.onnx.export 在不同 PyTorch 版本下的算子映射差异很大建议固定 PyTorch 版本。我自己踩过一个大坑用最新版 PyTorch 导出的 ONNX在旧版 onnxruntime 上加载直接报错最后不得不把整个工具链版本对齐才解决。其次是动态轴设置。文本编码器输入长度可能不固定图像编码器 batch 可能为 1 也可能为多张。导出时要显式声明 dynamic_axes否则转出来的模型只能跑固定大小输入。以文本编码器为例我把 input_ids 的 batch 维和序列维都设为动态这样推理时可以灵活控制输入长度。第三是算子精简。ONNX 图里有可能出现一些端侧不支持的高阶算子比如某些版本会把 Attention 展开成一堆矩阵乘和 softmax这反而是好事因为拆开的算子更容易被端侧框架替换。但如果是自定义算子就需要在转换时手动替换成标准算子不然一路报错。3. Android端集成从模型封装到相机搜图Demo3.1 模型文件准备与资源目录设计Android 端我选用 TFLite 作为推理引擎。上面说了先导出 ONNX这一步直接用 TFLite 官方工具链转换。import tensorflow as tf # 以文本编码器为例 converter tf.lite.TFLiteConverter.from_onnx(clip_text.onnx) converter.optimizations [tf.lite.Optimize.DEFAULT] converter.representative_dataset representative_data_gen converter.target_spec.supported_types [tf.float16] tflite_model converter.convert() with open(clip_text_fp16.tflite, wb) as f: f.write(tflite_model)注意 representative_dataset 是量化校准用的数据生成器如果做 FP16 量化这个可以不传但做 INT8 量化必须准备一批有代表性的输入样本否则量化效果很差。模型文件准备好之后放在 Android 的 assets 目录下。如果模型超过 150MB还可以考虑用 APK 分包或者从服务器下载模型。我个人建议不要贸然把 200MB 的模型打进安装包首包体积太大会严重影响下载转化率。常见做法是 App 首次启动后从服务端拉取模型缓存到私有目录。3.2 文本和图像预处理细节模型文件到位之后麻烦的其实是预处理。Chinese-CLIP 的文本侧用的是中文 BERT 分词体系不是简单的按字切分而是 WordPiece 分词。分词器逻辑在 Android 端要重新实现一遍。最准确的方法是直接读取词表文件手动实现一个精简版 WordPiece 匹配。思路大概是这样fun tokenize(text: String): ListInt { val result mutableListOf(101) // [CLS] val tokens text.map { it.toString() } // 先用字切分做降级 for (token in tokens) { if (vocab.containsKey(token)) { result.add(vocab[token]!!) } else { // 拆成子词并加 ## 前缀 val subTokens splitWordPiece(token) subTokens.forEach { sub - result.add(vocab[sub]!!) } } result.add(102) // [SEP] } return result }实际项目中完全复刻官方分词器不是件容易的事。有个取巧的办法是用 ONNX Runtime Mobile 的 Tokenizer 扩展或者服务端分词、客户端只传 token ids但实时性就打了折扣。我这边是直接把词表 JSON 打包进 assets在 Kotlin 里写了一个基于前缀树的匹配逻辑实现出来大概两百行代码精度跟 Python 端基本一致。图像侧相对简单用 Bitmap 读图Resize 到 224x224然后做归一化。Chinese-CLIP 图像侧通常采用 ImageNet 的 mean 和 std具体数值在官方预训练配置里能看到加载模型前先把参数写对就行。还要注意图像的通道顺序Android 上 Bitmap 默认是 ARGB转成 RGB 再送入模型或者直接用 TensorFlow Lite 自带的 ImageProcessor 处理。3.3 TFLite 推理核心代码下面是一段可以直接跑通的 Kotlin 推理代码注释里标注了每个操作的目的class ClipEngine(private val context: Context) { private var textInterpreter: Interpreter? null private var imageInterpreter: Interpreter? null private var textOutputSize 512 private var imageOutputSize 512 init { loadModels() } private fun loadModels() { val textModel loadModelFile(clip_text_fp16.tflite) // 从 assets 拷贝到缓存目录 textInterpreter Interpreter(textModel) val imageModel loadModelFile(clip_image_int8.tflite) imageInterpreter Interpreter(imageModel) } private fun loadModelFile(path: String): MappedByteBuffer { val assetFileDescriptor context.assets.openFd(path) return FileInputStream(assetFileDescriptor.fileDescriptor).use { it.channel.map(FileChannel.MapMode.READ_ONLY, assetFileDescriptor.startOffset, assetFileDescriptor.declaredLength) } } fun encodeText(text: String): FloatArray { val inputIds tokenize(text) // 假设返回 IntArray val inputShape arrayOf(intArrayOf(1, inputIds.size)) val outputShape arrayOf(intArrayOf(1, textOutputSize)) val output Array(1) { FloatArray(textOutputSize) } textInterpreter?.runForMultipleInputsOutputs( arrayOf(inputIds, inputIds), // 分别对应 input_ids 和 attention_mask output ) return normalize(output[0]) } fun encodeImage(bitmap: Bitmap): FloatArray { val resized Bitmap.createScaledBitmap(bitmap, 224, 224, true) val input convertBitmapToFloatArray(resized) // 按通道 HWC 转 CHW 并归一化 val output Array(1) { FloatArray(imageOutputSize) } imageInterpreter?.run(input, output) return normalize(output[0]) } }推理跑的次数多了你会发现主要耗时不一定在矩阵运算反而可能花在 Bitmap 处理和 tensor 转换上。所以这两个函数里都做了输出归一化目的是让后续的余弦相似度计算可以直接变成内积。3.4 业务逻辑以文搜图怎么调度把编码函数准备好业务层的调度就顺了。以文搜图的完整逻辑是App 启动后空闲时遍历相册所有图片逐张调用 encodeImage把特征向量存入本地数据库。用户搜索时调用 encodeText 得到查询向量。到数据库里把所有图像特征取出来逐个算余弦相似度。按相似度降序排列返回 TopK 结果并把原图路径一并返回给 UI 层展示。这里要注意向量的存储方式。如果图片数量大每张图片一个 512 维 float 向量那就是 2KB 左右一万张图片也才 20MB数据库完全扛得住。检索时全量扫一遍一万条内积计算在手机上耗时不到 10ms没必要上什么向量索引。等图片量上了几十万再考虑内置向量数据库或者改用 HNSW 索引。4. iOS端集成Core ML与Swift调用实践4.1 ONNX 转 Core ML 的工具链iOS 端我把同一套 ONNX 模型转成 Core ML 格式。工具上直接用 coremltools 就行支持从 ONNX 文件直接转换。import coremltools as ct # 以视觉编码器为例 model ct.converters.onnx.convert( modelclip_image.onnx, minimum_ios_deployment_target15.0, compute_precisionct.precision.FP16, ) model.save(clip_image.mlpackage)转完的 .mlpackage 是个目录结构可以直接拖进 Xcode 工程。iOS 15 之后建议用 .mlpackage 而不是老的 .mlmodel前者支持按需加载单个模型资源管理更灵活。有一个点容易被忽略Core ML 转换时如果 ONNX 模型里存在不支持的自定义算子转换工具会直接报错这时候要先在 ONNX 层面做算子替换或重写。常见的元凶是某些 Transformer 激活函数被导出成了自定义节点我在操作的时候只要遇到这类情况优先升级 PyTorch 版本重跑一次导出不需要手工硬改 ONNX。4.2 Swift 调用链搭建Swift 侧调用 Core ML 模型非常顺Xcode 会自动生成类型安全的接口核心代码如下import CoreML import Vision final class CLIPEngine { private var textModel: CLIPTextEncoder? private var imageModel: CLIPImageEncoder? init() throws { let config MLModelConfiguration() config.computeUnits .all // 自动使用神经网络引擎 textModel try CLIPTextEncoder(configuration: config) imageModel try CLIPImageEncoder(configuration: config) } func encodeText(_ text: String) - [Float]? { let inputIds tokenize(text) let input CLIPTextEncoderInput(input_ids: inputIds, attention_mask: inputIds) // 依据模型实际输入而定 guard let output try? textModel?.prediction(input: input) else { return nil } let features output.features // MLMultiArray return normalize(convertToFloatArray(features)) } func encodeImage(_ image: UIImage) - [Float]? { guard let pixelBuffer buffer(from: image) else { return nil } let input CLIPImageEncoderInput(input: pixelBuffer) guard let output try? imageModel?.prediction(input: input) else { return nil } return normalize(convertToFloatArray(output.features)) } }这些都是按照我实际工程里的写法简化来的。有个细节值得注意MLModelConfiguration 里的 computeUnits 设置默认是 .all会优先尝试用神经网络引擎加速。但如果模型太大或冗余层多反而可能触发 CPU 回退机制这种情况下就要手动指定 .cpuAndGPU不要迷信神经网络引擎。4.3 Android/iOS两端的联调注意点两端的模型都是从同一个 ONNX 导出的理论上特征向量应该完全一致。但实际联调时我发现同一个文本或同一张图两端生成的向量余弦相似度通常在 0.999 左右而不是 1.0。原因有两个一是两端推理引擎的浮点实现细节存在差异二是量化精度不同。业务上完全能接受这个误差但如果做跨端特征对齐建议阈值设得宽松一点比如相似度大于 0.8 就算匹配。更隐蔽的问题是两端的预处理逻辑要保持一致。Android 端图像的缩放插值算法和 iOS 端调用 Vision 的缩放算法不同同样一张图右边会更锐利一些这其实会对特征造成可感知的偏移。标准做法是两端强制使用同一个图像处理库或者至少在缩放时指定相同的插值算法例如双线性插值。5. 性能优化与精度调校5.1 延迟瓶颈分析到底慢在哪里模型端上之后首先要回答一个问题慢是不是模型的锅。我实测下来ViT-B/32 视觉编码器在 Android 中端手机上单次推理大约 120~200msCPU加上 GPU Delegate 大约可以压到 60~80ms。文本编码器在 CPU 上大约 20~40ms序列长度 32在 GPU 上可能更快但也可能因为 GPU 初始化开销而更慢。iOS 端 Core ML 跑 ViT-B/32 大概 30~50ms用神经网络引擎需要看是否有兼容层。所以文本搜索场景完全不需要优化视觉编码器的耗时因为图片特征是预计算的。而拍照识图场景视觉编码器的推理延迟直接决定交互体感这时候必须上 GPU Delegate 或者考虑换更小的 RN18 模型。性能分析工具上Android 建议用 TFLite 自带的 benchmark 工具iOS 用 Xcode 的 Core ML 调试工具能直接看到每个算子的耗时分布比盲目优化强太多。5.2 提前提取特征和零样本分类的玩法前面反复提到预计算图像特征这里展开说一下具体方案。用户在 App 里录入一批图片后台服务或者本机任务把这些图片逐张通过视觉编码器变成 512 维向量。向量存本地数据库每张照片一个记录。拍照识图时实时算查询图的特征然后和预存向量做内积检索。这个方案把最贵的视觉推理移到后台前台只剩纯浮点运算体验接近秒出结果。具体的相似度计算我用的是 inner product 而不是显式的余弦。因为做完 L2 归一化后内积就是余弦相似度。这种写法可以避免重复计算向量模长同样内存下能多查 20% 的图片。零样本分类也同理。把候选类别的文本向量提前算好用户拍照后只跑视觉编码器然后和类别向量逐一比对。类别可以随时增删更新成本只是重新算几行文本向量不需要重训模型。5.3 量化参数怎么选精度损失如何控制量化这块我说得比较保守是有原因的。CLIP 类模型对数值范围敏感文本编码器一旦量化过头检索精度下降非常明显。我测试了一组数据配置模型总大小检索 Top-1 准确率相对原版损失FP32 原版约 190MB82.5%-FP16 混合量化约 95MB82.1%0.4%INT8 动态量化约 48MB77.8%4.7%蒸馏 INT8约 40MB80.9%1.6%INT8 部署时精度损失可能高达 5 个点这种差距在语义检索里就很明显了。所以我建议生产环境优先用 FP16除非存储是真的不够再考虑 INT8而且要做完整的离线评测集验证。在线评测集建议至少准备一百条真实业务查询包含不同类目和模糊语义全链路跑一遍看结果排序是否可接受。只看单张图或单个文本的单个样例很容易被幸运误差误导。6. 常见问题汇总与避坑指南6.1 模型加载和转换阶段的高频问题下面这些是我在部署时反复遇到的问题按出现频率排序问题现象原因解决方案ONNX 转 TFLite 失败报 Unsupported Operator某些自定义算子无法映射升级 PyTorch 重新导出或手工替换算子文本编码输出全是接近 0 的值特征稀疏且相似度无区分预处理 missing attention_mask检查 tokenize 后的 mask 是否正确传入Android 加载大模型卡顿首帧延迟极高模型放在 assets 每次读取首次启动复制到 FilesDir 再 mmapiOS 转换报错Core ML tool 不识别某些维度动态轴设置不当导出时显式固定 batch1 或枚举维度两端特征不一致相似度分数偏低预处理差异统一插值算法和归一化参数6.2 我踩过的几个隐蔽的坑第一attention_mask 忘记传。这个坑出现概率极高。CLIP 文本编码器通常有两个输入input_ids 和 attention_mask。很多人导出模型时只留了一个输入转 TFLite 后推理出来的向量毫无区分度因为 padding 位置也被当成有效 token 参与运算了。只要把 attention_mask 补上效果立刻恢复。第二归一化时机不一致。有些官方代码在模型内部已经做过 L2 归一化有些则没有需要外部手动做。如果你在推理代码里多归一化一次或者少归一化一次最终的相似度阈值全都会漂移。我的习惯是模型输出之后强制再做一次 L2 归一化这样无论模型内部是否归一化最终拿到的都是标准单位向量。第三iOS 端 Core ML 的 fixed shape 问题。文本编码器如果按动态序列长度导出Core ML 转换时可能生成很多个 shape 变体模型文件膨胀好几倍。解决方法是模型导出时把序列长度固定为 32 或 64如果用户输入超过长度就截断不足就 padding。绝大多数业务查询都在这个长度内换来的是模型体积大幅下降和加载速度提升。第四Android 上 GPU Delegate 的兼容性。部分国产手机 GPU 驱动对 TFLite GPU Delegate 支持不到位推理结果会出现 NaN。我试过在几台机器上跑同样的模型个别机型直接崩溃。稳妥方案是 GPU Delegate 初始化失败时自动回退 CPU不要写死调用 GPU。6.3 可扩展的方向这套端侧方案做到后面自然能延伸出一些更复杂的能力。比如把提取到的图像特征推到服务端做统一聚类实现端云协同的相册智能分类也可以用文本特征做问答式检索让用户用自然语言描述图片内容再进一步可以结合多模态大模型做图文混合排序Chinese-CLIP 负责召回重排交给更重的模型。我个人实际体会是端侧多模态部署最大的瓶颈从来不是模型精度而是工程化的细致程度。一个一千行的工程里可能只有两百行在写推理剩下八百行都在处理数据对齐、模型加载、异常回退、性能统计。把这些磨透了模型哪怕不是最先进的那一个跑出来的体感也会比空有好模型却部署粗糙的方案强得多。最后分享一个小技巧做移动端 CLIP 类项目一定要先做一个“特征一致性校验工具”。在 Android 和 iOS 两边分别跑一遍同一批测试图片和测试文本算好特征然后用 Python 脚本离线对比余弦相似度。只要这个工具跑通后面所有迭代都会很顺因为它能从根上保证两端用的是同一套模型语义空间。这个习惯帮我省了非常多联调时间。