这份事后分析读起来像是一份幻觉报告。一名用户根据一份语气笃定的建议采取了行动,但结果证明该建议是错误的——这种错误在模型正常完成输出的情况下是不会出现的。然而,追踪记录显示模型并未完成输出。在预期的 800 个 Token 中,供应商连接在第 412 个 Token 时断开了。客户端的错误处理程序记录了这次失败。但随着 Token 的到达,持久化的部分消息已被写入对话历史,在用户的 UI 中看起来与其他完整的回答毫无二致。于是用户采信了它。支持团队将该工单归类为内容质量问题,花了整整两周时间才将其转交给平台团队。
这条链路中没有任何环节属于模型故障。模型对生成的 412 个 Token 表现得非常正确。失败的原因在于流式 UI 和持久化对话历史在“什么才算是一条消息”的问题上产生了隐秘的分歧。而正是这种流式传输本应缓解的故障模式,导致这一分歧成为了权威记录。
这是乐观渲染(Optimistic Rendering)与持久化存储之间的契约。大多数聊天产品只是从教程或框架中继承了这种模式,而从未将其视为一项契约,这种鸿沟最终表现为一系列看似模型 Bug 实则不然的尾部故障。
流式传输是叠加在事务契约之上的 UX 优化
聊天 UI 中之所以存在流式传输,是因为感知的延迟。如果产生 800 个 Token 需要 8 秒,而你必须等待完整的 8 秒才能看到响应,那么这个模型给人的感觉是不可用的;但如果你能在 300 毫秒内看到第一个 Token,它就会变得完全可用。这种优化是真实存在的,对用户的影响也是真实存在的。但这种优化并没有改变一个底层问题:一条消息何时才算存在?
在非流式的世界里,这个问题很简单。HTTP 调用返回一个完整的响应,客户端持久化它,UI 渲染它。持久化和显示都位于同一个事件的下游。在一个特定的时刻,一条消息正式成为消息,整个系统对此达成共识。
流式传输将这些操作拆解开了。现在出现了一系列事件——Token 到达、UI 更新、客户端可能会也可能不会在过程中进行持久化、流可能会通过携带 stop_reason 的 message_stop 事件正常结束,也可能会断开。这些操作中的每一个都需要对消息存在的问题有一个立场,而开发者往往倾向于让每个子系统独立回答这个问题。渲染器说:“我有 Token 了,我要渲染。”持久化层说:“我有 Token 了,我要持久化。”错误处理程序说:“我报错了,我要记录日志。”没有人是契约的负责人。
真正重要的契约是:只有当模型完成生成时,对话历史中才存在一条消息。除此之外的任何内容都是过程中的产物(In-flight Artifacts)。流式 UI 允许将这些过程中的产物作为一种 UX 礼仪展示给用户,但历史记录不允许将它们记录为完整的消息。一旦你把这份契约写下来,一切就显而易见了。团队之所以会犯错,是因为没有人把它写下来,而且框架的默认设置通常将过程中的缓冲区和持久化历史视为同一个存储。
乐观写入如何演变为权威记录
导致此类事故的模式通常始于一个合理的特性:不要因为用户在生成过程中刷新页面就丢失响应。随后的实现几乎总是一样的:随着每个分块(Chunk)的到达,将其持久化到本地存储或服务器。页面重新加载时,根据已持久化的内容重构对话。用户回到了他们离开的地方,流式传输感觉非常丝滑。
陷阱在于失败路径。流的结束应该发送一个包含 stop_reason 的终止事件——自然完成是 end_turn,达到预算限制是 max_tokens,工具调用边界是 tool_use,显式停止是 stop_sequence。一个没有这些终止事件而结束的流并没有产生一条消息,它产生的是一段残片。但是,随着分块到达而写入的持久化路径并没有终止事件的概念。它只是接收什么就写入什么。错误处理程序记录了断开连接,但并不会回滚已写入的内容。下一次加载时,系统会看到一条与成功生成无法区分的记录,且数据模型中没有任何标志来标识它是残片。
用户可见的失败是最糟糕的一种。残缺的回答以与完整回答相同的样式渲染。对话基于它继续。用户基于它采取行动。如果连接在模型给出建议的中途断开——“这里最安全的选择是”——那么这半截话读起来就像是模型从未写完的后半句的权威陈述。UI 中没有任何警告,因为 UI 根本无从得知。
这并非假设。针对主流聊天产品和 SDK 的 Bug 报告都记录了这种完全相同的模式。在几乎所有案例中,操作层面的修复都是增加一个此前从未有人意识到需要的“最终化握手”。
草稿位模式
这一修复是架构层面的。传输中的缓冲区(in-flight buffer)与对话历史必须是两个独立的存储。流式渲染器向草稿位(draft slot)写入数据。持久化层向草稿位写入数据。只有终结处理器(finalization handler)——即观察到明确 stop_reason 的代码——才会将草稿提升(promote)到对话历史中。一个永远无法完成的残缺片段永远不会被提升,不会出现在下一次页面加载中,也永远不会成为用户可以回溯看到的“真实”答案。
草稿位不一定非得是一个独立的数据库。在最简单的实现中,它可以是消息行上的一个标志:is_draft: true,直到流正常完成,然后在记录 stop_reason 的同一个事务中将其翻转为 is_draft: false。历史查询会过滤掉草稿。流式 UI 允许读取自己的草稿并将其渲染为“进行中”,如果用户回到一个留有孤儿草稿的标签页,UI 可以提供一个恢复功能——“这个回答中断了,要重新生成吗?”——这是针对该场景的正确体验,而如果你无法区分草稿和已完成的内容,就无法提供这种体验。
更难实现的版本在设计上就保证了正确性。持久化层接收流,且仅在出现 stop_reason 时才写入一行数据。渲染器直接读取流进行展示,绝不要求持久化层在传输过程中记录任何内容。这将读路径与写路径分离,并消除了“三个月后有人忘了翻转标志”这类所有可能的错误。这会让你失去“刷新韧性”的功能,除非你还构建了服务端可恢复流——这是现代 AI SDK 提供的功能,只有当产品真正需要时才值得投入工程力量去开发。
大多数团队都高估了用户在页面重新加载时保留传输中内容的真实意愿。用户想要的是模型完成后的答案。中断流的重新加载是罕见的;“将残缺内容视作正式内容”的事故在绝对数量上可能更少,但一旦发生,其代价却不成比例地高,因为用户会根据错误信息采取行动。
Stop-Reason 准则
在客户端,操作规范是:除非你观察到一个带有表示完成的 stop_reason 的明确终端事件,否则不要将任何流输出视为完整消息。end_turn 是完成。max_tokens 不是——模型是被你的预算切断的,即使 token 本身逻辑连贯,响应在结构上也是不完整的。tool_use 也不是助手轮次的完成;它是向工具调用的移交,必须在工具调用解决后,助手轮次才能被视为结束。拒绝流(Refusal streams)使情况进一步复杂化,因为一些供应商发出的拒绝文本看起来像普通内容,仅在元数据字段中信号化拒绝。
终结消息的客户端代码应该是一个单一函数,负责检查终止原因并做出决定。如果终止原因是 end_turn,则提升草稿。如果是 max_tokens,则提升草稿但将消息标记为截断,并渲染一个继续操作的提示。如果流在收到任何终止原因之前报错,则不要提升;显示错误并让用户重试。决策逻辑只存在于这一个地方。渲染器永远不会绕过它。持久化层也永远不会绕过它。能够产生对话历史记录的路径只有一条,且该路径受终端事件管控。
服务端也有类似的准则。如果你是从后端而不是客户端将助手消息记录到数据库,同样的规则也适用:在流正常结束之前不要插入行。如果你为了可观测性而发出结构化日志——Token 计数、延迟、终止原因——这些可以增量写入,因为可观测性存储不是对话历史。将这两个存储在概念上分开是大部分工作的核心;一旦分开,正确的规则就会自动应用到每个存储。
衡量关键指标,确保 UX 永远不会脱离模型状态
监控层可以在客户发现之前捕捉到这类 Bug。需要跟踪的指标是流的完成率:以终端事件结束的流与开始的流的比率。一个健康的产品,这个数值会非常接近 1。一个性能下降的供应商、一个不稳定的代理或最近对流式管道的更改,都会在任何面向用户的指标发生变化之前先拉低这个数值。
第二个指标是已渲染 Token 与已完结消息之间的差距。如果你的 UI 每分钟渲染一万个 Token,而你的终结处理器只看到八千个 Token 被确认完结,那么差值就是那些触达了用户但从未被标记为完成的传输中内容。这个差距是“将残缺内容视为正式内容”风险的先导指标,而且它对于任何单次调用的成功率指标都是不可见的,因为每个单独的调用可能并没有明显的失败——仅仅是它们的结尾丢失了。
第三个指标是闭环指标:用户对从未完结的消息采取行动的频率。如果你的数据模型区分了草稿和已完成内容,这就是一个你可以运行的联表查询。如果模型不区分,那么这个频率就是不可知的,这本身就是一个诊断结论:数据模型丢失了运营团队所需的信息。
架构启示
流式传输(Streaming)是叠加在事务契约(transactional contract)之上的 UX 优化,而契约本身比这种优化更为古老。契约规定,只有当模型完成生成时,消息才算存在。优化则提出,我们要在模型完成之前就显示 Token,从而让用户无需等待。只有当代码库能够明确区分“传输中缓冲区”(in-flight buffer)与“持久化历史记录”(durable history)时,这两者才是兼容的;而一旦团队将“随到随显”与“随到随存”视为同一种操作,冲突便会随之而来。
产生这类 Bug 的团队并非不懂流式传输,而是因为他们对流式传输足够了解并优化了 UX,却继承了一个本就具有“乐观性”的持久化层。在非流式传输的世界里,持久化的产物与显示的产物源于同一个事件,这种乐观性是正确的。但在流式传输的世界里,显示的产物是暂时的,而持久化的产物应当是最终的,此时这种做法就错了。修复方法是将契约重新引入代码:定义一个由终止事件触发的最终化函数(finalization function),使其成为写入历史记录的唯一路径。其他所有环节都可以是乐观的,但历史记录不行。
会员专享
余下内容仅对会员开放。
会员可读完整内容 —— 每一个公开发布的观点背后,那些框架、决策与推理的全貌。
- —完整文章,包含未公开存档的部分
- —可落地的工作框架,附带权衡与决策依据
- —新文章抢先看,先于公开发布
随时取消 · 一次订阅,畅读全部