找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4682

积分

0

好友

641

主题
发表于 昨天 10:48 | 查看: 7| 回复: 0

上一篇文章发布后,收到了不少反馈。有人认可这个方向的价值,也有人直接提出了一个尖锐的问题:

“你让一个农村老人去 HuggingFace 下载 2.4GB 的模型文件,然后用 SAF 文件选择器导入?你认真的吗?恐怕连他会上网的儿子女儿都不一定会操作吧?是不是该有个按钮直接下载。”

我得承认,上一个版本的体验确实存在明显的门槛。整个流程对用户而言过于复杂:

  1. 打开浏览器,搜索 HuggingFace
  2. 找到对应模型仓库
  3. 下载一个约2.9GB的 .task 文件到手机
  4. 回到 App,点击“选择模型文件”
  5. 在系统文件管理器里找到刚才下载的文件
  6. 等待复制和加载

整整六个步骤,任何一步都足以劝退一位六十多岁的老人,甚至包括他的子女。

这次更新,我把这一切简化成了一步:打开 App,点击“一键下载 AI 大脑”。 不仅如此,现在AI生成回复后,会自动进行语音播报,并且语速经过调整,更适合老年人收听。

村医AI应用界面截图展示模型选择、对话、识药与SOS功能

三个关键升级

升级一:App 内直接下载模型

这是本次迭代最核心的改进。用户不再需要了解 HuggingFace 是什么,无需通过浏览器下载,也跳过了文件选择器导入的步骤。

打开 App 后,如果检测到本地没有模型,首页会显示一个醒目的提示卡片——“需装配本村 AI 大脑才能看病”,下方则是一个大大的按钮——“一键下载 AI 大脑”。

点击按钮后,会弹出模型选择面板,提供两个选项:

  • 极速推荐版(Gemma 3,约 1.5GB):体积小,回复快,适合配置较新的手机。
  • 完整兼容版(Gemma 4 E2B,约 2.4GB):回答更聪明、更全面,兼容大多数老设备。

用户只需选择一个版本,下载便会自动开始。进度条实时更新,下载完成后模型自动加载。整个过程支持断点续传,避免了因网络中断而前功尽弃的情况。下载完成后,所有功能即可离线使用。

整个流程,用户要做的仅仅只是按下一个按钮。

技术细节:一个不那么简单的下载器

你可能会觉得,下载文件有什么难的?用 DownloadManager 或者 OkHttp 几行代码不就搞定了吗?但实际开发中遇到了不少具体问题。

问题 1:HuggingFace 的重定向链

HuggingFace 的模型下载链接并非直链。当你请求一个 URL 时,它通常会返回 302 重定向到 CDN,而 CDN 可能还会再次 307 重定向到实际的存储节点。我遇到的最多情况是连续跳转 3 次才到达真正的文件下载地址。

HttpURLConnectioninstanceFollowRedirects 属性默认会自动跟随重定向,但它存在一个问题:在跨协议(HTTP → HTTPS)或跨域重定向时,可能会丢失 Range 请求头。这将直接导致断点续传功能失效。

因此,我关闭了自动重定向,改为手动处理每一次跳转:

connection.instanceFollowRedirects = false

val responseCode = connection.responseCode
if (responseCode == 302 || responseCode == 307 || responseCode == 308) {
    val newUrl = connection.getHeaderField("Location")
    if (newUrl != null && redirects < 10) {
        finalUrl = newUrl
        redirects++
        continue
    }
}

手动跟随重定向的好处在于,每次建立新连接时,都可以确保 Range 头被正确地重新设置。

问题 2:2GB 文件的断点续传

农村的网络环境并不稳定,Wi-Fi 信号时好时坏,下载中途中断是常有的事。如果每次中断都要从头开始下载 2.4GB,那么这个功能就形同虚设。

实现断点续传的核心在于 HTTP 的 Range 请求头:

val destinationFile = File(context.filesDir, fileName)
var downloadedBytes = if (destinationFile.exists()) destinationFile.length() else 0L

if (downloadedBytes > 0) {
    connection.setRequestProperty("Range", "bytes=$downloadedBytes-")
}

如果服务器支持,会返回 206 Partial Content,从断点处继续传输。如果返回的是 200 OK,则说明服务器不支持断点续传,此时必须删除已下载的部分文件,重新开始,否则文件会损坏。

还有一个边界情况:如果文件已经完整下载,再次发送带 Range 头的请求,服务器会返回 416 Range Not Satisfiable。这并非错误,意味着文件已下载完毕,直接走成功逻辑即可。

问题 3:仅限 Wi-Fi 下载

