☰
流式与透传:从底层机制到工程落地的完整实践指南
2026/10/12 2:17:11 网站建设 项目流程

1. 流式与透传到底在解决什么问题

1.1 从一次糟糕的等待体验说起

如果你用过基于大语言模型搭建的对话应用,大概率遇到过这种场景:用户敲下一段问题,点击发送,然后界面就卡住了。转圈图标转了三秒、五秒、十秒,最后“唰”地一下蹦出一大段完整回答。功能上没毛病,但体验上很别扭——用户不知道系统是在思考、卡死了,还是根本没收到请求。

这个问题的根源在于,早期大多数调用方式是一次性请求、一次性返回。模型在服务端把整段话全部生成完毕,再打包成一个完整的响应体发回来。对于短回答还好,一旦回答长度上千字,等待时间就会明显拉长。而流式要做的,就是把这段“憋大招”的过程拆开,让模型每生成一小段内容就立刻推给前端,用户看到文字像打字机一样逐字冒出来。

透传则是另一个维度的需求。当你的应用不是直接面向终端用户,而是夹在中间做一层转发、代理或者编排时,上游推过来的流式数据需要原封不动地传到下游,中间不能缓存、不能聚合、不能改变时序。这两件事经常一起出现,但解决的是不同层面的问题:流式解决“生成端到消费端的实时性”,透传解决“中间层对流数据的无损搬运”。

1.2 谁需要关心这两个能力

我把适用人群分成三类。第一类是做对话产品的前端和后端开发者,流式直接决定了产品的“手感”,是刚需。第二类是搭建智能体编排系统的工程师,一个请求可能要经过意图识别、工具调用、结果整合多个环节,每个环节都可能是流式的,透传能力决定了整条链路能不能保持低延迟。第三类是做网关、中间件、SDK 封装的开发者,他们需要把不同厂商、不同协议的流式接口统一成一套对外接口,透传是基本功。

这三类人的关注点不一样:前端关心怎么消费流、怎么渲染;编排层关心怎么在多个流之间做转换和拼接;网关层关心怎么在不破坏流的前提下做鉴权、限流、日志。这篇文章会把这几个层面都覆盖到,但重点放在“怎么落地”上,而不是停留在概念介绍。

1.3 一个容易混淆的点:流式不等于透传

很多人把这两个词混着用,其实差别很大。流式是一种数据产生和消费的模式,强调的是“边生成边传输”。透传是一种数据处理策略,强调的是“中间不加工”。一个系统可以是流式的但不透传——比如中间层把上游的流收集起来,做一次格式转换再重新以流的形式发出去;也可以是透传的但不流式——比如把一个大文件分块转发,但每块之间没有实时性要求。

理解这个区别很重要,因为它决定了你在设计系统时该在哪一层做什么事。如果你把两者混为一谈,很容易写出“看起来是流式,实际上中间全缓存了”的代码,延迟一点没降下来,还白白增加了复杂度。

2. 流式的底层机制与核心实现要点

2.1 流式传输的几种常见载体

流式传输不是某一种特定技术,而是一类技术的统称。落到具体实现上,常见的有这么几种载体,各有各的适用场景。

第一种是基于 HTTP 的分块传输。服务端在响应头里声明不使用固定长度,然后一块一块地写数据,每块自带长度标识。这是最通用的方式,兼容性好,几乎所有 HTTP 客户端都支持。缺点是它是单向的,客户端不能在中途再往服务端推数据。

第二种是服务端推送事件。它在 HTTP 之上定义了一套简单的文本协议,每条消息以特定前缀开头,用空行分隔。浏览器原生支持,断线还能自动重连,非常适合“服务端持续推、客户端只接收”的场景,比如进度通知、实时消息流。

第三种是WebSocket。它是全双工的,建立连接后双方都能随时发消息。适合需要双向交互的场景,比如实时协作、语音对话。但它的连接管理比前两种复杂,需要处理心跳、重连、连接状态同步等问题。

