Extra data: line 2 column 1 (char 160)
跑翻译实验的时候,terminal 刷出了这行错误。三个 streaming 测试,全军覆没。
背景:实验需要 Streaming
接上回:CopilotX v2.3 让 OpenClaw 终于能动手了。
这次的场景不同。我在做 ContextWeave 论文的补充实验(EXP18),需要通过 CopilotX 调用 Claude Sonnet 4 和 Gemini 2.5 Pro 翻译 100K 字符的文档。实验脚本用的是 OpenAI Python SDK:
from openai import OpenAI
client = OpenAI(
base_url="https://api.polly.wang/v1",
api_key="polly-copilotx-xxx"
)
response = client.chat.completions.create(
model="claude-sonnet-4",
messages=[{"role": "user", "content": huge_document}],
stream=True,
max_tokens=16384
)
for chunk in response: # 💥 JSONDecodeError!
...
Non-streaming 一切正常。但 streaming 是必须的——100K 文档翻译动辄几分钟,不用 streaming 的话 120 秒 timeout 会把请求杀掉。
诊断:160 个字符处断裂
Extra data: line 2 column 1 (char 160) —— 这个错误信息很精确:JSON 解析器在第 160 个字符处遇到了「多余的数据」。
这意味着 SDK 期望收到一个 JSON 对象,但实际收到的是两个(或更多)JSON 对象拼在一起。
我用 curl 直接看原始 SSE 输出:
curl -s --no-buffer -X POST https://api.polly.wang/v1/chat/completions \
-H "Authorization: Bearer $KEY" \
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"Say hi"}],"stream":true}' \
| head -20
输出(修复前):
data: {"choices":[...],"id":"chatcmpl-xxx"}data: {"choices":[...],"id":"chatcmpl-xxx"}data: {"choices":[...],"id":"chatcmpl-xxx"}data: [DONE]
所有 data: 行粘在一起了。没有空行分隔。
SSE 协议:空行是灵魂
Server-Sent Events (SSE) 是一个简单但严格的协议。标准格式长这样:
data: {"id":"chunk1","choices":[...]}\n
\n ← 空行 = 事件结束
data: {"id":"chunk2","choices":[...]}\n
\n ← 空行 = 事件结束
data: [DONE]\n
\n
每个事件由一个 data: 行和一个空行组成。空行是事件之间的分隔符。
OpenAI Python SDK(以及任何标准 SSE 客户端)依赖空行来切分事件。没有空行,SDK 就把多个 JSON 对象当成一个来解析 → 💥 Extra data。
根因:四个字符的 Bug
打开 CopilotX 的 streaming 代码(client.py):
async def chat_completions_stream(self, payload: dict) -> AsyncIterator[bytes]:
"""POST /chat/completions with stream=true — yields raw SSE lines."""
async with self._client.stream("POST", url, json=payload, ...) as resp:
async for line in resp.aiter_lines():
if line: # ← 🐛 就是这行
yield (line + "\n").encode("utf-8")
yield b"\n"
httpx.aiter_lines() 按 \n 拆分流。原始 SSE 流长这样:
data: {"id":"chunk1",...}\n → line = 'data: {"id":"chunk1",...}'
\n → line = '' (空字符串!)
data: {"id":"chunk2",...}\n → line = 'data: {"id":"chunk2",...}'
\n → line = ''
if line: 在 Python 中把空字符串判为 False,于是所有空行被过滤掉了。
SSE 事件分隔符 → 空行 → 空字符串 → if line: 跳过 → 事件粘连 → JSON 解析失败。
四个字符(if l),毁掉了整个 streaming。
修复:删掉一行
async for line in resp.aiter_lines():
# Yield ALL lines including empty ones — empty lines are
# SSE event delimiters and MUST be preserved for clients
# (e.g. OpenAI Python SDK) that rely on them to separate
# JSON chunks.
yield (line + "\n").encode("utf-8")
去掉 if line: 守卫,让空行原样透传。
修复后的 curl 输出:
data: {"choices":[...],"id":"chatcmpl-xxx"}
data: {"choices":[...],"id":"chatcmpl-xxx"}
data: {"choices":[...],"id":"chatcmpl-xxx"}
data: [DONE]
每个 data: 行之间有了空行分隔。✅
验证:实验跑起来了
部署修复,重启服务,再跑 EXP18:
======================================================================
EXP18: Long-Context LLM Baseline
======================================================================
📄 Loading pseudo-document (100K)...
99,848 chars, 158 paragraphs
🔬 A1: Claude Sonnet 4 · direct · minimal
⏳ Translating (may take 5-15 minutes)...
... streaming: 197 chars received
... streaming: 423 chars received
... streaming: 633 chars received
... streaming: 833 chars received
Streaming 稳定输出,不再报错。100K 文档翻译实验顺利启动。
为什么这个 Bug 潜伏了这么久?
回看 CopilotX 的版本线:
| 日期 | 版本 | 里程碑 |
|---|---|---|
| 2/9 | v2.0 | 远程部署 |
| 2/13 | v2.3.0-2.3.1 | Tools 支持 |
| 2/13 | v2.3.2 | 错误透传 + 测试套件 |
| 2/20 | v2.3.3 | SSE 空行修复 ← 本文 |
v2.3.2 加了测试套件,但测试用的是 httpx 直接调用,不是标准 SSE 客户端。httpx 不在乎空行分隔——它本来就是按行读的。只有当真正用 OpenAI SDK(它实现了完整的 SSE 解析)去消费流时,问题才暴露。
这是一个经典的「测试和客户端不同路径」case:
- 开发时: curl 看到输出,觉得没问题(人眼不在乎空行)
- 测试时: httpx 直接读行,不解析 SSE 事件边界
- 实战时: OpenAI SDK 严格按 SSE 协议解析 → 💥
教训:Proxy 的测试应该用最终客户端来测,而不是底层 HTTP 库。
技术小结
| 项 | 内容 |
|---|---|
| Bug | if line: 过滤了 SSE 空行分隔符 |
| 症状 | OpenAI SDK 报 JSONDecodeError: Extra data |
| 根因 | 空行 → 空字符串 → Python falsy → 被跳过 |
| 修复 | 去掉 if line: 条件,让所有行透传 |
| 改动量 | 1 个文件,5 行改动 |
| 影响 | 所有 streaming 客户端恢复正常 |
有时候,最致命的 bug 只有四个字符。
CopilotX 版本线:
| 日期 | 版本 | 博文 |
|---|---|---|
| 2/9 | v1.0 | 把 Copilot 变成本地 API |
| 2/9 | v2.0 | 从本地工具到远程服务 |
| 2/13 | v2.3.0-2.3.1 | 让 OpenClaw 终于能动手了 |
| 2/20 | v2.3.3 | 一个空行引发的「血案」 ← 本文 |

