☰
KIMI API流式输出:Android端SSE接入实现与优化
2026/10/21 4:46:18 网站建设 项目流程

简介:面向 Android 开发者的 Kimi API 流式输出实现资源,解决在 Android 端对接 Kimi 大模型 API 并处理流式文本输出的问题,涵盖从发起请求到逐字展示返回内容的完整链路。适用于聊天机器人、智能助手等打字机效果的实时交互场景,适合具备 Android 与 Kotlin 基础、希望快速掌握大模型 API 接入的开发者。压缩包共 813 个文件,约 14.99MB,以 Kotlin 源码、XML 布局与 JSON 数据配置为主,同时包含 Gradle 构建脚本、JAR 依赖库及编译生成的 dex/class 字节码文件,properties 文件记录参数配置,txt 文件提供说明参考,结构对应一个完整可运行的 Android 项目。已有 1231 人学习/下载。通过这份资源可获得网络请求封装、流式数据处理逻辑、界面刷新机制等核心实现,便于理解 Kimi API 流式返回在多线程、协程与 UI 更新中的衔接方式,配合 Gradle 配置可直接导入 IDE 阅读或二次开发;已编译的 APK 与 class 文件可用于效果验证,便于排查流式传输中的常见问题。

1. KIMI API 流式输出:让Android应用拥有“打字机”级响应体验

做过大模型客户端的人应该都有感受:如果用户问完问题,屏幕要干等几秒甚至十几秒才一次性弹出全部文字,那体验基本算是毁了。流式输出的价值不在于“炫技”,而在于把首字延迟压到 1 秒以内,让用户觉得对面的模型是在思考、在打字,而不是卡死了。这套“KIMI API 流式输出”项目,就是一套完整的 Android 端流式接入实现,核心是围绕 SSE(Server-Sent Events)协议建立一条长连接通道,逐行读取模型返回的增量 token,并在 UI 上实时渲染。它适合两类人:一类是想快速把 Kimi 模型接到自己 App 里的业务开发者,另一类是正在做 ChatUI 但被流式解析、消息拼接、网络切换这类问题折磨的客户端工程师。看完这篇,你可以直接拿这套代码做底子,改改模型参数就能跑。

2. 项目定位与技术选型:为什么流式模式是移动端的必然选择

2.1 流式输出和普通 JSON 返回的本质区别

KIMI API 同时支持同步返回和流式返回两种模式,两者的差异不只是“快一点”那么简单。同步模式下,服务端要等完整内容生成完毕才一次性返回,一个长回答可能耗时 15 秒到 30 秒,期间客户端完全空转;流式模式则通过 SSE 服务端推送,服务端每生成一小段文本就立刻通过已建立的 HTTP 连接推给客户端。也就是说,你看到的第一个字可能只花了 800ms,后面每个 token 间隔只有几十毫秒,整体感知速度完全不是一回事。

从协议层面来看,SSE 是单向的文本事件流,它复用普通的 HTTP 连接,不需要像 WebSocket 那样升级协议、处理帧掩码,也没有重连时的复杂握手。在移动端弱网环境下,SSE 的恢复成本比 WebSocket 低很多,这也让我在实际项目中更倾向于在“模型对话”这个场景下使用它。Kimi 的流式接口本身就是标准 SSE 格式,每条事件以event:开头标记事件类型,随后是data:携带 JSON 数据。这个格式在 Android 端可以直接用 OkHttp 的 EventSource 接口处理,不需要自己解析原始字节流。

2.2 依赖选型与基础配置

实现这套项目用到的依赖非常克制,核心只有两个:OkHttp 和 okhttp-sse 扩展库。前者负责 HTTP 连接、超时控制与连接池管理,后者提供 EventSource 抽象,帮你屏蔽掉 SSE 格式解析的底层细节。项目用的是 OkHttp 4.x 版本,配合 Kotlin 协程使用,网络层全部走挂起函数,避免回调地狱。

// build.gradle.kts (Module) dependencies { implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp-sse:4.12.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2") }