第四种是基于流的 RPC 或自定义协议。一些高性能场景会用 gRPC 这类框架,它原生支持服务端流、客户端流和双向流。优势是性能好、有强类型约束,劣势是浏览器端支持需要额外的代理层。

选哪种,取决于你的消费端是什么。如果是浏览器里的对话界面,服务端推送事件通常是最省事的选择;如果是服务间调用,分块传输或流式 RPC 更合适;如果需要双向实时交互,那就得上 WebSocket。

2.2 流式数据的边界问题:怎么知道一段话结束了

流式传输最容易被低估的难点,是分块边界。数据是一块一块来的,但一块的结尾不一定是一个完整的语义单元。举个具体的例子:模型输出的内容里包含一个 JSON 结构,第一块数据可能是{"name": "张,第二块是三", "age": 30}。如果你在收到第一块时就尝试解析 JSON,必然失败。

这个问题在纯文本场景下相对好处理,因为文本可以逐字拼接,用户看到的就是连续的字符流。但一旦涉及结构化数据——比如工具调用的参数、函数返回的结果——就必须处理边界。常见的做法有三种。

第一种是缓冲加分隔符。约定一个特殊的分隔符,比如换行或者自定义标记,每次收到数据后先追加到缓冲区,然后按分隔符切分,只处理完整的片段。这种方式简单可靠,但要求上游配合输出分隔符。

第二种是增量解析。用一个状态机或者流式解析器,逐字符喂入,遇到完整的结构就吐出一个结果。JSON 的流式解析库就是干这个的,它能在不完整的情况下保持状态,等后续数据补齐。这种方式对上游没有额外要求,但实现复杂度高。

第三种是累积后统一解析。如果对实时性要求不高,可以先把所有块收集起来,等流结束后再解析。这是最简单的做法,但牺牲了流式的意义,只适合那些“必须完整才能处理”的场景。

我的经验是,纯展示类的文本流用第一种就够了,涉及工具调用和结构化输出时,老老实实上第二种,别图省事。

2.3 背压:被忽视的性能杀手

流式系统里有一个隐蔽但致命的问题叫背压。简单说,就是生产数据的速度超过了消费数据的速度。上游模型每秒吐出一千个字符,下游渲染或者处理每秒只能消化五百个,多出来的数据要么堆积在内存里,要么被丢弃。

堆积的后果是内存持续增长,最终可能拖垮进程;丢弃的后果是数据不完整,用户看到的内容缺斤少两。两种都不能接受。

处理背压的核心思路是让消费端有能力告诉生产端“慢一点”。在分块传输里,这通常靠 TCP 的滑动窗口机制自动完成——消费端不读,发送端的缓冲区满了自然就阻塞了。但在应用层,尤其是经过多层转发时,这个信号很容易丢失。中间层如果无脑地把上游数据全读进内存再往下发,背压就失效了。

实操中我一般会做两件事。一是给缓冲区设上限,超过就暂停从上游读取,等下游消费完再继续。二是监控缓冲区的增长趋势,如果持续增长说明下游处理能力不足,需要告警甚至降级。这两件事听起来简单,但在多层嵌套的编排系统里,每一层都要正确处理,漏一层就前功尽弃。

3. 透传的实现细节与常见陷阱

3.1 透传的本质:做一个“透明管道”

透传听起来简单——收到什么就发什么,不改动。但真正做到“透明”并不容易,因为中间层往往会不自觉地引入副作用。

最常见的副作用是缓冲。很多 HTTP 客户端和框架默认会把响应体读完再交给你,这在普通请求里没问题,但在流式场景下等于把流变成了非流。要避免这个,必须显式地使用流式读取接口,一块一块地拿数据,拿到就往下写,不攒着。

第二个副作用是头部和元数据的改写。中间层可能出于各种原因修改响应头,比如加个追踪 ID、改个内容类型。大部分情况下无害,但如果改动了和流式相关的头——比如把分块传输的标识去掉,或者错误地设置了内容长度——下游就会解析失败。透传时要明确哪些头可以动、哪些必须原样保留。

