
简介面向 Android 开发者的 Kimi API 流式输出工程资源围绕大模型对话接口在移动端的接入与展示展开覆盖请求鉴权、流式事件回调、逐字渲染、超时与异常中断处理等关键链路。压缩包共包含813个文件包体约14.99MB其中包括 Kotlin/Java 源码、Gradle 构建脚本、XML 与 JSON 配置、dex 与 APK 编译产物以及大量 Git 内部索引和资源清单文件能较完整反映项目编译与运行环境。已有1230人学习下载说明其对实现 Android 端流式对话场景具有实际参考价值。研读源码可快速理解消息协议解析、协程或回调线程调度、UI 增量更新等实现方式比对构建产物和本地依赖目录也能帮助排查集成过程中的版本冲突与签名问题。整体适合有一定基础、准备在自有应用中接入 Kimi 或兼容 OpenAI 流式接口的开发者可显著缩减从零调通的成本。1. KIMI API 流式输出到底是什么一次请求把答案分段送达做对话机器人最难受的体验不是模型答错而是答得慢还没反馈用户点下发送转圈十几秒页面一动不动后端日志一看 Kimi API 其实早就在出字只是一直等到全量生成完才一次性返回。KIMI API 流式输出解决的就是这个问题请求里把 stream 打开服务端用 SSEServer-Sent Events把一段回答拆成几十个增量事件逐条推出来前端收到一个字渲染一个字首字能在几百毫秒内出现读者体验从「转圈等待」变成「打字机效果」。这篇笔记写给要自己接 Kimi 开放平台 API 的后端和全栈工程师。我会把流式输出从最底层的 SSE 报文结构、OpenAI 兼容 SDK 接入、Spring AI 工程化落地到 401 鉴权失败、上下文超限、Nginx 缓冲、限流排队、中文乱码这五个高频翻车点完整过一遍最后补上中止生成和用量统计两个上线前必做的收尾动作让这个能力从「能出字」真正走到「能上线」。2. 接 Kimi API 前先对齐四个参数用 curl -N 直接看 SSE 报文写代码之前我建议先做一件看起来有点多余的事用 curl 把流式响应打出来看一眼。流式输出的一切特征——增量内容、事件边界、结束标记——全在这一段原始报文里。把这段报文看懂后面用 Python SDK、Node 包、Spring AI 都只是在用不同语言替你做同一件事。2.1 base_url、api_key、model、streamKimi API 的四个必填项先对齐四个参数任何一个错了都跑不通参数取值说明base_urlhttps://api.moonshot.cn/v1Kimi 开放平台的 OpenAI 兼容协议入口api_keysk-开头的密钥在 Kimi 开放平台控制台创建不是网页版登录态modelmoonshot-v1-8k/moonshot-v1-32k/moonshot-v1-128k按上下文长度和成本选新模型以账号可用列表为准streamtrue流式开关不传则返回完整 JSONbase_url 是整个接入的钥匙。Kimi 的 API 与 OpenAI Chat Completions 协议兼容所以几乎所有 OpenAI 生态的 SDK、网关、框架都能通过替换 base_url 直接连上 Kimi这也是后面 Spring AI 能零改造接入的前提。api_key 在控制台里创建创建后只显示一次复制出来建议直接写进环境变量而不是写死在代码里。网页版、APP 里的登录态不是 API key拿那个去鉴权只会得到 401。model 的选型思路一般是简单问答、意图识别用 8k单价低、首字快写代码、做总结用 32k分析长文档、整本书级别输入用 128k。上下文窗口越大单价越高先想清楚业务最长会传多少历史消息再决定用哪个别一上来就拉满。2.2 用 curl -N 直连 Kimi一条流式响应在线上长什么样参数确认后先执行下面这条命令不需要写任何代码export MOONSHOT_API_KEYsk-你的密钥 curl -N https://api.moonshot.cn/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer ${MOONSHOT_API_KEY} \ -d { model: moonshot-v1-8k, messages: [{role: user, content: 用一句话介绍杭州}], stream: true }-N参数在这里很关键它告诉 curl 关闭缓冲区收到一个数据包就打印一个模拟前端逐块渲染的效果。不加-N时 curl 可能会攒包等所有内容到达后才一次性打印你会误以为流式没生效。正常情况下你会看到类似下面的输出注意这不是完整的响应体是被流式切成的一段段 SSE 事件data: {object:chat.completion.chunk,choices:[{index:0,delta:{role:assistant,content:},finish_reason:null}]} data: {object:chat.completion.chunk,choices:[{index:0,delta:{content:杭州},finish_reason:null}]} data: {object:chat.completion.chunk,choices:[{index:0,delta:{content:是},finish_reason:null}]} data: [DONE]每一条data:后面是一个合法的 JSON两条事件之间用空行\n\n分隔。所有 chunk 里的delta.content按顺序拼接起来就是模型生成的那句完整回答最后以data: [DONE]收尾。字段细节以你实际抓到的为准但结构一定是这个套路。2.3 看懂 SSE 分片结构delta 增量与事件边界第一次接触流式的人最容易犯的错是把delta.content当成message.content来读。非流式响应里message.content是完整回答而流式响应的每个 chunk 里只有一小段增量文本有时候增量是空的——比如第一个 chunk 只带了role字段用来告诉你角色信息没有 content。所以解析时一定要判空空增量直接跳过。SSE 的事件边界是空行这意味着网络传输可以任意拆分字节流但解析时必须等收集到完整的data:...\n\n才算一个事件。这也是为什么流式解析不能用「收到一点就解析一点」的思路而是要把数据先拼进缓冲区按\n\n切出完整事件后再逐条处理。流式相对非流式还有一个容易被忽略的优势可中断。非流式请求一旦发出去除了粗暴断开连接没有别的办法流式则可以在任意时刻停止读取停止后服务端会感知到连接关闭不再继续生成。这个特性到后面接前端 AbortController 时会变成产品层面的「停止生成」按钮。3. 用 OpenAI 兼容 SDK 接 Kimi 流式Python 与 Node 的最小可跑通脚本curl 验证通过后就可以进入业务代码了。如果你的项目是 Python 或 Node 写的直接用官方 OpenAI SDK 就能连 Kimi不需要自己封装 HTTP 层也不用纠结 SSE 底层解析SDK 全帮你处理好了。3.1 为什么 Kimi 能直接用 OpenAI SDKKimi 的 Chat Completions 接口在请求格式、鉴权方式、响应结构上都与 OpenAI 对齐请求体里同样是model、messages、stream这些字段响应里同样是choices[0].delta.content的增量结构结束标记同样是[DONE]。这意味着 openai 官方 SDK 只要把base_url从 OpenAI 的地址换成 Kimi 的地址其余调用代码一行不用改。团队里如果已经有封装好的 OpenAI 调用层接入 Kimi 的成本就只剩一个配置项。代价是协议兼容也带来了误配模型名的风险比如有人没改 model拿默认的gpt-4o-mini去请求 Kimi直接 400这个坑后文会细讲。3.2 Pythonopenai 库接 Kimi 流式的最小脚本与参数说明Python 侧最小可跑通的脚本长这样from openai import OpenAI client OpenAI( api_keysk-你的密钥, # 生产环境建议从环境变量读取 base_urlhttps://api.moonshot.cn/v1, # 关键指向 Kimi 而非 OpenAI ) resp client.chat.completions.create( modelmoonshot-v1-32k, messages[{role: user, content: 用 200 字介绍杭州}], streamTrue, ) for chunk in resp: delta chunk.choices[0].delta.content if delta: print(delta, end, flushTrue)需要 openai 库 1.0 以上版本。streamTrue是流式开关打开后create方法返回的不是一个完整响应对象而是一个可迭代的 chunk 流。循环里每个 chunk 取choices[0].delta.content注意它可能是None比如角色声明、空增量所以必须判空再拼接。flushTrue是终端里的即时打印开关不加的话 Python 的 stdout 可能攒缓冲看起来就像没在流式输出。生产代码里通常不是 print而是把 delta 传给后续的消息队列或 WebSocket 通道但判空和逐块处理的逻辑完全一样。3.3 Nodeopenai 包接 Kimi 流式并在业务里做增量拼接Node 侧用openainpm 包写法几乎与 Python 一一对应import OpenAI from openai; const client new OpenAI({ apiKey: process.env.MOONSHOT_API_KEY, baseURL: https://api.moonshot.cn/v1, }); const stream await client.chat.completions.create({ model: moonshot-v1-32k, messages: [{ role: user, content: 用 200 字介绍杭州 }], stream: true, }); let answer ; for await (const chunk of stream) { const delta chunk.choices[0]?.delta?.content ?? ; if (delta) { answer delta; process.stdout.write(delta); } }注意两点一是用了可选链?.和空值合并??因为流式中途的 chunk 结构并不保证完整delta和content都可能缺二是累积了一个answer字符串很多人会忽略这一步。流式接口只给你增量最终完整回答需要自己拼这个拼好的answer是后续落库、计费、评估质量的数据源一定要保留。增量拼接还有个工程细节不要在收到每个 delta 时都去写数据库或打日志。生成过程可能每秒吐出几十个 chunk逐条写库会把存储打爆。正确做法是先拼内存流结束时统一写一次。4. 搭建 Spring AI 工程把 Kimi 流式接到对话机器人与 SSE 渲染到了实际业务侧需求通常是「搭建 Spring AI 项目工程实现对话机器人中基本对话和流式输出两大核心功能」。Spring AI 官方对 OpenAI 兼容协议支持得最成熟Kimi 又复用了这套协议所以最直接的路子是引入 openai 相关的 starter把 base-url 指到 Kimi业务代码完全复用 Spring AI 的流式编程模型。4.1 Spring AI 依赖与配置项把 OpenAIChatModel 指向 Kimi先引入依赖版本号建议走 Spring AI BOM 统一管理避免 starter 和核心包版本打架dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency配置文件的写法我这边 Spring AI 1.x 版本下是下面这样spring: ai: openai: base-url: https://api.moonshot.cn/v1 api-key: ${MOONSHOT_API_KEY} chat: options: model: moonshot-v1-32k属性前缀在不同版本里可能有细微差别但核心就三件事base-url 指向 Kimi、api-key 配开放平台的密钥、model 填 Kimi 的模型名。这里也是容易翻车的地方如果不改 modelSpring AI 的默认值是 OpenAI 的模型请求发到 Kimi 地址却带着 OpenAI 模型名结果就是 400。配置完成后Spring AI 会帮你自动装配一个ChatModelBean。它不是专门为 Kimi 写的但它内部连接的地址已经是 Kimi流式调用时对上层完全透明。4.2 Controller 用 SseEmitter 把 Flux 增量推给前端在 Spring MVC 工程里最常见的做法是 Controller 返回SseEmitter把 Spring AI 的Flux流式增量逐个推给前端RestController public class ChatController { private final ChatModel chatModel; private final Executor aiExecutor; public ChatController(ChatModel chatModel, Executor aiExecutor) { this.chatModel chatModel; this.aiExecutor aiExecutor; } GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chatStream(RequestParam String message) { SseEmitter emitter new SseEmitter(60_000L); FluxString stream ChatClient.builder(chatModel).build() .prompt().user(message).stream().content(); aiExecutor.execute(() - stream .doOnNext(content - safeSend(emitter, content)) .doOnComplete(emitter::complete) .doOnError(emitter::completeWithError) .subscribe()); return emitter; } private void safeSend(SseEmitter emitter, String content) { try { emitter.send(SseEmitter.event().data(content)); } catch (IOException e) { emitter.completeWithError(e); } } }ChatClient...stream().content()返回的FluxString每个元素是一段增量文本比直接操作ChatResponse的嵌套结构省事得多。SseEmitter(60_000L)表示 60 秒内没有任何事件就超时断开长回答场景建议设成 300 秒或更长。safeSend里的IOException处理是必要的前端一旦断开send会抛异常这时要调completeWithError让 Flux 的订阅结束否则底层连接已经死了Flux 还在继续生成白烧 token 还浪费资源。4.3 前端 fetch 读流 AbortController 中断一个可直接落地的渲染函数前端接收 SSE 不建议用EventSource原因是 Kimi 这类 OpenAI 兼容接口都是 POST 请求而原生EventSource只支持 GET也没法自定义鉴权 header。用fetch结合ReadableStream是目前最顺的路子const ctrl new AbortController(); async function chatStream(text) { const resp await fetch(/chat/stream?message encodeURIComponent(text), { signal: ctrl.signal, }); const reader resp.body.getReader(); const decoder new TextDecoder(utf-8, { stream: true }); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const events buffer.split(\n\n); buffer events.pop() ?? ; for (const event of events) { const dataLine event .split(\n) .find((line) line.startsWith(data:)); if (!dataLine) continue; const payload dataLine.slice(5).trim(); if (payload [DONE]) return; try { const json JSON.parse(payload); const delta json.choices?.[0]?.delta?.content ?? ; renderDelta(delta); } catch (_) { // 不完整或非数据事件等下一个分片 } } } } function renderDelta(delta) { const box document.getElementById(answer); box.textContent delta; // 用 textContent别用 innerHTML } document.getElementById(stopBtn).onclick () ctrl.abort();解析套路是固定的缓冲区按\n\n切事件保留下半段不完整数据等下一个分片每个事件里找data:前缀的行[DONE]表示流结束delta.content就是渲染内容。停止按钮绑定ctrl.abort()前端中断后fetch 连接关闭服务端 SseEmitter 会抛异常并取消 Flux链路就断了。渲染时用textContent而不是innerHTML拼接既是防 XSS也是避免每来一个增量都触发一次 DOM 重排。4.4 服务端为什么必须异步推送别把 Tomcat 线程堵在生成上上面代码里有个容易被忽略的设计Flux的订阅被放进了aiExecutor.execute(...)。这不是多此一举。chatModel.stream(...)返回的 Flux 是惰性的真正订阅它时才会发请求、收数据。如果直接在 Controller 方法里同步订阅Tomcat 线程会一直占着等整个流结束。一条长回答生成 15 秒这个 Tomcat 线程就被占用 15 秒同时来 50 个用户线程池直接打满。SseEmitter模式的好处是 Controller 方法返回后Spring MVC 立刻释放线程真正的流式生成在独立线程池里跑。这个线程池要单独配别拿 Spring 默认的SimpleAsyncTaskExecutor那个每提交一个任务就新建线程高并发下线程数会失控Bean(name aiExecutor) public Executor aiExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(16); executor.setQueueCapacity(256); executor.setThreadNamePrefix(kimi-ai-); executor.initialize(); return executor; }核心线程 4 到 8 就够流式任务是 I/O 密集型的线程主要在等网络不需要堆太多队列 256 给突发流量留缓冲超过后拒绝策略默认会抛异常前端会看到 500比无限堆积导致内存被打爆好处理。5. 避坑Kimi 流式接入的 5 个高频翻车点从 401 到上下文超限链路跑通只是开始线上翻车往往都在边界条件上。下面 5 个坑我基本都踩过或看同事踩过按「现象 → 原因 → 解决」逐个说清楚。5.1 401 incorrect api keyKey 没生效的三种常见原因现象请求直接返回unexpected status 401 unauthorized: incorrect api key provided: sk-svcac****sk 开头那段就是你的密钥前缀一眼就能认出是鉴权失败。原因基本逃不出这三种一是从控制台复制密钥时带上了隐藏的换行或空格粘贴进 IDE 或 shell 变量里肉眼看不出来二是环境变量没生效进程读到的还是旧的或空的 key三是把网页版或 APP 的登录态当成 API key 用了Kimi 的 API key 只能从开放平台控制台创建网页版账号密码不是一回事。解决顺序固定先在 shell 里echo $MOONSHOT_API_KEY看输出尾部有没有多余字符再用第一节的 curl 带同一个 key 验证最后才轮到检查代码。curl 通过而代码 401基本就是代码里变量名拼错或者配置被覆盖。记住在代码里打明文 key 这种操作上线前一定要清干净我见过一次因为 key 写死在配置里跟着代码仓库泄露的事故。5.2 400 context length 超限1M 也会被撑爆的请求体现象请求报api error: 400 this models maximum context length is 1048576 tokens. however...或者moonshot-v1-128k对应的 128k 超限报错后面通常会跟你的实际 token 数。很多人的第一反应是我传的内容没多大啊是不是接口坏了。实际上这个报错跟业务体感对不上是因为没意识到 Kimi 的上下文窗口算的是整个messages数组的总 token 数。对话轮数一多、历史消息全量回传、system 指令写得冗长累计起来很容易超限。报错里的 1048576 就是 1M 上下文上限看起来很大但你要是把整本小说每轮都全量带上一样会被撑爆。这个问题和流式无关输入校验在生成之前就发生了。解决靠三招滑动窗口裁剪历史只保留最近 N 轮老消息要么丢要么做摘要压缩超长文档别直接塞 messages先切片再检索相关片段送入system 指令精简多余的风格描述挪到产品层。裁剪逻辑要放在组装请求的地方统一处理不要散落在各个业务调用里否则每个新功能都会踩一遍超限坑。5.3 Nginx 把 SSE 缓冲住了前端一直不渲染直到生成完现象直连后端服务字是一个个蹦出来的一走到 Nginx前端就像回到了非流式转圈十几秒后一次性出全文偶尔还直接 502。原因有两层Nginx 默认开启proxy_buffering会尽量把上游响应攒着再转发SSE 这种长连接被缓冲后完全失去实时性另一个是proxy_read_timeout默认 60 秒长回答中途如果超过 60 秒没有新事件Nginx 会掐断连接。SSE 接口直连正常、过网关就变样先查这两项。解决是在 Nginx 的 location 里关掉缓冲并放宽超时location /chat/stream { proxy_pass http://backend-service; proxy_http_version 1.1; proxy_set_header Connection ; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; chunked_transfer_encoding on; }同时后端要给这个接口加X-Accel-Buffering: no响应头和Cache-Control: no-cache。前者是告诉 Nginx 这个响应不要缓冲后者防止中间缓存层把 SSE 当普通页面缓存住。加完这两样SSE 过网关才真正通透。5.4 429 限流与「聊天的人太多」退避重试与队列削峰现象高并发时段请求返回 429 限流错误产品侧能看到「和 Kimi 聊天的人太多了」之类的排队提示业务脚本偶发失败。原因Kimi API 对账号有并发和速率限制忙时会排队这在大模型公有云 API 里是常态不是玄学。流式接口本身又是长连接单路请求占用的服务端资源比普通接口高账号一忙 429 来得更快。解决要分两层瞬时失败做指数退避重试加随机抖动避免所有请求同时重试把网关打崩持续性流量高峰靠业务侧的消息队列削峰把对话请求排进 MQ消费端控制并发数。重试代码里注意只对 429 和网络超时重试业务 400 错误不要重试重试也没用async function callKimiWithRetry(prompt, maxRetries 3) { let delay 1000; for (let i 0; i maxRetries; i) { try { return await chatStream(prompt); } catch (err) { if (err.status ! 429) throw err; await sleep(delay Math.random() * 500); delay * 2; } } throw new Error(Kimi API 持续限流); }另外流式接口的并发控制要单独设阈值。普通 API 并发 50 没问题流式长连接可能 20 路就把账号限额打满了上线前先压测出这个数然后在网关层做并发限流把多余的请求直接返回「稍后再试」好过打进 Kimi 侧被限流重试连环滚雪球。5.5 UTF-8 被拆成乱码TextDecoder 的 stream 参数现象流式输出里英文和数字正常中文偶尔出现这样的替换字符重试几次又好了时灵时不灵很难复现。原因出在 HTTP 分块传输的边界上。一个 UTF-8 汉字占 3 个字节网络分块完全可能在某个字节中间断开前端拿到这段不完整的 Uint8Array 直接解码半个字就被替换成了 UFFFD。这个问题在非流式场景几乎不会遇到因为完整 JSON 一次性解码流式逐块解码正好踩在边界上。解决方式是在创建 TextDecoder 时打开流模式const decoder new TextDecoder(utf-8, { stream: true });流模式下解码器内部会缓存未完成的字节序列等下一块数据到达时补齐跨块汉字就能被正确还原。注意别在循环里每次new TextDecoder()那等于每次都重置状态流模式就失效了。后端推送时也不要自己去做半个字的裁切处理把完整增量交给浏览器边界问题由 TextDecoder 处理这是浏览器标准能力自己 parse 字节反而易错。6. 进阶中止生成与用量核算让流式从「能出字」到「能上线」流式能出字只能算演示版。上线前有两件事很多人会漏中止生成和 token 用量核算。6.1 从最后一个 chunk 里捞 usage计费别漏流式接口和普通接口一样按 token 计费别以为流式就按时间算钱。大多数 OpenAI 兼容实现在流结束时会有一个finish_reason为stop的最后一个 chunk里面带上usage字段。前端或后端拿到它把prompt_tokens、completion_tokens、total_tokens记进日志let usage null; for await (const chunk of stream) { if (chunk.choices?.[0]?.finish_reason stop chunk.usage) { usage chunk.usage; } } console.log(本次消耗 tokens${usage?.total_tokens});如果线上发现最后一个 chunk 没带 usage不同版本兼容性不一致就在非流式请求或日志侧补一次统计。账目这事不能靠估算我习惯把每轮的 usage 打点上报月底对账单时这就是后悔药。6.2 前端 abort 之后后端和 Kimi 侧发生了什么点击停止按钮触发ctrl.abort()后链路是这样的浏览器关闭 fetch 连接服务端的 SseEmittersend抛 IOExceptioncompleteWithError触发 Flux 的 cancelSpring AI 底层关闭到 Kimi 的 HTTP 连接Kimi 侧感知连接断开后停止生成。已经生成完的部分照常计费但后续不再产生新 token。这里最容易翻车的做法是在后端把中断异常 catch 住然后继续 subscribe。前端都断了后端还在跑生成的 token 全白烧。正确逻辑是异常发生后立即结束订阅通知上游停止。前端也要在 abort 后把「停止」按钮置灰防止用户连续点击触发多次中断。6.3 验证流式链路的一句话习惯我现在每接一路大模型 API固定用同一套验收动作先 curl -N 确认原始流式报文正常再开前端看打字机效果和首字延迟然后点停止按钮验证中断同时观察后端日志里 Flux 是否被 cancel、Kimi 侧是否停止计费最后对比一轮会话的 usage 日志与账单数字是否对得上。这四步过了这个流式功能才算稳了希望帮到你。本文还有配套的精品资源点击获取