先记住这个答案
流式响应以SSE事件序列推进:先收到message_start(内含content为空数组的Message对象,携带id、model、role等元数据);随后每个内容块经历content_block_start、多个content_block_delta(按index定位,delta含具体文本、工具JSON片段或思考)和content_block_stop;接着可能有message_delta调整顶层stop_reason、usage,最后message_stop表示正常结束。
- message_start携带消息级元数据,content为空数组
- content_block_delta按index携带文本或工具JSON增量
- message_stop只表示正常结束,不携带业务数据
三种事件在事件流中的定位与顺序
message_start是流中第一个事件,其data.message携带Message对象的元数据,包括id、model、role和初始为null的stop_reason,而content字段固定为空数组。前端应在此处创建消息记录,初始化一个空的块集合和消息级标志位,后续所有增量都基于该记录的上下文更新。
随后每个内容块会先收到content_block_start声明index和类型。content_block_delta携带相同的index和增量delta,delta.type决定解析方式:text_delta直接提供字符串,input_json_delta给出partial_json分片,thinking_delta记录扩展思考。客户端必须按到达顺序把分片拼到属于该index的缓冲中,而不是直接当作完整内容使用。
当一个块内所有增量完成,content_block_stop标记该块结束。所有内容块处理完毕后,可能出现一个或多个message_delta用于更新顶部对象,例如补充最终stop_reason和usage统计。真正收尾的是message_stop,它不带任何业务字段,只是表示整个消息已完整输出;因此若需要读取最终状态,必须在message_delta里进行,而不是等message_stop。
同时渲染文本和工具参数的实时界面
假设需要实现一个聊天前端,模型在一次响应里既输出解释文字,又要调用get_weather工具,即返回一个文本内容块和一个tool_use内容块。收到message_start后界面创建一条消息记录;收到content_block_start时按index为0初始化文本缓冲区,为1初始化工具输入缓存,并记录两者的类型。
随后content_block_delta持续到达,前端根据index路由:索引0的text_delta把delta.text追加到可编辑的文本区域,索引1的input_json_delta把partial_json片段拼进字符串缓冲区,用于后续解析。注意不能对每个partial_json单独调用JSON.parse,因为模型可能一次只输出半个键值;这里采用实时解析器:尝试对累积的字符串做JSON解析,失败就等待下一次追加,成功则刷新工具参数预览。由于文本追加是原生的,渲染不会卡顿;工具预览也只在完整JSON出现后更新一次。
当流接近尾声,message_delta携带的stop_reason变为tool_use。此时前端应立刻隐藏“生成中”的旋转指示,并允许用户点击“执行工具”按钮,同时保留文本与工具块。最后message_stop到来时只触发清理临时缓冲的finally逻辑,不在此处读取stop_reason,因为该值已经在上一步的message_delta中拿到并保存到消息状态里。
中断、分片不完整与多事件交错的处理边界
如果网络断开或收到error事件(例如overloaded_error),流正常不会出现message_stop。任何已显示的文本增量都可以保留,但应标记为“响应被中断”;未完成的tool_use块其partial_json碎片如果没有收全,不能直接解析,应丢弃该块或置为“工具调用失败”状态,防止前端尝试执行不完整的参数。
另一个失效点是多个内容块可能交错产生事件,且同一SSE连接的TCP分片可能把不同事件的JSON数据拆散到同一帧中。客户端必须先用行缓冲切分data:字段,再将一个完整事件交给分发器;如果某块只有content_block_start而一直没等到对应的content_block_stop,允许保留已显示的文本,但工具块的内容则不适合做部分恢复,因为JSON必须完整才能获得对象值。
还要注意message_delta可能不止出现一次,当服务端发生模型切换或补充usage时,会发送多个此类事件。因此客户端不能假设第一条message_delta之后必然紧接message_stop,而应当每次都把顶层字段更新到最新;只有收到message_stop才允许把整个消息标记为已完成并提交数据库,这能避免中途意外状态覆盖。
容易答错的地方
- 认为message_stop携带最终usage和stop_reason
- 错误的来源是混淆了事件名称。
message_delta负责顶层字段的最终更新,包括stop_reason和usage,并且可能多次发送;message_stop只发送一个不附加业务数据的空对象。若要取最终状态必须在message_delta中累计。 - 将content_block_delta视为单一文本增量
delta类型不只text_delta,还可能有input_json_delta和thinking_delta。若不检查delta.type直接取text访问会得到undefined,工具输入的partial_json也不能被当作完整JSON来解析。必须根据类型维护不同的拼接逻辑,否则工具参数显示会损坏。
面试官还会怎么问?
流式响应过程中如何判断生成被截断而没收到message_stop?
正常流结束会收到message_stop。如果连接因网络错误或收到error事件而断开,就不会有message_stop,此时应视为中断。可依据应用需求设置超时或使用AbortController主动取消,但不应预设一个固定秒数。已显示的文本可保留,但未完成的工具块需作废。无需额外检查stop_reason,因为message_stop缺失本身即为异常。
多个content_block_delta的index是如何确定对应块的?
每个块的content_block_start设定固定index,后续所有content_block_delta都携带该index值,范围从0到块总数减一。前端可用数组作为容器,index就是数组下标,事件到达时直接定位到目标块并追加增量。
message_start里为什么content是空数组,而不是直接放好完整消息?
因为流式模式的核心目的是让客户端尽快开始渲染首段文本,而不是等待整个Message组装完毕。如果首个事件就带完整内容就失去了增量意义;空content也声明了后续需要动态构造块的顺序。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。