结合公开资料 + 开源代码,逐层讲解如何设计一个 ChatGPT / 千问 / 豆包级的 Android 移动端 AI 助理应用。
一、先看真实案例(联网检索结果)
检索并拉取了三份可参照的一手资料:
| 案例 | 性质 | 参照价值 |
|---|---|---|
| 豆包手机 App 技术架构调研(2aran.com) | 对字节豆包的七层架构猜想,基于火山引擎/Seed 团队公开披露 | 看清”LLM 原生 App”与传统 App 的本质差异 |
| skydoves/ChatGPT-Android(GitHub,1.4k+ star) | 开源,Jetpack Compose + Hilt + Stream Chat SDK + 模块化多模块工程 | 工程结构、DI、UI、会话编排的最佳实践 |
| lambiengcode/compose-chatgpt(GitHub) | 开源,极简,直连 OpenAI SSE 流式 | 流式响应的最小可运行实现(可直接抄) |
下面把这些案例的精髓提炼成一套设计方法论。
二、本质认知:LLM 原生 App ≠ 传统 App
豆包架构调研的核心结论(也是设计的总纲):
传统移动 App 后端 =「确定性请求/响应 + CRUD 数据库」; LLM 原生 App 后端 =「概率性 token 流 + GPU 推理集群 + KV Cache 当一等公民调度」。
落到 Android 客户端,这意味着 两个”传统 App 没有的专属难点”:
- 流式渲染:token 一个个到达,要边到边渲染 Markdown/代码块/表格,还要处理中断、重生成、滚动跟随。
- 实时音频管线:本地采集 → 回声消除/降噪 → 上行 → 服务端端到端语音模型 → 下行音频流式播放,且要支持随时打断(用户一开口就截停 AI)。
豆包实时语音用「端到端联合建模」(语音理解+生成一体),把传统 ASR→LLM→TTS 三段拼接压成一个模型,换来 250ms/300ms 级延迟下降和自然打断感。这是”LLM 原生”对”拼接式语音助手”的结构性替换。你若做语音,要尽早决定走级联(易实现、延迟高)还是端到端(难、需自研/对接端到端模型)。
成本结构也倒挂:传统 App 单次请求成本趋近于零;LLM App 每次对话都按 token 烧钱。所以客户端设计要考虑:本地缓存历史、压缩上下文、按任务难度路由到不同档位模型(简单闲聊走 Lite,复杂推理走 Pro)。
三、整体架构:端云协同全景
graph TD
subgraph Client["手机端 (Android)"]
UI["UI层 (Jetpack Compose)<br/>会话列表/聊天流 (流式Markdown渲染)<br/>语音通话页 (全双工音频)<br/>设置/账号/模型选择"]
VM["ViewModel层 (MVVM/MVI, StateFlow)<br/>ChatViewModel - 持有会话状态、接流式Flow<br/>VoiceViewModel - 音频管线状态机"]
Repo["Domain/Repository层 (单一数据源)<br/>ChatRepository - 历史(Room) + 远端(SSE) 合流<br/>StreamingClient - SSE/WebSocket → Flow<br/>VoiceEngine - AudioRecord/Track + AEC/VAD<br/>MemoryStore - 上下文压缩/记忆持久化"]
Data["Data层<br/>Room (本地会话历史) / DataStore (偏好/密钥)<br/>Retrofit+OkHttp (SSE流) / WebSocket (语音)"]
SML["端侧小模型(可选)<br/>唤醒词 / VAD / 降噪 (ONNX/MediaPipe)"]
UI --> VM --> Repo --> Data
SML --> Data
end
subgraph Cloud["云端"]
GW["接入/网关层<br/>长连接 + 按token限流计费 + 就近接入"]
OR["编排/Agent层<br/>Orchestrator: 模型推理 ↔ 工具调用 ↔ 回填"]
INF["推理服务层<br/>Continuous Batching / PagedAttention / Prefix Cache / PD分离"]
Mod["模型层<br/>路由 → Pro(难)/Lite(常规)/Mini(轻)/实时语音/视觉"]
Mem["记忆/数据层<br/>会话记忆 + RAG检索 + 多模态对象存储"]
Infra["基础设施层<br/>GPU集群 + veCCL互联 + 分布式KVCache(EIC) + 潮汐弹性调度"]
GW --> OR --> INF --> Mod --> Mem
INF --> Infra
end
Data -->|"SSE (文本流)"| GW
Data -->|"WebSocket/WebRTC (音频流)"| GW
关键点:手机算力跑不动 Pro 级大模型,主对话必在云端;端侧只跑唤醒词/VAD/轻量降噪这种小模型。这是”端云协同”,不是”纯端侧”。
四、Android 客户端分层设计(可直接落地的工程结构)
参照 skydoves/ChatGPT-Android 的多模块工程,这是目前社区公认的最佳实践:
app/ ← 启动、导航、组装
core-model/ ← 纯数据模型 (GPTMessage, GPTChatRequest/Response)
core-network/ ← Retrofit接口 + Interceptor + DI
core-data/ ← Repository (单一数据源)
core-designsystem/ ← 主题、组件 (LoadingIndicator, Background)
core-navigation/ ← 导航图
core-preferences/ ← DataStore
feature-chat/ ← 聊天功能 (ViewModel + Compose UI)
feature-login/ ← 登录功能
为什么这样切:每个 feature-* 只依赖 core-*,feature 之间互不可见;core-model 是纯 Kotlin(无 Android 依赖),可单测;core-network 封装所有 HTTP 细节,换协议只动这一层。
技术栈选型(社区主流,与案例一致):
- UI: Jetpack Compose(声明式,天然适合”状态→UI”的流式更新)
- 架构: MVVM + 单向数据流(或 MVI,大团队更稳)
- DI: Hilt(Dagger 编译时安全)
- 网络: OkHttp + Retrofit(
@Streaming做 SSE) - 本地: Room(历史)、DataStore(偏好)
- 异步: Kotlin Coroutines + Flow(流式的天然载体)
- 启动: AppStartup(比 ContentProvider 更轻)
五、关键技术点逐个拆解(附真实代码)
1. 流式响应(SSE)——这是 LLM App 的”主干道”
豆包调研原话:“传统 App 里流式是边角料,这里是主干道。” 文本走 SSE,语音走 WebSocket/WebRTC。
lambiengcode 的实现是最小可运行范本,三个要点:
(a) Retrofit 接口加 @Streaming,返回 ResponseBody 而非反序列化对象:
interface OpenAIApi {
@POST("v1/chat/completions")
@Streaming
fun textCompletionsTurboWithStream(@Body body: JsonObject): Call<ResponseBody>
}
为什么要 @Streaming + ResponseBody?默认 Retrofit 会把整个响应体读进内存再反序列化,那就失去了”边到边吐字”的意义。@Streaming 让你能拿到原始字节流,自己逐行读。
(b) OkHttp 加 Bearer 头(密钥注入):
skydoves 的做法是用 Interceptor,而不是硬编码:
class GPTInterceptor @Inject constructor() : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.addHeader("Authorization", "Bearer ${BuildConfig.GPT_API_KEY}")
.build()
return chain.proceed(request)
}
}
并在 OkHttpClient 上设长超时(SSE 是长连接,默认 10s 超时会断流):
OkHttpClient.Builder()
.addInterceptor(GPTInterceptor())
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS) // 流式必须放宽
.writeTimeout(15, TimeUnit.SECONDS)
.build()
(c) 把字节流转成 Kotlin Flow<String>——这是整个流式架构的灵魂。callbackFlow 包裹同步的逐行读取:
override fun textCompletionsWithStream(params: TextCompletionsParam): Flow<String> =
callbackFlow {
withContext(Dispatchers.IO) {
val response = openAIApi.textCompletionsTurboWithStream(params.toJson()).execute()
if (response.isSuccessful) {
val reader = response.body()!!.byteStream().bufferedReader()
while (true) {
val line = reader.readLine() ?: continue
when {
line == "data: [DONE]" -> close()
line.startsWith("data:") -> {
val token = parseDelta(line) // 解析 content 字段
if (token.isNotEmpty()) trySend(token)
}
}
}
} else { trySend("Failure: ${response.errorBody()?.string()}"); close() }
}
}
ViewModel 里收集这个 Flow,逐 token 更新 UI 状态:
class ChatViewModel(private val repo: ChatRepository) : ViewModel() {
private val _streamingText = MutableStateFlow("")
val streamingText = _streamingText.asStateFlow()
fun send(prompt: String) = viewModelScope.launch {
_streamingText.value = ""
repo.textCompletionsWithStream(prompt).collect { token ->
_streamingText.value += token // 每来一个 token,UI 自动重组
}
}
}
Compose 端只订阅 streamingText,就实现了”字一个个蹦出来”的效果。这就是为什么 Compose + Flow 是 LLM 客户端的黄金组合。
豆包调研还点出一个网关层细节:断线重连 + 偏移续传。弱网下 SSE 会断,好的客户端会带
Last-Event-ID续传。开源案例都没做,但生产级必做。
2. 流式 Markdown 渲染——“LLM 专属难点一”
token 是半个词、半个代码块地到的,不能等整段生成完再渲染。要点:
- 用支持增量解析的 Markdown 库(如
compose-markdown/Markwon),把已到达的部分文本整体重渲染(而非增量 patch,否则代码块未闭合会崩)。 - 代码块用
LazyColumn或AnnotatedString,边到边高亮。 - 滚动跟随:流式输出时自动滚到底部,但用户手动上滑后应停止跟随(否则强抢焦点很烦)。用一个
userScrolled标志位。 - 中断/重生成:发
cancel()给 SSE 的Call,UI 回退到上一条用户消息。
3. 会话管理与持久化——“上下文是新的数据库”
豆包调研把 L2 记忆层定义为”会话记忆 + RAG 检索 + 多模态对象存储”。客户端侧落地:
- 本地历史:Room 存所有会话与消息,offline 可读、可回看。
- 上下文注入:每次请求把最近 N 条消息(或压缩后的摘要)塞进
messages数组。skydoves 的GPTChatRequest就是List<GPTMessage>(role: system/user/assistant)。 - 上下文压缩(对应压缩阈值触发的摘要思路):消息超 token 阈值时,本地用小模型把旧消息摘要,或服务端做。客户端要能处理”带摘要续聊”。
- 多端同步(可选):会话历史可跨设备同步——但这属于增量,不是 MVP 必需。
4. 实时语音管线——“LLM 专属难点二”
这是传统 App 完全没有的实时音视频工程量。一条完整链路:
graph LR
Mic["麦克风(AudioRecord)"] --> AEC["AEC/降噪(端侧小模型或WebRTC APM)"]
AEC --> UP["上行音频帧(WebSocket/WebRTC)"]
UP --> CLOUD["云端端到端语音模型"]
CLOUD --> DOWN["下行音频帧流式"]
DOWN --> PLAY["AudioTrack播放"]
PLAY --> VAD["用户开口→VAD检测→中断下行+截停AI"]
关键决策点:
- 打断:必须有 VAD(人声检测)。用户一发声,立刻
cancel()下行播放 + 发中断信号给服务端。豆包把打断响应延迟压到约 300ms。 - 全双工:用 WebSocket 或 WebRTC,不是 HTTP。HTTP 请求-响应模型做不了双向同时流。
- 端侧降噪/AEC:Android 有
AcousticEchoCanceler系统级 API,复杂场景上 ONNX 小模型。 - 如果不自研端到端语音模型,可走级联(ASR→LLM→TTS),易实现但延迟叠加、情绪丢失。豆包选端到端是为了砍掉这三段。
5. 工具调用 / Agent——“控制流从代码迁到模型”
豆包 2.0 自披露具备工具调用、Search Agent、长链路任务能力。客户端落地:
- 请求里带
tools定义(搜索、代码执行、绘图等)。 - 服务端返回
tool_call事件(也是流式 SSE),客户端展示”正在搜索…”的中间态气泡。 - 工具结果回填后,继续流式输出最终答案。
- UI 要为”中间态”设计专门的组件(loading chip、引用来源卡片),这是 Agent App 区别于纯聊天的视觉差异。
6. 安全、密钥、限流
- 密钥绝不硬编码:skydoves 用
secrets.properties+ Secrets Gradle Plugin,编译期注入BuildConfig.GPT_API_KEY。生产级更推荐走自建网关,客户端只持短期 token(类似 Bearer 24h TTL 思路),不持厂商 API Key。 - 越权/风控:服务端按 token 限流计费,客户端做”正在生成中”防连点。
- 隐私:语音/图片上传要明确授权;本地 Room 加密(SQLCipher)。
六、与”不发版改行为”的工程含义
豆包调研点出一个反直觉点:LLM App 加功能 ≠ 写代码发版。改 system prompt、加一个工具、换一档模型,就能改行为,部分免发版。
这对客户端设计的影响:把 system prompt、工具清单、模型路由表做成可远程下发的配置(类似 Firebase Remote Config / 自建配置中心),而不是写死在 APK 里。这样产品/运营能独立调优,无需走客户端发版流程。
七、从 MVP 到生产的演进路径
- MVP:Compose + MVVM + Retrofit SSE 直连厂商 API(抄 lambiengcode),单模型、纯文本、本地 Room 历史。能跑、能流式。
- 模块化:拆成 core-* / feature-* 多模块(抄 skydoves),加 Hilt、加设计系统、加设置页。
- 自建网关:客户端不再直连厂商,改连自建网关(密钥不外泄 + 统一限流计费 + 多模型路由)。这步开始接近生产级。
- 多模态/语音:加图片上传、文档解析;加 WebSocket 实时语音管线。
- Agent:加工具调用 UI、联网搜索中间态、长链路任务编排。
每一步都能在前面三个案例里找到对照实现,不需要从零摸索。
八、一句话总结
表面是个聊天/语音助手壳,骨子里是一套**“以流式 token 为中心、端云协同、上下文为一等公民”的系统。客户端用 Compose + Flow 吃下流式,用模块化 + 单一数据源**控住复杂度;后端的物理重心从”数据”彻底挪到”算力与上下文”——这是它和上一代超级 App 的根本不同。