第 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:提供方排队是尾部现象,均值看不见它。
③ 保留那个对照实验,而且它是本节最有价值的部分。
在链路中间显式加一层缓冲,然后看三个数哪个变:
| 观测 | 结论 |
|---|---|
| 只有首字时间变长,总时长基本不变 | 那一层在攒片段——这是最干净的证据 |
| 首字时间与总时长一起变长 | 那一层在攒,但你的总时长本来就在临界点上 |
| 三个数都没变 | 那一层不是瓶颈,去查别的一跳 |
为什么这个实验值钱?因为它是可控实验:你没有改模型、没有改提示词、没有改网络,只加了一层已知会缓冲的东西。变量只有一个,所以结论成立。这比「线上观察了一周」可靠得多。
还有一条口径纪律要提醒:三个数要在同一批请求上取。 跨时间比会混入提供方负载变化——周一和周五的差异,可能全是对方机房忙不忙。
最后把这次修正写成一条可复用的纪律,放进旁注:
任何「两个时间相除」的判据,都要先问一句:这两个量的关系稳定吗?
不稳定就不能除。除出来的不是指标,是一个会骗你的数。