SSE流式输出实战:协议、网关缓冲与断线重连排坑

发布时间:2026/10/27 11:40:45

SSE流式输出实战:协议、网关缓冲与断线重连排坑 1. 先把这件被说烂了的事讲透SSE到底在流式对话里扮演什么角色前阵子帮一个团队看他们的对话产品现象很典型后端日志显示模型已经在吐字了前端界面上那个“思考中”的转圈图标还转足了三秒然后一大段文字“啪”地整块蹦出来。用户的第一反应不是“好快”而是“是不是卡了”。这个问题排查下来根子不在模型、不在网络带宽而在服务端那条响应链路被中间层攒着没有往下推。这类问题几乎每个做流式输出的团队都会撞上一次而理解服务器发送事件Server-Sent Events下文简称SSE的机制是绕开它的前提。SSE说穿了就是一件事服务端把HTTP响应开着不关一小块一小块地往里写文本浏览器收到一块就交给回调处理一块。它不是什么新协议就是HTTP/1.1的chunked传输加上一套约定好的文本帧格式连协议升级都不需要。这件事听起来朴素但它恰好匹配了“大模型一边生成一边返回”这个场景的需求——数据单向、内容是纯文本、客户端只需要接收。所以你会看到市面上绝大多数对话式AI产品的流式输出效果背后都是这套机制在撑着。这篇文章想写给三类人看正在做对话类产品、需要把流式效果做扎实的后端工程师需要自己手写流式解析、被EventSource的各种限制绊住的前端同学以及线上出现过“卡顿”“整块蹦字”“连接莫名断开”问题、还没找到根因的运维和架构同学。我会从方案选型算账讲到协议字段的坑从服务端最小实现讲到网关配置最后把线上真实排查过的问题整理成一张速查表。不追求面面俱到但求看完能直接动手改自己的代码。1.1 四种方案摆在一起算笔账为什么最后选的是它做流式推送摆在桌面上的选择其实有四类短轮询、长轮询、WebSocket、SSE。很多团队一开始本能地选WebSocket觉得“双向、全双工、看起来更高级”结果做完发现成本全花在了自己不需要的能力上。短轮询是客户端每隔一两秒问一次“有新内容吗”。它的优点是实现极其简单任何后端框架都能写任何网关都不会拦。缺点是延迟完全由轮询间隔决定间隔调小则请求量暴涨而且绝大多数请求是空手而归。放到流式输出场景里一轮对话可能持续二十秒按1秒间隔就是二十次请求其中十几次返回空数组纯属浪费。长轮询是短轮询的改良版服务端把请求挂住有数据才返回返回后客户端立刻再发一个。延迟降下来了但每次返回都要重新建立一次HTTP请求头部开销反复付出而且连接建立/销毁的抖动在服务端不太好控。WebSocket是真正的双向全双工协议层面从HTTP升级到独立的帧协议。它适合聊天室、协同编辑、在线游戏这类客户端也要高频发消息的场景。但在“用户点一下提问服务端往回吐字”这个流程里客户端在提问之后基本就是个纯接收方双向能力用不上三分之一却要额外承担连接管理、心跳、重连、负载均衡粘性、部分企业网关不支持协议升级等一系列工程负担。SSE的定位就很清楚了单向、服务端到客户端、纯文本、基于标准HTTP。它不需要协议升级中间件、反向代理、CDN、鉴权网关全都认得它是一条普通HTTP请求过基础设施的阻力最小。断线重连这件事浏览器还帮你做了一半自带重连逻辑。维度短轮询长轮询WebSocketSSE通信方向客户端拉客户端拉双向服务端推实时性差受间隔限制较好好好协议升级不需要不需要需要不需要浏览器内置重连无无无有消息格式任意任意二进制/文本仅UTF-8文本过网关阻力极小小较大小连接数限制无无相对宽松HTTP/1.1下同域约6条适合场景低频状态查询通知类双向交互单向流式推送这张表我基本每次技术评审都会拿出来过一遍。选型的判断标准其实就一句你的数据流是不是单向的。是单向的SSE几乎总是性价比最高的那一个需要双向的老老实实上WebSocket。1.2 协议本质一条永远不结束的HTTP响应理解SSE最有效的方式是把它和一次普通HTTP请求做对比。普通请求的流程是客户端发请求服务端算完一次性把响应体写完设置Content-Length连接关闭或者复用。客户端拿到的是完整结果中间过程不可见。SSE的流程是客户端发请求服务端先把响应头写出去并flush然后进入一个循环每当有数据就写一小段文本进去并再次flush。因为响应头里没有Content-Length或者明确使用了chunked编码浏览器不知道这个响应什么时候结束就保持连接打开收到一块解析一块触发一次回调。这个连接可以持续几十秒、几分钟直到某一方主动关闭。这里有个容易被忽略的点响应头必须先发出去。很多框架默认会等你写完整个body才一起发或者在你写入一定量之前做缓冲。如果你没有显式调用“把头部刷出去”的方法客户端那边就一直是“请求已发出、等待响应头”的状态表现出来就是前面说的——模型吐完字了前端才一次性收到。这个坑后面会专门展开。再看帧格式。SSE的响应体不是一个JSON数组也不是分块传输的原始字节而是一套纯文本的行协议规则很朴素每一行以字段名: 值的形式出现冒号后可以有一个空格一行只有一个字段多行组成一个事件空行表示一个事件结束浏览器收到空行才会把攒着的数据交给回调以冒号开头的行是注释行浏览器直接忽略这是做心跳的标准手法。支持的字段只有四个data消息内容、event事件类型对应前端addEventListener的名字、id事件ID会写进Last-Event-ID用于断线续传、retry重连间隔毫秒数服务端建议客户端用。就这四个没有更多了。这种极简设计的好处是实现者不用记太多东西坏处是它没法表达复杂结构——所以实际项目里大家都会把业务数据序列化成JSON塞进data字段。1.3 大模型流式输出选中它的三个硬理由第一个理由是数据方向天然单向。用户提问是一次独立的POST请求模型生成是另一条通道往回推。这两件事本来就是分离的强行用一条双向连接捆在一起反而增加复杂度。第二个理由是内容就是文本。SSE只支持UTF-8文本这在别的场景是缺点在对话场景是完美匹配。模型吐出来的就是token拼成的字符串不需要二进制帧不需要序列化开销。第三个理由是断线恢复有标准答案。移动网络切换、笔记本合盖再打开、Wi-Fi掉一下这些在真实用户身上天天发生。SSE的id字段配合浏览器自动携带的Last-Event-ID请求头给了你一套现成的续传语义服务端发过的每个事件打个序号客户端重连时把这个序号带回来服务端从缓存里把后面的补上。虽然实际项目里因为模型生成的不可重复性多数团队选择的是“重连后重新生成”但协议层面的这套设计思路比你自己在WebSocket上从零实现一套消息确认要省心得多。2. 协议里那些文档一句话带过、实际会坑人的细节官方文档把SSE的格式说得很清楚但它不会告诉你“多行data会被拼成带换行的字符串”“中文被切成两半时怎么办”“EventSource报错时你根本看不到状态码”。这些都得自己在项目里撞一遍。2.1 四个字段的真实行为与双换行分帧先说data。如果一条消息里有多行data浏览器会把它们用换行符\n连接成一个字符串末尾的换行会去掉。也就是说data: 第一行 data: 第二行客户端回调里拿到的是第一行\n第二行。这个特性在你想保留格式的时候很有用但也容易踩坑如果你的内容本身包含\n按行拆分后重新拼接可能出现意想不到的空白。再说id。它的值会保存在客户端内部重连时作为Last-Event-ID请求头发送。注意两点一是空值不更新IDid:后面什么都不写相当于重置但不会清空已存的ID二是ID里不能有\0空字符。retry只接受整数毫秒浏览器收到后会用这个值覆盖默认重连间隔。默认间隔各浏览器不完全一致大致在两三秒左右。event字段决定了前端用哪个监听器接收。不写event时默认是message事件。这个机制在多路复用的场景下很好用——比如同一条流里既推正文token又推引用来源、又推结束信号你可以用三种event名分开处理前端各挂各的监听器。注意分帧靠的是空行也就是连续两个换行符。如果你的服务端一次写入的数据末尾忘了带空行客户端会一直攒着不触发回调如果多写了一个空行就会触发一个data为空的事件前端不做判空就会渲染出一个空消息。我在一个项目里见过更隐蔽的版本服务端用模板字符串拼接帧某次改动把\n\n写成了\r\n\r\n在某些客户端上依然工作但在另一个环境里直接卡住不动。原因是规范里行结束符允许\r\n、\n、\r但不同实现对混合使用的宽容度不一样。稳妥的做法是全程统一用\n别混。2.2 EventSource的默认行为清单以及它管不到的地方EventSource是浏览器内置的客户端用法就一行const es new EventSource(/api/stream?sessionIdabc); es.onmessage (e) appendText(e.data); es.onerror () console.warn(连接异常浏览器会自动重连);它自带的能力包括自动重连、自动带Last-Event-ID、自动解析帧、断线后按retry指定的间隔重试。这些全都不用你写代码确实省事。但它的限制同样明显而且是硬限制只能发GET请求。构造函数只接受URL没有method参数。这意味着你的参数只能塞进query string长文本prompt塞进URL会被长度限制卡死也不好放进日志。不能自定义请求头。所以常见的Authorization: Bearer xxx没法加只能把令牌放query或者依赖Cookie。而依赖Cookie意味着要处理跨域凭据问题得同时配置withCredentials和服务端的Access-Control-Allow-Credentials。拿不到响应状态码。onerror触发时你无法知道是401、403、500还是网络断开。这在排查权限问题时非常难受前端只能统一提示“连接失败请刷新重试”。HTTP/1.1下同域连接数限制。浏览器对同一域名下的HTTP/1.1连接有并发上限通常6条而SSE连接是长期占用的。如果用户同时开了多个标签页或者页面上还有其他长连接很容易出现第7条连接排队、页面其他请求被饿死的情况。开启HTTP/2可以大幅缓解这个问题因为多路复用让多个流共享一条TCP连接但要注意HTTP/2下这个限制变成了流数量限制服务端要相应调整max_concurrent_streams。只支持文本。二进制数据得自己base64编码体积膨胀三分之一。正因为这些限制只要项目稍微正式一点前端几乎都会放弃EventSource改用fetch加流式读取。这不是多余是刚需。2.3 用fetch接管流式读取手写解析器的三个关键点做POST、加请求头、拿状态码、统一错误处理fetch加ReadableStream是现在的标准解法。核心思路是拿到响应的body reader循环读取Uint8Array块用TextDecoder流式解码按帧切分。async function streamChat(payload, onToken, onDone) { const resp await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json, Accept: text/event-stream, Authorization: Bearer ${token} }, body: JSON.stringify(payload) }); if (!resp.ok) { // 这里能拿到真实状态码比 EventSource 强的地方就在这 throw new Error(HTTP ${resp.status}); } const reader resp.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; // stream: true 保证多字节字符被切断时不会乱码 buffer decoder.decode(value, { stream: true }); // 按空行切帧最后一段可能不完整留在 buffer 里 const frames buffer.split(\n\n); buffer frames.pop(); for (const frame of frames) { let eventName message; const dataLines []; for (const line of frame.split(\n)) { if (!line || line.startsWith(:)) continue; const idx line.indexOf(:); const field idx -1 ? line : line.slice(0, idx); const val idx -1 ? : line.slice(idx 1).replace(/^ /, ); if (field data) dataLines.push(val); else if (field event) eventName val; } if (dataLines.length) onToken(eventName, dataLines.join(\n)); } } onDone(); }这段代码里有三个地方最容易出错我逐个说。第一TextDecoder必须用流式模式。一个中文字符在UTF-8里占三个字节网络分块完全可能把它切在中间。如果你用的是decoder.decode(value)不带stream: true被切断的那几个字节会被当作非法序列替换成乱码字符。表现出来就是回答里偶尔冒出一个“”。这个bug复现概率取决于网络分块大小本地测试几乎碰不到一上真实网络就零星出现非常讨厌。第二按\n\n切帧时最后一段必须留在缓冲区。上面代码里frames.pop()拿到的就是可能不完整的那段留到下一轮拼接。如果你图省事直接遍历全部就会出现一帧被拆成两半、两个残缺帧各触发一次回调的情况前端渲染出一堆断句。第三别用resp.text()。有些人为了省事用了await resp.text()然后试图在后面做解析。这会把整个流读完才返回流式的意义就没了等于花了大价钱做了个假的打字机效果。3. 服务端实现从能跑到敢上生产中间隔着多少个坑写一个能跑的SSE接口只需要二十行代码但要让它在生产环境里稳定跑上几个月需要处理的细节比想象中多。3.1 三个必须设置的响应头少一个都可能出问题Node.js的最小实现大概是这样app.get(/api/stream, (req, res) { res.writeHead(200, { Content-Type: text/event-stream; charsetutf-8, Cache-Control: no-cache, no-transform, Connection: keep-alive, X-Accel-Buffering: no }); res.flushHeaders(); // 关键立刻把响应头推出去 const timer setInterval(() { res.write(: ping\n\n); // 注释行做心跳 }, 15000); req.on(close, () { clearInterval(timer); // 这里要顺手取消上游的模型请求省钱 }); });Content-Type: text/event-stream是协议要求的浏览器和EventSource靠它识别。charsetutf-8建议显式写上虽然规范默认就是UTF-8但某些中间设备会按默认字符集猜。Cache-Control: no-cache防止中间代理缓存这条响应。no-transform是为了让压缩代理、图片转码代理之类的东西不要动它。我也见过有团队写no-store效果类似。X-Accel-Buffering: no是给Nginx看的告诉它这条响应别做缓冲。这个头部不是标准但Nginx认它能在不改Nginx配置的情况下解决缓冲问题非常实用。不过如果你的入口是别的反向代理这个头就无效得改配置。flushHeaders()这一步是重中之重。Node的http.ServerResponse内部有缓冲如果不主动刷头部可能跟着第一块body一起发出去。虽然多数情况下第一块数据来得很快但如果你的处理逻辑里有等待模型响应的几秒用户就会盯着“等待服务器响应”看半天。3.2 心跳、超时与客户端断开检测心跳是必需的不是可选的。原因有两个一是中间设备负载均衡、Nginx、云厂商的网关普遍有空闲超时默认常见是60秒长一点的120秒。如果模型生成慢、中间有十几秒没有数据连接可能被中间层悄悄掐断而两端都以为是对方关的。二是某些网络环境下TCP连接的半开状态检测依赖应用层数据。定期发一行注释: ping\n\n是最轻量的保活方式不触发前端任何回调纯粹维持链路。间隔一般取15到30秒我习惯15秒比大多数网关的60秒超时留了足够余量。客户端断开检测同样必需而且直接关系到成本。用户点了“停止生成”或者直接关掉页面HTTP连接就断了但你的服务端如果还在傻乎乎地等着模型吐字那段时间的推理费用就白花了。Node里监听req.on(close)Go里是r.Context().Done()Python异步框架里是request.is_disconnected()。拿到信号后要做两件事清掉心跳定时器以及中断上游的模型请求。后者很多人会忘结果就是日志里一堆“用户已离开但生成还在继续”的记录。还有一个容易忽略的点写失败的捕获。连接断了之后继续往res里write会抛异常ERR_STREAM_WRITE_AFTER_END之类如果不catch轻则刷错误日志重则把进程搞挂。建议所有写入包一层判断function safeWrite(res, chunk) { if (res.writableEnded || res.destroyed) return false; try { res.write(chunk); return true; } catch (e) { return false; } }3.3 代理层与网关90%的“假流式”问题出在这我在开头提到的那个“整块蹦出来”的问题最后定位到的就是Nginx的proxy_buffering。这个参数默认是on意思是Nginx会先把上游的响应攒在缓冲区里攒够一定量或者上游结束才往客户端发。对于普通接口这能提升性能对于SSE就是灾难——你的每一条帧都被攒着用户看到的就是最后一次性出现。修复方式两种任选location /api/stream { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ; proxy_buffering off; proxy_cache off; chunked_transfer_encoding on; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }或者更省事的方式在后端响应头里加X-Accel-Buffering: noNginx会针对这条响应关闭缓冲不用改配置文件。前者适合你有配置权限、想统一管控的场景后者适合你只能改代码、配置由别的团队管的场景。两个都做当然也行不冲突。proxy_read_timeout默认60秒指的是“两次读操作之间的最大间隔”。如果你的流在两次写之间超过这个时间没有数据Nginx会认为上游挂了并断开连接。配合心跳把间隔压到15秒这个值其实可以保持默认但很多团队习惯性调到几百秒甚至0图个省心。我倾向于保守心跳15秒超时设120秒既不误杀也能及早在链路真出问题时暴露。如果你前面还有一层云厂商的负载均衡或者API网关那还得单独确认它们支持不支持长连接、空闲超时是多少、有没有响应缓冲。这些东西不在你的代码仓库里出问题时最难查建议上线前拿curl -N逐层测一遍。3.4 连接数与资源模型别用同步框架干这活SSE连接是长占用的。一个用户在线对话十分钟就有一条连接被占十分钟。这意味着你选的框架和部署方式必须能扛住“大量空闲但存活的连接”。Node.js这类事件驱动模型天然适合几千条空闲连接对内存压力不大因为每条连接占的只是一个socket句柄和一点状态。Go的goroutine也没问题每条连接几十KB栈空间起步。问题出在同步阻塞型的框架上。比如某些Python的同步Web框架每个请求占一个线程线程池通常只有几十个工作线程几条SSE连接就能把池子占满导致整个服务无法响应别的请求。如果你用的框架是这种模型务必换成异步版本或者干脆把SSE接口拆到独立的服务里别和主业务抢线程池。多实例部署时还有个问题如果客户端重连会打到另一台机器那么Last-Event-ID的续传就失效了因为缓存不在那台机器上。解决办法有三种——把流状态放外部存储Redis之类、用一致性哈希做路由、或者干脆放弃续传重连时重新生成。第三种最省事实际项目里选它的最多代价是用户会看到内容重来一遍所以前端最好给个“重新生成中”的提示别让用户以为是bug。4. 对接模型输出从“看起来像流式”到“真的是流式”把SSE通道打通只是第一步。我见过不少项目链路是通的、帧也是一个个发的但用户体验依然不好——因为发得太碎或者发得太慢或者中途还在做二次加工。4.1 上游是真流式吗先确认这件事模型服务商提供的流式接口返回的通常也是SSE格式。你的服务端要做的是解析上游的帧取到增量文本再重新封装成自己的帧往下发。这里有两个常见失误。第一个失误是上游读完再转发。有些实现为了“方便处理”先把上游流读完整合再逐字往外发。表面上看前端也有打字效果但是首字延迟完全没改善——用户还是要等模型全部生成完才看到第一个字。判断方法很简单看服务端日志里“收到上游第一块”和“下发第一块”的时间差正常应该是毫秒级。第二个失误是缓冲策略不当。追求极致的话上游来一个token就转发一个token但这会产生大量极小的TCP包网络开销反而大。比较实际的做法是按时间或按大小做轻量合批累积到一个句子边界或者超过50毫秒就 flush 一次。这样既保证视觉上的连贯感又不会把连接打满。我做过的项目里50到80毫秒的发车间隔在视觉上最舒服太碎反而显得“抽搐”。4.2 前端渲染Markdown增量解析的坑比你想的多模型输出通常是Markdown前端要边收边渲染。这里最典型的问题是收到半个代码块。比如已经收到了python开头但闭合的三个反引号还没来这时候如果直接丢给Markdown渲染器整个页面的DOM结构可能被打乱后面的内容全跑到代码块里去了。处理方式有几种。稳妥的做法是每次渲染前对文本做一次“未闭合标记检测”统计的数量奇数就在末尾临时补一个闭合符再渲染下一个token到了再重新算。粗体**、行内代码的反引号同理。另一种做法是流式期间只做纯文本渲染等done事件到了再做一次完整Markdown渲染替换掉。后者实现简单但视觉上会闪一下前者体验更好但要处理边界情况。还有一个性能问题如果每收到一个token就触发一次React的setState一秒几十次重渲染会让页面明显掉帧尤其是在长对话里DOM节点已经很多的情况下。常规解法是用requestAnimationFrame做节流把同一帧内的多个token合并成一次更新或者用专门为流式设计的增量Markdown解析库只更新变化的那一小段。4.3 结束信号与错误透传别让前端干等SSE流怎么告诉前端“说完了”两种方式服务端主动res.end()关闭连接或者发一个约定的事件名比如event: done。我推荐两者都做先发event: done带一个结束标记可以包含token用量、耗时等统计再关闭连接。原因是前端收到done之后可以立刻停止loading状态、把消息标记为已完成、释放节流定时器而连接关闭这个动作本身前端不一定能区分是正常结束还是异常断开。如果只靠关闭连接那用户看到的可能是“回答到一半光标还在闪”。错误透传同理。如果模型调用中途失败别只是把连接关掉应该发一个event: errordata里带上错误码和可读的提示然后关闭。前端收到后可以把已生成的部分保留在下面补一条错误提示让用户能点重试。比起整个回答消失、只弹一个“网络错误”这种处理体面得多。5. 排查实录那些线上真实出现过的问题下面这些是我和团队在不同项目里实际撞过的按现象归类整理方便对照。5.1 常见问题速查表现象大概率原因排查手法处理方式内容整块蹦出无逐字效果代理层缓冲或框架未flushcurl -N直连服务端看是否逐块输出加X-Accel-Buffering: no或关proxy_buffering偶尔出现乱码字符多字节字符被分块切断检查解码是否用流式模式TextDecoder加stream: true连接约60秒后固定断开网关空闲超时看断开时间是否规律加15秒心跳注释行第7个标签页无法建立连接HTTP/1.1同域6连接上限看是否在多标签场景复现上HTTP/2或减少长连接数用户点停止后仍在计费未监听断开事件看日志中是否有已断连接的写入监听close中断上游请求重连后内容重复从头开始多实例无共享状态检查重连是否落到不同节点接受重生成或做状态外置前端报错但看不到原因EventSource不暴露状态码抓包看响应状态改用fetch方案长回答后期越来越卡每token全量重渲染看DevTools性能面板rAF节流或增量渲染中文标点被拆成两次渲染按字节切分而非按帧看是否有半帧被处理修正缓冲区分帧逻辑5.2 几个我常用的调试手法用curl -N直连。-N是关闭curl自身的缓冲。这条命令能最快分辨问题出在服务端还是中间的代理层curl -N -H Accept: text/event-stream http://127.0.0.1:3000/api/stream?qtest如果直连能看到一行行往外冒而通过域名访问是整块的那问题百分之百在中间层不用去翻业务代码。浏览器DevTools的EventStream面板。新版Chrome在网络请求详情里有一个专门看事件流的标签页能看到每一帧的时间戳、事件名和内容。判断“是不是真流式”看时间戳的间隔就够了——如果所有帧的时间戳都一样说明是被攒着一起到的。服务端打三个时间点。请求进来、上游第一块到达、下发给客户端第一块。这三个时间点能定位绝大多数性能问题。我习惯把这三个值顺手写进结构化日志出问题时不用复现翻日志就行。故意造一个慢上游。在自己的测试环境里把模型响应换成每两秒吐一个字然后观察前端表现。这个笨办法能暴露所有和超时、心跳、重连相关的问题比在真实环境里碰运气高效得多。5.3 容量这件事别拍脑袋SSE连接的资源消耗主要不在CPU而在内存和文件描述符。每条连接大概占几十KB的内存取决于框架的缓冲设置加上一个socket句柄。一台中等配置的机器系统层面文件描述符调到几万扛住几千条并发长连接是没问题的。真正的瓶颈通常在模型侧。一次对话要占一个推理并发你的SSE连接数上限实际上等于并发推理能力乘以平均对话时长内的到达率。做个简单估算如果平均一轮对话20秒服务端能同时处理100路推理那么理论上一分钟内能服务300轮对话但如果用户平均每5分钟才发一次同时在线人数就能撑到1500左右。这个数决定了你要开多少实例也决定了要不要做排队和限流。压测的时候要注意普通的压测工具发完请求就断开测不出长连接的真实表现。得用能保持连接的脚本模拟“建立连接—持续读—中途断开”的完整生命周期否则压出来的数字是假的。提醒一句上线前一定要在真实网络环境移动4G/5G、弱网模拟下走一遍完整流程。本地局域网里跑得再顺也挡不住移动网络切换时的那一次重连。6. 最后说点实操之外的东西我做过的几个对话产品里性能指标最好看的那个用户满意度反而不是最高的。后来复盘发现差距全在细节体验上首字出来的那一刻有没有光标、生成过程中滚动是不是跟手、用户点了停止之后界面给不给反馈、网络抖一下之后是重来还是接上。这些东西在技术方案里一个都不会写但它们决定了用户觉得这个产品“快不快”。有个具体的做法我一直保留着在流式开始时立刻渲染一个空的助手气泡加闪烁光标而不是等第一个token到了才创建节点。视觉上这一下把“等待”变成了“开始”主观感受差别很大。另一个是给“重新生成”加个明确的状态别让重连重生成的内容和上一次的混在一起用户会以为自己看错了。SSE本身没什么神秘的二十行代码就能跑起来。真正花时间的是把它在真实网络、真实网关、真实用户行为下打磨稳定。上面这些坑我基本每个都踩过一遍希望你看完能少踩几个。
延伸阅读

更多相关文章

2026/10/27 11:40:45

大模型与知识图谱融合架构设计与实践

1. 大模型与知识图谱的融合趋势 最近两年,大模型和知识图谱这两个技术方向都获得了突破性进展。作为一名长期跟踪AI技术发展的从业者,我观察到这两者的结合正在催生新一代智能系统。大模型以其强大的语言理解和生成能力著称,而知识图谱则以结…

2026/10/27 11:40:45

AI代理视频渲染如何实现确定性?HyperFrames框架原理与实践

1. 为什么“确定性渲染”是 AI 代理做视频的第一道生死线如果你最近在折腾 AI 代理自动生成视频,大概率遇到过这种让人抓狂的情况:同一个提示词、同一份素材、同一个脚本,跑第一遍出来的视频没问题,跑第二遍字幕错位了&#xff0c…

2026/10/27 11:40:45

基于改进Mask R-CNN的自卸车部件识别与优化实践

1. 项目背景与核心挑战 自卸车作为工程机械领域的重要运输工具,其关键部件(如货箱、液压缸、车架等)的实时识别与状态监测对智能运维和自动化作业至关重要。传统人工检测方式存在效率低、成本高、主观性强等问题,而基于深度学习的…

2026/10/27 12:31:05

TailwindCSS 背景渐变动效实战与优化

1. TailwindCSS 背景渐变动效实战指南在现代化前端开发中,背景渐变效果已成为提升界面视觉层次的关键手段。TailwindCSS 作为当下最流行的原子化 CSS 框架,其渐变系统却常被开发者低估。本文将深入解析如何突破默认配置限制,实现复杂渐变场景…

2026/10/27 12:31:05

Unity新手系统化学习指南:从零搭建3D交互场景

1. 为什么Unity新手需要系统化学习?三年前我第一次打开Unity时,被网上各种零散教程搞得晕头转向。有人教如何导入模型,有人讲物理引擎,但没人告诉我这些模块之间该如何配合。直到浪费了两周时间后,我才意识到&#xff…

2026/10/27 12:31:05

在线GIF制作工具评测与实用技巧

1. 在线制作GIF闪图的核心价值 GIF闪图作为一种动态图像格式,已经成为现代网络社交中不可或缺的表达方式。相比静态图片,它能通过循环播放的短动画更生动地传递情绪和信息。而在线制作工具的出现,彻底改变了传统需要下载专业软件才能制作GIF的…

2026/10/27 12:31:05

Delphi直连MySQL:libmysql.dll版本适配与原生C API封装指南

简介:本资源是面向Delphi中高级开发者的技术实践包,聚焦于绕过OLE DB、直接调用MySQL原生客户端库(mysql.dll)实现数据库连接与操作的核心方案。适用于需提升数据库访问性能、规避Windows平台中间层依赖、或定制轻量级数据访问层的…

2026/10/27 12:31:05

马尾发型全攻略:从高度定位到高颅顶不塌的实操技巧

说实话,过去我对马尾的印象就是“没时间洗头时的遮羞布”:随便抓一把头发用皮筋一捆,五分钟出门。直到去年开始认真研究ponytail这个造型,才发现它远比想象中有技术含量,甚至值得当作一个完整项目来拆解:同…

2026/10/27 12:26:05

Claude Code输出风格与系统提示词实战指南

1. Claude Code深度解析:Output Styles与系统提示词的实战应用 在AI辅助编程领域,Claude Code正逐渐成为开发者工具箱中的重要成员。作为一名长期使用各类编程助手的开发者,我发现真正发挥其潜力的关键在于掌握Output Styles(输出…

2026/10/26 0:20:20

全国计算机二级C语言真题PDF高效刷题指南

简介:这份PDF是全国计算机二级C语言考试的试题与答案解析,专为备考二级C语言的考生准备。内容涵盖C语言特点与设计理念、程序基本组成单位、main函数执行流程、函数嵌套调用规则、标识符命名规范、整型实型字符型数据类型、八进制十六进制数合法形式、运…

2026/10/27 9:34:54

requests+BeautifulSoup:爬虫抓取h1标题的入门实战指南

简介:一份以实战为导向的Python爬虫入门资源,面向具备基本编程基础、希望快速掌握网络数据抓取的初学者,可解决从零编写简单爬虫的常见困惑。实例以requests库发送HTTP请求、BeautifulSoup解析HTML文档,完整演示从网页中提取 标题…

2026/10/27 0:59:21

递推最小二乘参数辨识MATLAB编程:二阶系统与渐消记忆实现

简介:这是一篇面向控制系统、信号处理与机器学习等领域学习者的最小二乘参数辨识程序说明文档。资源共包含一个docx格式文档,压缩包大小七十八KB,内容围绕递推最小二乘算法和渐消记忆最小二乘算法展开:先构造一个极点位于单位圆内…

2026/10/27 0:02:28

WorkBuddy_新建任务与工作模式19_“模式选错”的典型代价

“模式选错”的典型代价 摘要 在软件工程中,设计模式、架构模式、并发模式乃至团队协作模式的选择,往往在项目早期就决定了后续的开发效率和维护成本。本文通过多个真实场景,剖析“模式选错”带来的典型代价——包括返工成本、技术债务累积、…

2026/10/27 0:02:28

WorkBuddy_新建任务与工作模式20_新手推荐路径

新手推荐路径 摘要 在 AI 辅助开发工具快速普及的今天,新手面对最多的困惑往往不是"工具够不够强",而是"我该从哪一步开始用"。本文提出一条经过实践验证的三阶段学习路径:先从 Ask(问答式交互)起…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