深入 Android 端侧多模态 AI 推理全链路:从 Gemini Nano Multimodality 到 Compose 实时交互的工程实践

上个月做本地相册智能搜索,遇到一个实际问题:用户拍了张植物照片,想直接在手机上识别并问”这盆绿植多久浇一次水”。传统做法是端侧物体检测配云端 LLM,但图片上传对隐私敏感场景是硬伤,离线环境直接不可用。

正好 Android 16 的 AICore 在 Pixel 设备上正式支持了 Gemini Nano Multimodality——第一个能在手机上同时理解图文输入的端侧多模态模型。花了两周把整条链路跑通,坑没少踩。这篇文章按模型加载、图像编码、流式推理、Compose 集成的顺序,把踩过的坑和验证过的方案整理出来。

Gemini Nano Multimodality 的架构骨架

先搞清楚最关键的问题:图片是怎么变成大模型能理解的东西的。

Gemini Nano 本身是 decoder-only Transformer,原生只吃文本 token。多模态能力靠前面挂一个**视觉编码器(Vision Encoder)**来实现。编码器基于 ViT 架构:输入图片被切成固定大小的 patch(16×16 像素一格),每个 patch 经卷积嵌入后变成一组向量,输出一个 token 序列。

输入图片 (512×512)
  → Vision Encoder (ViT)
  → 1024 个图像 token (每个 768 维)
  → 与文本 token 在 embedding 空间拼接
  → Gemini Nano Decoder 联合推理

文本 token 和图像 token 在进入 Decoder 前需投影到同一个 embedding 空间,这是跨模态理解的前提。Gemini Nano 的做法是加一层轻量投影矩阵,把视觉特征映射到文本 embedding 维度。AICore 封装了这些细节,对开发者透明,但搞清楚内部机制对后续性能调优有帮助——比如你会知道为什么图片尺寸直接影响 token 数量和推理延迟。

模型加载:AICore 下载与内存策略

AICore 的加载 API 设计克制,核心就几步:

val config = GenerativeModelConfig(
    capabilities = setOf(GenerativeCapability.MULTIMODAL)
)
val aicore = AiCore.getDefault()
if (!aicore.isAvailable(context, config)) {
    aicore.download(context, config)
}
val session = aicore.createSession(context, config)

下载的模型文件落在 /data/data/com.google.android.aicore/files/ 下,INT4 量化后约 1.8GB。AICore 做了一件关键的事:将模型权重加载到连续内存区域,而非分散分配。GPU/NPU 推理依赖连续内存来高效搬运数据,碎片化的内存布局会直接导致推理失败。

踩过一个坑:部分机型上 download() 在 WiFi 下也会莫名中断。根因是 Google Play Services 的下载服务优先级受限,后台被系统 kill。解法是把下载逻辑放进 ForegroundService

class ModelDownloadService : Service() {
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        startForeground(NOTIFICATION_ID, buildNotification())
        scope.launch { aicore.download(this@ModelDownloadService, config) }
        return START_STICKY
    }
}

KV Cache 预分配也容易忽略。多模态场景下,图像 token 数量在 256–1024 个,是纯文本 prompt 的十倍以上。KV Cache 按纯文本预分配的话,首次多模态推理会触发重新分配,引入数百毫秒延迟。在首帧推理前做一次预热:

// 发送最小图文请求,触发 KV Cache 初始化
session.sendMessage(
    Content(parts = listOf(ImagePart(dummyBitmap), TextPart("init")))
)

Pixel 8 上这行代码能省下约 400ms 的首帧延迟。

图像预处理与 Token 对齐

图片进入视觉编码器前需要归一化和尺寸对齐。ViT 要求固定输入尺寸,一般是 512×512 或 336×336:

suspend fun Bitmap.prepare(): Bitmap = withContext(Dispatchers.Default) {
    Bitmap.createScaledBitmap(this@prepare, 512, 512, true)
}

分辨率选 512 还是 336 是个 trade-off:512 产生 1024 个 token,336 只产生约 576 个。token 越少推理越快,但小物体和文字识别能力下降明显。实测下来,照片搜索这类需要细节的场景,512 是必要代价。