2.4GB 的模型如果使用移动数据下载,很容易耗光用户一个月的流量套餐。因此,在开始下载前会进行网络类型判断:

private fun isWifiConnected(context: Context): Boolean {
    val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
    val network = cm.activeNetwork ?: return false
    val cap = cm.getNetworkCapabilities(network) ?: return false
    return cap.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
}

如果不是 Wi-Fi 环境,则会提示用户“建议在 Wi-Fi 环境下下载模型”,并阻止下载开始。

问题 4:进度反馈的节流

如果在下载过程中每读取一个 8KB 的缓冲区就更新一次 UI 进度,界面刷新将过于频繁。这里采用了时间节流策略,确保每 500 毫秒最多更新一次进度:

val currentTime = System.currentTimeMillis()
if (totalBytes > 0 && currentTime - lastEmitTime > 500) {
    lastEmitTime = currentTime
    val percent = (downloadedBytes.toFloat() / totalBytes) * 100f
    emit(DownloadState.Progress(percent, downloadedBytes, totalBytes))
}

整个下载器使用 Kotlin Flow 进行封装,状态流转清晰:Idle → Progress → Success / Error。调用方只需 collect 状态流,即可获得实时下载进度,实现了 UI 层与下载逻辑的零耦合。

升级二:引擎大迁移——从 MediaPipe 到 LiteRT-LM

上个版本使用的是 MediaPipe LLM Inference 引擎,模型格式为 .task。这次我们迁移到了 Google 最新的 LiteRT-LM 引擎,模型格式也相应变更为 .litertlm

为什么要更换引擎?主要有三个原因。

1. 原生多模态支持

MediaPipe 的 LLM Inference API 本质上是“文本进,文本出”。若要进行图片理解,需要开发者自行处理视觉特征提取等步骤。

而 LiteRT-LM 原生支持文本、图片、音频三种输入模态:

// 文本推理
val contents = Contents.of(Content.Text("你好"))

// 图片推理——拍照识药功能就依赖于此
val contents = Contents.of(
    Content.ImageBytes(imageBytes),
    Content.Text("这是什么药?怎么吃?")
)

// 音频推理——语音可直接送给模型,无需先转文字
val contents = Contents.of(
    Content.AudioBytes(audioBytes),
    Content.Text("请分析这段语音描述的症状")
)

请特别注意最后一项——音频推理。这意味着老人的语音不再需要先经过 ASR(语音转文字)模块处理,而是直接将 PCM 音频数据送入大模型。模型自己“听”,自己理解。

这样做有两个好处:一是链路更短,延迟更低;二是避免了 ASR 转写过程中可能丢失的信息,例如语气、停顿以及方言特征。

2. 更灵活的后端配置

LiteRT-LM 允许为不同的计算模态配置不同的硬件后端:

val config = EngineConfig(
    modelPath = modelPath,
    backend = Backend.CPU(),        // 文本推理走 CPU
    visionBackend = Backend.GPU(),  // 图片推理走 GPU
    audioBackend = Backend.CPU(),   // 音频推理走 CPU
    maxNumTokens = 2048,
    cacheDir = context.cacheDir.absolutePath
)

为什么文本和音频推理使用 CPU,而图片处理使用 GPU?这是经过实际测试踩坑后得出的经验。Gemma 4 模型在某些设备的 GPU 上进行文本推理时可能出现数值精度问题,但使用 GPU 处理图片反而更加稳定。目前的这个组合经过了多台设备的验证,是相对最稳定的方案。

3. 更优雅的会话管理

LiteRT-LM 提供了原生的 Conversation 概念,支持多轮对话和系统指令设置:

val conversation = engine.createConversation(
    ConversationConfig(
        systemInstruction = Contents.of(Content.Text(systemPrompt))
    )
)

这对于问诊场景非常重要。老人在一次咨询中可能会说多句话,上下文是连贯的。例如,他先说“我头疼”,AI 追问几个问题后他又说“还有点想吐”。有了会话管理,模型就能将这些信息串联起来进行综合分析,而不是每次都将用户的输入当作一个全新的、孤立的问题。

升级三:TTS 语音播报——AI 不光能“看”,还能“说”

这个功能描述起来很简单:AI 分析完症状后,将结果自动朗读出来。

但对于我们的目标用户群体而言,这可能是最关键的一个功能

试想一下:一位七十岁的老人,患有老花眼,看清手机屏幕上的小字非常吃力。即使你把文字调得再大,他阅读起来也可能很困难。但如果手机能用清晰的人声把结果读出来,他通过“听”就能理解所有信息。

实现上的几个细节