第三个副作用是编码转换。如果中间层对数据做了字符集转换或者压缩解压,虽然内容语义没变,但字节流变了。对于纯文本这通常没事,但如果下游依赖精确的字节内容(比如校验和、签名),就会出问题。透传场景下,最好保持字节级别的一致。

3.2 超时设置:透传链路上的定时炸弹

流式请求的持续时间往往比普通请求长得多。一个普通接口可能几百毫秒就返回了,但一个流式对话可能持续几十秒甚至几分钟。如果中间层用的是默认的超时配置,很可能在流还没结束时就主动断开了连接。

这个问题在多层转发时会被放大。假设客户端超时 60 秒,第一层网关超时 50 秒,第二层服务超时 40 秒,那么实际可用的时间只有 40 秒,而且任何一层的超时都会导致整条链路中断。更麻烦的是,超时断开后,上游可能还在继续生成,白白浪费资源。

我的做法是分层设置、逐层放宽。最内层(最接近模型的一层)超时最长,往外逐层递减,但要保证外层比内层留有余量。同时,对于流式请求单独配置超时,不要和普通请求共用一套参数。另外,要区分“连接超时”和“读取超时”——连接建立可以短一点,但读取每一块数据的间隔要留足,因为模型生成本身就有快有慢。

3.3 错误处理:流中途出错了怎么办

非流式请求出错很好处理,返回一个错误码和错误信息就完事了。但流式请求可能在中途出错——前面已经推了一部分内容,后面突然断了。这时候下游已经消费了一部分数据,状态是不完整的。

处理这种情况,通常有两种策略。一种是在流内传递错误。约定一个特殊的消息类型或者字段,用来表示“后面没有数据了,原因是某某”。下游收到这个信号后,知道流是异常结束的,可以给用户一个提示,或者触发重试。这种方式的好处是错误和正常数据走同一条通道,下游处理逻辑统一。

另一种是依赖连接状态。流正常结束时会有一个明确的结束信号(比如分块传输的最后一块、事件流的特定事件),如果连接在没有收到结束信号的情况下断开,就视为异常。这种方式不需要额外的协议约定,但下游要能区分“正常断开”和“异常断开”。

我倾向于第一种,因为显式传递错误信息能让下游做出更精准的处理。比如是限流导致的错误,下游可以等待后重试;是内容审核拦截,下游就该直接终止。这些信息如果只靠连接状态是拿不到的。

4. 从零搭建一个流式透传链路的实操记录

4.1 场景设定与技术选型

假设我们要搭一个中间层,夹在客户端和大模型服务之间。客户端发起请求,中间层转发给模型服务,模型服务以流式返回,中间层原样透传给客户端。中间层还要做一些基础的事:鉴权、记录请求日志、统计 token 消耗。

技术选型上,中间层用一个支持异步 IO 的 Web 框架,这样处理流式请求时不会阻塞其他请求。HTTP 客户端也要选支持流式读取的,不能用那种一次性读完的封装。客户端这边,用一个简单的命令行工具模拟,能实时打印收到的每一块数据就行。

选异步框架的原因是流式请求会长时间占用连接,如果用同步模型,每个流式请求都要占一个线程,并发一高线程池就爆了。异步模型用事件循环处理,一个线程能扛很多并发连接,资源利用率高得多。

4.2 中间层的核心代码结构

中间层要做的事情可以拆成几个步骤:接收客户端请求、校验鉴权信息、构造发往模型服务的请求、以流式方式读取模型响应、逐块转发给客户端、在流结束时记录日志。

关键点在于“逐块转发”这一步。伪代码大致是这样的:先建立到模型服务的连接,拿到响应对象,然后进入一个循环,每次从响应里读一块数据,读到就立刻写给客户端,同时刷新输出缓冲。循环直到读到结束标志。整个过程不能有任何“先收集再发送”的操作。

