简介:面向 Android 开发者接入 KIMI 大模型流式输出的工程实践包,聚焦对话式 AI 应用在移动端的落地:客户端如何封装请求、如何分片接收 SSE 流式数据并做增量渲染,以及返回内容实时上屏时的界面交互处理,适合有一定 Android 开发基础、希望把大模型对话能力集成进 App 的工程师参考。压缩包共 813 个文件,体积约 14.99MB,以 flat、json、xml 等配置与资源文件,dex、class、bin、jar 等编译产物,kt、java 源码,以及 properties、gradle、bat 等构建脚本为主;dex、apk 与 keystream、len 类文件同时出现,说明包内同时保留了工程源码、依赖库与可运行的编译结果,便于对照验证。已有 1233 人学习下载。读者可从中获取流式响应的接入思路、请求与回调的代码组织方式,以及工程目录结构与依赖配置的完整参考,用于排错定位和二次开发。
1. 三秒白屏之后再改:Kimi API 流式输出在 Android 端值不值得动
某开发者拿着一个写作助手的 Demo 来问:用户点击发送后界面白屏三秒,然后整段答案一次性显示出来,用户还以为 App 卡死了。问题不在网络,而在调用方式把 Kimi API 当成了普通 HTTP 接口,等完整响应构造完毕后才能读取。打开流式输出后,服务端每生成一小段就推一个data:分片,首字延迟从三秒压到几百毫秒,用户看到的是逐字生长的文字,中途还能随时取消。看着只是在请求体里加个stream: true,落到 Android 端却牵出一串问题:SSE 怎么分帧、UTF-8 多字节会不会被切坏、每个 token 触发一次重组会不会把帧率打穿、页面退出后谁来取消连接。这份记录从协议层一路写到 Compose 渲染,中间夹着几条实际踩过的坑,适合正在给客户端接大模型对话的 Android 开发者。
2. 拆开 SSE 分帧:一条流式响应在网络上长什么样
2.1 从 chunked 到 text/event-stream,一层层剥
先把链路摊平看。客户端发出一个普通 POST,服务端返回 200,响应头里带Content-Type: text/event-stream,HTTP/1.1 下再叠一层Transfer-Encoding: chunked,HTTP/2 下换成 DATA 帧。这层分块传输由 OkHttp 透明解码,response.body.source()读到的已经是去掉块长度前缀的连续字节流,业务代码不需要关心 chunk 边界在哪里。真正需要自己解析的是它上面那层 SSE 文本协议:事件之间用空行分隔,每个事件内一行一个字段,字段格式是字段名: 值,冒号后可以有一个空格。规范里定义了data、event、id、retry四种字段,实际对话场景只用得上data,其他三种看到就跳过。
data字段可以出现多次,同一事件里的多行data要用换行符拼接成一个整体。一个典型分片长这样:
data: {"id":"chat-xxx","choices":[{"index":0,"delta":{"content":"流"},"finish_reason":null}]} data: {"id":"chat-xxx","choices":[{"index":0,"delta":{"content":"式"},"finish_reason":null}]} data: [DONE]注意每个data:之间那个空行,它就是分帧边界,缺了它客户端永远凑不出一个完整事件。还有一个容易被忽略的语法:以冒号开头的行是注释,服务端常用它做心跳保活(形如: ping),客户端必须原样丢弃,不能当成数据。另外服务端可能把delta.content拆成空字符串发出来,那是role或者结尾finish_reason的占位,拼接到 UI 前要过滤掉空值,否则会出现光标闪一下却没内容的观感问题。
分帧逻辑本身不难,难在边界情况。一次 TCP 读到的字节可能刚好停在data: {"id":"ch中间,也可能一次读到三个完整事件外加半个。因此解析器必须做成流式的状态机:按行读、遇到空行才提交一个事件,绝不能假设“一次读取等于一个完整 JSON”。这条假设一旦写错,本地测速很快时看不出来,一到弱网就随机丢字符,是流式接入头号翻车点。
2.2 请求体里真正影响流式体验的几个参数
流式请求的 JSON 结构和非流式基本一致,差别只有stream一项,但有几个参数会直接影响“看起来快不快”。下表是接入时我会固定的那几项:
| 参数 | 建议值 | 说明 |
|---|---|---|
stream | true | 关掉就是一次性返回,前面所有优化白做 |
model | 控制台当前可用的模型 ID | 别在代码里硬编码过期的型号 |
temperature | 0.2 ~ 0.4 | 问答类偏保守,创作类可放大 |
max_tokens | 按业务截断 | 太长会在结尾无意义地拖时间 |
messages | system 放最前 | 系统提示词公用的,尽量短 |
max_tokens值得单说。它不是“越长越好”,而是硬上限:设成 4096,模型就可能真的生成满 4096 个 token 才停,流式看着一直在动,成本也在涨。多数客服、助手类场景把上限压到 512 到 1024,配合提示词里“请简洁回答”,体验和成本都好控制。
另外还有两个 HTTP 层的参数必须手工处理,它们比 JSON 体更容易出事:Accept请求头建议显式设成text/event-stream,某些网关会依据它决定是否开启即时刷新;readTimeout必须单独放开,OkHttp 默认 10 秒,而流式连接在两次分片之间完全可能静默十几秒等待模型生成,超时就抛SocketTimeoutException,用户看到文字打一半停住。常见做法是给流式请求单独建一个OkHttpClient实例,把readTimeout和callTimeout设为 0,普通接口继续用带超时的那个,两边别混。
2.3 先用手写 curl 跑通,再动客户端代码
接口调不通的时候,最快的定位方式是绕开客户端,用 curl 直接看原始字节。这一步能把“服务端没返回流”和“客户端解析写错”彻底分开,省掉大量在 Android Studio 里打断点的功夫。
# -N 关闭 curl 自己的输出缓冲,否则会攒够一大块才打印,看起来还是非流式 curl -N -X POST "https://<平台域名>/v1/chat/completions" \ -H "Authorization: Bearer $KIMI_API_KEY" \ -H "Content-Type: application/json" \ -H "Accept: text/event-stream" \ -d '{ "model": "<控制台可用的模型ID>", "stream": true, "temperature": 0.3, "messages": [ {"role": "system", "content": "你是简洁的技术助手"}, {"role": "user", "content": "用三句话解释 SSE"} ] }'-N是关键参数,不加它 curl 会做输出缓冲,你看到的仍然是一次性吐出来,会误判成服务端没开流式。Authorization头用环境变量传,别直接贴在命令里,历史记录会留痕。
跑通之后,再补一条带时间统计的写法,用来量首包时间(TTFT):
curl -N -o /dev/null -w '首字节: %{time_starttransfer}s\n总耗时: %{time_total}s\n' \ -X POST "https://<平台域名>/v1/chat/completions" \ -H "Authorization: Bearer $KIMI_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"<模型ID>","stream":true,"messages":[{"role":"user","content":"你好"}]}'time_starttransfer就是从发出请求到收到第一个字节的时间,这个数字才是流式改造真正改善的指标;time_total反而会因为流式连接保持更久而变大,别拿它当性能回归依据。工程上我一般要求首字节控制在 800ms 以内,超过就先查网络链路和提示词长度,而不是怀疑解析代码。
注意:curl 能看到完整分片,就说明服务和密钥都没问题,后面所有“文字不出来”的排查都应该收敛到客户端解析器上,不要再去翻接口文档。
3. Android 端接入:okhttp-sse 还是自己读 BufferedSource
3.1 两条路线的取舍
Android 上接 SSE 有两条主流路线:用官方的okhttp-sse扩展库,或者直接用response.body.source()自己按行读。两条都能跑通,区别在可控性和代码量。
| 维度 | okhttp-sse | 手写 BufferedSource |
|---|---|---|
| 代码量 | 少,回调式 | 中等,需要写状态机 |
| 原始字节可见性 | 低,只给拼好的 data | 高,能逐行落日志 |
| 断线重连 | 不自动,需自己重建 | 完全自己控制 |
| 异常信息 | 通过onFailure给 Response | 可直接拿到状态码和 body |
| 适合场景 | 快速验证、标准对话 | 需要埋点、要抓原始流排查 |
我的习惯是先用okhttp-sse把链路跑通,验证服务端和鉴权没问题;等要做可观测性、要把原始分片落到文件做回放测试时,再换成手写解析。别一开始就自己造轮子,容易在协议细节上栽跟头。
有一点要提前说清楚:okhttp-sse在连接断开后只会回调onFailure,并不会自动重连。别指望它像浏览器里的EventSource那样自己续上,重连策略必须写在业务层,而且要带退避,否则网络抖动时会瞬间打出一串请求。
3.2 用 EventSource 拉流:依赖、请求与回调
先加依赖,okhttp-sse是独立 artifact,光有 okhttp 本体不够:
// build.gradle.kts dependencies { implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp-sse:4.12.0") // 提供 EventSource }然后是客户端封装。要点有三个:请求体必须是 POST JSON、Accept头显式声明事件流、用的是那个超时放开的 client 实例。
class ChatStreamClient(private val streamClient: OkHttpClient) { private var source: EventSource? = null fun start( apiKey: String, payloadJson: String, onDelta: (String) -> Unit, onEnd: () -> Unit, onError: (Throwable, Int?) -> Unit ) { val body = payloadJson.toRequestBody("application/json".toMediaType()) val request = Request.Builder() .url("https://<平台域名>/v1/chat/completions") .addHeader("Authorization", "Bearer $apiKey") .addHeader("Accept", "text/event-stream") .post(body) .build() val listener = object : EventSourceListener() { // 每个完整事件回调一次,data 已去掉 "data: " 前缀 override fun onEvent( eventSource: EventSource, id: String?, type: String?, data: String ) { if (data == "[DONE]") { // 结束标记,不是 JSON,先判掉 onEnd() return } parseDelta(data)?.let(onDelta) // 只把增量文本往上抛 } override fun onClosed(eventSource: EventSource) { onEnd() // 服务端主动关闭,正常收尾 } override fun onFailure( eventSource: EventSource, t: Throwable?, response: Response? ) { onError(t ?: IOException("stream failed"), response?.code) } } source = EventSources.createFactory(streamClient).newEventSource(request, listener) } // 页面退出、用户点停止时必须调,否则连接会挂到超时 fun cancel() { source?.cancel() source = null } } // 从单个分片里抠出增量文本,任何字段缺失都返回 null 而不是抛异常 fun parseDelta(json: String): String? = runCatching { JSONObject(json) .optJSONArray("choices") ?.optJSONObject(0) ?.optJSONObject("delta") ?.optString("content") ?.takeIf { it.isNotEmpty() } }.getOrNull()onEvent回调里data已经是拆好前缀的单行 JSON,多行data由库用换行拼接过,需要自己再按行处理。[DONE]必须先判断,它不是合法 JSON,直接丢给JSONObject会抛异常,然后被runCatching吞掉,表现为“最后一段总是丢”——这个坑我见过不止一次。
parseDelta用runCatching兜底是刻意为之:流式分片偶尔会出现非 JSON 的心跳或者调试信息,一条脏数据不该让整个对话崩掉,静默跳过即可。但要注意别把兜底写成默认路径,如果日志里parseDelta返回 null 的比例超过 5%,说明协议理解有问题,得回去看原始分片。
3.3 手写解析器:按行读才安全
换成手写路线之后,解析器的核心就一句话:按行读,遇空行提交。OkHttp 的BufferedSource.readUtf8Line()会正确处理 LF 和 CRLF,也会正确处理 UTF-8 多字节字符跨缓冲区的情况——中文字符被切成两半还能正确解码,这一点非常关键。
suspend fun readStream( response: Response, onDelta: (String) -> Unit ): Boolean = withContext(Dispatchers.IO) { val source = response.body?.source() ?: return@withContext false val buffer = StringBuilder() var finished = false while (currentCoroutineContext().isActive) { // readUtf8Line 返回 null 表示连接结束;它内部会处理多字节边界 val line = source.readUtf8Line() ?: break when { line.isEmpty() -> { // 空行 = 一个事件结束,提交缓冲区 if (buffer.isNotEmpty()) { val payload = buffer.toString() buffer.setLength(0) if (payload == "[DONE]") { finished = true; break } parseDelta(payload)?.let(onDelta) } } line.startsWith("data:") -> { if (buffer.isNotEmpty()) buffer.append('\n') // 规范允许 "data:" 后带一个空格,两个都要能处理 buffer.append(line.removePrefix("data:").removePrefix(" ")) } line.startsWith(":") -> Unit // 注释/心跳,丢弃 else -> Unit // event:/id:/retry: 场景用不到 } } finished }返回Boolean表示是否收到了[DONE],这个值很有用:false就是“断流”,可以据此决定是否展示重试按钮,而不是让 UI 一直转圈。while里的isActive让协程取消能立刻生效,用户点停止时不需要等下一个分片。
关键在不要自己按字节切。见过有人这么写:val buf = ByteArray(1024); input.read(buf); String(buf)——中文 UTF-8 是三个字节,一个 1024 字节的边界正好切在字中间,就会解出替换字符,UI 上表现为随机出现“锟斤拷”。readUtf8Line内部用带状态的解码器处理跨缓冲的字符,用它就绕开了这个问题。
提示:手写解析时,把每一行原始文本按需写进环形日志缓冲区(保留最近 200 行),排查断流时这份日志比任何还原都直观。
4. 从回调到 Compose 状态:节流、背压与自动滚动
4.1 callbackFlow 桥接与取消语义
回调式的EventSource和协程式的 Compose 之间需要一个桥,callbackFlow是标准做法。桥接的关键不是转发数据,而是把“取消”和“资源释放”绑在一起:awaitClose里必须调cancel(),否则 ViewModel 销毁后连接还活着,回调继续往已经没人观察的 Flow 里 emit,重连几次就攒出一堆僵尸连接。
fun streamChat(apiKey: String, payload: String): Flow<StreamEvent> = callbackFlow { val client = ChatStreamClient(streamClient) client.start( apiKey = apiKey, payloadJson = payload, onDelta = { trySend(StreamEvent.Delta(it)) }, onEnd = { trySend(StreamEvent.Done) channel.close() // 正常结束,关闭 channel }, onError = { t, code -> trySend(StreamEvent.Error(t, code)) channel.close(t) // 带上异常关闭,上游能 catch 到 } ) awaitClose { client.cancel() } // 收集方取消时一定走到这里 }.buffer(Channel.UNLIMITED) // 生产快于消费时别丢 tokenbuffer(Channel.UNLIMITED)是有意加的。默认callbackFlow的缓冲区是 64,模型输出密集时下游 UI 处理慢一点就会触发trySend失败,trySend失败是静默的——返回FAILED但没人看返回值,表现就是“偶尔少几个字”,极难复现。要么开大缓冲,要么在trySend失败时打日志,我更倾向后者加上前者一起用。
channel.close(t)传异常这个细节也值得留意。不传的话onError只是发了一个事件,Flow 正常结束,上游的catch捕不到任何东西,错误处理形同虚设。
4.2 高频 delta 的合并策略
模型每个 token 推一个分片,一秒二三十个很正常。如果每个分片都写一次MutableState,Compose 侧每秒就要重组二三十次,再加上列表项布局和文字测量,中低端机上直接掉帧。Compose 的 snapshot 系统确实会把同一帧内的多次写入合并,但那要求写入发生在同一个帧周期内,跨帧的写入还是各算各的。
比较稳的做法是在 ViewModel 侧做时间窗合并,把 32ms 内的增量攒成一批再提交,大约是两帧一次:
private suspend fun consume(flow: Flow<StreamEvent>) { val pending = StringBuilder() var lastFlush = 0L flow.collect { event -> when (event) { is StreamEvent.Delta -> { pending.append(event.text) val now = SystemClock.uptimeMillis() if (now - lastFlush >= 32L) { commit(pending) // 提交并清空 lastFlush = now } } StreamEvent.Done -> { commit(pending) // 收尾,别漏最后一批 _ui.update { it.copy(isStreaming = false) } } is StreamEvent.Error -> { commit(pending) _ui.update { it.copy(isStreaming = false, error = event.code) } } } } } private fun commit(pending: StringBuilder) { if (pending.isEmpty()) return val chunk = pending.toString() pending.setLength(0) _ui.update { it.copy(text = it.text + chunk) } }32ms 这个值不是玄学,是两帧的时间,再小就失去合并意义,再大会感觉文字一顿一顿跳。真机上我用 32 到 50 之间的值,低端机取大一些。Done和Error分支里的commit不能省,最后一批增量往往只有几个字,漏掉就是“回答结尾缺半句”,而这种缺失在测试里极难被注意到。
还有个小优化:it.text + chunk每次都会新建字符串,长文本下是 O(n²)。几千字的回答影响不大,但如果你要支持上万字的生成,改成维护一个StringBuilder加版本号自增来触发重组会更好,别在这个位置上过早优化,先量一下再说。
4.3 自动滚动与输入区状态
文字在长,列表就得跟着滚。这里最容易犯的错是用animateScrollToItem:每个分片触发一次动画,动画还没跑完下一个又来了,结果就是列表抽搐。正确做法是用不带动画的scrollToItem,靠高频更新本身形成平滑观感。
// 只有当用户还停留在底部时才自动跟随,手动上翻后停止打扰 LaunchedEffect(ui.text) { val layout = listState.layoutInfo val lastVisible = layout.visibleItemsInfo.lastOrNull()?.index ?: -1 val isAtBottom = lastVisible >= layout.totalItemsCount - 1 if (isAtBottom && layout.totalItemsCount > 0) { listState.scrollToItem(layout.totalItemsCount - 1) } }isAtBottom的判断不能省。用户往上翻去看前面的内容时,如果还强行滚到底,体验极差,这在长回答场景里是高频投诉点。判断阈值可以放宽一点,允许差一两项也算在底部,避免因为滚动惯性导致的边界抖动。
输入区状态要和isStreaming联动:流式进行中禁用发送按钮、把“发送”换成“停止”,停止按钮触发viewModelScope里那个 job 的cancel(),走awaitClose释放连接。另一个常被忽略的是返回键:用户按返回直接退出页面,viewModelScope会随 ViewModel 清理而取消,但如果用的是 Activity 作用域的协程,连接就不会断,必须在onCleared或DisposableEffect里显式取消一次。
5. 避坑与排查:五条血泪记录和一份最小可观测集
5.1 五条踩过的坑,按现象、原因、解决写
第一坑:中文随机出现乱码或半个字。现象是回答里偶尔蹦出替换字符,且只在弱网或长回答时出现。原因是解析器没用按行读,而是自己InputStream.read(ByteArray(1024))再String(bytes),一个 UTF-8 汉字三个字节,正好被块边界切开。解决办法是换成BufferedSource.readUtf8Line(),它内部维护解码状态,跨缓冲的多字节字符能正确拼接;如果非要自己读字节,必须用CharsetDecoder而不是String(bytes)。
第二坑:文字打到一半就停,日志里是 SocketTimeoutException。现象是短问题正常,长回答必断,且断点位置飘忽。原因是复用了全局的那个OkHttpClient,readTimeout是默认的 10 秒,而两个分片之间静默十几秒完全正常。解决办法是给流式请求单独建 client,readTimeout(0, TimeUnit.SECONDS)、callTimeout(0, TimeUnit.SECONDS),同时用业务层的空闲超时(比如 60 秒没有任何分片就主动断开)兜底,别真的无限等。
第三坑:回答打完了,发送按钮还是灰的。现象是文字完整显示,但 UI 一直停在“生成中”。原因是只处理了[DONE]分片,没有处理onClosed,而服务端有时直接关连接不发结束标记;或者[DONE]被丢进 JSON 解析抛了异常又被runCatching吞掉。解决办法是[DONE]判断必须前置到 JSON 解析之前,同时onClosed和onFailure都要把isStreaming置回 false,三条路径一个都不能少。
第四坑:退出页面后偶发崩溃,或者 GC 抖动明显。现象是连续进出对话页十几次之后,App 变卡甚至 ANR。原因是连接没取消,回调还在往已被销毁的 Compose 状态里写,同时callbackFlow的awaitClose没写cancel()。解决办法是把client.cancel()放进awaitClose,收集方用repeatOnLifecycle(STARTED)或collectAsStateWithLifecycle,让页面不可见时自动停止收集,连接自然断开。
第五坑:打字机一顿一顿,列表滚动抽搐。现象是文字更新不连续,滚动条来回跳。原因有两个:每来一个分片就重组一次,以及用了带动画的滚动。解决办法是 32ms 时间窗合并增量,滚动改用scrollToItem并在用户上翻时停止跟随。顺带一提,如果列表项里还套了AnimatedContent或者阴影模糊,重组的代价会翻倍,流式期间把这些装饰关掉。
5.2 最小可观测集:断流时该看什么
流式问题难查,是因为默认情况下什么都看不见。我会在解析器上挂几个必打的日志字段,出问题时按这个顺序看:
| 字段 | 用途 | 异常判读 |
|---|---|---|
| 请求开始时间 | 算首包延迟 | 超过 2s 先查链路和提示词长度 |
| 首个分片到达时间 | TTFT | 与上一项差值就是真实首包 |
| 分片计数 | 判断是否中途停 | 结果为 0 说明服务端没开流 |
| 最后一行原始文本 | 定位断点 | 停在data: {"id"就是半包 |
是否收到[DONE] | 区分正常结束与断流 | 未收到即断流,可给重试入口 |
| HTTP 状态码 | 鉴权与限流 | 401 查 key,429 查频率,400 查参数 |
外加一个环形缓冲:保留最近 200 行原始 SSE 文本,放在内存里,出问题时通过“反馈问题”按钮连同设备信息一起带出。这套东西加起来不到一百行代码,但能省掉的来回沟通非常可观。我在三个项目里都加过,每次断流定位从半天缩到十分钟。
注意:日志里落 API Key 是最容易犯的低级错误。请求头打印时统一做一次脱敏,只留前四位和后四位。
6. 把流式响应当成可回放的测试用例
流式解析器最麻烦的地方在于,它的 bug 只在特定分片节奏下出现:弱网、半包、服务端提前关闭、末尾缺[DONE]。而这些节奏没法靠手点复现。我的做法是把真实响应录下来,当成 fixture 回放。
录制点在解析器入口,把每一行原始文本按顺序写进文件,一行一条,末尾标注是正常结束还是 EOF 断开:
// 只在 debug 构建里启用,release 直接短路 object StreamRecorder { private val lines = mutableListOf<String>() fun record(line: String) { if (BuildConfig.DEBUG) lines += line } fun dump(tag: String, finished: Boolean) { if (!BuildConfig.DEBUG) return val file = File(context.filesDir, "stream_$tag.txt") file.writeText(buildString { lines.forEach { appendLine(it) } appendLine(if (finished) "#END:DONE" else "#END:EOF") }) lines.clear() } }有了 fixture 之后,测试就不再依赖网络。把解析逻辑从Response上解耦,改成接收一个LineSource接口,测试用一个假的实现逐行喂数据:
@Test fun `断流时应返回未完成并保留已收到的文本`() { // 这份 fixture 是真实录到的:model 生成到一半连接被断开,没有 [DONE] val lines = listOf( """data: {"choices":[{"delta":{"content":"你好"}}]}""", "", """data: {"choices":[{"delta":{"content":",我是"}}]}""", "#END:EOF" ) val out = StringBuilder() val finished = parseStream(lines.asSequence(), onDelta = { out.append(it) }) assertEquals("你好,我是", out.toString()) // 增量一个不丢 assertFalse(finished) // 明确识别为断流 }回放测试真正值钱的地方在断言清单,我会固定检查这几项:
| 用例 | 输入特征 | 期望结果 |
|---|---|---|
| 正常结束 | 完整分片 +[DONE] | 文本完整,finished = true |
| 末尾半包 | 最后一行是data: {"id" | 前面文本保留,finished = false |
| 心跳穿插 | 分片间夹: ping | 心跳被丢弃,文本不重复 |
| 中文切分 | 单字被拆成多行data: | 拼接后无乱码、无丢字 |
| 空 delta | content为"" | 不产生空提交,不触发多余重组 |
这份表每加一个线上 bug 就补一行,慢慢就变成了这个模块的回归基线。我从那次“回答结尾总是缺两三个字”的事故之后养成了习惯:任何流式解析的改动,先跑回放用例,再上真机;fixture 没覆盖到的场景,宁可不改。希望帮到你。
本文还有配套的精品资源,点击获取