Claude API 流式响应:事件序列如何工作,以及怎么正确消费
流式到底改变了什么
一次非流式的 Claude API 请求,会把连接一直挂着,等模型写完,再返回一个完整的 JSON 对象。流式请求返回的内容一样,但它是在生成过程中,以一连串小事件的形式陆续送达的。
这个差别在两个地方要紧。交互式界面因此显得跟手——首字符在几百毫秒内就出现,而不是等整段写完;而 agent 类负载(编码助手、工具调用循环、一切跑得久的东西)需要增量输出来显示进度,也需要在答案已经跑偏时能提前中断。
流式并不会让生成变快。到最后一个 token 的总时长基本不变。变的是首字节时间,以及你能在响应完成之前拿它做什么。
传输层:服务器推送事件
在请求体里把 stream 设为 true,响应就会以服务器推送事件流(SSE)的形式返回,内容类型是 text/event-stream。
每个事件是两行加一个空行:event: 行给出事件类型,data: 行承载一个 JSON 对象。空行才是分隔符——只按换行切分的解析器会把多行负载切坏。
自己动手写解析器之前,SSE 有两个特性值得先记住。事件可能被拆在多个 TCP 包里,所以必须缓冲到看见空行终止符为止。另外流里会夹带周期性的 ping 事件,里面没有任何有用内容;它们存在的目的是防止中间链路把空闲连接掐掉,你的解析器必须忽略它们,而不是当成格式错误。
官方 SDK 已经把这些都处理好了。手搓的解析器,正是大多数流式 bug 的产地。
事件序列
Anthropic 的流不是一串扁平的文本片段,而是一个有结构的信封。理解这个结构,才能正确处理工具调用和结束原因。
|
事件 |
出现时机 |
承载什么 |
|---|---|---|
|
|
开头一次 |
消息外壳:id、模型、角色,以及含输入 token 的初始用量 |
|
|
每个内容块一次 |
块索引与类型—— |
|
|
每块多次 |
增量负载——真正的内容在这里 |
|
|
每个内容块一次 |
表示该索引的块已完结 |
|
|
接近末尾一次 |
顶层变化: |
|
|
最后一次 |
流结束 |
|
|
任意时刻 |
什么都没有——忽略 |
|
|
任意时刻 |
错误对象;此后流即终止 |
最容易被忽略的是嵌套关系。一个响应可以包含多个内容块,每块有自己的索引。文本和工具调用是同一条流里的不同块类型,它们的增量按索引交错,光看到达顺序是不够的。
增量的累积
对文本块来说,每个 content_block_delta 带一个 text_delta,其中有 text 字段。还原完整响应,就是按顺序把这些片段按块索引拼起来。
朴素写法——把每个增量都拼进同一个字符串——只在响应恰好只有一个文本块、且没有工具调用时才成立。一旦出现工具调用,这种写法会悄悄把 JSON 片段混进正文里。
正确做法是维护一个从块索引到累积内容的映射。收到 content_block_start 时按块类型建条目,收到 content_block_delta 时追加到对应索引的条目上,收到 content_block_stop 时标记完结。SDK 内部就是这么做的,也只有这个版本能扛住工具调用。
流式下的工具调用
模型调用工具时,内容块类型是 tool_use,它的增量是 input_json_delta——是 JSON 字符串的片段,不是解析好的对象。
这些片段单独拿出来都不是合法 JSON。一个 {"city": "Tokyo"} 的工具入参,可能先到 {"ci,再到 ty": "To,最后 kyo"}。你必须把整个字符串拼完,在该索引的 content_block_stop 之后一次性解析。
逐个增量去解析的代码,几乎每次调用都会抛错。这是工具型 agent 里最常见的流式 bug,而且测试时容易漏掉——因为很短的工具入参有时候确实一次就到齐了。
结束原因与用量
接近末尾的 message_delta 事件承载 stop_reason,而且这是唯一能拿到它的地方。实际需要分支处理的取值有:end_turn 表示自然写完,max_tokens 表示被你自己设的上限截断,tool_use 表示模型在等你执行工具并回传结果,stop_sequence 表示命中了你提供的停止序列。
把被截断的响应当成完整响应,是一个真实存在的故障模式。只要你的程序会把模型输出按结构化数据解析,解析前就该先看 stop_reason——max_tokens 意味着你手上的只是个片段。
用量数据分散在两个事件里:输入 token 在 message_start,输出 token 在 message_delta。只读其中一个来做成本归因,结果一定是错的。账单的其他影响因素见 Claude API 价格。
值得处理的失败形态
error 事件可能在流的中途到达——此时你可能已经收到了可用的文本。过载和限流状况是以这种方式浮现的,而不是 HTTP 状态码,因为状态行在生成开始前就已经以 200 发出去了。你的处理逻辑需要一条先有部分内容、随后失败的路径,而不只是成功或失败两分。
卡住的流和慢的流不是一回事。如果超过你的容忍时长没有收到任何事件——连 ping 都没有——那这条连接多半在上游已经死了,不会自己恢复。正确的形状是一个空闲超时,且每收到一个事件(含 ping)就重置它。
重试一条流不是免费的。如果你在已经收到部分输出之后重试,要么丢掉已经付过费的 token,要么冒着在会话记录里重复它们的风险。这件事该在上生产之前就定下来,而不是等它发生。
经由中转或网关的流式
如果你的请求要经过一层中转,流式是最容易被悄悄弄坏的那个能力。一个把上游响应整个缓冲、最后一次性放出来的中转,返回的内容逐字节相同,却把流式的全部好处消灭干净——而且它能通过任何只检查最终文本的测试。
要用秒表验,不要用断言验。测量到第一个可见分块的墙钟时间,和直连做对比。两者若一样,说明链路上某处在缓冲。
另一种坏法是工具调用被弄乱:会重新序列化事件的中转,可能打乱块索引,或把 input_json_delta 片段错误合并。所以要拿一个带工具调用的请求去测这条链路,而不是只测一次普通补全。中转的完整评估清单见 Claude API 中转。
常见问题
流式会比一次性请求更贵吗? 不会。计费按 token,token 数是一样的。流式改变的是投递方式,不是消耗量。
能同时用流式和工具调用吗? 可以。工具调用表现为 tool_use 内容块,其增量是部分 JSON,按块累积、一次性解析即可。
为什么会看到空事件? 那是 ping 事件,用来防止中间链路把空闲连接掐掉,不含任何内容,忽略即可。
流式输出和非流式输出完全一致吗? 内容一致。区别在信封:流式把同一条消息拆成事件,并把 stop_reason 与输出用量放在 message_delta 里,而不是顶层响应对象里。
怎么知道响应是完整的? 看 message_delta 里的 stop_reason。end_turn 表示模型自己写完了;max_tokens 表示被你的上限截断了。
ROIBest AI 在 Anthropic 原生格式之外同时提供 OpenAI 兼容端点,已有的流式客户端改一下 base URL 就能继续用。若还在决定接哪套协议,可参考 什么是 OpenAI 兼容 API。