这里有个细节容易被忽略:写客户端时也要用流式接口。有些框架的响应对象默认会缓冲,你写进去的数据不会立刻发出去,而是等整个响应结束才一次性发送。必须显式地调用刷新,或者使用框架提供的流式响应类型。我踩过这个坑,代码逻辑看起来完全正确,但客户端就是收不到实时数据,排查了半天才发现是框架的缓冲在作怪。

4.3 参数配置与超时计算

超时配置我一般这么算:先测出模型生成一段典型长度回答的耗时,比如平均 30 秒,最长可能到 90 秒。那么最内层的读取超时设成 120 秒,留出余量。中间层如果还有一层网关,网关的超时设成 150 秒,比内层长。客户端的超时设成 180 秒,最长。

连接超时可以短一些,比如 5 到 10 秒,因为建立连接本身很快,如果连不上说明网络或服务有问题,没必要等太久。

除了超时,还要设置空闲超时。有些场景下流会暂停一段时间——比如模型在调用工具,中间没有输出。如果空闲超时设得太短,会误判为连接断开。我一般把空闲超时设成比正常生成间隔大几倍的值,比如正常间隔是几百毫秒,空闲超时设成 30 秒。

4.4 实测数据与效果对比

搭好之后我做了个对比测试。同样的请求,非流式模式下,客户端从发起到收到完整响应平均耗时 8 秒,期间界面完全无响应。流式模式下,第一块数据平均 0.6 秒就到达了,用户几乎立刻看到反馈,完整响应耗时还是 8 秒左右,但感知上的等待从 8 秒降到了 0.6 秒。

透传的开销也测了一下。中间层转发带来的额外延迟平均在 20 到 50 毫秒之间,主要是网络往返和数据处理的开销。这个量级相对于模型生成时间可以忽略不计。但如果中间层做了缓冲,额外延迟会飙升到和完整生成时间相当,流式的意义就没了。

内存占用方面,透传模式下中间层的内存占用基本恒定,不随响应长度增长。而缓冲模式下,内存占用和响应长度成正比,长回答能吃掉几十兆内存。并发一高,差距非常明显。

5. 常见问题排查与避坑经验

5.1 客户端收不到实时数据

这是最常见的问题,表现是客户端要等很久才一次性收到全部内容。排查思路从后往前推:先确认客户端本身是不是流式消费的,有些 HTTP 客户端库默认会读完整个响应体;再确认中间层是不是用了流式响应类型,很多框架的默认响应是缓冲的;最后确认中间层读取上游时是不是流式的,如果用了“读取全部内容”的接口,前面做得再对也没用。

我整理了一个排查顺序表,按这个顺序查基本能定位到问题。

排查环节检查点常见错误
客户端是否使用流式读取接口用了默认的完整读取
客户端是否逐块处理并渲染收集完再统一渲染
中间层输出是否使用流式响应类型用了普通响应类型
中间层输出是否每块后刷新缓冲忘记刷新或框架自动缓冲
中间层输入是否流式读取上游用了完整读取接口
上游是否真的流式返回上游本身做了聚合

5.2 流中途断开且没有错误信息

这种情况通常是某一层的超时触发了。排查时先看各层的超时配置,确认是不是内层超时比外层还长,导致外层先断开。然后看日志里有没有超时相关的记录,定位是哪一层断的。

还有一种可能是网络中间设备——比如负载均衡器、反向代理——有自己的空闲超时,即使应用层配置没问题,中间设备也会断开长时间没有数据的连接。这种情况需要在中间设备上也调整超时,或者让应用层定期发送心跳数据保持连接活跃。

5.3 结构化数据解析失败

前面提到过,流式传输中结构化数据的边界很难处理。如果遇到解析失败,先确认是不是在数据不完整时就尝试解析了。解决办法是引入缓冲和增量解析,只在确认数据完整时才解析。

另一个容易忽略的点是多字节字符的边界。一个中文字符在 UTF-8 里占三个字节,如果一块数据的结尾正好切在一个字符的中间,直接按字符串处理会得到乱码。正确的做法是按字节缓冲,等凑齐完整字符再解码。这个问题在英文场景下不明显,但中文场景下很常见。

