Grok SSE 事件怎么消费
流式最常见的错误,是把「一个事件」当成「一次回答」。有人取第一个 chunk 的 delta.content 打印出来,看到只有半个词,认定流式坏了;也有人把 data: [DONE] 也丢进 JSON 解析,直接抛异常。还有一类更隐蔽:推理模型先吐 reasoning_content、后吐 content,代码只认后者,于是「开头那段思考去哪了」成了新问题。
这一节把事件流的消费拆成三步:切分、拼接、判停。
一、问题不是「能不能流」,而是「事件怎么切」
按 2026-11-04 官方 Streaming 页,流式输出用 Server-Sent Events(SSE),请求里设 "stream": true 即可开启。服务端把内容按增量推回来,每条事件是一行 data: {...},最后以 data: [DONE] 结束。
三个前提决定了消费代码怎么写:
- 文本输出模型都支持流式,图像输出模型不支持。这是模型能力边界,不是参数问题。
- 增量是碎片,不是句子。一条事件可能只有一两个字,拼接是消费方的责任。
- 推理与正文是两条增量流。官方示例里,第一条事件的
delta带的是reasoning_content,之后才出现content。
所以消费循环要同时处理「两条流」和「一个终止符」,缺一个都会出错。
二、最小可运行示例:SSE 消费循环
下面的脚本不联网、不需要真实密钥。它用一段 mock 事件流模拟服务端推送,把推理增量与正文增量分别拼起来,并读取最后一条的用量。
"""SSE 消费:切分事件、拼接增量、识别终止符。"""
import json
def iter_sse(lines):
"""从 SSE 文本行里取出 data 负载;遇到 [DONE] 结束。"""
for line in lines:
line = line.strip()
if not line or line.startswith(":"):
continue # 空行与注释行不是数据
if not line.startswith("data:"):
continue
payload = line[5:].strip()
if payload == "[DONE]":
return
yield payload
def consume(lines):
"""把事件流拼成 (推理增量, 正文增量, 用量)。"""
reasoning, content, usage = [], [], None
for payload in iter_sse(lines):
chunk = json.loads(payload)
if chunk.get("usage"):
usage = chunk["usage"]
choices = chunk.get("choices") or []
if not choices:
continue # 纯用量或错误事件:不打断整条流
delta = choices[0].get("delta", {})
if delta.get("reasoning_content"):
reasoning.append(delta["reasoning_content"])
if delta.get("content"):
content.append(delta["content"])
return "".join(reasoning), "".join(content), usage
MOCK = [
'data: {"choices":[{"delta":{"reasoning_content":"先判断问题类型。"}}]}',
'data: {"choices":[{"delta":{"content":"数据库"}}]}',
'data: {"choices":[{"delta":{"content":"索引"}}]}',
'data: {"usage":{"total_tokens":42}}', # 纯用量事件:没有 choices,不能打断流
'data: {"choices":[{"delta":{"content":"能加速查询。"}}],"usage":{"total_tokens":42}}',
'data: [DONE]',
]
def offline_check() -> None:
reasoning, content, usage = consume(MOCK)
print("推理增量:" + reasoning)
print("正文增量:" + content)
print(f"用量:{usage}")
if __name__ == "__main__":
offline_check()
真正上线时,把 lines 换成 HTTP 响应体的逐行迭代即可;consume() 的骨架不用改。注意 mock 里专门放了一条没有 choices 的纯用量事件,用来验证这类事件不会打断整条流——真实流量里这种事件一定会出现,而且位置取决于你用的客户端:xAI SDK 每个块都带累计费用,走 OpenAI SDK 或裸 REST 时则必须显式传 stream_options: {"include_usage": true},否则用量只在最后那条 choices 为空的块里出现一次,中间增量块完全没有。
三、事件结构的三处易错点
| 位置 | 正确理解 | 常见误写 |
|---|---|---|
delta.reasoning_content |
推理增量,先于正文出现 | 只读 delta.content,丢掉思考过程 |
delta.content |
正文增量,需逐条拼接 | 把单个 chunk 当完整回答 |
data: [DONE] |
终止信号,不是数据 | 丢进 json.loads 导致解析异常 |
三件要强调的事:
- 两条流要分开存。推理内容通常不直接展示给终端用户,但它是多轮续接的关键;正文才是要渲染的部分。混在一个字符串里,后面既不好展示也不好裁剪。
- 终止符要显式处理。
[DONE]之后不应再有数据;如果循环没退出,轻则多解析一次报错,重则把异常吞掉、看起来像「流没结束」。 - 接口形态不同,事件结构也不同。官方 Streaming 页给出的是 Chat Completions 的事件示例(
choices[].delta);Responses API 的流式事件是另一套结构,本节不臆测其字段名,具体以官方页当前示例为准。
四、预期输出与异常分支
运行消费循环,预期输出:
推理增量:先判断问题类型。
正文增量:数据库索引能加速查询。
用量:{'total_tokens': 42}
换成真实流时,若出现异常,按下面顺序定位:
| 现象 | 优先怀疑 | 第一步动作 |
|---|---|---|
| 正文只有半句 | 循环提前退出 | 打印收到的事件条数与是否见到 [DONE] |
| 解析报错 | 把 [DONE] 当 JSON |
在解析前先判终止符 |
| 思考内容丢失 | 只读 content |
同时收集 reasoning_content |
| 用量为空 | 只在最后一条取 | 改为「每条有新值就覆盖」 |
最后一行尤其重要:用量字段不是每条都保证有,取「最后一个非空值」比取「最后一条」稳。这个细节不显眼,但对账时会直接影响能不能把本地记录对上账单(见 5.1)。
五、判据卡
| 判断项 | 该这么做 | 不该这么做 |
|---|---|---|
| 事件切分 | 只认 data: 行,跳过空行与注释 |
整段响应一次性 json.loads |
| 增量拼接 | 推理与正文分开累加 | 把单个 chunk 当答案 |
| 终止处理 | 见 [DONE] 立即结束 |
把终止符当数据解析 |
| 用量读取 | 取最后一个非空值 | 假定每条都带 usage |
| 接口形态 | Chat 与 Responses 分别对照官方示例 | 用一套字段套两个接口 |
这张卡的重点是先切对,再拼对。四类现象里「只有半句」和「解析报错」常常同源:终止符与循环边界没处理好,表现一个是内容不全一个是直接崩,但修法都在消费循环的头尾。
六、来源与核验
- 官方 Streaming 页:
https://docs.x.ai/developers/model-capabilities/text/streaming(核验日期 2026-11-04)。SSE、stream: true、事件示例(delta.reasoning_content/delta.content/[DONE])、文本模型支持而图像模型不支持的说明,均以该页为准。 - 官方示例同时给出的多语言 SDK 写法(AISDK、OpenAI SDK、xAI SDK、cURL)说明事件结构一致;本节只取字段层面可核验的部分。
- 官方成本追踪页:
https://docs.x.ai/developers/cost-tracking(核验日期 2026-11-04)。流式下stream_options.include_usage的必要性、用量只落在最后一个空choices块、xAI SDK 每块携带累计值,均以该页为准(F56)。 - Responses API 的流式事件结构不在该页示例范围内,本节不作断言;具体字段以官方页当前示例为准。
七、动手试
- 给
consume()加一个「首 token 时间」的记录:在收到第一条带content的事件时打时间戳,并与请求发出时间相减。想一想:这个指标能帮你区分「模型慢」还是「网络慢」吗? - 把 mock 事件流改成「推理增量与正文增量交替出现」,验证两条流的拼接是否仍然正确,并说明为什么不能假设「推理一定全部先到」。
- 写一个容错分支:当某条
data:负载不是合法 JSON 时,跳过并计数,而不是让整个流崩掉。想一想:这个计数在什么情况下应该告警?