LLM API 的 streaming 是最容易添加、也最容易做错的功能之一。在演示里它只是一个开关,stream: true,再加上一个打印 token 的循环。在生产环境中,它变成四个独立的问题:事件模型(如何解析而不脆弱)、超时策略(多长时间的沉默意味着流已死)、用量核算(当响应分片到达时如何拿到 token 计数),以及当一条流在回答中途断掉时会发生什么。这四项里任何一项做错,症状都很眼熟:回答卡住、账单与仪表盘对不上,或者一次抖动之后出现重复生成。本文是构建者系列的流式章节,接在结构化输出和工具调用指南之后。
LLM API 流式事件模型,两种形态
两家主要供应商都通过 Server-Sent Events 进行流式传输,也都在传输层之上叠加了语义化事件类型,但形态差异会改变你的解析器。
OpenAI。默认情况下,API 会先生成模型的完整输出,再以单个响应返回;streaming 让你可以在生成继续的同时开始打印或处理。官方流式指南聚焦于基于 SSE 的 HTTP 流式(stream=true)。另有独立的 WebSocket 传输,用于需要持久连接并经 previous_response_id 提供增量输入的场景;那是另一种传输选择,不是解析器细节。在 HTTP 路径上,Responses API 使用语义化事件:每个事件都带预定义 schema 的类型,因此你可以只监听关心的那几种。几乎每个集成都要处理的事件包括:用于增量文本的 response.output_text.delta,用于已完成响应的 response.completed,用于流内错误的 error,以及 response.created。有些生命周期事件只发出一次,其他则会随响应生成反复发出。如果流式输出对用户可见,还要把该指南中的 Moderation 风险一节纳入上线评审。
Anthropic。每个事件都带一个 SSE 事件名,例如 event: message_stop,以及其 data 中对应的 type。流式文档 定义了完整流程:message_start 最先到达,其 content 为空;随后,对每个内容块,会有一个 content_block_start,一个或多个 content_block_delta 事件,以及一个 content_block_stop;然后是一个或多个 message_delta 事件,携带对最终消息的顶层变更;最后是一个 message_stop。每个内容块都带有一个 index,对应它在最终 content 数组中的位置,一旦响应混合了文本、工具调用和思考,这一点就很重要:delta 类型包括 text_delta、input_json_delta 和 thinking_delta。
解析来自 LLM API 的 server-sent events:四个陷阱
传输层很简单,这正是失败模式如此隐蔽的原因。大多数坏掉的解析器都栽在四个陷阱上:
- 把 ping 当成内容。Anthropic 的文档指出,事件流可能包含任意数量的
ping事件。一个假设“每个事件都带着我需要的东西”的解析器,要么崩溃,要么输出垃圾。要显式处理 ping:跳过它们,可选地为保活逻辑统计其数量。 - 累加累计型计数器。token 计数位于
usage字段中,属于message_delta事件,它们是累计的。如果你把每次出现的值加起来,就会重复计算两三遍。正确的习惯是覆盖:保留最新值,在最后使用它。这是 token 仪表盘与供应商控制台对不上最常见的原因。 - 拒绝未知事件类型。按照版本策略,可能会新增事件类型,代码应当优雅地处理未知类型。用严格白名单枚举事件名的解析器,会在供应商更新时失效。给每个分发加一个默认分支:记录未知类型并继续。
- 把 HTTP 200 当作成功。错误可能出现在事件流内部。Anthropic 的文档给出了典型例子:在使用量很高时,你可能会在流内收到一个
overloaded_error,它在非流式场景下通常对应 HTTP 529。所以状态行成功只意味着“流已打开”,而不是“回答已完成”。你的循环必须同时检查两件事:流内错误事件,以及完成事件(response.completed事件,或message_stop事件)是否真的到达了。
流式超时处理:看空闲间隔,而不是总时长
一条流会给你带来三种不同的时钟,它们需要三种不同的策略:
- 连接建立。等待 HTTP 响应头的时间。这一项可以保持严格,因为它衡量的是一次请求,而不是一次生成。
- 空闲时间:事件之间的间隔。这才是流式处理中真正重要的那一个,而且应当按每次间隔来度量,而不是从第一个字节到最后一个字节。一个持续稳定产出事件的长回答是健康的,哪怕它跑上几分钟;一条已经沉默的流则不是。
- 总时长。作为兜底的硬上限,依据你自己的负载数据来设定,而不是靠期望。
官方文档中的两个细节塑造了空闲策略。第一,ping:既然 ping 事件可能出现任意多次,就把任何事件(包括 ping)都视为会重置空闲时钟的活动;不要在存活检测中把 ping 过滤掉。第二,工具使用:Anthropic 的文档指出,使用工具时,模型工作期间流式事件之间可能有延迟,因为工具参数是以分块的部分 JSON 形式、逐个键拼装后到达的。按纯文本调好的超时,在工具进入循环的那天就会变成误判的断连。实操做法是:对带工具的轮次放宽空闲阈值,或为它们单独设一个阈值,并把工具侧的拼装规则留在工具调用指南放置的位置。
用量核算:Anthropic 的 message_delta usage 与 OpenAI 的 completed 事件
两条流式路径上都有用量数据,但只出现在明确定义的时刻,而这决定了核算纪律。
在 OpenAI 一侧,最终的用量数字是已完成响应的一部分,因此从 response.completed 事件中读取。在 Anthropic 一侧,usage 字段(属于 message_delta 事件)携带计数,而且要记住它们是累计的:取最后一次出现的值。在完成时刻之前的一切都不具权威性,由此引出两条让成本数据保持可信的规则:
- 只在完成事件到达时计入用量。在回答中途死掉的流从未交付最终数字;如果你做了估算或外推,就把这次调用标记为估算值,而不是实测值。(这是成本中枢那条“只有已完成的工作才计入”的规则在流式场景下的推论。)
- 把被中断的流放进单独的桶里。不要把部分流的调用与正常调用合并到同一个聚合里,否则你从成本的执行顺序中得到的按功能成本对比就会开始骗你。把不完整流的数量和比例作为独立指标跟踪。
有一个相关问题本文不会从流的形态来回答:某条被中断的流是否计费。这要由你自己的用量记录和供应商的计费文档来回答,无法从事件顺序推断。
当流中断时:重试,还是渲染已到达的内容
当一条流死掉或送来流内错误时,没有普遍正确的答案,只有一个决策框架:
- 重试会重新生成回答。这适合批处理和自动化流水线:那里的消费方期望得到一个完整结果,部分结果毫无用处。把它控制在重试预算之内,并记住可重试与不可重试的区分:流内的
overloaded_error是容量信号,含义与其非流式场景下的 HTTP 对应物相近,而其他错误类型可能是策略或请求问题,重试无法解决,正如错误码分诊和重试预算指南所阐述的那样。 - 渲染部分结果则适合面向用户的对话界面。用户已经看到了 token;把它们抽走再从头重新生成,比一条诚实的续接路径更糟。这带来的义务很窄但很重要:你的系统必须能在 UI 和数据中区分完整回答与部分回答,这样下游任何环节都不会把被截断的响应当作最终结果。
不该做的事很机械:不要把流内错误事件当作带单一重试策略的 HTTP 层失败。先看事件的错误类型,再套用上面的矩阵。
流式调用要多记录哪些字段
来自可观测性指南的日志字段清单原样适用于流式,另有五项值得从第一天就接上:
- 完成事件是否到达(这条路径上最有用的单一可靠性信号);
- 收到了多少个 delta 事件,作为粗略的响应形态指标;
- 流内错误的类型(如果有),与 HTTP 状态分开记录;
- 观测到的事件间最大间隔,这是你调校空闲阈值而不是靠猜所需要的数据;
- 是否向用户渲染了部分结果,以便质量和履约指标能将其排除。
流式检查清单
- 按语义解析:依据事件类型分支,并显式跳过 ping。
- 覆盖用量计数器;绝不累加累计型字段。
- 让未知事件类型记录日志并继续;不要因为它们让流失败。
- 把 HTTP 状态视为“流已打开”,而不是“回答已完成”;检查流内错误和完成事件。
- 按事件间隔设置空闲超时,任何事件(包括 ping)都重置它,并对带工具的轮次放宽。
- 只在完成时刻读取用量;其他情况标记为不完整,而不是当作数据。
- 按消费方类型决定重试还是渲染,每条路径一种策略,并让流内错误先走错误类型矩阵。
- 记录上面五个流式字段,包括事件间最大间隔。
流式并不比生产集成的其他部分更难;它只是对假设更不容忍。线协议很简单,那四个问题长期存在,而处理得好的团队会把“流是否完成了”当作整个技术栈中一等公民的事实。当你的调用经过兼容端点时,端点级细节就在平台自己的文档。