第一次接 API 的人常卡在一个地方:代码写完了,请求发出去,要么一直等到最后才吐出结果,要么一行行往外冒,完全不知道哪个才该用、为什么表现不一样。这篇从两种模式的传输原理讲到工程影响,最后给一张按任务场景选模式的对照表和一份切换时的排查顺序。
第一次接 API 的人,八成都会卡在同一个地方:代码写完了,请求也发出去了,结果要么一动不动等到最后一次性吐出一大段,要么一行行往外冒、像有人在另一边打字。两种表现背后是两套不同的输出模式,不是模型换了,也不是运气问题。搞不清楚它们的差别,后面做界面、做超时、做重试都会踩坑。
这篇不聊模型好坏,只聊「结果怎么从服务端交到你手上」这件事。先把两种模式的传输原理讲清楚,再逐项对比体验、出错和资源占用,最后给一张按任务场景选模式的对照表和一份切换时要走的排查顺序。看完至少能回答一个问题:我手上这个功能,到底该用哪种模式接。
两种模式到底差在哪:一个等齐,一个边生边给
非流式的逻辑最直白:客户端发一次请求,服务端把整段结果生成完,打包成一个响应体一次性返回,连接随即结束。你拿到的是一份完整成品,前面没有半个字,后面也不会再补。对调用方来说,这就是一次普通的请求-响应,状态码和响应体就能说明一切。
流式则是服务端一边生成一边往外推,客户端连着收到很多个小片段,直到收到明确的结束标记才知道说完了。它交出来的不是成品,而是一条正在生长的内容流。关键点在于:差别不在模型本身,而在传输层怎么把结果送到调用方手里,同一个模型完全可以两种模式都支持。
- 非流式输出
- 服务端生成完整结果后,一次性放在单个响应体里返回
- 流式输出
- 服务端边生成边分块推送,客户端连续读取多个小片段直到结束标记
- 首字时间
- 从发出请求到收到第一个可见字符的时间,流式下这个值通常远小于总耗时
底层怎么实现的:从一次响应到一条长连接
流式最常见的落地方式是保持一条长连接,服务端分块写入、客户端逐块读取,典型协议就是 SSE(Server-Sent Events)。它基于普通 HTTP,服务端把响应头声明成事件流,之后按固定格式一段段写数据,每段之间用空行分隔,最后发一个结束事件。客户端只要按行读、遇到空行算一条消息即可。
curl -N -H "Accept: text/event-stream" \
-H "Authorization: Bearer $API_KEY" \
https://<your-endpoint>/v1/chat/completions \
-d '{"model":"<model>","messages":[{"role":"user","content":"hi"}],"stream":true}'非流式请求-响应发完就结束,连接可以立刻复用;流式的连接要一直挂着,中间任何一环断掉,输出就停在半截。代理、网关、负载均衡对长连接的处理策略各不相同,有的会缓冲响应、有的会按空闲时间掐断,这些都会让「本地能跑、线上不出字」。换协议或换接入点之前,先确认对方的缓冲和超时口径。
- 确认响应头里声明的确实是事件流类型,而不是被中间层改成了普通响应
- 确认没有中间代理在累积缓冲,否则你会一次性收到全部内容、失去流式的意义
- 确认结束标记的写法,不同兼容层对最后一条事件的字段处理并不一致
- 确认空闲多久会被断开,并据此设置自己的心跳或读取超时
- 确认客户端读取是按块还是按行,按块读要自己拼包,别把半条消息当完整消息处理
用户感知:首字时间比总耗时更影响体感
非流式下用户看到的是白屏等待,总耗时多长就等多久,体感完全由总时长决定。同样生成一段几百字的内容,如果一次性返回,用户前几秒会觉得「是不是卡了」。这不是错觉,是没有任何中间反馈导致的。
流式下用户先看到第一个字,后面的内容持续追加,即使总耗时一分没少,主观上也会觉得快很多。所以面向人的界面和面向机器的批处理,对这两种模式的容忍度完全不一样:人怕的是等待没有反馈,机器怕的是拿到的结果不完整。
| 场景 | 更适合的模式 | 原因 |
|---|---|---|
| 人对人/人对界面的对话 | 流式 | 首字先到,等待有反馈 |
| 后台批量抽取结构化字段 | 非流式 | 要完整结果才能落库校验 |
| 代码补全、边写边提示 | 流式 | 用户需要逐字看到建议 |
| 离线报表、定时任务 | 非流式 | 没有人在等,逻辑越简单越好 |
| 需要边看中间结果边决定下一步 | 流式 | 但必须能处理半截数据 |
出错时机不同,错误处理的写法也不同
非流式把错误集中在一次响应里:看状态码、看错误体,判断完就可以决定重试还是放弃。因为结果要么全有要么全无,不会出现半成品,代码里一个判断分支就能覆盖大部分情况。
流式麻烦得多:可能前半段正常、中途才断,客户端必须能处理「已经拿到一部分内容后连接失败」这种状态。这时候那份内容是不完整的,下游如果直接当成品用,就会悄悄出错。处理方式无非两种:要么明确标记为截断、让调用方自己决定怎么办;要么丢弃重来,代价是前面的算力白花。
- 区分连接超时与中途静默连接超时是压根没连上,可以安全重试;中途静默是连着但不再出字,要先判断是模型在想还是链路断了,别急着断开重发。
- 给不完整结果打标记在收到结束标记之前,把已累积的内容标为未完成状态,下游消费时强制检查这个标记。
- 决定重试边界只有确认本次生成没有产生任何有效内容时,才做整段重试;一旦已经吐字给用户,重试意味着界面内容会闪回,要慎重。
- 记录断点信息日志里带上已接收片段数、最后一条事件的时间和内容摘要,排查时能一眼看出断在第几段。
计费和并发:模式变了,钱和连接数怎么算
计费口径一般按实际消耗计算,模式本身通常不直接改变单价,但流式长连接会占用额外的连接资源。非流式的连接用完即走,连接池很快回收;流式要挂到你读完最后一个片段,高并发下更容易先撞上连接数或并发上限,而不是先撞上费用。
具体单价、限速规则和连接上限,各家口径和时段都可能变,别背数字,去站内页面看当时的数值。到 /official-api 核对官方口径,到 /price-watch 看价格追踪记录。中转站接入的话,还要确认对方有没有对长连接做额外限制。
| 对比项 | 非流式 | 流式 |
|---|---|---|
| 连接占用 | 短连接,用完即走 | 长连接,需挂到读完 |
| 计费影响 | 按实际消耗 | 通常也按实际消耗,但连接资源更紧张 |
| 断线恢复 | 整段重发即可 | 已输出部分难复原,需打标记 |
| 客户端复杂度 | 低 | 明显更高 |
| 适合并发模型 | 高并发短请求 | 中等并发、长回复 |
选完之后还要补的几件事
模式定了不等于活干完了。超时设置、重试策略、日志字段都要按模式重新定一遍。流式的超时至少有两层:一层是建立连接的超时,一层是两次数据之间的空闲超时,两者含义不同,阈值也不该一样。只设一个总超时,往往会把「正常的慢」和「真的断了」混为一谈。
另外,把模式切换当成一次接口变更来对待:先在小流量上验证,再逐步放大。流式接进生产前要处理的坑比非流式多,别默认本地跑通就等于能上线。如果你的链路里是中转站转发,还要额外确认它是否完整透传了流式响应,有些转发会在中间做缓冲或改写。
- 分别设置连接超时和片段空闲超时,不要共用一个阈值
- 重试前判断本次是否已产生有效输出,避免界面内容闪回
- 日志里记录模式、片段数、结束标记是否出现,方便对比排查
- 小流量灰度验证后再放大,尤其是长回复场景
- 如果走中转,先确认流式响应是否被完整透传
选型清单:什么任务用哪种模式
选型不用纠结,按「谁在等、要什么形态的结果」两条来判断就够了。人在等,就优先流式;机器在等完整结果,就优先非流式。如果链路里还有一步要求先拿到完整内容才能继续,那一步之前就别硬上流式。
| 任务特征 | 推荐模式 | 注意点 |
|---|---|---|
| 面向人的实时输出 | 流式 | 处理好中途断连和半截内容 |
| 结构化抽取、落库校验 | 非流式 | 一次拿到完整结果,逻辑简单 |
| 工具调用、需要中间结果 | 流式 | 但下游必须能消费未完成数据 |
| 高并发短请求 | 非流式 | 长连接占用更少资源 |
| 链路任一步要求完整才能继续 | 非流式 | 别为了体感牺牲正确性 |
模式选错的代价通常不是慢,而是拿到一份不完整的结果却以为它是完整的。
最后给一个简单的判断动作:写代码前先问自己三个问题——有没有人在盯着屏幕等?下游消费这份结果时能不能容忍半截?断线重来的代价能不能接受?三个都偏向人能忍、机器能容、重来不贵,那就用流式;只要有一个卡住,回到非流式更省心。拿不准就去站内实测工具跑一遍对比。
下一步想知道自己常用的接入点在流式下的实际首字表现和断连情况,可以到站内测速工具跑一次真实请求,再决定要不要换模式或换接入点。→常见问题
同一段内容,流式和非流式跑出来的结果会不一样吗?
内容本身通常没有本质差别,差别在传输和拼装。但流式下客户端是自己把片段拼起来的,拼装逻辑写错、结束标记没处理干净,就可能出现丢字、重复或截断,看起来像模型的问题,其实是客户端没接好。
SSE 是不是就是流式的唯一实现方式?
不是。SSE 是常见且好实现的一种,特点是单向、基于普通 HTTP。实践中还有分块传输、WebSocket 等方式。选哪种取决于你的客户端环境和服务端兼容层支持什么,接之前先确认对方文档写的是哪一种。
流式是不是一定更省钱?
不一定。计费一般按实际消耗的输入输出量算,跟传输模式关系不大。流式省的是等待体验,不是账单。真正影响花费的是输出长度、是否命中缓存、以及重试次数,这几项到 /tools/model-cost-calculator 换算更直观。
非流式能不能改成假流式,先把整段拿回来再分段吐给用户?
可以,适合服务端不支持流式、但前端想要打字机效果的情况。代价是首字时间没变好,用户还是得等整段生成完,只是等待期间有东西在动。如果后端支持真流式,优先用真的。
流式接进生产最容易翻车的是哪一步?
多半是断流后的兜底。长连接可能被网络、代理或中间层掐掉,客户端要能识别「收到一半停了」,并且决定是重试、标记截断还是降级成非流式。这块不写清楚,上线后很容易出现内容缺一截但没人发现的情况。