中文语音引擎的适配

Android 系统自带的 TextToSpeech 看似简单,但在各品牌、各型号的国产手机上,其中文语音支持情况千差万别。有些手机默认只安装了英文语音引擎,有些手机的中文区域设置(Locale)并非标准的 Locale.CHINESE

为此,我们实现了一个兜底检测链:依次尝试 Locale.CHINESELocale.CHINALocale.SIMPLIFIED_CHINESE,哪个能用就用哪个:

val locales = listOf(Locale.CHINESE, Locale.CHINA, Locale.SIMPLIFIED_CHINESE)
for (loc in locales) {
    val result = tts?.setLanguage(loc)
    if (result != TextToSpeech.LANG_MISSING_DATA &&
        result != TextToSpeech.LANG_NOT_SUPPORTED) {
        supported = true
        break
    }
}

如果这三个都不支持怎么办?我们会弹出一个 Toast 提示,引导用户去系统设置中下载中文语音数据包。这虽然不是最完美的方案,但至少保证了应用不会因此而崩溃。

语速调整

默认的 TTS 语速对老年人来说可能太快了。我们将语速设置为 0.8 倍:

tts?.setSpeechRate(0.8f)

0.8 这个值是经过实际测试的——比正常语速稍慢,有助于理解,但又不会慢到让人感到不耐烦。

初始化时序问题

TextToSpeech 的初始化是异步的。调用 new TextToSpeech(context, listener) 后,引擎并非立即可用,需要等待 onInit 回调。

这就产生了一个问题:如果 AI 推理速度极快,在 TTS 引擎尚未初始化完成时就已经生成了结果文本,该怎么办?

我们采用了一个待播报队列(pending queue)来解决。如果 TTS 未就绪,先将待播报的文本缓存起来。等到初始化回调 onInit 成功后,立即播报缓存的内容:

fun speak(text: String) {
    if (!isReady) {
        pendingText = text  // 先缓存起来
        return
    }
    tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "village_doc_tts")
}

// onInit 回调成功后
pendingText?.let {
    speak(it)
    pendingText = null
}

随时可以“再听一遍”

在历史对话界面中,每一条 AI 回复的气泡都可以被点击以重新朗读。我们在气泡右下角设计了一个小喇叭图标,老人只需点一下图标,就能再听一遍。这个交互远比让他们回忆如何上滑查找文字,或者长按复制要简单和直观得多。

双引擎架构的进化

整体架构也进行了升级。现在的双引擎不再是 MediaPipe 与 ML Kit 的搭配,而是 LiteRT-LM 与 ML Kit AICore 的组合:

┌──────────────────────────────────────────────┐
│              GemmaManager                     │
│        (统一推理接口,自动选择)                 │
├──────────────────┬───────────────────────────┤
│  ML Kit AICore   │  LiteRT-LM Engine         │
│                  │                           │
│  Pixel 9+ / S25 /│  所有其他 Android 手机     │
│  OPPO Find X8 等 │                           │
│                  │                           │
│  模型自动下载     │  App 内一键下载            │
│  零配置          │  断点续传、Wi-Fi only      │
│  最佳性能        │  通用兼容                  │
│                  │                           │
│  文本 + 图片     │  文本 + 图片 + 音频        │
│  (音频降级为文本) │  (原生多模态)             │
└──────────────────┴───────────────────────────┘

GemmaManager 为上层提供了一个统一的、透明的接口。UI 层只需调用 inferText()inferWithImage()inferWithAudio() 等方法,完全无需关心底层具体使用了哪个引擎。引擎的选择在初始化时自动完成:优先尝试使用性能更佳的 ML Kit AICore(如果设备支持),若不支持则自动回退到兼容性更广的 LiteRT-LM 引擎。

语音问诊的完整链路

将以上所有升级串联起来,现在一次完整的“语音问诊”链路如下所示:

老人按住按钮说话
      ↓
AudioRecorderService 录制 16kHz PCM 音频
      ↓
自动添加 WAV 头(LiteRT-LM 需要标准音频格式)
      ↓
GemmaManager.inferWithAudio()
      ↓
LiteRT-LM 原生音频理解(不需要 ASR 中间步骤)
      ↓
返回结构化 JSON(症状分析、危险等级、建议)
      ↓
JsonParser 解析出 displayText
      ↓
TtsHelper.speak() 自动语音播报结果
      ↓
同时存入 Room 数据库(ChatHistory)
      ↓
UI 显示对话气泡,点击可重新朗读

整个过程,老人需要进行的操作只有一步:按住按钮说话。剩下的所有步骤——录音、推理、播报、存储——全部自动完成。