图像 token 与文本 token 的拼接不是简单前后堆叠。Gemini Nano 用了交错嵌入(Interleaved Embedding):prompt 中用 <image> 占位符标记图片位置,视觉 token 插入对应位置,前后加 <bos><eos> 分隔符区分模态边界。模型靠这套机制建立”这张图”和”这段文字”的关联。

一个折腾了我半天的问题:EXIF 旋转信息。Bitmap 解码时不一定自动校正,图片进入视觉编码器可能是横躺或倒置的:

fun Bitmap.correctOrientation(path: String): Bitmap {
    val exif = ExifInterface(path)
    val rotation = when (exif.getAttributeInt(
        ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL
    )) {
        ExifInterface.ORIENTATION_ROTATE_90 -> 90f
        ExifInterface.ORIENTATION_ROTATE_180 -> 180f
        ExifInterface.ORIENTATION_ROTATE_270 -> 270f
        else -> 0f
    }
    if (rotation == 0f) return this
    return Bitmap.createBitmap(this, 0, 0, width, height,
        Matrix().apply { postRotate(rotation) }, true)
}

图片方向错误时,模型看到颠倒内容,输出质量断崖式下降。这个方向校正看似不起眼,但端侧场景下用户拍照角度五花八门,不做就是生产级事故。

流式推理与 Compose 实时渲染

Gemini Nano Multimodality 在 Pixel 8 Pro 上的实测数据:512×512 图片配合 50 token 文本,首 token 延迟约 1.8 秒,生成速度约 12 token/秒。延迟不低,但零网络开销和隐私保护是端侧独有的优势——图片不出手机这件事本身,对很多业务场景已经是刚需。

流式输出通过 Kotlin Flow 暴露:

session.sendMessageStream(content).collect { response ->
    _responseText.update { it + response.text }  // response.text 是增量 1-5 token
}

接入 Compose 时直接对接 StateFlow

@Composable
fun MultimodalChat(session: GenerativeSession) {
    val responseText by viewModel.responseText.collectAsStateWithLifecycle()
    Text(text = responseText, style = MaterialTheme.typography.bodyLarge)
}

12 token/秒意味着约每 80ms 触发一次重组。高频重组在低端设备上可能导致掉帧。我用 debounce 做节流,每 200ms 推一次:

var buffer by remember { mutableStateOf("") }
var displayText by remember { mutableStateOf("") }

LaunchedEffect(Unit) {
    snapshotFlow { buffer }
        .debounce(200)
        .collect { displayText = it }
}

重组频率降低 60%,视觉上用户感知不到差异——200ms 的渲染间隔完全在”实时”体感范围内。

三个让人头疼的工程细节

整条链路跑通后回头看,最耗时的不是模型逻辑,而是这些零碎问题。

内存碎片。 Pixel 6(8GB RAM)上系统运行几小时后,可用连续内存不足 1.5GB,模型加载直接失败。这不是容量不够,而是时序问题——在 App 启动早期加载模型,趁内存还没被碎片化,抢占连续区域。我把加载时机移到了 Application.onCreate(),成功率从 70% 提到了 95% 以上。

温控降频。 连续 5 次图文推理后,SoC 温度上升触发降频,推理延迟从 1.8 秒飙到 4 秒以上。目前的折中策略是每 3 次推理后强制冷却 2 秒——粗暴但有效。更优雅的方案还在探索,但短期内不解决温控,端侧多模态的连续使用体验就是半残。

Token 序列超长。 图像 1024 token 加上文本 50 token,再叠加 KV Cache 增长,总序列长度轻松突破 2048。Gemini Nano 的上下文窗口是 4096,多轮对话中很快耗尽。实际业务需要做滑动窗口裁剪,保留最近 2–3 轮对话的 KV Cache。

端侧 AI 工程化的真实面貌就是这些脏活累活。花在内存管理、温控策略、方向校正上的时间,是写推理逻辑代码的两倍——这对做了多年客户端开发的人来说倒不意外,但对从服务端 AI 转过来的同学,心态上需要调整一下。模型能力只是起点,把模型稳定地跑在参差不齐的终端设备上,才是真正的门槛。