那些将模型未完成的残缺回答存入数据库的流式 UI
这份事后分析读起来像是一份幻觉报告。一名用户根据一份语气笃定的建议采取了行动,但结果证明该建议是错误的——这种错误在模型正常完成输出的情况下是不会出现的。然而,追踪记录显示模型并未完成输出。在预期的 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 报告都记录了这种完全相同的模式。在几乎所有案例中,操作层面的修复都是增加一个此前从未有人意识到需要的“最终化握手”。
草稿位模式
- https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons
- https://docs.anthropic.com/en/docs/build-with-claude/streaming
- https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-resume-streams
- https://ai-sdk.dev/docs/advanced/stopping-streams
- https://ai-sdk.dev/docs/troubleshooting/stream-abort-handling
- https://aws.amazon.com/blogs/compute/serverless-strategies-for-streaming-llm-responses/
- https://platform.claude.com/docs/en/test-and-evaluate/strengthen-guardrails/handle-streaming-refusals
- https://rxdb.info/articles/optimistic-ui.html
- https://www.freecodecamp.org/news/how-to-use-the-optimistic-ui-pattern-with-the-useoptimistic-hook-in-react/
