第05章-流式链路:从响应到界面之间流动的是什么

4 阅读16分钟

第 5 章 流式链路:从响应到界面之间流动的是什么

难度 ★4 | 约 4 学时 | 6 节

这一章讲一条流。它从模型出发,经过一串你未必知道名字的中转,最后变成屏幕上的字。每一节都对应一个「这条流可能在哪断掉」的位置——学会定位,比学会配置有用。

学习目标:讲清流式为什么必要、一条流里到底流动着什么,以及协议分层在哪一层。

本章交付物:一条可复现的流式链路 + 首字时间 / 总时长对照数据


5.1 流式改的是感知延迟,不是真实延迟

先纠正一句最常见的汇报话术:「上了流式之后响应快多了。」

总时长基本没变,甚至可能略长。

机制很简单:流式不减少模型要生成的总量,它只是把产出一边生成一边交付。分帧、编码、更多的网络往返,甚至会把这笔时间加上去一点。所以「变快了」这个感受是真实的,但它变的不是那个总数。

变的是另一个数:用户多久看到第一个字。

讲到这里必须停一下,把「延迟」拆成三个数——不拆开,这一节就只是一句解释,读者拿不到下一步。

数量什么它变差时用户看到什么
首字时间从请求发出到第一个单位出现空屏干等,并且很快怀疑功能坏了
输出速度首字之后,每个单位多快字在爬,读到一半开始不耐烦
总时长从发出到结束能感觉到,但要读完才知道

三个数最要命的地方是:它们可以指向相反方向。 首字很快但生成很慢(立刻开始然后龟速爬),与首字很慢但生成很快(空屏两秒然后整段落下),是两种截然不同的体验,而它们在「总时长」这一个指标上可能给出同一个数。

由此得到一条反直觉的推论:输出速度够快时,总时长几乎不影响体验判断。 因为用户的注意力被「已经有字在动」这件事接管了——只要还在动,他就愿意等。 反过来,首字慢一次,用户就会怀疑功能坏了;而这件事在总时长上完全看不出来。

有一个量级参照可以说,但它只能当参照,不能当结论:第三方测量给出的是非流式要等数秒才出第一个字符、流式首个字在几百毫秒量级。这两个数的价值在于让你知道「差的是数量级」,不在于让你拿它们去设阈值——那件事在 5.2 与 5.6 会讲清楚为什么不能做。

所以这一节的产出不是「上流式」,而是一张三行的表,加上一句纪律:任何「快不快」的结论,都必须先说清是哪一行的数。 只说「快了」的汇报,等于没说。

另外一句要提前埋:首字时间是这三个数里最难自己控制的那个——它的大头不在你的前端。下一节会把它拆开。


5.2 首字时间是 AI 功能的第一体验指标

「用户觉不觉得它活着」这件事,有一个可以测量的名字:首字时间。

它有一个被广泛引用的定义:从请求发出,到第一个生成的单位到达客户端。 同一套口径里还定义了另外两个——每个输出单位的时间(首个之后的吞吐)与队列时间(提供方侧的排队)。这三个数合起来才是一条完整的延迟画像(5.1 那张表)。

现在说这一节真正要补的三处定界,每一处都能让你少量错一次。

定界一:量到「第一个可见的单位」为止。

这一条是踩出来的:推理模型会先产生一段用户看不见的思考。 如果你量的是「模型内部开始动了」,那你量到的是空屏之外的那段时间——它跟用户盯着空白页的时长不是一回事。所以口径必须是第一个可见单位。

定界二:它由四件事决定,而前两件不在你的前端。

① 网络往返。 你的用户到边缘、边缘到提供方。 ② 提供方队列。 你排在第几个。忙时这里能把首字从数百毫秒抬到数秒。 ③ 提示词处理(预填充)。 输入翻三倍,预填充时间成倍增长——这就是 2.1 与 2.5 那套「压输入」的直接回报。 ④ 模型的第一个解码步。 模型多大、思考档位多高,决定这一步多慢。

三条主要退化来源也就清楚了:提供方排队、提示词变大、路由决策(把请求送去更便宜但更忙的模型)。请注意这三条的共同点:它们都不在你的前端。 这一句能直接省掉读者一次错误排查——首字慢的时候,先别去优化你的首屏。

定界三:它的名字还没定。

「首字时间」这套语义约定目前仍是开发中状态,字段名可能变。所以本书的写法是固定的:正文写「量什么」,附录写「可能叫什么」并标状态。 把指标写成「量什么」而不是「查哪里」,是本书对付未稳定规范的一贯办法——规范会改字段名,但不会改「用户在等第一个字」这件事。

最后一条工程纪律,属于这一节最重要的动作:记尾部,不记均值。

因为提供方排队是尾部现象:大多数时候很快,峰值时突然很慢。均值会把那条长尾摊平,让你得出「平均 800 毫秒,还不错」的结论,而真实情况是有一批用户在干等三秒。 所以要记第 95 百分位(有条件就记第 99)。均值是给汇报看的,尾部是给用户用的。