5.4 并发高了之后性能下降

流式请求会长时间占用连接,并发一高,连接数、文件描述符、内存都会成为瓶颈。优化方向有几个:一是用异步 IO 模型,避免一个连接占一个线程;二是合理设置连接池大小,复用到上游的连接;三是监控资源使用,提前扩容。

还有一个隐蔽的问题是日志。如果每个数据块都打一条日志,高并发下日志量会爆炸,磁盘 IO 成为瓶颈。我的做法是只记录流的开始和结束,中间的数据块不打日志,或者只记录块数和总字节数。

6. 进阶:多级编排中的流式透传

6.1 多个流之间的拼接与转换

实际系统里,一个请求往往要经过多个环节。比如先调用一个模型做意图识别,根据结果再调用另一个模型生成回答,最后可能还要调用工具补充信息。每个环节都可能是流式的,怎么把它们串起来是个问题。

如果环节之间是串行的,前一个流结束后才开始后一个,那处理起来相对简单,依次消费即可。但如果是并行的——比如同时调用多个模型,把结果合并——就需要处理多个流的同步。常见做法是给每个流打上标识,下游根据标识区分数据来源,按需合并。

转换也是常见需求。上游返回的是一种格式,下游需要另一种格式。转换时要注意不能破坏流式特性,也就是要逐块转换、逐块输出,不能攒着一起转。

6.2 流式场景下的可观测性

非流式请求的可观测性很好做,记录请求、响应、耗时就行。流式请求复杂一些,因为响应是渐进的。我一般会记录这几个指标:首块延迟(从请求发出到收到第一块的时间)、块间间隔的分布、总块数、总字节数、是否正常结束。

首块延迟是最关键的指标,它直接决定用户体验。块间间隔能反映生成的稳定性,如果间隔忽大忽小,可能是上游负载不均。总块数和总字节数用于计费和容量规划。是否正常结束用于统计异常率。

这些指标要按流维度聚合,不能只看平均值,因为流式请求的分布往往很分散,平均值会掩盖问题。用分位数来看更准确,比如首块延迟的 95 分位、99 分位。

6.3 一个容易踩的坑:流的取消

用户可能在流还没结束时就取消了请求,比如关闭了页面或者点了停止按钮。这时候中间层要能把取消信号传递到上游,让上游停止生成,释放资源。如果中间层只是断开了和客户端的连接,但没有通知上游,上游会继续生成直到完成,白白消耗算力。

实现取消传递的关键是监听客户端的断开事件,一旦检测到断开,立刻取消对上游的请求。不同框架的取消机制不一样,有的用上下文,有的用取消令牌,要按框架的约定来。这个功能看起来不起眼,但在高并发场景下能省下可观的资源。

7. 我个人的一些实操体会

流式和透传这两个能力,刚接触时觉得是锦上添花,做深了才发现是很多系统的地基。我印象最深的一次是给一个内部工具加流式,代码改动不大,但用户反馈完全变了,从“能用但不想用”变成了“用着挺顺手”。体验这东西,用户说不出来具体哪里好,但能感觉到。

透传这块,最大的教训是不要相信任何默认配置。框架的默认超时、默认缓冲、默认响应类型,几乎都是为非流式场景设计的,直接拿来用必踩坑。每引入一个新组件,都要确认它在流式场景下的行为,该改的改,该关的关。

还有一个体会是,流式系统的调试比普通系统麻烦得多。普通请求出问题,看日志基本能定位。流式请求出问题,可能是时序问题、边界问题、并发问题,光看日志不够,往往要抓包或者加详细的埋点。所以前期把可观测性做好,后期能省很多事。

最后说个具体的技巧:测试流式功能时,别只用短请求测。短请求太快,很多边界问题暴露不出来。要用长请求、包含特殊字符的请求、会触发工具调用的请求,多测几种极端情况。我吃过亏,短请求测得好好的,一上长文本就出乱码,排查半天是字符边界的问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询