用 Charles 看懂 AI 对话“逐字回复”:客户端视角的 SSE 流式渲染
用 Charles 看懂 AI 对话“逐字回复”客户端视角的 SSE 流式渲染┌──────────────────┐ ┌───────────────────┐ ┌──────────────────┐ ┌────────────────┐ │ App / WebView │ │ Charles测试环境│ │ Chat BFF / 网关 │ │ 模型服务 │ │ │ │ │ │ │ │ │ │ 空回答气泡 │ │ 观察请求时长、头 │ │ 鉴权并保持响应 │ │ 持续生成增量 │ │ 读取并渲染增量 │ │ 与响应事件 │ │ │ │ │ └────────┬─────────┘ └─────────┬─────────┘ └────────┬─────────┘ └───────┬────────┘ │ ① POST /api/chat │ │ │ ├──────────────────────────►├─────────────────────────►├──────────────────►│ │ │ │ │ ② 生成首段 │ ③ chat.delta │ │ data: {messageId:msg_demo_01,sequence:1,delta:你好}\n\n │ │◄──────────────────────────┼──────────────────────────┤ │ │ ④ 客户端立即追加“你好” │ │ │ │ │ ……持续重复…… │ │ │ ⑤ chat.completed │ │ │ │◄──────────────────────────┴──────────────────────────┘ │ └── 完整回答生成前客户端已经开始展示 ─────────────────────────────────────┘摘要AI 聊天的“逐字回复”通常不是客户端播放动画。更常见的实现是服务端把持续生成的内容编码为 SSEServer-Sent Events响应客户端边接收、边解析、边追加到回答气泡。对客户端而言关键不只是“能收到流”还包括确认传输没有被缓冲、处理超时与取消、并避免把服务端内部实现耦合进 UI。本文按实际排查顺序展开先用 Charles 识别流式请求再看懂一段真实事件流最后运行最小示例并补齐上线需要的边界。读者默认熟悉 HTTP 和基础异步编程。先用 Charles 确认这真的是流式响应吗在自己的测试环境中只对聊天 API 域名开启 Charles 的 SSL Proxying。要查看 HTTPS 明文调试设备需要信任 Charles 证书如果 App 使用证书绑定无法解密是正常安全行为不应为抓包削弱线上策略。发送一条聊天消息后在 Charles 中找到对应的POST /api/chat路径以项目为准。客户端和 Charles 应同时呈现以下现象观察点正常流式时的现象它说明什么客户端界面请求未结束第一段文字已出现用户不必等待完整回答Charles HeadersContent-Type: text/event-stream该响应采用 SSE 格式Charles Duration请求持续数秒而非首段到达后结束同一个响应体仍在持续写入客户端日志多次收到增量时间逐步推进客户端不是一次性拿到全文Charles 用来观察传输链路客户端埋点用来确认真正的到达与渲染时刻。建议让同一轮请求携带requestId它能把客户端日志、Charles、网关和服务端日志关联起来。SSE 是什么一条没有立刻结束的 HTTP 响应普通 HTTP 接口通常等服务端算完后再返回完整 JSONSSE 则让响应体保持打开服务端每产生一段内容就写入一条事件。它是服务端到客户端单向的文本事件流响应类型为text/event-stream。普通 HTTP客户端请求 ───────── 等待 ───────── 完整 JSON ─► 一次性更新 UI SSE 客户端请求 ─► 第一段 ─► 追加 ─► 下一段 ─► 追加 ─► 正常结束 ^ 已可阅读HTTP/1.1 的keep-alive主要是复用 TCP 连接SSE 的关键是当前这一次 HTTP 响应还没有结束。HTTP/2、HTTP/3 同样可以传输 SSE只是底层连接复用方式不同。方案适用场景AI 文本回复中的取舍普通 HTTP一次性查询、批处理简单但用户要等完整回答轮询低频状态刷新空请求多延迟与成本互相拉扯SSE单向文本、进度、日志与“提交一次、持续返回”高度匹配WebSocket双方高频互发消息适合协作、语音等对普通文本回复往往偏重这里的“逐字”只是视觉效果。一次网络读取不等于一个汉字也不等于一个模型 token它可能包含半个事件、一个完整事件或者多个事件。因此客户端必须按 SSE 事件边界解析不能按网络分块直接更新 UI。读懂一段 Charles 抓包一句话如何分段抵达下面是一次脱敏后的完整示意。文中的chat.*是团队自定义的 BFF 示例协议不是 SSE 标准也不是某个厂商的官方事件名。Charles 的 Response 原始文本会在请求完成后完整呈现不同网关和 HTTP 版本的显示细节会不同但data:行和空行的语义相同。Charles Session ────────────────────────────────────────────────────────────── Method: POST URL: https://test-api.example.com/api/chat Status: 200 OK Duration: 2.84 s Response: text/event-stream; charsetutf-8 Request Body开始 ────────────────────────────────────────────────────────────── {message:用一句话解释 SSE,stream:true,requestId:req_demo_01} Response Body按服务端写入顺序 ────────────────────────────────────────────────────────────── id: 1 event: chat.created data: {messageId:msg_demo_01} id: 2 event: chat.delta data: {messageId:msg_demo_01,sequence:1,delta:SSE} id: 3 event: chat.delta data: {messageId:msg_demo_01,sequence:2,delta: 让} : ping id: 4 event: chat.delta data: {messageId:msg_demo_01,sequence:3,delta:回答更快可见。} id: 5 event: chat.completed data: {messageId:msg_demo_01,finishReason:stop} HTTP 响应结束空白行意味着一条完整 SSE 事件结束。客户端的气泡会这样变化id: 2 → SSE id: 3 → SSE 让 id: 4 → SSE 让回答更快可见。 id: 5 → 停止 loading固定最终状态严格说上面按空行分隔的是SSE 事件不是 TCP 网络包一个事件可能被拆到多个网络包多个事件也可能合并在一次读取中。客户端永远按空行组装事件。从外到内看字段以这条事件为例id: 3 event: chat.delta data: {messageId:msg_demo_01,sequence:2,delta: 让}id、event、data是 SSE 协议字段JSON 中的messageId、sequence、delta是业务字段。两层不要混淆。字段代表什么客户端如何使用id: 3事件游标由服务端分配支持续传时标记最后收到的事件不是 token 序号event: chat.delta示例协议中的新增文本事件决定本次应追加文字而不是结束或报错data:事件正文本例约定为 JSON等完整事件到达后再解析messageId这一条助手回答的标识找到正确的回答气泡sequence同一回答内的增量序号发现重复、缺失或乱序属于业务约定delta本次新增的片段执行currentText delta不是完整回答finishReason本轮结束原因仅在完成事件中用于收尾或提示: ping是客户端忽略的注释心跳不能渲染到聊天气泡。超时、心跳与重连流为什么会中途断开SSE 没有统一的最长连接时间。一条流会被客户端网络库、应用服务、反向代理、负载均衡或 CDN 中最先超时的一层关闭。最容易忽略的是空闲超时它限制“多久没有任何字节通过”不是限制请求总时长。无心跳 文本事件 ───────────────长时间无字节──────────────► 网关关闭 有心跳 文本事件 ─── : ping ─── : ping ───► 下一段文本事件 每次重置空闲计时器心跳是一个完整 SSE 注释事件: ping也就是: ping\n\n。它不会显示给用户但可让中间层知道连接仍活着。先找出链路中最短的 idle timeout再把心跳间隔设置得更短并留出余量。例如最短超时为 60 秒时可从 15–30 秒开始验证最终取决于网关、CDN 和移动网络配置。机制解决什么不解决什么: ping尽量避免空闲超时服务端崩溃、网络切换、总时长上限retry: 3000建议原生EventSource断线后的等待时间保活它不会主动发送字节idLast-Event-ID服务端可重放事件时的断点续传随机生成的回答一定能无缝续写chat.completed明确本轮正常结束异常断线的自动恢复GET EventSource可使用浏览器的自动重连机制fetchPOST、Android 和 iOS 的重连策略需要应用自己实现。没有收到约定的chat.completed的连接关闭都应被当作中断保留已显示文字并让用户重试或重新生成。客户端协议只关心展示不耦合服务端内部实现客户端不应该知道服务端有多少内部能力更不应该识别内部检索、数据库查询等名称。服务端负责把内部过程转译成稳定的展示协议客户端只处理回答文字、消息生命周期和可选的展示卡片。服务端内部实现可随时变化 │ 转译 ▼ 客户端协议chat.created → activity可选 → chat.delta* → chat.completed │ └── 客户端只认识固定事件和产品语义事件客户端动作chat.created创建空助手气泡显示 loadingchat.delta按messageId找到气泡并追加deltaactivity可选显示“正在查找资料”等临时、脱敏提示card可选渲染已约定的商品、地图或文件卡片未知类型安全降级chat.completed停止 loading保存最终状态chat.failed/chat.cancelled保留已有片段并展示失败或已停止下面是一个可选展示事件。为说明字段使用 JSONC实际传输时删除注释{ messageId: msg_01J..., // 要更新的回答气泡 kind: retrieving, // 客户端约定的展示类型 label: 正在查找资料 // 用户可见文案 }服务端以后替换内部实现客户端只要仍收到retrieving就不需要修改。只有用户授权、客户端原生能力或专属卡片交互等场景客户端才需要理解更具体的能力协议。跑通最小示例服务端写事件客户端读事件下面的服务端示例只保留三件事写出id event data 空行、最后结束响应。保存为server.mjs后运行node server.mjs再执行curl -N http://localhost:3000/stream。-N会关闭curl自身缓冲。// server.mjsimporthttpfromnode:http;// 写一条完整 SSE 事件id 定位事件末尾空行表示事件结束。constsend(res,id,event,data)res.write(id:${id}\nevent:${event}\ndata:${JSON.stringify(data)}\n\n);http.createServer(async(req,res){// 这个最小示例只提供流式接口。if(req.url!/stream)returnres.writeHead(404).end();// 声明 SSE跨域头仅方便本地浏览器控制台试验。res.writeHead(200,{Content-Type:text/event-stream; charsetutf-8,Cache-Control:no-cache, no-transform,Access-Control-Allow-Origin:*,});res.flushHeaders();// 同一轮回答的业务标识客户端据此定位回答气泡。constmessageIdmsg_demo_01;send(res,1,chat.created,{messageId});// 模拟模型逐段产出文字。for(const[index,delta]of[流式,响应,不是,前端动画。].entries()){send(res,index2,chat.delta,{messageId,sequence:index1,delta});awaitnewPromise((resolve)setTimeout(resolve,180));}// 明确完成再结束 HTTP 响应。send(res,6,chat.completed,{messageId,finishReason:stop});res.end();}).listen(3000,()console.log(http://localhost:3000/stream));预期输出形状如下每个空白行都不可省略id: 1 event: chat.created data: {messageId:msg_demo_01} id: 2 event: chat.delta data: {messageId:msg_demo_01,sequence:1,delta:流式} id: 3 event: chat.delta data: {messageId:msg_demo_01,sequence:2,delta:响应} id: 6 event: chat.completed data: {messageId:msg_demo_01,finishReason:stop}Android 客户端POST 流用 OkHttp 读取响应体Android Chat 通常需要 POST、鉴权和请求体因此直接用 OkHttp 读取ResponseBody。下面示例基于 OkHttp 5.3.0dependencies{// Android 网络层版本应由项目统一管理。implementation(com.squareup.okhttp3:okhttp:5.3.0)}Callback运行在 OkHttp 工作线程示例只负责读流和解析调用方收到回调后应通过 ViewModel/协程切回主线程更新 UI。importokhttp3.Callimportokhttp3.Callbackimportokhttp3.MediaType.Companion.toMediaTypeimportokhttp3.OkHttpClientimportokhttp3.Requestimportokhttp3.RequestBody.Companion.toRequestBodyimportokhttp3.Responseimportorg.json.JSONObjectimportjava.io.IOExceptionimportjava.util.concurrent.TimeUnit// 服务端有心跳时可关闭读超时否则应配置大于心跳间隔的超时。privatevalsseClientOkHttpClient.Builder().readTimeout(0,TimeUnit.MILLISECONDS).build()funstreamChat(prompt:String,onDelta:(eventId:String,messageId:String,sequence:Int,delta:String)-Unit,onCompleted:(eventId:String,messageId:String)-Unit,onError:(Throwable)-Unit,):Call{// 构造 POST 请求真实项目从安全存储读取 Authorization。valbodyJSONObject().put(prompt,prompt).toString().toRequestBody(application/json; charsetutf-8.toMediaType())valrequestRequest.Builder().url(https://test-api.example.com/api/chat).post(body).build()valcallsseClient.newCall(request)call.enqueue(object:Callback{overridefunonFailure(call:Call,error:IOException)onError(error)overridefunonResponse(call:Call,response:Response){response.use{streamResponse-// 先处理非 2xx再逐行读取尚未结束的响应体。if(!streamResponse.isSuccessful)returnonError(IOException(HTTP${streamResponse.code}))valfieldsmutableListOfString()valsourcestreamResponse.body?.source()?:returnonError(IOException(empty body))while(!source.exhausted()){vallinesource.readUtf8Line()?:break// 空行表示一个完整 SSE 事件其余行暂存。if(line.isEmpty()){dispatch(fields,onDelta,onCompleted)fields.clear()}elsefieldsline}}}})returncall}privatefundispatch(fields:ListString,onDelta:(String,String,Int,String)-Unit,onCompleted:(String,String)-Unit,){// 提取标准 id/event/data 字段心跳只有冒号行会自然被忽略。valeventIdfields.firstOrNull{it.startsWith(id:)}?.removePrefix(id:)?.trim()valeventfields.firstOrNull{it.startsWith(event:)}?.removePrefix(event:)?.trim()valdatafields.filter{it.startsWith(data:)}.joinToString(\n){it.removePrefix(data:).trimStart()}if(eventIdnull||data.isBlank())return// 按团队自定义 chat.* 协议分发eventId 可用于日志和断线诊断。valjsonJSONObject(data)when(event){chat.delta-onDelta(eventId,json.getString(messageId),json.getInt(sequence),json.getString(delta))chat.completed-onCompleted(eventId,json.getString(messageId))}}调用时把 UI 更新切回主线程停止按钮保存Call并调用cancel()// 保存 Call供“停止生成”按钮取消本轮请求。valchatCallstreamChat(prompt解释 SSE,onDelta{eventId,messageId,_,delta-viewModel.appendDelta(messageId,delta)// eventId 可同时写入日志。},onCompleted{_,messageId-viewModel.finishMessage(messageId)},onError{error-viewModel.markInterrupted(error)},)// 用户点击“停止生成”时调用。// chatCall.cancel()解析器按空行组装 SSE 事件因此不会把一次 OkHttp 读取误当成一个事件readUtf8Line()也避免了手动处理半行文本。生产实现还应为sequence做去重/缺失检测并将eventId写入日志以便和 Charles 抓包对应。上线时重点检查四件事问题客户端应做什么与 Charles 配合如何定位用户停止生成调用 OkHttpCall.cancel()保留已显示片段确认请求结束服务端还应取消后续后台工作异常断流未收到完成事件就显示“连接中断”提供重试/重新生成对照结束时间、状态码和网关日志高频增量渲染网络层立即读取UI 层按帧或 16–50 ms 批量提交Charles 正常而 UI 卡顿时重点查渲染与线程切换首段慢或被缓冲记录 TTFT 和最后收字节时间检查请求时长、响应头、心跳与网关 idle timeout至少记录这些指标TTFT提交到首个文本增量、相邻事件间隔、完成率、取消率、异常断流率、端到端总时长以及messageId/requestId。对流式接口而言HTTP 200 不等于体验正常也可能是 30 秒后才吐出第一段。结论理解 SSE 最有效的路径是把客户端界面、Charles 抓包和事件文本放在一起看客户端早于请求结束显示文字Charles 显示text/event-stream与持续响应体事件以空行分隔并逐段追加。随后再补上心跳、取消、重试、渲染节流与埋点流式 AI Chat 才能从“本地看起来能跑”变成稳定的线上体验。