LLM流式响应:UTF-8分块怎么解码
LLM流式响应出现中文乱码,常见原因不是模型生成了坏字符,而是客户端把一次网络读取当成一段完整UTF-8文本。传输分块可以从一个字符的中间切开;字符完整以后,又不一定刚好构成一条SSE事件。两层边界需要分别保存状态。
文章目录
下面用可以穷举切分点的Python例子,区分字节、字符、行与事件。Read in English。
一次read,不代表一个token
网络读取返回的是当前可用的一段字节,而不是模型token、JSON对象或事件的保证边界。服务端一次写入与客户端一次读取也不必一一对应。用每次收到的片段直接更新业务状态,等于把传输调度当成协议分隔符。
先明确客户端所接收的协议。如果是SSE,需要识别事件流;如果是其他格式,应按那种格式定义分帧。接口都叫stream,并不能证明它们拥有相同的结束标记或错误消息结构。
网络读取
任意字节片段
UTF-8增量解码
缓冲未完整字符
行解析
缓冲未完整行
事件解析
空行后交付data
为什么replace不能修复分块解码
一个中文字符的UTF-8编码通常包含多个字节。如果前一块只到达它的开头,按独立文本解码可能报错。改成errors=replace会插入替换符;后一块再单独解码,原字符也不会自动拼回来。错误只是从可见异常变成了静默损坏。
Python codecs文档提供维护状态的增量解码器。它可以保留一块末尾不足以组成字符的字节,等下一块到来再继续解码。EOF时则需要final=True,区分暂时没到齐与真正截断的输入。
字符完整了,还需要等事件完整
WHATWG SSE规范规定事件流使用UTF-8,空行用于事件处理,多个data字段按换行组合。收到一行data,不意味着同一事件的后续字段已经结束;收到完整事件,才有条件解析其业务负载。
若业务把data定义为JSON,JSON解析应发生在组好相应事件数据之后,而不是逐网络片段尝试。事件数据是否JSON、哪种业务状态表示完成,属于上层接口约定;SSE本身并不替所有模型服务定义统一的完成字符串。
data: 你
data: 好
事件边界
你 ↵ 好
先用一个小参考验证边界
示例只实现LF/CRLF、data字段和空行交付,并使用严格UTF-8解码用于诊断。它不实现完整EventSource客户端,不支持单独CR换行、BOM、事件类型、id或retry,也没有重连逻辑。生产实现应使用符合目标协议的成熟组件。
# Diagnostic subset: UTF-8, LF/CRLF, data fields, blank-line dispatch.
# Not a complete EventSource client (no lone CR/BOM/id/retry handling).
import codecs
from itertools import chain
def data_events(chunks):
decoder = codecs.getincrementaldecoder("utf-8")("strict")
pending, data, events = "", [], []
for chunk in chain(chunks,[None]):
pending += decoder.decode(b"" if chunk is None else chunk,
final=chunk is None)
while "\n" in pending:
line,pending = pending.split("\n",1)
if line.endswith("\r"):
line = line[:-1]
if line == "":
if data:
events.append("\n".join(data))
data = []
elif not line.startswith(":"):
field,sep,value = line.partition(":")
if sep and value.startswith(" "):
value = value[1:] # Remove at most one space.
if field == "data":
data.append(value)
# Incomplete trailing event is intentionally not dispatched.
return events
wire = ": keepalive\r\ndata: 你好🙂\r\ndata: 第二行\r\n\r\ndata: 完成\n\n".encode()
expected = ["你好🙂\n第二行", "完成"]
for cut in range(1,len(wire)):
assert data_events([wire[:cut],wire[cut:]]) == expected
assert data_events([bytes([b]) for b in wire]) == expected
assert data_events([b"data: unfinished\n"]) == []
assert data_events([b"data: leading\n\n"]) == [" leading"]
wrong = "".join(bytes([b]).decode("utf-8",errors="replace") for b in wire)
assert wrong != wire.decode("utf-8")
try:
data_events([b"data: \xe4"])
except UnicodeDecodeError:
pass
else:
raise AssertionError("truncated UTF-8 accepted in strict mode")
print("every two-piece split, one-byte chunks, framing and EOF passed")
代码已执行:穷举每一个单切分点,再把输入按单字节切分,均得到相同的两条事件;还验证了多行data、保留额外前导空格、未结束事件不交付,以及严格模式下截断UTF-8报错。测试不连接任何模型服务,也不读取真实对话。
EOF不是成功完成的同义词
规范要求未遇到最后空行的残缺事件不交付。示例也保留了这项行为。但传输结束、事件完整和业务完成仍是三个条件:连接关闭可能是正常结束,也可能是中断,不能只凭EOF把界面状态改成“已完成”。
应用应根据实际服务文档识别完成状态或错误负载,并记录最后确认的业务阶段。若只收到半个结构化对象,不要把它保存成一个看似完整的结果。对用户展示的临时文本与最终可交付内容,也应有明确区别。
心跳表示连接活动,不表示任务推进
SSE注释行可以用于保持连接活动,示例里的keepalive不会形成data事件。若把每次收到字节都更新为“任务有进展”,长时间只有心跳的连接也会被误判为正在产出结果。应分别记录传输活动、完整事件与业务进展。
超时策略同样需要分层:连接没有任何字节,与持续有心跳但迟迟没有业务结果,是两种状态。界面可以准确显示等待,而不是因为连接尚未断开就承诺任务一定会完成。具体阈值由服务与产品需求决定,本例没有编造统一秒数。
参考代码按迭代器逐块处理输入,没有预先把所有网络块收集为列表;为方便测试,它仍将结果事件保留在列表中。真实长流应根据消费方式及时交付事件,并结合背压与上限管理资源,不能把这个小测试当作恒定内存客户端。
分片测试要故意制造不方便的边界
只拿英文和大块读取测试,可能永远碰不到问题。测试应包含中文、四字节字符、CR与LF拆开、多条事件同块、字段名拆开,以及断在事件结束前的情况。先比较分块版本与一次性参考的结果,再加入实际网络。
改变片段大小不应改变正常数据的语义。穷举一个切分点覆盖所有两块划分,单字节覆盖另一种极端;它们仍不是所有任意多块划分的完整证明。需要时加入随机切分、长度限制和其他协议字段的测试,别把有限测试写成绝对保证。
缓冲也需要上限与取消路径
增量处理减少了等待完整响应的需要,但不代表可以无限累计未结束的行或事件。根据服务约定设置缓冲上限;超过时按清晰错误策略停止,不能悄悄丢掉一段字节再继续声称结果完整。
用户取消、网络断开和服务错误应分别处理,释放连接与缓冲资源。若准备重试,还要考虑业务是否会被重复执行;站内Agent幂等重试讨论了重复动作。解码正确解决的是字节解释,不能代替重试协议。
别用逐字日志换排错便利
排查可以记录片段长度、解码状态、事件计数与错误位置,不必默认保存所有用户内容。本文使用的中文与事件数据完全虚构。若需要捕获真实负载,应先确认授权、最小化内容与保留期限。
定位时按层缩小范围:字节能否正确还原,行是否正确拼接,事件边界是否正确,最后才检查业务JSON和完成状态。若这套分层测试帮你修好一次流式乱码或截断问题,欢迎在文末自愿支持。
资料与补发说明
Python增量编码解码接口;WHATWG Server-sent events。图和测试为原创教学内容,不宣称实现完整SSE或某厂商客户端。10月16日补发,文章日期保留原计划14:00。
支持
如果文中的解释或示例帮你解决了问题、节省了时间,欢迎自愿打赏支持本站。
二维码可点击放大。更多支持方式见支持页面。


