接收 AI 流式响应时,直接把每次 read 得到的内容交给 JSON 解析器,是很容易隐藏错误的写法。一块网络数据可能只有半个汉字,也可能同时装着两条消息。
需要分别处理字节解码、行切分、事件组装和应用数据解析。本文写一个只提取 data 的 Python SSE 解析器,用离线字节输入验证边界,不调用任何模型 API。
一个容易复现的错误
假设业务数据是包含“你好”的 JSON。UTF-8 编码后,在“你”的第二个字节处分开,两块单独 decode 就可能失败。即使每块都碰巧能解码,一次收到的文本也不保证正好是一份 JSON。
更合理的处理顺序是:连续字节进入有状态解码器;文本按行解释;事件完整后才把 data 交给应用层。Python 增量解码器会跨调用保留未完成的编码状态,最后一次输入要以 final=True 结束。codecs 文档
SSE 使用 UTF-8;空行形成事件边界,多行 data 合并时保留换行;冒号开头的注释不会成为数据。遇到 EOF,尚未由空行结束的事件要丢弃。协议允许不同换行形式,因此只按单次输入中的两个 LF 切分并不稳妥。WHATWG SSE 规范
本例刻意限定范围
这是 data-only 解析实验,会忽略 event、id、retry 及未知字段,不提供事件类型分发、自动重连或续传。不能直接当成完整 EventSource 实现使用。
非法 UTF-8 在本例中选择严格报错,这是应用层输入策略,与浏览器规范解码的替换行为不同。解析出 data 之后仍需按照具体接口约定判断 JSON、结束消息或文本;示例里的 [DONE] 是人为约定的应用标记,不是 SSE 标准自带的结束符。
max_chars 分别限制单行字符数和一个事件累计 data 字符数,单位是字符,不是字节。调用方还需限制每次输入块和消费队列大小;本例一次 feed 返回列表,不宣称总内存恒定。
完整代码与复现方法
保存为 sse_lab.py,用 python3 sse_lab.py 运行。环境为 2026-09-16 的 Python 3.12.14,无第三方依赖。
"""Data-only SSE parser experiment, not a complete EventSource client."""
import codecs
import json
class SSEDataParser:
def __init__(self, max_chars=100_000):
self.decoder = codecs.getincrementaldecoder("utf-8-sig")("strict")
self.line = []
self.data = []
self.event_chars = 0
self.after_cr = False
self.closed = False
self.max_chars = max_chars
def _line(self, events):
line = "".join(self.line)
self.line.clear()
if not line:
if self.data:
events.append("\n".join(self.data))
self.data.clear()
self.event_chars = 0
elif not line.startswith(":"):
field, separator, value = line.partition(":")
if separator and value.startswith(" "):
value = value[1:]
if field == "data":
self.event_chars += len(value) + 1
if self.event_chars > self.max_chars:
raise ValueError("event too large")
self.data.append(value)
def feed(self, chunk, final=False):
if self.closed:
raise ValueError("parser closed")
events = []
for char in self.decoder.decode(chunk, final=final):
if self.after_cr:
self.after_cr = False
if char == "\n":
continue
if char == "\r":
self._line(events)
self.after_cr = True
elif char == "\n":
self._line(events)
else:
self.line.append(char)
if len(self.line) > self.max_chars:
raise ValueError("line too large")
if final:
self.closed = True
self.line.clear()
self.data.clear() # EOF is not a blank-line event terminator.
return events
def parse(chunks, **kwargs):
parser = SSEDataParser(**kwargs)
events = []
for chunk in chunks:
events.extend(parser.feed(chunk))
events.extend(parser.feed(b"", final=True))
return events
def main():
checks = 0
def check(name, actual, expected):
nonlocal checks
assert actual == expected, (name, actual, expected)
checks += 1
print("PASS", name)
wire = '\ufeff: keepalive\r\ndata: {"text":"你好"}\r\n\r\ndata: [DONE]\n\n'.encode()
expected = ['{"text":"你好"}', '[DONE]']
check("all_two_part_splits", all(
parse([wire[:split], wire[split:]]) == expected
for split in range(len(wire) + 1)
), True)
check("one_byte_chunks", parse([bytes([x]) for x in wire]), expected)
check("single_chunk", parse([wire]), expected)
check("multiline", parse([b"data: a\ndata: b\n\n"]), ["a\nb"])
check("bare_cr", parse([b"data: a\r\r"]), ["a"])
check("empty_data", parse([b"data\n\n"]), [""])
check("one_space_only", parse([b"data: x\n\n"]), [" x"])
check("case_sensitive", parse([b"Data: x\n\n"]), [])
check("unfinished_event", parse([b"data: x\n"]), [])
check("unfinished_line", parse([b"data: x"]), [])
check("empty_blocks_comments", parse([b": ping\n\n\n"]), [])
check("json_after_event", json.loads(parse([wire])[0]), {"text": "你好"})
for name, chunks, kwargs in [
("invalid_utf8", [b"data: \xff\n\n"], {}),
("incomplete_utf8", [b"data: \xe4"], {}),
("line_limit", [b"data: toolong\n\n"], {"max_chars": 8}),
("event_limit", [b"data: a\ndata: b\ndata: c\ndata: d\ndata: e\n\n"], {"max_chars": 8}),
]:
try:
parse(chunks, **kwargs)
except (UnicodeDecodeError, ValueError):
checks += 1
print("PASS", name)
else:
raise AssertionError(name)
print(f"{checks} scenario groups passed; two-part splits={len(wire) + 1}")
if __name__ == "__main__":
main()
实际输出
PASS all_two_part_splits
PASS one_byte_chunks
PASS single_chunk
PASS multiline
PASS bare_cr
PASS empty_data
PASS one_space_only
PASS case_sensitive
PASS unfinished_event
PASS unfinished_line
PASS empty_blocks_comments
PASS json_after_event
PASS invalid_utf8
PASS incomplete_utf8
PASS line_limit
PASS event_limit
16 scenario groups passed; two-part splits=58
第一组把同一段 57 字节的输入依次在每个位置分成两部分,共验证 58 种切分;第二组每次只输入一个字节;第三组把所有内容一次输入。三种供给方式都必须得到同样的两份 data。
其余检查覆盖多行数据、CR 换行、空 data、只去除一个前导空格、字段大小写、缺少结束空行、注释、事件后的 JSON 解析、非法及残缺 UTF-8,以及行和事件限制。这里是 16 组检查,不是 16 次真实网络请求,也不是所有 SSE 特性的兼容性认证。
接入真实流式接口的顺序
先确认状态码和响应类型,错误页面不能按成功流解析。对响应体连续读取时复用同一个解析器;一个连接结束就结束该解析器,不把上一条连接的半截状态带到下一条。
业务层需区分“响应体读完”“收到接口约定的完成事件”“用户取消”“传输异常”。文本可以逐步展示;工具参数即使被拆成多个有效 JSON 外壳,里面的参数字符串也可能仍未完整。应按对应接口的消息标识与完成条件累积,再做结构和业务校验,不能看到一段片段就执行工具。
若收到很多小事件,界面可按帧或短周期合并渲染;这属于展示策略,不应改变协议解析结果。断线恢复也必须依赖服务端实际提供的历史保存与重放能力,重连成功不代表丢失内容自动补齐。
参考资料
MDN:使用 SSE、MDN:读取流。本文未测试任何厂商 API 的延迟、吞吐或 token 生成方式。

790

被折叠的 条评论
为什么被折叠?



