资讯动态

大模型流式调用全指南:从HTTP分块传输到前端逐字渲染

发布时间:2026/10/19 18:15:07 来源:尧图企业网站定制
做后端或者AI应用开发的朋友应该都有过这种体验调一个大模型接口输入提示词后页面就干等着十几秒甚至几十秒过去,才一次性吐出一整段回答。遇到网络波动整段全断只能重来。这种模式叫一次性返回而真正适合AI对话场景的其实是流式调用请求发出后服务端每生成一小段内容就立刻推给客户端前端像打字机一样逐字输出。我之所以把这个话题单独拿出来写是因为它绝不只是加一个stream参数那么简单背后牵扯到HTTP分块传输、SSE协议、前后端配合、异常恢复一整套链路。这篇文章就从原理讲到实战适合正在开发聊天机器人、流式输出应用或者想把大模型接入到低延迟场景的开发者。1. 流式调用的核心思路与适用场景1.1 为什么非流式调用让人抓狂先把问题拆开看。非流式调用的完整流程是客户端把请求发给服务端服务端把整个提示词转发给大模型大模型内部逐字生成完整文本全部生成完毕后再一次性通过HTTP响应返回客户端。整条链路里用户能感知到的只有两个节点请求发出、完整响应到达。对大模型来说一段长文本的完整生成可能要十几秒这十几秒对用户完全是黑盒。做个简单测算假如你要生成一篇2000字的报告中文大约对应2500个token主流模型每个token生成耗时约20毫秒整个过程接近50秒。非流式调用意味着用户盯着进度条干等50秒期间看不到任何内容。我实际测过一段500字的回答非流式调用的首字延迟通常在8到15秒之间而流式调用能把首字延迟压到1到2秒。对对话类应用来说这两种体验之间的差别可能是「这个产品用户是否愿意用第二次」的差别。另一个致命问题是超时。网页端和移动端都有默认超时限制很多场景下读超时是30秒一旦大模型生成时间超过这个阈值连接直接断开用户看到的就是「请求失败」前面等待的时间里一个字都没留下。网络稍差时整个响应体还会因为中间某段TCP丢包导致整包重传体验雪上加霜。用生活类比来讲非流式就像去餐厅点了一道需要现做的菜厨房全部做完服务员才一次性端上来等的过程你只能干坐着流式调用则像吃火锅涮菜涮好一片吃一片。虽然整顿饭的总时长差不多但你的嘴里始终有东西体验完全不同。1.2 流式调用带来的四个直接收益第一首字延迟大幅降低。用户发出请求后第1个token一到就立刻展示对话应用要的就是这种「正在思考」的即时反馈哪怕后面生成速度再慢用户至少知道系统在干活。第二网络容错性变好。流式连接如果中断已经接收到的内容都还在可以基于已有内容做局部处理或提示用户重新生成而不是从零再来。某些场景下配合断点续传技术甚至能从断掉的位置继续接收。第三内存压力更平稳。一次性返回必须把完整文本缓存到内存里才能解析几千字的响应可能无所谓但当响应体达到几十MB时客户端内存占用会很难看。流式返回的数据是持续小份到达的可以边到边消费内存占用始终在低位。第四服务端资源利用更合理。非流式模式下网关和业务服务必须把一个完整的大响应全部接收完才释放连接长耗时请求会长期占用连接池。流式模式下数据持续往外发连接长期活跃但每一段处理都很轻整体并发能力更友好。1.3 典型适用场景流式调用最常见的场景就是大模型对话应用。除此之外我在实际项目中还碰到过不少场景适合走流式长文档生成与摘要。一份几千字的行业报告用户希望看到内容一段段地出来而不是一个进度条转半小时。流式输出还能让用户在生成过程中提前发现方向不对及时打断重写省下大量时间。实时翻译与语音转写。同声传译和语音识别天然是增量产生的识别出一句推一句比等全文识别完再一次性返回要合理得多。音频流本身就是流式的结果也应当是流式的。数据导出与日志推送。批量查询结果、监控日志流、系统实时事件这些数据的特点是量大、持续产生。按行推送给客户端比全量一次性返回对网络和浏览器都公平用户也能边接收边处理。命令执行的实时回显。远程执行脚本、运行构建任务、部署应用时把标准输出逐行推给前端执行进度一目了然。中断时还能保留已输出的日志排查问题方便很多。这些场景的核心共同点只有一个数据是逐渐产生的而不是一次性存在的。如果数据本身就具备流式特征却用非流式方式传输那就是在跟物理规律对着干。对比维度非流式调用流式调用首字延迟高往往10秒以上低通常1到2秒用户反馈转圈等待逐字输出超时风险高长文本易超时低连接保持活跃内存占用需缓存完整结果边到边消费断线恢复数据全丢可部分恢复2. 流式调用的底层原理与关键技术2.1 HTTP 分块传输与流式响应的关系很多人以为流式是WebSocket的专属能力其实用最普通的HTTP就能实现。HTTP/1.1协议中有一个Transfer-Encoding: chunked允许服务器不事先告诉客户端响应体的总长度而是把响应体拆成若干块逐一分发。每块的格式是「块大小十六进制 CRLF 块数据 CRLF」最后以大小为0的块结束。举个例子原始文本是Wikipedia分块传输时可能长这样HTTP/1.1 200 OK Transfer-Encoding: chunked 4\r\n Wiki\r\n 5\r\n pedia\r\n 0\r\n浏览器和curl这类工具会自动把这些块组装回完整内容但对应用层开发者来说我们感受到的现象是数据不是一次性到达的而是一段一段陆续出现的。在命令行里验证流式效果最简单的办法就是用curl加-N参数curl -N https://api.example.com/v1/chat/completions \ -H Authorization: Bearer YOUR_API_KEY \ -H Content-Type: application/json \ -d {model:some-model,messages:[],stream:true}HTTP/2则更进一步引入了数据帧DATA frame的概念不同流的数据帧可以在连接上交织传输。不过对大多数API调用场景来说我们感知到的底层模型还是「连接建立 → 陆续收到数据 → 收到结束标记」。这里要澄清一个常见误解分块传输只解决「数据分几批到达」的问题不解决「每批数据的业务含义」。HTTP层可能把一个完整的SSE事件拆成多个网络包也可能把几个事件合并成一个网络包一起推过来。应用层拿到的是原始字节流事件边界必须由应用协议自己界定。这个边界问题在后面的实战环节会反复遇到。2.2 SSE 协议为什么适合大模型场景SSE全称Server-Sent Events是一种基于HTTP的轻量级服务端推送协议。它的数据格式非常简单响应头里把Content-Type设置为text/event-stream每一条消息以「data: 内容」字段开头消息与消息之间用空行分隔。交付给前端后EventSource对象会自动解析成事件。大模型平台普遍采用类似SSE的格式常见的返回流长这样data: {choices:[{delta:{content:你}}]} data: {choices:[{delta:{content:好}}]} data: [DONE]虽然有些平台不完全符合标准SSE规范有的省略空行有的用自定义字段但核心思路是相同的把一段段JSON逐行推给客户端客户端按行解析。相比WebSocketSSE在大模型场景有几个实打实的优势一是实现成本极低。服务端只要会写HTTP响应就能做不需要额外的握手协议和帧解析。二是天然支持自动重连。EventSource内置断线重试机制省去自己写心跳和重连的麻烦。三是更安全。SSE是单向推送不容易触发跨站双向通信问题也更方便通过代理和防火墙。四是更符合大模型场景的通信模型。请求是单向的响应是多段的正好匹配SSE的「一请求、多响应」模式。2.3 WebSocket 什么时候才需要WebSocket是双向全双工协议适合需要客户端和服务端频繁互发的场景。在纯大模型对话场景里请求是单向的响应是流式的用SSE就足够。但如果你的应用需要用户随时中断生成同时还想持续推送其他状态事件比如音视频流、多人协作状态、游戏状态这时候WebSocket会更合适。我见过不少团队把SSE够用的场景硬改成WebSocket结果引入心跳、重连、消息序号、二进制帧等一系列复杂度最后得不偿失。选型之前先问自己你真正需要的是「客户端频繁往服务端发消息」吗如果只是「服务端往客户端持续推数据」SSE就是更优解。对比项SSEWebSocket通信方向服务端到客户端单向双向全双工协议基础普通HTTP独立握手加TLS自动重连内置需自行实现消息格式文本data: 开头文本或二进制均可复杂度低中高适用场景流式文本、事件推送聊天室、实时协作、音视频对大模型流式输出这个具体问题我的建议很直接优先用SSE别想太多。3. 后端实现流式调用的实操3.1 Python 客户端发起流式请求假设已经拿到了某大模型平台的API地址和密钥接口文档明确写了支持stream参数。最基础的做法是用requests库写一段流式请求import requests url https://api.example.com/v1/chat/completions headers { Authorization: Bearer YOUR_API_KEY, Content-Type: application/json } payload { model: some-model, messages: [{role: user, content: 请写一篇500字的短文}], stream: True } with requests.post(url, jsonpayload, headersheaders, streamTrue, timeout(10, 60)) as resp: resp.raise_for_status() for line in resp.iter_lines(decode_unicodeFalse): if not line: continue if line.startswith(bdata:): data_str line[len(bdata:):].strip() if data_str b[DONE]: break # 这里拿到一个JSON片段继续解析 print(data_str.decode(utf-8))这段代码有四个关键点。第一streamTrue必须设置否则requests会在响应结束后一次性把整个内容读入内存后面的iter_lines就毫无意义。第二timeout参数我特意传了元组(10, 60)分别对应连接超时和读取超时。连接阶段通常在几百毫秒内完成10秒足够读取阶段要等大模型陆续输出60秒更稳妥。如果只传一个数字连接和读取都会用这个值长文本生成时很容易中途断流。第三iter_lines的decode_unicode参数设为False。平台返回的行里JSON块中可能包含非ASCII字符不同平台对unicode行的处理方式不完全一致。与其让requests帮你解码不如自己拿到bytes后统一用utf-8解码处理更可控。第四resp.raise_for_status()不能省。如果密钥错误或参数有问题服务端可能返回401或400不检查直接解析会得到一堆无法理解的错误信息。先把状态码抛出来问题定位最快。3.2 用 httpx 做异步流式调用如果后端是FastAPI这类异步框架requests就不够看了。requests是一个纯同步库如果在异步事件循环里直接调用会阻塞整个进程并发请求时性能断崖式下跌。推荐换用httpx它同时支持同步和异步API风格和requests非常接近import httpx async def stream_chat(messages): url https://api.example.com/v1/chat/completions headers {Authorization: Bearer YOUR_API_KEY, Content-Type: application/json} payload {model: some-model, messages: messages, stream: True} async with httpx.AsyncClient(timeouthttpx.Timeout(10.0, read120.0)) as client: async with client.stream(POST, url, jsonpayload, headersheaders) as resp: resp.raise_for_status() async for line in resp.aiter_lines(): if not line: continue if line.startswith(data:): data line[len(data:):].strip() if data [DONE]: break yield datahttpx.Timeout区分了连接超时10秒和读取超时120秒。这个设计很合理连接建立不需要太长时间但读取阶段受模型生成速度影响必须给足余量。async for line in resp.aiter_lines()会在每行数据到达时立刻返回配合yield就能把流式数据往外抛。我通常把这个生成器直接接到FastAPI的StreamingResponse里形成「大模型流 → 后端流 → 前端流」的完整链路。3.3 服务端用 FastAPI 把流式数据转发给前端有些场景让前端直接调大模型平台这样会暴露API密钥也不方便做内容过滤、统计等中间逻辑。更合理的做法是让后端做代理后端接收大模型的流式数据解析或透传成SSE格式再转发给前端。FastAPI里最顺手的工具是StreamingResponsefrom fastapi import FastAPI from fastapi.responses import StreamingResponse app FastAPI() app.post(/chat) async def chat_endpoint(payload: dict): messages payload[messages] async def event_generator(): async for chunk in stream_chat(messages): yield fdata: {chunk}\n\n return StreamingResponse( event_generator(), media_typetext/event-stream, headers{Cache-Control: no-cache, Connection: keep-alive} )这里有两个非常容易踩的坑。第一个坑是media_type必须设为text/event-stream。浏览器只有看到这个Content-Type才会识别为SSE流并特殊处理。如果你写成application/json前端拿到的就是困惑的错误提示。第二个坑是Cache-Control: no-cache。如果缺少这个响应头中间代理层可能会把整个流缓存下来那结果就是从流式退化成一次性返回。这个问题我在5.4节还会详细讲。如果前端和后端不在同一个域名下还需要在FastAPI里配置CORS否则浏览器会因为跨域限制直接拒绝读取流from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins[http://localhost:5173], allow_methods[POST], allow_headers[Content-Type], )注意allow_origins在生产环境不要用通配符特别是涉及流式接口携带鉴权信息时通配符加credentials的组合会被浏览器直接拦截排查半天才发现是CORS配置问题很气人。4. 前端接收流式数据与逐字渲染4.1 用 fetch ReadableStream 逐块读取如果直接用EventSource它会自动解析SSE消息确实省事。但它有两个致命限制EventSource只能用GET方法而大多数大模型平台的接口都是POST同时它没法自定义Authorization请求头这是浏览器层面的限制。所以实际开发中我几乎不用EventSource做流式对话而是用fetch配合ReadableStream手动读取。标准写法如下async function chatStream(messages) { const response await fetch(/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages }) }); const reader response.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 lines buffer.split(\n); buffer lines.pop(); for (const line of lines) { if (line.startsWith(data:)) { const data line.slice(5).trim(); if (data [DONE]) continue; try { const json JSON.parse(data); const delta json.choices?.[0]?.delta?.content || ; if (delta) { appendToUI(delta); } } catch (e) { console.warn(解析失败, data, e); } } } } }reader.read()返回一个Promiseresolve之后得到的对象带两个字段done表示流是否结束value是当前这一块的Uint8Array字节数组。循环读取直到done为true就是整个流式响应走完的标准流程。注意buffer.split(\n)和lines.pop()这两行代码。因为网络包边界不稳定最后一行很可能是不完整的半行比如一个JSON只收到一半。不能直接处理必须放回buffer等下一段数据到达后拼完整再处理。这种「保留半行」的手法是流式文本处理的基本功。4.2 TextDecoder 的 stream 模式为什么不能省略这是我在流式调用里遇到频率最高的坑。流式返回的数据是字节流一个中文字符在UTF-8编码下占3个字节模型可能一次只吐出一个token的部分字节。举例来说「你」字的UTF-8编码是E4 BD A0三个字节网络分段时可能第一块只包含E4 BD第二块才包含最后的A0。如果直接用decoder.decode(value)而不开启stream模式第一个块会被当成无效的残缺序列丢弃中文就会出现「吞字」甚至乱码。有朋友问为什么不把value先拼起来等凑满3个字节再解码问题的关键在于流的边界不可控你无法保证一个完整的UTF-8字符不会横跨两个网络包也无法保证每次拿到的字节数恰好是3的倍数。正确做法就是上面代码里那样在创建TextDecoder时传入{ stream: true }decode时也传{ stream: true }。这个选项专门用于处理多字节编码字符被截断的边界情况让解码器内部记住状态下一段数据到达时接着上一段继续解。另外再补充一个buffer溢出的预防。如果某一行特别长远超正常JSON的长度很可能数据异常buffer会无限增长导致内存膨胀。稳妥的做法是给buffer设一个上限超过1MB就清空并打警告if (buffer.length 1024 * 1024) { console.warn(buffer overflow, clear it); buffer ; }4.3 打字机效果与渲染性能拿到delta后直接DOM操作是最简单的做法document.querySelector(#output).textContent delta;但内容一长频繁的append会导致页面卡顿尤其是在低端手机浏览器上数据到达速度远快于渲染速度界面会一顿一顿最后所有内容堆在一起突然全部出现。我通常维护一个文本队列用定时器批量追加const queue []; let timer null; function appendText(chunk) { queue.push(chunk); if (!timer) { timer setInterval(() { if (queue.length 0) { clearInterval(timer); timer null; return; } const text queue.shift(); document.querySelector(#output).textContent text; }, 16); } }16ms对应浏览器一帧的刷新间隔用这个节奏批量消费队列肉眼看起来就是平滑的打字机效果。如果不想用setInterval也可以用requestAnimationFrame配合队列长度判断原则是一样的把高频的流式数据变频到与渲染帧率匹配的消费速率。更进一步的优化是把输出容器改成虚拟滚动或分段渲染。当文本长度超过几千字时一次性把全部文本设置到textContent里会导致重排开销飙升。我的做法是每超过2000字就开启一个新的内容块旧块固定只向当前块追加文本。4.4 处理生成中断与用户取消流式对话应用几乎都有一个「停止生成」按钮。不能简单地把连接断掉就完事至少需要两件事调用reader.cancel()释放流资源然后通过AbortController通知fetch取消整个请求。标准写法const controller new AbortController(); async function chatStream(messages) { const response await fetch(/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages }), signal: controller.signal }); // ...读取流 } // 用户点击停止 stopBtn.onclick () { controller.abort(); };fetch传入signal后调用controller.abort()会中断所有后续的网络活动触发AbortError异常。需要在读取循环里捕获这个异常避免页面报红try { while (true) { const { done, value } await reader.read(); if (done) break; // ... } } catch (e) { if (e.name AbortError) { console.log(用户主动取消); } else { throw e; } }如果平台还提供了取消任务的服务端接口记得在abort后调用一下让大模型那边也停止生成避免后台继续消耗算力。有些模型是按token计费的生成了用户不要的内容浪费的是真金白银。5. 常见问题与排查技巧实录5.1 中文乱码或丢字这是流式调用出现频次最高的问题。排查思路按顺序走三步先看前端是否用了TextDecoder的stream模式这是最常见的丢字原因。再看后端在转发时是否对内容做了额外的编码转换。最后确认请求和响应头里的Content-Type是否都带charsetutf-8。我遇到过一个特别隐蔽的案例中游有一段代码用String类型拼接后再encode结果把原本正常的UTF-8字节序列重新decode成了latin-1前端再解码就全乱了。事后定位才发现是某一层没有显式指定编码。建议所有中间环节统一用bytes类型处理只在最前端做一次解码中间不要反复编解码。5.2 连接固定时间断流现象是短提示词正常长提示词或长输出总在固定时间断流比如每次都卡在30秒左右。原因多半是读超时设太短或者中间代理把空闲连接回收了。解决方案很直接把请求端的read_timeout调大到60秒以上如果经过nginx把proxy_read_timeout也调整到匹配值如果平台支持心跳或注释输出开启后连接会更活跃。这里有个小技巧把超时拆成connect和read两个维度connect保持5秒read单独放大。不要懒省事给一个数字否则你会在生产环境排查「为什么总是在第30秒断」这种问题。5.3 事件边界不稳定导致JSON解析失败不同平台的流式格式差异很大。有的严格按SSE规范用空行分隔有的每行都直接用data:开头有的甚至返回连续的JSON数组。解析报错时先别急着改代码用curl把原始响应打出来看一眼确认数据长什么样。我封装流式客户端时通常先写一个「行解析器」把字节流转成带边界的行再针对不同平台写对应的解析策略。解析失败时不要静默吞掉打印原始行内容和出错现场比靠猜快得多。5.4 中间代理缓冲导致流式失效如果前端拿到的不是逐步到达的内容而是等了很久后一次性全部出现大概率是中间代理开了缓冲。检查点有三个第一反向代理是否关闭了proxy_buffering。nginx默认会缓冲后端响应需要显式设置proxy_buffering off。第二是否启用了压缩中间件。压缩会引入额外缓冲并延迟数据到达流式接口一般不建议开gzip。第三应用层是否设置了Cache-Control: no-cache。我在本地调试一切正常一到测试环境就变回一次性返回排查半天发现是测试环境的接入层给响应加了gzip压缩压缩器为了凑一个完整的压缩块把数据缓存了。把压缩关掉流式立刻恢复正常。5.5 如何设计可观测的流式接口生产环境里流式接口出了问题很难查日志如果打全量内容会直接爆炸。我的做法是在网关层为每个流式请求记录三个埋点首字节时间、总数据量、最后完成时间。再配合指定请求ID的抽样完整日志流量大的时候只记录关键指标需要排查具体问题时按请求ID拉取全量链路日志。这样能快速定位「延迟在哪一段」是模型生成本身就慢还是网络传输慢还是前端渲染慢还是中间代理在捣乱。没有埋点这三个环节出了问题长一个样。5.6 平台差异适配经验不同大模型平台的流式字段命名并不统一。有的用delta.content有的用choices[0].message.content结束标志也有[DONE]和finish_reason两种。我在封装层会写一个统一的数据类把各平台解析成相同的内部结构之后再向后端传递。具体做法是先拉一份样例响应确认字段和结束标记再写映射函数。绝不写死单一平台。平台升级接口时也要回归测试一遍流式解析防止字段变动导致线上突然全挂。下面是一个排查速查表可以直接收藏现象可能原因处理方式中文乱码或吞字TextDecoder未开stream模式使用{ stream: true }解码固定时间断流读超时或代理空闲回收调大read_timeout与proxy_read_timeoutJSON解析报错事件边界判断不对按行分割并保留半行缓冲流式变成一次性返回中间代理缓冲或gzip关闭缓冲并设置no-cache页面卡顿渲染慢高频DOM更新16ms定时器批量追加停止按钮失效未调用reader.cancel或abort正确释放资源并处理AbortError跨域无法读取流CORS通配符与凭证冲突配置精确allow_origins6. 端到端最小示例串起整条流式链路6.1 后端完整代码把上面所有细节整合起来一个极简但完整的流式对话后端长这样import json import httpx from fastapi import FastAPI from fastapi.responses import StreamingResponse from fastapi.middleware.cors import CORSMiddleware MODEL_URL https://api.example.com/v1/chat/completions MODEL_KEY YOUR_API_KEY app FastAPI() app.add_middleware( CORSMiddleware, allow_origins[http://localhost:5173], allow_methods[POST], allow_headers[Content-Type], ) async def sse_generator(messages: list): payload { model: some-model, messages: messages, stream: True, } headers { Authorization: fBearer {MODEL_KEY}, Content-Type: application/json, } async with httpx.AsyncClient(timeouthttpx.Timeout(10.0, read120.0)) as client: async with client.stream(POST, MODEL_URL, jsonpayload, headersheaders) as resp: resp.raise_for_status() async for line in resp.aiter_lines(): if not line.startswith(data:): continue data line[len(data:):].strip() if data [DONE]: break yield fdata: {data}\n\n app.post(/chat) async def chat(payload: dict): messages payload[messages] return StreamingResponse( sse_generator(messages), media_typetext/event-stream, headers{Cache-Control: no-cache, Connection: keep-alive}, )这段代码的核心思路是「透传而不是二次解析」。后端不需要理解大模型平台返回的JSON结构只需要把data:开头的内容原样包装成标准SSE事件转发给前端。事件边界由前面的aiter_lines保证一层一层传下去。这样前端拿到的是统一的SSE流解析逻辑只需要维护一份。6.2 前端完整代码对应的前端核心逻辑const controller new AbortController(); async function runChat(messages) { const response await fetch(/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages }), signal: controller.signal }); const reader response.body.getReader(); const decoder new TextDecoder(utf-8, { stream: true }); let buffer ; try { while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n); buffer lines.pop(); for (const line of lines) { if (!line.startsWith(data:)) continue; const data line.slice(5).trim(); if (data [DONE]) continue; try { const json JSON.parse(data); // 兼容两种常见字段结构 const content json.choices?.[0]?.delta?.content || json.choices?.[0]?.message?.content || ; if (content) appendText(content); } catch (e) { console.warn(SSE解析失败原始片段, data); } } } } catch (e) { if (e.name AbortError) { console.log(用户主动停止生成); } else { console.error(流式请求异常, e); } } }这一版代码把5.1到5.6提到的坑都处理掉了TextDecoder开启stream模式、半行缓冲、AbortError捕获、兼容不同字段。实际项目里再配合前面4.3节的打字机队列就是一个能直接上线的流式对话前端。6.3 联调检验清单链路搭好后我习惯按下面这个清单逐项验证一项一项勾掉基本就能放心上线检查项方法预期结果原始大模型是否流式返回curl加-N请求平台接口逐段出现文字非一次性后端转发是否流式返回curl加-N请求/chat接口同样逐段出现浏览器是否逐字输出打开页面发起对话内容从第1秒开始出现中文是否有吞字乱码输入一个包含生僻字的测试问题完整显示不丢字停止按钮是否生效生成中点击停止立即停止且无控制台报错断网是否友好报错生成中关闭网络前端捕获错误并提示重试长时间生成是否断流请求一篇2000字长文全程连接不断代理是否缓冲观察浏览器网络面板的TTFB首字节时间接近模型首token时间这套清单我在每个流式项目里都会跑一遍每次都能揪出至少一两个藏在细节里的问题。流式调用这个功能表面看是加一个参数底层是对网络、编码、协议边界、渲染性能的综合理解。把这些点吃透再接入任何平台的流式接口你都能快速判断出问题出在哪一层。踩过几次坑之后我的个人体会是流式调用本身并不难难点在于把「字节流 → 行 → 事件 → 内容」这条链路里的每个边界都处理得干净利落。中文编码、超时、代理缓冲、平台差异这四个点真正常见也最折磨人。如果你刚开始做流式调用建议先把一个最小链路完整跑通Python发请求、FastAPI转发、前端逐字渲染代码越简单越好再逐步叠加异常处理。最后分享一个小技巧调试流式接口时直接在终端里用curl或者Node脚本打原始响应别急着在前端看效果。先在命令行里确认「字节确实是分批到的」再考虑浏览器和代理层的问题。这个习惯能帮你把一半的疑难杂症都扼杀在源头。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