5.3 流里流动的是「类型化片段」,不是一串文本

这一节的信息量决定了后面三节能不能读懂,所以宁可长一点。

先说形状:现行的界面流协议用服务器发送事件(SSE)格式。官方给的理由很务实——它是标准化的、可以用 ping 保活、可以重连、对缓存的处理更好。

但真正要记住的不是格式,是流里装着什么。它不是一个字符串在慢慢变长,它是一串带类型的片段。 按用途分组:

组里面有什么
生命周期开始 / 结束 / 中断 / 步骤开始 / 步骤结束
文本开始 → 增量 → 结束 三段式,每块带唯一编号
推理过程与文本同构的三段式(同样带编号),另有推理中产生的文件片段
来源网址 / 文档
文件生成或引用的文件
自定义按提供方前缀命名
任意结构化数据用带自定义后缀的类型名
错误流内错误(与 HTTP 错误不是一回事)
工具输入一组、输出一组,各有自己的状态机

现在解释两个「为什么」。

为什么文本要三段式而不是一次性给整块? 因为你要能显示「正在写」。开始片段建立位置,增量片段往里追加,结束片段收口。没有这三段,你的界面只能在整段到达后一次性渲染——那就等于没有流式。

为什么每块要带唯一编号? 因为同一轮里可能出现多块内容:先思考、再回答、答一半去调工具、回来接着答。没有编号的流只能顺序拼接,一旦出现交错就拼错。 编号是让「这一段属于哪一块」这件事在协议层可判的唯一办法。

然后是本节最容易忽略、也最容易让你写成乱码的一项:工具片段有自己的状态机。它的形状是:输入流式中 → 输入就绪 → 请求批准 → 已响应批准 → 输出就绪 / 输出出错 / 输出被拒。

请把这一串读一遍,然后想一件事:如果你把这条流当纯文本读,这一整段状态机就变成了乱码。 你会把「输入流式中」的半截 JSON 当成参数去解析,把「输出被拒」当成成功,把「请求批准」这个必须停下来等人的状态直接跳过。

最后一条工程细节:自定义后端要声明协议版本。 用协议规定的那个请求头标明,否则前端解析不了——它不会报错,它只会不渲染(5.4 会把这件事讲透)。

把这一节压成一句:流的单位是片段,不是字符。 你的解析器要按片段来写,你的渲染器要按块来管,你的状态机要认得出「现在在等谁」。


5.4 协议是有版本的服务端契约,两端必须钉在同一主版本

这一节讲一件「不报错的错」,而它恰好是本篇最难查的一类。

先看两次真实的协议换代,只看形状、不带版本号,因为形状比版本号活得久。

第一次换代:把「界面消息」从扁平的文本字段改成有序的片段数组,并把提交方式从「追加一条消息」统一成「发送一条消息」。

第二次换代:主流结果对象的流字段改名;流式结果上的若干响应转换方法被弃用,改为顶层无状态函数;事件回调改名;系统提示的选项从「系统」改叫「指令」。

两次换代的共同点不是「加了功能」,而是改了形状。而形状一改,旧代码不会报错——它会解析出一个空结果。

官方迁移说明里最值钱的一句不是这些改动,是那条部署规则:客户端与服务端必须钉在同一主版本。 因为混用的后果被写得很具体:静默的流解析失败——请求完成、响应流打开、客户端什么都不渲染。

请体会这三句话的层层递进:请求成功了,流也开了,界面是空的。 你在网络面板里看不到任何红色,在控制台里看不到任何异常,你只会觉得「这个功能好像没生效」。

所以这一节的产出是三条部署纪律,每条都能落进流水线:

① 把客户端与服务端钉成一组。 不是「都写 ^ 让 npm 自己升」,而是同一个版本号,并且一起改。判据是:你能不能在一次提交里同时看到两边的版本变更?

② 灰度期整批切,不许半新半旧。 半新半旧的部署会同时存在两种解析器,而它们对同一个响应的理解不同。这时候「有些人能用、有些人不能用」不是玄学,是版本混用。

③ 把「界面不动」加进监控。 它不会报错,所以只能靠侧面信号发现:流已打开但 N 秒内零片段、请求 200 但渲染节点数为零。这一类信号必须单独建一条告警,否则它永远不会自己出现。

最后一条写法纪律:正文不写具体版本号与字段名。 写「一次换代把这个字段换了名字」这种形状,具体对照表进附录并标核实日期。理由是这一篇核实期间就出现了两次换代,而二手文章还在按旧形状写——你今天钉死的字段名,就是读者明天的坑。


5.5 谁在缓冲:不给结论,给一条定位法

大纲原来的写法是:「谁在缓冲:网关、代理、CDN、压缩都可能把片段攒住——「本地是流式的、线上是一整坨」通常不是业务代码的问题。」

前半句对。后半句必须整句删掉,理由不是它一定错,而是它在诊断之前就把答案定了,而且定的方向恰好排除了两种最常见的自家问题。

