ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

KIMI API流式输出Android接入:SSE协议、OkHttp解析与避坑指南

KIMI API流式输出Android接入:SSE协议、OkHttp解析与避坑指南 简介KIMI API流式输出资源包面向需要在Android应用中集成Kimi大模型接口并实现逐字流式展示的开发者解决流式响应解析、UI实时刷新与中断处理等核心问题。压缩包为rar格式整体约14.99MB总计813个文件主要包含flat编译资源、json配置与数据示例、xml界面布局、dex/class字节码以及kt源码等覆盖从网络请求、数据解析到界面渲染的完整代码链路便于对照分析Android项目结构与Kimi API调用方式。目前已有1230人学习下载适合具备一定Android基础、正在接入Kimi或希望优化流式输出体验的开发者。获取后可得到工程级参考包括流式数据回调封装、打字机效果实现、异常与取消处理思路以及资源组织惯例能有效缩短调试周期、降低对接成本。1. KIMI API 流式输出从等全文到看逐字KIMI 的网页版聊起来行云流水字是一个一个蹦出来的但很多人第一次在 Android 里接官方 API 时等到的却是一整段 JSON 一次性砸回来体验从行云流水直接变成转圈干等。区别就在于有没有走 KIMI API 流式输出把 stream 参数打开模型生成的完整回答会被切成一块块 token通过 SSE 实时推到客户端从「等十几秒拿全文」变成「一两秒看到第一个字」。这篇笔记把一个 Android 端 Kimi 对话模块的拆解全过程写下来覆盖 SSE 协议怎么对接、OkHttp 怎么逐行解析、abort 怎么中断、以及 401 鉴权失败和上下文超限这类高频坑。适合要在 Android 里接大模型、又不想对着 SDK 黑匣子瞎猜的开发者。2. SSE 与 KIMI API从一次 HTTP 请求到流式响应的链路拆解先想清楚一个底层事实大模型不是一次性把整段回答算完的它是 token 级自回归每生成一个 token 都要做一次前向计算。非流式模式下KIMI 服务端把几百次计算的结果攒成一个完整 JSON 返回用户看到的效果就是「等十几秒然后一把出全文」。流式模式则把每次计算的结果即时推出来第一个 chunk 往往几百毫秒就到后面每几百毫秒跟一个。Android 端要做的是把这段天然的文本流接住、解析、一段段渲染。这一章把链路上的协议细节拆开。2.1 为什么是 SSE单向文本流正好匹配模型生成SSEServer-Sent Events是跑在 HTTP 上的单向文本流协议服务端往客户端持续推数据。对话机器人场景的语义就是「模型说、客户端听」单向完全够用。WebSocket 是双向实时通道要处理握手、心跳、二进制帧、状态机复杂度比 SSE 高一截在纯文本生成的场景里属于杀鸡用牛刀。轮询更不划算长文本生成要持续十几秒轮询期间大量请求拿不到有效数据纯浪费。KIMI API 对 OpenAI 的 /v1/chat/completions 协议做了兼容stream 打开后响应头变成 text/event-stream这就是标准的 SSE 通道。SSE 协议本身非常朴素一个普通 HTTP 响应就能承载HTTP/1.1 200 OK Content-Type: text/event-stream Cache-Control: no-cache # 每个 data: 行是一个增量 chunk空行表示事件分隔 data: {choices:[{delta:{content:你}}]} data: {choices:[{delta:{content:好}}]} data: [DONE]这里的关键认识是SSE 没有魔法body 就是连续的 UTF-8 文本行每行以 data: 开头事件之间用空行隔开流结束时服务端发一个 data: [DONE] 哨兵。Android 端解析这种格式不需要引入任何 SSE 专用库自己按行读就行。这也是后面所有代码的基础。另外注意SSE 标准里还有 event、id 这些字段KIMI 的兼容实现里基本只用到 data:解析时不要被标准文档里的其他字段带偏。2.2 请求构造streamtrue 是唯一开关KIMI API 的请求格式和 OpenAI 几乎一致差异只在 base-url 域名和模型 ID。先看一个最小请求# streamtrue 是流式开关缺省为 false不传就是一次性返回 curl https://api.moonshot.cn/v1/chat/completions \ -H Authorization: Bearer $MOONSHOT_API_KEY \ -H Content-Type: application/json \ -d { model: kimi-k2-0711-preview, messages: [ {role: system, content: 你是 Android 开发助手}, {role: user, content: 用一段话解释什么是 SSE} ], stream: true, max_tokens: 2048, temperature: 0.3 }这段命令的逻辑是把模型 ID、多轮消息、生成参数一次性交给服务端。参数里值得展开的是四个model 用你账号下可访问的模型 ID新模型上线后官方文档会同步更新代码注释里写的 kimi-k2-0711-preview 只是我当时用的版本messages 是对话上下文数组多轮对话必须把历史消息带进来否则模型没有前文记忆stream 是流式开关这是流式接入和普通接入唯一的请求差异temperature 控制随机性0.3 偏向稳定输出做工具类助手可以更低。max_tokens 单独说一句它限制的是「本次回答的最大输出长度」不是上下文总长。很多人把 max_tokens 设成 1048576 想塞长文档这是对参数的误用真正的上下文上限在服务端后面避坑章会讲。2.3 流式响应格式每个 chunk 都是一个 JSON流式响应里每行 data: 后面挂一个 JSON结构和非流式的 chat completion 类似只是 choices 里变成了 delta 字段存的是增量文本。一个典型 chunk{id:chatcmpl-abc123,object:chat.completion.chunk,model:kimi-k2-0711-preview,choices:[{index:0,delta:{content:的},finish_reason:null}]}解析时有两个坑要提前知道第一个 chunk 的 delta 里往往只有 role: assistant没有 content直接拿 delta.content 会拿到 null中间 chunk 的 content 是增量不是全文必须自己累加最后一个 chunk 的 finish_reason 变成 stop标志着生成结束。下面用一段 Python 最小实现演示协议解析Android 端思路完全一样import json import requests response requests.post( https://api.moonshot.cn/v1/chat/completions, headers{Authorization: Bearer api_key}, json{ model: model_id, messages: [{role: user, content: 讲个短故事}], stream: True, }, streamTrue, ) # 逐行读响应体忽略空行和非 data 前缀行 for line in response.iter_lines(): if not line or not line.startswith(bdata:): continue payload line[5:].strip() if payload b[DONE]: break chunk json.loads(payload) delta chunk[choices][0].get(delta, {}) content delta.get(content) if content: print(content, end, flushTrue)这段代码的逻辑是逐行读响应体只处理 data: 开头的内容去掉前缀后先判断结束哨兵再解析 JSON最后对 content 判空再输出。里面两个 if 是踩过坑的产物——不判 [DONE] 会把结束行当 JSON 解析直接抛异常不判 content 会多打一串 None 到界面。Android 端的 OkHttp 解析就是把这套逻辑从 Python 换成 Kotlin逐行的处理方式完全一致。3. Android 端接入 KIMIOkHttp 逐行解析与回调设计第 2 章把协议层拆完了这一章落到 Android 的工程实现。先回应一个常见问题封装 AI 交互逻辑到底用什么技术栈。我这边实际落地的方案是 OkHttp 协程 自定义回调接口不引入任何 SSE 专用库。原因是流式解析只需要「逐行读 字符串判断」两个能力OkHttp 原生就够Retrofit 虽然注解方便但它的 Call 被设计成一次响应一个 body流式场景反而要绕出 converter 层Ktor client 功能强团队不熟的话维护成本高。下面按请求构造、流式解析、线程回拨三个小节把代码写透。3.1 连接参数readTimeout 必须设为 0流式接入最容易翻车的不是解析是超时配置。看这段private val client: OkHttpClient OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.MILLISECONDS) // 流式响应不能设读超时 .writeTimeout(10, TimeUnit.SECONDS) .retryOnConnectionFailure(false) .build()connectTimeout 是建连的等待时间10 秒对正常网络绰绰有余readTimeout 是「读不到任何数据」的判定阈值这里必须设 0 表示无限等待。原因很直接KIMI 流式生成时两个 chunk 之间的间隔不稳定高峰期可能隔十几秒才推下一个 token默认的 10 秒读超时会让连接在长句生成中途被掐断。retryOnConnectionFailure 设为 false 也有讲究流式连接一旦断在中途重放请求会导致重复内容或重复计费不如让上层自己决定要不要重试。3.2 请求构造把 messages 序列化成 JSON接下来构造请求体把历史消息、流式开关、生成参数打包fun buildStreamRequest(apiKey: String, model: String, messages: ListChatMessage): Request { val messagesArray JSONArray().apply { messages.forEach { msg - put(JSONObject().apply { put(role, msg.role) put(content, msg.content) }) } } val bodyJson JSONObject().apply { put(model, model) put(stream, true) put(messages, messagesArray) put(temperature, 0.3) }.toString() return Request.Builder() .url(https://api.moonshot.cn/v1/chat/completions) .addHeader(Authorization, Bearer $apiKey) // 注意 Bearer 前缀 .post(bodyJson.toRequestBody(application/json; charsetutf-8.toMediaType())) .build() }这段的逻辑是把对象模型转成 API 需要的 JSON 报文messages 数组保持顺序就是保持对话顺序stream 写死 true 保证每次都走流式Authorization 用 Bearer 前缀拼上 key。注意 charsetutf-8 这个细节中文消息在 OkHttp 的老版本里如果不显式声明字符集可能被序列化成 ISO-8859-1服务端收到乱码。3.3 流式解析逐行读 data 前缀并回调增量请求 build 好核心在 enqueue 回调里逐行读 bodyfun streamChat(request: Request, callback: ChatCallback) { client.newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { // 主动 cancel 触发的失败不算错误不弹 UI if (!call.isCanceled()) { callback.onError(-1, e.message ?: network error) } } override fun onResponse(call: Call, response: Response) { if (!response.isSuccessful) { val error response.body?.string() ?: HTTP ${response.code} callback.onError(response.code, error) return } val fullText StringBuilder() response.body?.source()?.use { source - while (true) { val line source.readUtf8Line() ?: break if (!line.startsWith(data:)) continue val payload line.removePrefix(data:).trim() if (payload [DONE]) break val chunk JSONObject(payload) val delta chunk.optJSONObject(choices) ?.optJSONObject(delta) val token delta?.optString(content, ) ?: if (token.isNotEmpty()) { fullText.append(token) callback.onToken(token, fullText.toString()) } } } callback.onDone(fullText.toString()) } }) }这段是本篇的骨架逻辑分四段先检查 HTTP 状态码非 2xx 直接读 body 交出去401 和 400 的错误正文都在这里再逐行读 UTF-8 流跳过非 data 前缀的行然后解析 delta.content注意用 optString 而不是 getString避免 Key 不存在时抛 JSON 异常最后把增量 token 和当前累计全文一起回调。把 fullText 传给回调是有意的设计UI 层不需要自己再维护一份拼接逻辑而且出错时能看到已经收到的部分。onResponse 里有个隐蔽坑要提醒while 循环里的 readUtf8Line() 是阻塞的如果用户在生成中途点击停止call.cancel() 会让这个阻塞的读直接抛 IOException程序会跳到 onFailure。所以 onFailure 里必须用 call.isCanceled() 区分「主动中断」和「真实错误」前者不能弹错误提示后者才需要 UI 反馈。3.4 回调接口与主线程切换网络回调跑在 OkHttp 的线程池里直接操作 View 会崩。我一般定义一个接口把 token 和状态切成主线程再分发interface ChatCallback { fun onToken(token: String, accumulatedText: String) fun onDone(fullText: String) fun onError(code: Int, message: String) } // 调用时切到主线程网络线程不能直接碰 View private val mainHandler Handler(Looper.getMainLooper()) override fun onToken(token: String, accumulatedText: String) { mainHandler.post { renderView.appendToken(token) } }这样整个链路就闭合了网络层只负责跟 KIMI 通信UI 层只负责渲染中间用回调接口隔离。如果项目里已经用了协程也可以把 onToken 改成回调 withContext(Dispatchers.Main)效果一样。首 token 到达前UI 上要放 loading 状态这点看似 trivial但没有它用户会反复点发送按钮造成并发请求所以发送按钮在流式进行中要禁用直到 onDone 或 onError 再恢复。4. 流式渲染与 abort 打断把 token 变成可中断的对话上一章把 token 从网络流里接了出来这一章处理两个「看起来简单、做起来全是细节」的问题增量文本怎么渲染得像个对话框用户点停止时怎么干净利落地打断。4.1 打字机渲染保护 repeated setText 的流畅性最简单粗暴的做法是每收到一个 token 就 textView.text 全文token 间隔几十毫秒时肉眼看起来就是打字机。但这个做法有两个隐患setText 全量刷新在 TextView 内容变长时会掉帧滚动位置会跳。我实际用的是增量追加加节流class TokenRender { private val buffer StringBuilder() private var lastFlushTime 0L fun append(token: String, textView: TextView, scrollView: ScrollView) { buffer.append(token) val now System.currentTimeMillis() // 33ms 约等于 30 帧刷新率肉眼连续但 setText 频率可控 if (now - lastFlushTime 33) { flush(textView, scrollView) } } private fun flush(textView: TextView, scrollView: ScrollView) { textView.text buffer.toString() scrollView.post { scrollView.fullScroll(View.FOCUS_DOWN) } lastFlushTime System.currentTimeMillis() } }33 毫秒对应约 30 帧每秒的刷新率人眼看起来还是连续打字但 setText 的次数从每个 token 一次降到了一秒 30 次中长文本时掉帧明显减少。滚动要放在 post 里等 setText 完成后再滚否则滚到旧高度。如果直接用 RecyclerView 做消息列表做法类似消息 Item 内部持有同一个 TextViewonToken 里 notifyItemChanged 会闪烁更稳的做法是直接操作 ViewHolder 里的 TextView不触发 notify。流式过程还有个渲染取舍Markdown 要不要实时渲染。常见做法是流式阶段只显示纯文本等 onDone 后再统一用 Markwon 渲染一次。原因很直白Markdown 语法是成对出现的 这个围栏在生成到一半时只有开没有闭实时渲染会闪出诡异的样式。4.2 abort 打断cancel 连接加标志位挡残留stop 按钮背后要做两件事断掉网络连接让服务端停止生成挡住断连瞬间可能残留的 token不让它们再进 UI。private var activeCall: Call? null private var isStopped false fun stopGenerate() { isStopped true activeCall?.cancel() } fun startGenerate(messages: ListChatMessage) { isStopped false val request buildStreamRequest(apiKey, model, messages) val call client.newCall(request) activeCall call streamChat(call, object : ChatCallback { override fun onToken(token: String, accumulatedText: String) { // 防止取消后残留 token 进入 UI if (!isStopped) renderView.appendToken(token) } override fun onDone(fullText: String) { if (!isStopped) renderMarkdown(fullText) } override fun onError(code: Int, message: String) { if (!isStopped code ! -1) showError(message) } }) }activeCall 持有当前正在执行的 CallstopGenerate 里 cancel 后阻塞中的 readUtf8Line 会抛 IOException前文说的 isCanceled 判断在这里生效。isStopped 标志位是为了挡住 cancel 之后、onFailure 回调之前可能残留的最后几个 token网络层的取消不是瞬时的断开需要几十毫秒这期间可能还有一两个 chunk 到达。如果用协程方案Job.cancel() 配合 Dispatchers.Main 也可以但网络读流不在协程里cancel 协程不会断连接还是要 call.cancel()。abort 的一个容易被忽略的副作用是计费已经生成的 token 是按实际量计的用户点停止只停止后续生成不退还已生成的部分产品上要把「已生成内容保留」做成交互预期而不是假装什么都没发生。4.3 中断之后断点续传是伪需求网络切换、App 退后台、服务端高峰断连都会让流式连接中断。很多产品第一反应是做断点续传我直接说结论模型服务端是无状态的续传没有意义重新发起请求只会从第一个 token 重新生成。正确做法是把已收到的文本保留在界面上同时提供一个「重新生成」按钮。要做「接着上一段继续写」不是续传而是在 messages 里追加一条用户指令比如「从刚才停下的地方继续不允许重复已出现的内容」再走一遍完整请求这样有上下文但生成是全新的一轮。App 退后台是流式中断的高发场景。onPause 里如果强行 cancel回来就再也收不到回答不 cancel后台流还会继续跑UI 恢复时发现 token 已经攒了一大段。我的做法是按产品预期区分如果是「用户主动切走」保留连接但不刷新 UI回到前台一次性渲染当前全文如果是「点停止」才动用 cancel。这个取舍要在需求阶段定清楚否则实现时两边不讨好。5. 避坑手册KIMI 接入中五个高频问题与排查路径接入过程踩过的坑集中在五个地方鉴权、上下文长度、超时缓冲、渲染编码、共用 Key 限流。每条都按「现象 → 原因 → 解决」记录后面做重复对接时直接查。5.1 401 Unauthorizedincorrect api key 的三层原因现象请求刚发出去就收到 HTTP 401响应正文是 unexpected status 401 unauthorized: incorrect api key provided: sk-svcac…后面是脱敏的 key 片段。原因绝大多数情况不是 key 本身失效是传递方式出错。第一key 被复制进了多余字符换行、首尾空格、引号直接拼进 header 就会鉴权失败第二部分封装库会自动加 Bearer 前缀调用时又手动加了一次变成 Bearer Bearer sk-xxx第三header 名写错把 Authorization 写成 authentication 或 token服务端根本没读到。另外如果用的是团队控制台创建的受限 Key权限范围不对也会被拒错误码同样是 401。解决先不写代码用 curl 带最原始的参数打一发确认 key 本身有效再查代码里 header 拼接处打一条脱敏日志确认最终发出的 Authorization 长什么样。我自己的习惯是 key 统一放本地 properties读取后 trim() 再拼接杜绝换行残留。注意打日志时对 api key 做脱敏只保留前几位防止 key 泄漏进日志系统。5.2 上下文 1048576 tokens历史消息是怎么把窗口撑爆的现象请求返回 400错误正文是 api error: 400 this models maximum context length is 1048576 tokens. However, your messages resulted in X tokens。原因messages 里堆了太多历史。多轮对话每次把全部历史原样带上再贴进长文档或长代码token 总量就逼近甚至超过模型上下文上限。1048576 是 KIMI 上下文窗口的实际数值本地估算和实测容易有偏差中英文混合时偏差更大超了就是 400。解决对历史做滑动窗口裁剪只保留 system 提示词和最近 N 轮对话长文本进入前先摘要不要把几万字原文反复塞进 messages。下面是一个简单的估算裁剪实现fun trimMessages( history: ListChatMessage, maxTokens: Int 900_000 ): ListChatMessage { val result ArrayListChatMessage() var used 0 for (msg in history.asReversed()) { // 中英混合按每 2 个字符约 1 token 估算留出输出余量 val estimated msg.content.length / 2 if (used estimated maxTokens) break result.add(0, msg) used estimated } return result }估算按中英混合每两个字一个 token 来算偏保守但够用先把历史的 token 数压在窗口的 90% 以下留出输出空间再谈完整对话。注意 max_tokens 是输出上限不要拿它来限制历史长度这是两个东西。5.3 流式中途卡死readTimeout 与代理缓冲现象首 token 正常生成到一半停住十几秒后整个请求失败或者服务端明明在推客户端一直收不到。原因三个常见源。readTimeout 被设成默认 10 秒KIMI 高峰期两个 token 间隔超过 10 秒就直接断服务端经过 Nginx 反代时缓冲开着chunk 被攒在代理层Android 端网络切换导致连接失效。解决readTimeout 设 0这个前面提过流式接入第一优先级代理层关掉 buffering加响应头 X-Accel-Buffering: no断线后不要自动重连保留已收文本并给重试按钮。「高峰期变慢」是服务端排队导致的token 间隔被拉长这里没有黑匣子核心就一句话别让客户端替服务端背锅客户端把超时判得苛刻就是误杀。5.4 增量文本乱码与半截符号现象中文偶尔出现乱码或者文本结尾少了一半代码块标记在流式过程中显示成裸露的 。原因乱码几乎都是解码字符集不一致比如用 BufferedReader 读流时没指定 UTF-8半截符号是流式渲染的固有问题Unicode 字符可能被拆分在两个 chunk 边界直接渲染会闪一下残缺字符。解决读取统一用 OkHttp 的 readUtf8Line内部按 UTF-8 处理App 层面不要对 chunk 字节做手动解码。Markdown 半截符号的处理前文提过流式阶段纯文本显示onDone 后统一渲染这是最省心的方案比任何增量修复都稳。5.5 共用 Key 的限流与停用现象多人共用同一个 key 联调忽然有人连续收到 429 或连接重置另一种是请求 400提示 organization has been disabled。原因KIMI 对账号维度的并发和用量有限制共用 key 时流量互相挤占organization disabled 通常是团队控制台里账号状态异常或欠费和 code 无关。注意这两个错误跟「key 错了」表现不同排查方向完全不同。解决联调阶段给每个端分开申请测试 key 或走网关做分发429 做指数退避重试间隔按 1 秒、2 秒、4 秒递增organization 状态问题直接找账号管理员代码层面无解。共用 key 时还要防另一件事有人在测试环境把 key 打进日志泄漏后会被外部刷出异常账单这个问题一旦发生基本只能吊销 key没有后悔药。6. 进阶验证技巧先打原始报文再谈封装6.1 用 curl 验证原始流接任何大模型 API我都有一个雷打不动的习惯先不写代码用 curl 把服务端返回的原始流打出来看。这一步能过滤掉排查链路上至少一半的假问题。命令很简单# -N 关闭缓冲边收边显示避免 curl 攒一批才输出 curl -N https://api.moonshot.cn/v1/chat/completions \ -H Authorization: Bearer $MOONSHOT_API_KEY \ -H Content-Type: application/json \ -d {model:kimi-k2-0711-preview,messages:[{role:user,content:hi}],stream:true} \ | sed -n /data:/p-N 是关键它告诉 curl 不要缓冲而是边收边输出用 sed 过滤出 data: 行能第一时间判断是服务端没推、还是客户端解析丢了。如果这一步输出正常而 App 里收不到问题就锁定在 App 侧的网络层。6.2 Spring AI 一行接入验证通过后如果团队后端用 Spring AI可以快速搭一个流式对话接口免去自己拼 JSON。Spring AI 的 OpenAI 协议兼容层可以直接指向 KIMIspring: ai: openai: api-key: ${MOONSHOT_API_KEY} base-url: https://api.moonshot.cn/v1 chat: options: model: kimi-k2-0711-preview # 以账号可用的模型 ID 为准 stream: trueFluxString content chatClient.prompt() .user(解释一下 SSE 流式输出) .stream() .content();base-url 指向 moonshot 域名后返回的 Flux 就是流式 token 序列配合 WebFlux 可以直接对前端做 SSE 转发。但我还是要提醒一句Spring AI 封装了协议的复杂度也隐藏了协议的细节生产环境出了问题最终还是要回到第 2 章的原始报文去排查。这套流程带我避开的坑里印象最深的一次是我把 base-url 写成了 https://api.moonshot.cn/v1/chat/completions 全路径多了一个后缀404 了半小时最后是 curl 原始请求对比才定位到是路径拼错。从那以后我每次接新模型都强制走一遍 curl 原始流再写代码这个习惯就再也没改过。希望帮到你。本文还有配套的精品资源点击获取
返回列表