SSE 流式返回中文变问号,问题不在编码

群里有人发截图,用 split("\n\n") 切流式返回,中文全是菱形问号,底下七八个人接"我也是"。

这坑我去年蹲了整整一天。当时认为是模型侧编码有问题,换了两家 API,最后发现是我自己把字节切坏了。

错在哪

SSE 的响应体是字节流,网络给的分片跟行没有任何关系。一个 chunk 可能只到 data: {"cho 就断了,也可能一口气来五个完整事件。for chunk in resp.iter_content() 拿到的每个 chunk 都是随机切的。

麻烦在 UTF-8。中文占三个字节,这三个字节被切在两个 chunk 之间时,你对任一半单独 decode() 都会得到替换字符,而且信息已经丢了,后面怎么拼都补不回来。正确做法是增量解码器,它会把不完整的尾巴留在内部,等下一块来接。

第二个坑更隐蔽:事件边界是空行,不是换行。按 \n 切完就当一条事件用,多行 data: 立刻出错。规范里一个事件的多条 data: 是用 \n 拼成一条消息,不是多条。

第三个是流结束。上游最后一条消息后面不跟空行的情况很常见,不少网关就这样。按"空行即结束"写,最后一句会被整个吞掉,用户看到的效果就是回答短了一截。

一个能直接用的

Python 3.8 以上,零依赖:

python 复制代码
"""把 SSE 字节流切成事件。

踩过的三个坑:
1. 网络分片不按行切,一个 chunk 可能只有半行,也可能一次来五行。
2. 中文按 UTF-8 是多字节,跨 chunk 边界硬解会出乱码,得用增量解码器。
3. event 字段和 data 多行拼接的语义不一样,得分开处理。
"""
from __future__ import annotations

import codecs
from dataclasses import dataclass
from typing import Iterator


@dataclass
class Event:
    event: str
    data: str
    id: str = ""

    def __str__(self) -> str:
        return f"event={self.event} data={self.data!r}"


class SSEParser:
    def __init__(self) -> None:
        self._buf = ""
        self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
        self._event = ""
        self._data: list[str] = []
        self._id = ""

    def feed(self, chunk: bytes) -> list[Event]:
        """喂一段字节,返回这次能拼完的事件。"""
        self._buf += self._decoder.decode(chunk)
        events = []
        while True:
            idx = self._buf.find("\n")
            if idx == -1:
                break
            line = self._buf[:idx]
            self._buf = self._buf[idx + 1:]
            if line.endswith("\r"):
                line = line[:-1]
            ev = self._handle_line(line)
            if ev is not None:
                events.append(ev)
        return events

    def _handle_line(self, line: str) -> Event | None:
        if line == "":
            return self._flush()
        if line.startswith(":"):
            return None  # 注释行/心跳
        key, sep, value = line.partition(":")
        if not sep:
            key, value = line, ""
        if value.startswith(" "):
            value = value[1:]
        if key == "event":
            self._event = value
        elif key == "data":
            self._data.append(value)
        elif key == "id":
            self._id = value
        return None

    def _flush(self) -> Event | None:
        if not self._data and not self._event:
            return None
        ev = Event(
            event=self._event or "message",
            data="\n".join(self._data),
            id=self._id,
        )
        self._event = ""
        self._data = []
        return ev

    def close(self) -> list[Event]:
        """流结束时调用,把没有空行收尾的最后一个事件也吐出来。"""
        tail = self._buf
        self._buf = ""
        if tail and not tail.endswith("\n"):
            self._handle_line(tail)
        ev = self._flush()
        return [ev] if ev else []


def parse_stream(chunks: Iterator[bytes]) -> Iterator[Event]:
    parser = SSEParser()
    for chunk in chunks:
        yield from parser.feed(chunk)
    yield from parser.close()


def iter_openai_deltas(chunks: Iterator[bytes]):
    """OpenAI 风格:只关心 data 里的 JSON,遇到 [DONE] 收工。"""
    import json

    for ev in parse_stream(chunks):
        if ev.data == "[DONE]":
            return
        try:
            payload = json.loads(ev.data)
        except json.JSONDecodeError:
            continue
        for choice in payload.get("choices", []):
            piece = choice.get("delta", {}).get("content")
            if piece:
                yield piece

对着代码看三个地方。

codecs.getincrementaldecoder("utf-8") 管跨分片的多字节字符,errors="replace" 是兜底,真碰上坏字节不至于整条流崩掉。

feed 里先 find("\n"),找一行处理一行,剩下的留在 self._buf 等下一批。\r\n 单独剥掉 \r,Windows 网关和部分反代就这么发。

close() 是收尾。缓冲区里那截没有换行结尾的残行要补处理一次,再把最后的事件 flush 出来。这个不调,最后一句就没了。

[DONE] 用的 return 不是 continue。OpenAI 的流里 [DONE] 后面理论上干净了,但我真接过一个代理,它会在 [DONE] 后头追一条空的 keep-alive。用 continue 就多解析一次。

实跑

模拟真实网络,每 7 个字节切一刀,故意让它落在中文中间:

ini 复制代码
import json
from sse_parse import SSEParser, iter_openai_deltas

def fake_network():
    full = b""
    for ch in ["流", "式", "解", "析"]:
        payload = {"choices": [{"delta": {"content": ch}}]}
        full += ("data: " + json.dumps(payload, ensure_ascii=False) + "\n\n").encode()
    full += b"data: [DONE]\n\n"
    # 模拟真实网络:每 7 个字节切一刀,故意切在多字节字符中间
    for i in range(0, len(full), 7):
        yield full[i:i+7]

print("裸切片再拼,容易乱码:", flush=True)
text = "".join(iter_openai_deltas(fake_network()))
print("解析结果:", text, flush=True)

p = SSEParser()
print("逐字节喂,事件数:", flush=True)
raw = "event: delta\ndata: 你好\n\ndata: ok\n\n".encode()
print(sum(len(p.feed(bytes([b]))) for b in raw), flush=True)

输出:

makefile 复制代码
裸切片再拼,容易乱码:
解析结果: 流式解析
逐字节喂,事件数:
2

第一行是我留的对照组。同样的分片,换成每片单独 decode() 再拼,那里会出来"流"加两个替换字符。

逐字节那段是极端情况,整个流拆成单个字节一个个喂,最后拿到 2 个事件。增量解码在最小粒度上也扛得住。

想自己验证乱码的话,注意别搞错错法:b"".join(分片).decode() 是先拼后解,这种反而正常。真正的错法是每片先 decode() 再连起来,线上最常见的写法就是这个。

测了哪些边界

16 个用例,python -m pytest -q 全绿。挑几条有意思的:

  • test_line_split_across_chunks,一行拆两次喂,中间不能吐事件
  • test_multibyte_utf8_split,在"好"字的字节中间切断,拼回来必须是"你好世界"
  • test_multiline_data_joined,两个 data: 行拼成 line1\nline2
  • test_comment_and_heartbeat_ignored,: ping 和纯注释不能产生事件
  • test_crlf_line_endings,\r\n 结尾要能正确切
  • test_close_flushes_tail_without_trailing_blank_line,结尾没有空行时不能吞最后一条
  • test_id_persisted_across_events,id 跨事件保持,断线重连要靠它续位置

最狠的是 test_parse_stream_byte_by_byte,把整段流拆成单字节依次喂进去,事件顺序和内容全得对。这条过了,线上基本不会再出乱码。

浏览器那边

前端接流的话,EventSource 只能 GET,聊天接口基本都是 POST,用不上。只能 fetch 拿 response.body.getReader() 自己解。

同样的坑换个形式再来一遍:TextDecoderStream 能帮做增量解码,但按行切还是得自己写缓冲,\r\n 也还是得管。

一句经验:别在 chunk 回调里 split 完直接渲染。永远留一个字符串缓冲,只处理到最后一个换行符之前的部分,剩下的等下一块。

代码就一个文件,要的话直接抄。

相关推荐
panbook14 分钟前
从零实现一个时序数据库(一):用 Rust、Arrow 和 Parquet 跑通写入与 SQL 查询
后端
Zan16 分钟前
SQLite + SQLx 实战:为什么已经发布的数据库迁移文件,绝对不能再修改?
数据库·后端·面试
微步AICRM22 分钟前
一物一码验真的并发链路设计:码段管理、四级关联与异常判定
后端·架构
陈随易36 分钟前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员
IT_陈寒43 分钟前
Vue的这个响应式陷阱,差点让我加班到天亮
前端·人工智能·后端
我叫黑大帅1 小时前
PHP 常驻 Worker 中 MySQL 连接中断问题报告
后端·面试·php
写码跑山的老艾2 小时前
优惠券为何能反复使用?一次算价与提单不一致的资损复盘
后端
RAG学徒loading2 小时前
我把 RAG 召回从 57.6% 做到 78.8%,然后发现 75% 的真实问题它答不了
后端
用户42569399996202 小时前
我的 HashMap 查找全废了,但 36 个测试全绿
后端