这是一个方法论问题,值得单独说一句:诊断类的判断题,不能把答案写进题干。 一旦读者带着「不是我的代码」这个前提去查,他会跳过最该查的那两跳——而这两跳恰好是最常见的两处。

所以这一节不给结论,给一条有序定位法:用不缓冲的客户端逐跳测。

具体动作:先直连源站(跳过代理与 CDN),看片段是不是分块到达;再加上代理,看变没变;再加上 CDN,再看。命令行客户端(curl -N 那种不缓冲的模式)与页面是两条独立的观测通道——两条都要看,因为它们指向不同的层。

然后把结果按三路分诊:

观测结果问题在哪最可能的原因
直连也成批到达你自己的应用与源站应用写入没 flush、事件终止符写错、框架把响应攒起来、源站侧有缓冲
直连正常,经代理 / CDN 成批那一层反向代理的响应缓冲、CDN 的聚合、压缩器在等块
命令行及时,页面一次性更新你的前端解析器按整段读、渲染没按片段追加、状态更新被批处理

这张表的用法是从上往下:先排除第一行,再谈第二行。 因为第一行是自家代码,改动成本最低、也最容易被忽略;而把它跳过直接去查 CDN,会让你在错误的地方花掉一天。

现在说四个极易搞错的技术点——它们都属于「你以为你关对了开关」:

① 让代理不缓冲的响应头,必须由上游发出。 代理会把它从发给客户端的响应里去掉,所以在下游加同名响应头对上游缓冲无效。你以为你加了,其实那一层根本没收到。

② 管请求体的那个开关,改不了响应缓冲。 大提示词被缓冲是另一回事,别拿它当响应侧的证据。

③ 代理的读取超时是「上游可以静默多久」,不是「响应总时长上限」。 默认在 60 秒量级。它计时的是沉默,不是长度。

④ 负载均衡器的空闲超时是另一个独立计时器。 它和代理的读取超时不同名、不同层,改一个不会改另一个。

还有一条保活纪律,两个半句都不能少:

① 长间隔时发注释型心跳,建议约每 15 秒一次,间隔要短于路径上最短的那个空闲超时——不是短于你以为的那个。

② 心跳若本身被缓冲,发得更勤也没用。 这一条是很多人白忙一天的原因:他们加的是「更多的心跳」,而不是「不被缓冲的心跳」。

最后一条不能忘的:流式响应上绝不能加允许缓存的响应头。 加了它,整段响应会被缓存下来再重放——那一层不叫缓冲,叫「把它变成了一次性下载」。

把这一节压成一句:「本地是流式的、线上是一整坨」是一个待定位的症状,不是一个可归因的结论。 先分层,再改一层。


5.6 一个可观测的验证:三个数分别记,不做除法

大纲原来的写法是:「量出首字时间与总时长的比值,并在链路中间加一层缓冲看它怎么变。」

后半句对,前半句的比值必须删掉——理由很硬:首字时间与总时长之间没有固定的比值关系。

权威口径说得很清楚:这两个数可能指向相反方向。而把两个可以反向的量相除,会得到一件很荒唐的事:截然不同的两种体验,可能给出同一个数。

首字很快但生成很慢(立刻开始然后龟速爬),与首字很慢但生成很快(空屏两秒然后整段落下)——它们在同一个比值上可以完全相同,而用户的感受截然相反。

更糟的是这个比值随输出长度单调变化:同样快的首字,回答越长,比值越小。于是你会得到一个「指标改善」的信号,而这个信号只是因为回答变长了。这正是本书在别处反复盯的那种假绿灯——指标好转,体验没变,而你以为你修好了。

所以判据改成三件事。

① 三个数分别记,不做除法。

首字时间、每个输出单位的平均时间(或直接记输出速度)、总时长。三个都要,一个都不能替代另一个(5.1 那张表就是这三行)。

② 首字时间记百分位,不记均值。

记第 95 百分位,有条件就记第 99。理由在 5.2:提供方排队是尾部现象,均值看不见它。

③ 保留那个对照实验,而且它是本节最有价值的部分。

在链路中间显式加一层缓冲,然后看三个数哪个变:

观测结论
只有首字时间变长,总时长基本不变那一层在攒片段——这是最干净的证据
首字时间与总时长一起变长那一层在攒,但你的总时长本来就在临界点上
三个数都没变那一层不是瓶颈,去查别的一跳

为什么这个实验值钱?因为它是可控实验:你没有改模型、没有改提示词、没有改网络,只加了一层已知会缓冲的东西。变量只有一个,所以结论成立。这比「线上观察了一周」可靠得多。

还有一条口径纪律要提醒:三个数要在同一批请求上取。 跨时间比会混入提供方负载变化——周一和周五的差异,可能全是对方机房忙不忙。

最后把这次修正写成一条可复用的纪律,放进旁注:

任何「两个时间相除」的判据,都要先问一句:这两个量的关系稳定吗?

不稳定就不能除。除出来的不是指标,是一个会骗你的数。