录音模块的实现细节

音频录制看起来是基础功能,但也有几个值得分享的实现细节。

采样率选择 16kHz 而非 44.1kHz。语音识别不需要 CD 级别的音质,16kHz 的采样率已经足够覆盖人声的主要频率范围。这样做的好处是数据量只有 44.1kHz 的三分之一左右,在进行模型推理时能有效减少内存压力。

最短录音保护。考虑到老人可能因手抖而误触,按下录音按钮后立即松开,这样录制到的音频可能只有零点几秒。过短的音频会让模型难以进行有效分析。因此,我们设置了一个 1.5 秒的最低门槛——如果录音长度不足 1.5 秒,会自动用静音数据(零填充)补齐:

val minPcmSize = sampleRate * 1 * 2 * 1.5 // 48000 bytes
if (pcmData.size < minPcmSize) {
    val paddedPcm = ByteArray(minPcmSize.toInt())
    System.arraycopy(pcmData, 0, paddedPcm, 0, pcmData.size)
    pcmData = paddedPcm
}

手动拼接 WAV 文件头。LiteRT-LM 的 Content.AudioBytes 要求输入标准格式的 WAV 文件(包含 44 字节的 RIFF 头),而不是原始的 PCM 数据。因此,在录音结束后,我们需要手动为 PCM 数据拼接一个 WAV 文件头。这 44 个字节需要严格按照格式逐个填写——方法虽然看起来有点“笨”,但确保了绝对的可靠性和兼容性。

灵活的模型管理

下载模型只是开始。用户可能下载多个模型进行尝试(比如先试试极速版,又下载完整版),因此需要能够灵活切换、管理乃至删除模型。

我们在应用的设置页面中实现了一个简单的模型管理器:

  • 自动扫描 filesDirfilesDir/models 目录下所有的 .litertlm 模型文件。
  • 以列表形式展示,显示文件名和文件大小。
  • 点击列表项即可切换激活该模型。
  • 支持左滑删除模型文件。

当前激活的模型路径会保存在 SharedPreferences 中(key: active_model_path)。下次打开应用时,会优先加载上次激活的模型,无需用户再次手动选择。

尚未完成的工作

每次列出“待办事项”时,清单似乎总比“已完成”的还要长。目前主要的待优化点包括:

  • 方言适配:目前的 TTS 播报使用的是标准普通话。但许多农村老人的日常交流语言是方言,标准的普通话对他们来说可能不够亲切。我们曾考虑接入方言 TTS 引擎,但尚未找到理想的离线解决方案。
  • 语音唤醒:目前仍需“按住按钮”才能录音。理想状态是老人喊一声“村医”就能激活应用。然而,在离线环境下实现高精度、低误触率的语音唤醒,本身就是一个技术难题。
  • 模型微调:通用的 Gemma 模型对中文医疗术语的理解还不够精准。考虑使用专业的中文医疗对话数据对模型进行 LoRA 微调,但这需要领域专家(医生)的参与进行数据标注。
  • 下载速度优化:当前的下载器是单线程的 HttpURLConnection。对于 2.4GB 的大文件,实现多线程分片下载可以显著提升速度。不过,这也会增加实现的复杂度,目前暂未实施。

写在最后

时常有人问我,花费大量时间精力去开发一个“可能用户量不大”的 App,是否值得?

我是这样理解的。

技术领域每天都在热烈讨论 AGI、百万级上下文窗口、多模态统一模型。这些宏大的议题固然重要,但距离普通人的具体生活,似乎又有些遥远。

而对于一位身在乡村的老人来说,当他晚上感到胸口发闷时,他需要的不是 AGI——他需要的只是一部能告诉他“你这个情况,最好赶紧让家里人带你去医院看看”的手机。

这一句简单提示的背后,是 2.4GB 的模型权重、16kHz 的 PCM 音频采样、0.8 倍速的 TTS 语音合成、一个需要手动跟随三层 302 重定向的下载链接,以及一行 if (pcmData.size < minPcmSize) 的静音填充代码。

技术未必总要致力于解决“最宏大”的问题。有时候,能切实解决“最贴近”的需求,便已足够。

项目已在 开源实战 社区发布,欢迎对移动端AI应用、边缘计算感兴趣的朋友在 云栈社区 交流探讨。




上一篇:熬夜加班救不了你,但这40个GitHub仓库可以(程序员副业/逆袭/资源合集)
下一篇:OpenClaw中国镜像站正式上线,字节跳动火山引擎提供基础设施支持
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-7 16:56 , Processed in 0.579349 second(s), 43 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表