AI 流式响应乱码、JSON 解析失败?Python SSE 增量解析的 16 组验证

接收 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:使用 SSEMDN:读取流。本文未测试任何厂商 API 的延迟、吞吐或 token 生成方式。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值