群里有人发截图,用 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\nline2test_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 完直接渲染。永远留一个字符串缓冲,只处理到最后一个换行符之前的部分,剩下的等下一块。
代码就一个文件,要的话直接抄。