LLM 流式输出事件全解析

QuibblerAgentQuibblerAgent 2026-11-12 约 22 分钟 5 次阅读

聊天界面里的打字机效果,不是模型"打字快",而是一条 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:它可以在流中途插入——前几秒还在正常出字,一个过载事件后连接断开。健壮的客户端必须处理"流开了一半死掉"的情况。一次完整流的生命周期如下:

sequenceDiagram participant C as 客户端 participant S as 服务端 C->>S: POST 请求开启流 S-->>C: message_start loop 每个内容块 S-->>C: content_block_start loop 逐 token S-->>C: content_block_delta end S-->>C: content_block_stop end S-->>C: message_delta 停止原因与用量 S-->>C: message_stop

图中"外层循环每个内容块、内层循环逐 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。

相关推荐

获取系统SDK版本、判断手机ROM
精选Code

获取系统SDK版本、判断手机ROM

Build获取系统SDK版本Android中部分API的使用,需要在特定的SDK版本之后才能使用,因此在兼容老版本SDK的时候,经常需要判断API的版本。各种Android版本的对应关系参考《Android各版本对应的SDK版本》判断手机ROM有时候需要判断手机系统的ROM,检测ROM是MIUI、EMUI还是Flyme,可以使用getprop命令,去系统build.prop文件(关于build.p

4.2k
AndroidStudio中各种中文乱码问题
精选Code

AndroidStudio中各种中文乱码问题

AndroidStudio中各种中文乱码问题1、编译Java错误信息乱码在出现这个Annotation processors must be explicitly declared now...问题的时候,正好也发现AndroidStudio Build Output错误信息都乱码。经常遇到各种问题,习惯了,现在遇到的问题,都是以后的答案。 1.1、修改项目build.gradle(无效)在整个p

2.8k
Function Calling 详解:让大模型调用你的函数
精选Code

Function Calling 详解:让大模型调用你的函数

问大模型“北京今天多少度”,它只能用训练数据里的旧知识猜,或者老实承认不知道。但如果告诉它“我这里有个查天气的接口”,它会说:“帮我调用 get_weather,城市参数填北京。”你的程序执行调用,把结果递回去,它再组织成一句自然的回答。Function Calling(函数调用)让模型从“只会聊天”进化为“会办事”——它是大模型连接外部世界的第一块基石,也是一切 Agent 系统的地基。本文按“…

91