有几个细节值得说明一下。okhttp-sse这个库是独立于 OkHttp 发布的,版本号必须与主库保持一致,否则会出现类找不到的运行时异常。另外协程库我选择androidx.lifecycle里的viewModelScope,而不是自己裸建CoroutineScope,这样 ViewModel 销毁时协程会自动取消,不会有后台线程泄漏的问题。

2.3 项目目录结构:从入口到底层链路的模块划分

拿到这份源码之后,第一件事不是跑起来,而是先看它目录是怎么切的。这套项目的划分方式很干净:

app/src/main/java/com/example/kimiapi/ ├── MainActivity.kt # 入口,只做页面初始化和权限申请 ├── network/ # 网络层 │ ├── ApiClient.kt # OkHttp Client 单例,配置超时和日志 │ ├── KimiApiService.kt # 请求封装,定义 URL、Header 和 Body │ └── StreamParser.kt # SSE 事件流解析,把 data 转成业务实体 ├── repository/ # 数据仓库层 │ └── ChatRepository.kt # 对话仓库,上游拿数据,下游抛状态 ├── viewmodel/ │ └── ChatViewModel.kt # UI 状态持有者,管理 Loading/Success/Error └── ui/ ├── ChatAdapter.kt # RecyclerView 适配器,负责气泡展示 └── ChatActivity.kt # 对话界面,处理输入和事件绑定

这里我想强调一下分层的动机。很多人做这种接入会直接把网络请求写在 Activity 里,项目跑通是没问题,但你一旦要加“多轮对话记忆”“换模型参数”“拦截消息做埋点”这些功能,Activity 就会变成一片乱麻。这套代码把网络层拆到network,把数据与 UI 的桥接放到repository和viewmodel,你要做二次开发时只需要改其中一层,其他层不用动。这个结构对新手友好,对想加功能的熟手也留了足够的操作空间。

3. 核心模块拆解:一条流式消息从进入到上屏的旅程

3.1 发起请求前的准备工作:构建可复用的 OkHttp EventSource

这一步是整套流式链路的地基。需要创建一个带 Tail 的OkHttpClient,配置合理的超时时间,同时把 API Key 作为拦截器统一附加到请求头。注意这里有个关键点:超时时间千万不能套用普通 JSON 接口的 8 秒或 10 秒配置,因为流式接口的连接时长本身就可能超过 30 秒,特别是模型在思考阶段时,中间可能长时间没有数据返回但连接仍然是健康的。如果超时设得太短,会出现“长时间没有 token 就自动断开”的假故障。

object ApiClient { private const val API_KEY = "Bearer sk-xxxxxxxxxxxx" private const val BASE_URL = "https://api.example.com/v1/chat/completions" val client: OkHttpClient by lazy { OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.MILLISECONDS) // 流式场景不设读超时 .writeTimeout(30, TimeUnit.SECONDS) .addInterceptor { chain -> val request = chain.request().newBuilder() .header("Authorization", API_KEY) .header("Content-Type", "application/json") .build() chain.proceed(request) } .build() } }

这段代码里最有价值的配置是readTimeout(0, TimeUnit.MILLISECONDS)。流式场景中,每次数据返回的间隔完全由服务端生成速度决定,不做读超时可以避免误杀慢速输出。缺点是如果网络假死,连接不会自动断开,这时需要依赖后面的“心跳检测机制”来兜底。另外注意 API Key 的拼接需要带Bearer前缀,协议对 Header 的格式要求非常严格,少了这个前缀会直接 401。

3.2 发起流式请求并监听事件

有了客户端配置,下一步就是真正发出流式请求并接收事件。这里用的是okhttp-sse库的EventSources.createFactory方法,它会帮我们解析 HTTP 响应里的 SSE 帧格式,然后把解析好的事件回调到EventSourceListener中。

class StreamParser { fun startStream( messages: List<Map<String, String>>, onEvent: (String) -> Unit, onError: (Throwable) -> Unit, onFinished: () -> Unit ) { val request = Request.Builder() .url(ApiClient.BASE_URL) .post(generateBody(messages)) .build() val factory = EventSources.createFactory(ApiClient.client) val eventSource = factory.newEventSource(request, object : EventSourceListener() { override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) { if (type == "completionEvent") { val content = parseDeltaContent(data) onEvent(content) } else if (type == "errorEvent") { onError(RuntimeException("Stream error: $data")) } } override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) { onError(t ?: IOException("Unknown stream failure")) } override fun onClosed(eventSource: EventSource) { onFinished() } }) } }

