🔍 CopilotX v2.3.3:一个空行引发的「血案」

🔍 CopilotX v2.3.3:一个空行引发的「血案」
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/9v2.0远程部署
2/13v2.3.0-2.3.1Tools 支持
2/13v2.3.2错误透传 + 测试套件
2/20v2.3.3SSE 空行修复 ← 本文

v2.3.2 加了测试套件,但测试用的是 httpx 直接调用,不是标准 SSE 客户端。httpx 不在乎空行分隔——它本来就是按行读的。只有当真正用 OpenAI SDK(它实现了完整的 SSE 解析)去消费流时,问题才暴露。

这是一个经典的「测试和客户端不同路径」case:

  • 开发时: curl 看到输出,觉得没问题(人眼不在乎空行)
  • 测试时: httpx 直接读行,不解析 SSE 事件边界
  • 实战时: OpenAI SDK 严格按 SSE 协议解析 → 💥

教训:Proxy 的测试应该用最终客户端来测,而不是底层 HTTP 库。

技术小结

项内容
Bugif line: 过滤了 SSE 空行分隔符
症状OpenAI SDK 报 JSONDecodeError: Extra data
根因空行 → 空字符串 → Python falsy → 被跳过
修复去掉 if line: 条件,让所有行透传
改动量1 个文件,5 行改动
影响所有 streaming 客户端恢复正常

有时候,最致命的 bug 只有四个字符。


CopilotX 版本线:

日期版本博文
2/9v1.0把 Copilot 变成本地 API
2/9v2.0从本地工具到远程服务
2/13v2.3.0-2.3.1让 OpenClaw 终于能动手了
2/20v2.3.3一个空行引发的「血案」 ← 本文

Comments