聊天界面里的打字机效果,不是模型"打字快",而是一条 HTTP 长连接上持续到达的事件流。模型边生成边推送,客户端边收边渲染——这套机制的协议,就是流式输出事件。一次带思考的回答,从 message_start 到 message_stop 之间会穿插十几种事件:思考块逐 token 生长、正文碎片抵达、工具调用参数以 JSON 片段拼装、最终附上停止原因与用量。
本文给全这套事件词表:先看 SSE 的线格式与八种消息级事件,再用一条真实完整流逐事件走读,然后拆内容块与增量类型、停止原因与用量,最后给出客户端组装算法。协议定义以 Claude API 为准,走读样本取自 GLM 的兼容实现(结构同源,个别字段有差异,文中随讲随标)。
1、SSE 与事件总览
传输层用的是 SSE(Server-Sent Events):普通 HTTP 响应不关连接,服务端一行行往里写,格式见 MDN 文档。每个事件两行——event 是类型名,data 是一段 JSON。一次流的开头长这样:
event: message_start
data: {"type": "message_start", "message": {"id": "msg_01", "model": "...", "role": "assistant", "content": [], "usage": {...}}}
event: content_block_delta
data: {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "你"}}
注意 data 里的 JSON 自带 type 字段,与 event 行一致——客户端解析时认 type 就够了。八种消息级事件如下:
| 事件 | 作用 |
|---|---|
| message_start | 流开头,携带消息骨架:id、模型、role、初始 usage |
| ping | 保活心跳,无业务内容,可直接忽略 |
| content_block_start | 开启一个内容块,携带 index 与块类型 |
| content_block_delta | 块内增量到达,携带 index 与增量类型 |
| content_block_stop | 关闭一个内容块 |
| message_delta | 收尾前的元信息更新:停止原因与最终用量 |
| message_stop | 真正的终点,之后不再有任何事件 |
| error | 中途出错,如过载;流可能就此中断 |
多数表格漏掉的是 error:它可以在流中途插入——前几秒还在正常出字,一个过载事件后连接断开。健壮的客户端必须处理"流开了一半死掉"的情况。一次完整流的生命周期如下:
图中"外层循环每个内容块、内层循环逐 token"是整个协议的骨架:一条消息由若干块组成,每个块由若干增量拼成。
2、一条真实流的走读
下面是一次真实请求的完整事件骨架(思考与正文增量各有百余条,中间以省略行表示,结构与到达顺序原样保留):
event: message_start
data: {"type":"message_start","message":{"id":"msg_20261112150800...","model":"glm-5.3","role":"assistant","content":[],"stop_reason":null,"usage":{"input_tokens":0,"output_tokens":0}}}
event: ping
data: {"type":"ping"}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"The"}}
(中间约 170 条 thinking_delta 逐词推进,略)
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"2491a23a..."}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: content_block_start
data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"1"}}
("1"、"\n"、"2"、"\n"……逐字到达,略)
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"\n9\n10"}}
event: content_block_stop
data: {"type":"content_block_stop","index":1}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":22,"output_tokens":195,"cache_read_input_tokens":0,"server_tool_use":{"web_search_requests":0},"service_tier":"standard"}}
event: message_stop
data: {"type":"message_stop"}
骨架事件逐个看。message_start 的字段各有讲究:id 是服务端的消息编号,content 是空数组等着块来填,stop_reason 为 null 表示尚未结束,usage 全零——GLM 兼容实现此刻不给输入数,要等流末;ping 夹在开头与第一个块之间,纯保活。content_block_start 把思考块以空字符串预开,thinking 与 signature 两个字段都等着增量来填。
思考碎片的粒度值得细看:英文基本一词一条,"The"" user"" is" 各自到达;中文按词,"每个""数字" 两个汉字合在一条;标点常单独成条;但粒度并不严格等于 token——"."\n\n" 这样的尾部一条就带了句号、引号和两个换行。JSON 里的 \n 与 " 是转义写法,解析后就是真实字符。对渲染层的结论只有一句:来一条追一条,不要对粒度做任何假设。
思考内容说完,signature_delta 一次性到达——对整段思考的完整性签名,多轮回传思考时必须原样带上。随后 index 0 关闭、index 1 开启,块的先后由 index 保证:思考在前,正文在后。正文把"逐 token"演示得最直白:数字与换行各占一条,而最后一条 "\n9\n10" 一口气带了三个 token——服务端在流稳定时会合并小增量,省事件数。拼装结果就是 "1\n2\n…\n10"。
收尾前的 message_delta 一条顶三条:stop_reason 为 end_turn,自然说完;usage 在此定型,输入 22 个 token、输出 195 个;GLM 的扩展字段也在这里——cache_read_input_tokens 记缓存读取,server_tool_use 记服务端工具次数,service_tier 记服务档位。本流的统计如下:
| 统计项 | 数值 |
|---|---|
| 骨架事件 | 9 条(起止与开关节点) |
| 思考增量 | 约 170 条 |
| 正文增量 | 17 条 |
| 输出用量 | 195 token |
表中增量占了全流的九成五以上,事件数与输出 token 数同量级——这是流式层一个朴素的自检法:如果两者差出一个数量级,多半是解析层丢了事件。message_stop 关闭流,组装结束。
3、内容块的种类
先把层级关系摆正:message 系列事件管整条回复的起止与元信息,content_block 系列传消息内部的内容碎片,两组事件各管一层、互不重叠。本例的层级结构如下:
message(一条完整回复,role 为 assistant)
├── content_block index=0: thinking 块
└── content_block index=1: text 块
树上每个节点各有自己的起止:消息层由 message_start 与 message_stop 包夹,块层由 content_block_start 与 content_block_stop 包夹。content_block_start 能开的块不止思考与正文两种,完整清单如下:
| 块类型 | 内容 | 出现场景 |
|---|---|---|
| text | 可见正文 | 普通回答 |
| thinking | 思维链 | 推理类模型 |
| redacted_thinking | 加密思考 | 安全策略触发时整体下发 |
| tool_use | 工具调用 | 模型决定调工具 |
块按 index 从零递增、按序到达:思考块在前、正文在后;一次回复也可以只有正文,或并列多个 tool_use 块(并行工具调用)。redacted_thinking 是个特例:它没有增量过程,content_block_start 后直接 stop,内容是一段加密数据——模型判断思维链不宜明文展示时的兜底,客户端原样保存、原样回传即可。
服务端工具(联网搜索、代码执行等)还会产生各自的结果块,如 web_search_tool_result——结构同上一张表,只是类型更多,出现即表示对应工具已经跑完。完整字段定义见官方流式文档。
4、增量类型的五种
真正逐 token 到达的是 content_block_delta 里的 delta,类型共五种:
| 增量类型 | 携带字段 | 挂在哪类块上 |
|---|---|---|
| text_delta | text | text |
| thinking_delta | thinking | thinking |
| signature_delta | signature | thinking |
| input_json_delta | partial_json | tool_use |
| citations_delta | citation | text |
前两种好理解:正文与思维链的碎片。signature_delta 值得单独讲:它是对 thinking 内容的完整性签名,跟着思维链碎片一起流过来。多轮对话要把思考块原样传回(保留思考机制,见官方文档),签名就是"这段思考没被篡改"的凭证——客户端不要理解它,只需把 signature 与 thinking 拼完整、存下来、下次带上。
input_json_delta 是工具调用的流式形态:tool_use 块的参数是一个 JSON 对象,但模型生成它同样是逐 token 的,于是参数以字符串碎片到达。比如调用天气工具,参数流可能是这样的:
content_block_delta {"delta": {"type": "input_json_delta", "partial_json": "{\"city\""}}
content_block_delta {"delta": {"type": "input_json_delta", "partial_json": ": \""}}
content_block_delta {"delta": {"type": "input_json_delta", "partial_json": "上海\"}"}}
content_block_stop
三段 partial_json 拼起来才是完整的 {"city": "上海"}。客户端的正确姿势是逐段追加字符串,等 content_block_stop 之后再一次性解析——中途解析必然失败,因为碎片随时可能截断在 JSON 语法中间。citations_delta 则在开启引用功能时出现,为正文片段逐个挂上出处标注。
5、停止原因与用量
流怎么结束、花了多少 token,都藏在 message_delta 里。
5.1、六种停止原因
停止原因的完整取值如下:
| stop_reason | 含义 | 客户端该做什么 |
|---|---|---|
| end_turn | 自然说完 | 正常收尾 |
| max_tokens | 触到输出上限 | 输出被截断,考虑重试或加额度 |
| stop_sequence | 命中自定义停止串 | 正常收尾 |
| tool_use | 要调工具 | 执行工具,把结果回传继续 |
| pause_turn | 服务端工具暂停 | 按文档把上下文回传续跑 |
| refusal | 安全拒答 | 检查分类信息,走兜底话术 |
表里最要紧的是 max_tokens 与 tool_use:前者意味着文本可能断在半句话上,直接展示会出事故;后者意味着对话没结束,harness 要执行工具并把结果作为下一轮输入——agent 循环正是靠这个停止原因驱动的。
5.2、用量的占位与补全
用量与停止原因的设计可以概括成八个字:开场占位,收尾补全。有些信息只有流跑完才知道——输出 token 数要等最后一个字生成完才能统计,停止原因更是到最后一刻才确定,中途随时可能因上限被截断或转去调工具。对照本例的两段数据最直观:
// message_start:占位,空值与零值
{"usage": {"input_tokens": 0, "output_tokens": 0}, "stop_reason": null}
// message_delta:收尾补全,真实值
{"delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 22, "output_tokens": 195}}
对照里还藏着一层分工:message_start 的 usage 带输入侧(Claude 官方流起点即给出,GLM 兼容实现常为零值),message_delta 的 usage 带最终累计。计费统计以收尾的合并结果为准。输入侧为什么起点就能给?因为输入在流开始前已经处理完——正好呼应 KV Cache 的分工:输入是算完的部分,输出才是边生成边计数的部分。另有两个实现细节:message_delta 是中间事件而非终点,真正的流终止符是 message_stop;部分实现还会在生成中途周期性下发 message_delta 携带累计用量,客户端不应假设它只出现一次。
6、客户端组装算法
把事件流拼回完整消息,规则只有四条:message_start 建骨架;content_block_start 按 index 开块;delta 往对应块追加;content_block_stop 关块,最后按 index 排序拼出 content 数组。三十行内可以实现:
class StreamAssembler:
def __init__(self):
self.message = None
self.blocks = {} # index -> {"type": ..., "text": ..., "json": ""}
def feed(self, ev): # 逐事件喂入
t = ev["type"]
if t == "message_start":
self.message = ev["message"]
elif t == "content_block_start":
self.blocks[ev["index"]] = dict(ev["content_block"], buf="")
elif t == "content_block_delta":
d = ev["delta"]
blk = self.blocks[ev["index"]]
if d["type"] in ("text_delta", "thinking_delta"):
blk["buf"] += d["text"] if "text" in d else d["thinking"]
elif d["type"] == "input_json_delta":
blk["buf"] += d["partial_json"] # 收齐再解析
elif t == "message_delta":
self.message["stop_reason"] = ev["delta"].get("stop_reason")
self.message["usage"] = ev.get("usage", self.message.get("usage"))
elif t == "error":
raise RuntimeError(ev["error"])
def result(self):
return {"message": self.message,
"blocks": [self.blocks[i] for i in sorted(self.blocks)]}
四个实现细节容易踩坑。其一,ping 直接跳过,别让它进任何分支;其二,error 之后流可能戛然而止,渲染层要能保留已收到的部分而不是清屏;其三,input_json_delta 只追加不解析,关块后统一 json.loads;其四,断线重连要靠业务层重发请求——SSE 本身不保证恢复,中途死掉的流只能重来。SDK 通常把这些都包好了,官方 SDK 的流式助手(如 Python 的 stream.get_final_message())就是这套组装逻辑的封装,自研客户端时再手写。
7、总结
回顾全文:八种消息级事件搭骨架,五种增量类型填血肉,六种停止原因决定收尾动作,用量两段式输入在头输出在尾;客户端按 index 开块追加关块,注意 error 中断与 JSON 碎片延迟解析。对于做流式渲染或自研 harness 的团队,建议直接用官方 SDK 的组装助手,只有理解了这套事件词表,打字机效果、思考过程展示、工具调用进度这些产品细节才做得稳——你看到的每一个字背后,都是一次 content_block_delta。