completionEvent是 Kimi API 流式返回中最重要的一个事件类型。当服务端生成了一部分内容时,就会推送这个事件,data字段中携带的是一个完整的 JSON 字符串,里面包含增量文本、消息ID、结束标记和 token 用量等信息。这里有个容易被忽略的细节:onEvent回调里的type是事件类型名,而data是 JSON 字符串,不是纯文本。你需要在内部再解析一次,提取出choices[0].delta.content字段,才是真正的增量文本。

// 服务端单帧推送的数据结构 { "id": "chatcmpl-7Zx1a2b3c", "object": "chat.completion.chunk", "created": 1699999999, "model": "kimi-32k", "choices": [ { "index": 0, "delta": { "content": "这是一个增量token" }, "finish_reason": null } ] }

3.3 事件体解析与消息状态更新

拿到原始 JSON 后,不能直接把它拼到 UI 上,还需要做一层转化。这里我习惯的做法是定义一个StreamChunk数据类,把 JSON 里的有效字段抽取出来,然后交给 ViewModel 更新状态。这样做的好处是把底层协议与 UI 解耦,后面如果换成其他大模型 API,你只需要替换StreamParser的解析逻辑。

data class StreamChunk( val contentDelta: String, // 增量文本 val isFinish: Boolean, // 是否生成完毕 val messageId: String, // 当前消息的唯一标识 val totalTokens: Int? // token 用量统计,最后一帧才返回 ) fun parseDeltaContent(data: String): String { val json = JSONObject(data) val choices = json.optJSONArray("choices") if (choices == null || choices.length() == 0) return "" val delta = choices.getJSONObject(0).optJSONObject("delta") ?: return "" return delta.optString("content", "") }

这段解析逻辑有几个边界情况特别注意。第一,当模型回答结束时,服务端会推送最后一帧,此时choices里delta可能是一个空对象,但finish_reason字段是stop,所以不能因为“解析不到 content 就丢掉最后一帧”,否则你会漏掉finish_reason和 token 用量。第二,在多轮对话中,每条消息的id不同,你需要用它来区分当前渲染的是哪条气泡,而不是简单地把所有增量追加到同一个字符串里。第三,totalTokens只在最后一帧返回,如果你想做计费展示,务必在收到isFinish为 true 的那一帧里去读。

3.4 ViewModel 层:如何把回调流安全地送到 UI 线程

StreamParser的回调是跑在 OkHttp 的线程池里的,直接拿它去更新 RecyclerView 会触发线程异常。正确的做法是让 ViewModel 持有一个MutableStateFlow,在onEvent回调里通过viewModelScope.launch把数据切换到主线程并更新状态。这里我踩过一个坑:如果每收到一个 token 就emit一次完整文本,RecyclerView 会频繁触发局部刷新,在低端机上会出现掉帧甚至卡死。优化手段是“节流合并”——把一小段时间内的多个增量拼成一条再刷新。

class ChatViewModel : ViewModel() { private val _uiState = MutableStateFlow<ChatUiState>(ChatUiState.Initial) val uiState: StateFlow<ChatUiState> = _uiState // 用于拼接当前回答的临时缓冲 private val answerBuffer = StringBuilder() fun startChat(userMessage: String) { answerBuffer.clear() val messages = buildMessages(userMessage) _uiState.value = ChatUiState.Loading viewModelScope.launch { val parser = StreamParser() parser.startStream( messages = messages, onEvent = { content -> answerBuffer.append(content) _uiState.value = ChatUiState.Streaming(answerBuffer.toString()) }, onError = { error -> _uiState.value = ChatUiState.Error(error.message ?: "网络异常") }, onFinished = { _uiState.value = ChatUiState.Success(answerBuffer.toString()) } ) } } private fun buildMessages(userMessage: String): List<Map<String, String>> { return listOf( mapOf("role" to "user", "content" to userMessage) ) } }

这段代码展示了一个最小的流式状态机:Initial表示初始态,Loading表示正在等待第一帧,Streaming表示正在接收增量,Success表示流式结束,Error表示异常中断。UI 层只需要观察uiState这个 Flow,根据状态切换加载动画、文本内容和错误提示。构建messages时,目前这个实现只传了当前用户消息,如果要做多轮对话,你需要在这里把历史消息一并塞进去,后面我会在二次开发章节专门讲。

3.5 UI 展示层:RecyclerView 的稳定增量刷新策略

最后一步是把这个状态渲染到界面上,本质上是 RecyclerView 的局部更新问题。我的实现是让 Adapter 暴露一个updatePartialText(position, newText)方法,每次Streaming状态触发时只更新最后一个 item 的文本,而不是调用notifyDataSetChanged全量刷新。

另外还有一个小细节,关于流光效果——如果你想让输出更像 ChatGPT 那样有一个光标在闪烁,可以在 Adapter 里给最后一条消息单独加一个 View 的透明度动画,动画只在Streaming状态运行,一旦流式结束就停止并隐藏。这个视觉效果不算复杂,但对产品体验的提升非常直接。

4. 运行步骤:把这份代码在本地跑起来的完整流程

4.1 前置条件与开发环境要求

开始之前,确认你的开发环境满足下面几个条件。第一,Android Studio 版本建议用最新稳定版,Kotlin 插件版本 1.9 以上,因为代码里用到了StateFlow和data object等较新的语法特性。第二,AGP(Android Gradle Plugin)版本需要 7.4 以上,build.gradle.kts里的配置才能原样运行。第三,准备一个可以访问公网的环境,因为 API 请求是直接走 HTTPS 的。

如果你拿到的项目包自带gradle/wrapper目录,建议直接使用项目指定版本的 Gradle,避免因为版本不一致导致编译报错。代码需要用到网络权限,记得在AndroidManifest.xml里声明:

<uses-permission android:name="android.permission.INTERNET" />

还有一个调试期非常好用的配置。为了让每次请求的响应速度符合真实场景,你在拿到 SDK 后应该顺手检查一下默认的 API 地址端口。如果 API 网关做了私有化部署,地址会和项目默认值不同,你只需要改ApiClient.BASE_URL这一处常量即可,不用动其他业务代码。

4.2 最小化改造:不需要改业务代码就能完成换 Key 接入

从仓库拉下来的代码默认带的是一个过期或不可用的测试 Key,你需要在ApiClient里把它替换成自己的 Key。这个操作只需要改API_KEY常量。这里我给一个“先验证后集成的建议”:先单独运行项目,确认能收到流式文本,再定制 UI 样式。

// 在 MainActivity 中将其注释替换为自己的Key private val YOUR_API_KEY = "Bearer sk-your-key-here"

替换完 Key 后直接 Run,App 会进到一个最简单的聊天页面。试着问一句“用一句话介绍你自己”,正常情况下页面会在 1 秒内出现打字机效果,并逐字输出完成。如果出现 401,先检查 Key 是否复制完整——很多人是复制 Key 时把末尾的空格或多出来的引号带进去了。如果 429,说明账号配额不足,要等一段时间再试。

4.3 验证流式行为的关键标志

怎么判断这套代码真正跑通了呢?不看编译成功,而是看下面这三个现象。第一,页面上的文本是“逐字蹦出来”的,而不是等待数秒后一次性出现完整回答。第二,在系统网络监控工具里,能看到同一个 URL 存在持续的长连接,期间分数次推送数据。第三,当你在生成中途锁屏或切到后台再回来,文本还在持续累积,没有被系统杀掉——这个体现的是协程对后台任务的处理能力。

如果第一眼没看到逐字输出,大概率是解析环节出了问题。最常见的错误是把StreamParser当成了“批量下载器”,手动把data里的所有内容拼接起来再一次性传递给 UI。正确做法是每收到一个onEvent回调,就立刻把content增量送出去。这里给出一个排查思路:在onEvent回调的方法入口加一条Log.d("StreamParser", "eventType=$type content=$content"),如果日志里一条条信息开启,说明链路是通的,问题出在 ViewModel 拼接或 UI 刷新层。

5. 常见问题排查:流式接入期最容易踩的五个坑

5.1 现象:一直等不到第一帧数据

这个是最常见的情况:请求发出去,Logcat 安静得像什么都没发生,两三秒后才一次性返回所有内容。原因在于服务端在生成第一个 token 之前需要漫长的 Prefill(预填充)时间,特别是你传入的 histories 很长、或者问题涉及大量背景知识时,这个时间可能超过 5 秒。另一个可能是代理或网关层面把 SSE 响应缓冲了,它不是边收边转,而是等全部响应结束才一次性转给客户端。

解决路径分两步。第一步确认不是代码问题,在 OkHttp 的日志拦截器里看响应是否被 Buffer 了。如果日志输出是“一次性输出完整响应”,那就是网络链路问题,需要调整代理配置关闭缓冲。第二步做应用层兜底,在 UI 上把“等待第一帧”的时间作为 Loading 状态展示,超过 10 秒再提示超时,而不是让用户干等。

5.2 现象:流式内容出现重复或拼接断裂

这是比“拿不到数据”更诡异的问题。具体表现是:文本输出过程中,前一段 token 被重复追加了一次,或者两条增量中间丢了一个字符,导致最终答案语义不通。原因通常是事件帧解析时没有处理好\r\n与\n的混用。SSE 协议标准规定帧之间以空行分隔,但不同服务端实现可能使用不同的换行符,OkHttp 的 SSE 解析器对这种差异做了兼容,但如果你自己在data里做了二次 split,就很容易踩到拼接断裂的坑。

解决的觉悟是:永远不要在自定义代码里对data做“按行拆分再重组”的操作。你可以通过onEvent回调拿到的data是完整的一帧 JSON,直接把它丢给解析函数。如果确实需要对超长文本分段截断,截断位置必须选在 Unicode 字符的边界上,不能用Byte长度切,否则中文会被劈成乱码。

5.3 现象:点击停止后回调仍在触发

用户点击“停止生成”按钮后,理论上流式连接应该终止。但实际很多项目里,按钮虽然置灰了,onEvent却还在不停输出,后台线程也没有释放。原因是只用EventSource.cancel()取消了连接,但 OkHttp 底层的连接池和回调队列依然在工作,协程的 Job 没有同时取消。

我在这个项目里的解决方案是把EventSource对象和协程 Job 绑定在一起管理。点击停止按钮时,先赋 Job.cancel() 再赋 eventSource.cancel()。顺序不能反过来,因为如果先断连接,OkHttp 会触发一次onFailure回调,如果 Job 还没取消,这次回调会进来一次意外状态更新。

class ChatRepository { private var currentJob: Job? = null private var currentEventSource: EventSource? = null fun stopStream() { currentJob?.cancel() currentEventSource?.cancel() currentJob = null currentEventSource = null } }

这个设计同时解决了另一个隐藏问题:用户在快速连续发消息时,上一次的请求可能会污染下一次的回答。每次startStream之前调用一次stopStream,可以保证任意时刻只有一条流式连接在活跃状态。

5.4 现象:长对话出现明显卡顿和内存上涨

当对话轮数超过 20 轮、文本长度累计到几万字符时,UI 刷新会开始掉帧,内存占用量也在持续上涨。原因不是 API 返回变慢了,而是 Adapter 每次通知局部刷新时都要重建整条 item 的视图,且历史消息文本全部被SpannableString缓存了下来。

应对方案是给消息列表做“虚拟化”处理。RecyclerView 本身已经复用了 item 视图,但对于超大文本,你需要限制每个 item 的最大显示长度,超出部分折叠起来,点击展开查看完整内容。另外,增量刷新时不要用notifyItemChanged(position)会触发 ViewHolder 的onBindViewHolder全量重绘,而是通过自定义 View 暴露一个appendText(text)方法,内部只拼接 Spannable 的新增部分。

5.5 现象:API 返回 401 或 429 导致整个流程崩掉

这两个错误码是流式接入里出现频率最高的鉴权类报错,处理方式必须做到“体面不可感知”。401 是 Key 无效或过期,需要在收到这个状态时跳到设置页提示用户重新输入 Key。429 是请求被限流,表示账号的每分钟请求数(RPM)或每分钟 Token 数(TPM)超了配额,这种情况盲目重试只会加重限流。

推荐的做法是用 OkHttp 的RetryAndFollowUpInterceptor配合自定义重试策略。对于 429,读取响应头里的Retry-After字段,按其指定的秒数延迟重试;对于 401,直接放弃重试,抛出让用户处理的上层错误。不要试图在代码里硬循环“重试 5 次”,那只会让你的 App 被网关封禁更长时间。

6. 二次开发经验:把 Demo 改造成可上线的对话产品

如果只是把流式文本打印出来,那这个项目只能算 demo。想真正用于生产,我最建议你从下面三个方向做二次开发。第一个是引入“多轮上下文管理”,把历史消息按对话轮次组装进请求上下文,让模型能记忆前文;第二个是给输出文本做 Markdown 渲染,因为模型默认输出包含大量代码块和列表,纯文本展示效果非常劝退;第三个是加入“停止生成”和“重新生成”按钮对 Token 用量做统计展示,这个对控制成本意义重大。

多轮上下文的实现并不复杂,核心是维护一个MutableList<Map<String, String>>作为消息历史。在buildMessages里把历史列表和当前用户问题合并:

fun buildMessagesWithHistory(history: List<ChatRecord>, newQuestion: String): List<Map<String, String>> { return history.map { record -> mapOf("role" to record.role, "content" to record.content) } + listOf(mapOf("role" to "user", "content" to newQuestion)) }

注意这里有个“上下文窗口”问题:把全部历史都塞进去,最终会超过模型的 token 上限。你需要自己实现一个滑动窗口,比如只保留最近 10 轮对话,或者按字符数截断旧记录。这个截断策略直接影响模型的记忆效果——窗口太长会导致接口报错,太短则让模型“失忆”。实际调优过程中,不同业务的敏感度差异非常大,代码里加一个可配置的参数比较好:

private val MAX_HISTORY_ROUNDS = 10 private val MAX_HISTORY_CHARS = 4000

第二个方向是 Markdown 渲染。Android 生态中常用的方案是集成com.github.jeziellago:compose-markdown或io.noties.markwon,但直接引入第三方库会带来包体积上升。这里我提供一个轻量思路:增量渲染的风险比较大,更稳妥的做法是在onFinished后才对完整文本做一次性 Markdown 转换,流式期间只做纯文本展示。虽然代价是结束时有一瞬间的格式跳变,但可以完美避免“半个代码块被解析成错误格式”的问题。在一次性输出中,用 Markwon 绑定 TextView 即可:

val markwon = Markwon.builder(context) .usePlugin(FencedCodePlugin.create()) .build() markwon.setMarkdown(binding.tvMessage, fullText)

第三个方向是 Token 用量展示。finish_reason为stop的那一帧里带有usage.total_tokens字段,把它存到StreamChunk里即可。这个数据的价值不只是展示给用户看,你还可以用来做成本统计,比如按天汇总用户消耗的 Token 数。这里的坑是:SSE 流式响应中usage字段只出现在最后一帧,如果中途出错或者用户手动停止,你就要到错误事件的响应体里去找已经消耗的部分。

说到线上环境的稳定性,我最想分享的教训是:永远要在客户端和服务端之间加一层“超时看门狗”。SSE 是一个长时间保持的连接,虽然 OkHttp 禁用了读超时,但如果网络全国范围内不可用,连接不会自动断开。我习惯在ChatViewModel里起一个Debounce计时器,比如每 15 秒检查一次——如果最近 15 秒没有任何新 token 到达,就主动触发一次重连或通知用户“网络已断开”。这个检查和心跳不同,它不是定期发 Ping,而是观测“我方是否收到新数据”。实现起来只需要在onEvent回调里重置计时器即可。从那以后,我每次接入任何大模型 SDK,都会强制走一遍“首帧时延、断线重连、多余上下文截断”这三个维度的压测,这套习惯让线上问题率直接降了一个等级。希望这篇拆解能帮你在接入 KIMI 流式 API 时少走几段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询