让 Codex 用上自家代理:一次 SSE 空行引发的血案

让 Codex 用上自家代理:一次 SSE 空行引发的血案

最近 Codex 的 Computer Use 能力刷屏了——让模型直接看屏幕、点窗口、读截图,俨然一个住在桌面里的 Agent。我也想一探究竟,于是把 Codex 桌面端接到了自家的 CopilotX 代理上。

刚跑通的时候是真的爽:我让它"只描述当前可见窗口、不要操作",它老老实实用 Computer Use 看了一眼桌面,连接层崩了还会自己恢复重试,最后准确报出了 VS Code、Edge、Chrome、Teams 一堆窗口。体验拉满。

但在这之前,我先掉进了一个坑里——一个藏在一行 if line: 后面的坑。这篇就记录我怎么把它揪出来的。

背景:CopilotX 是什么

CopilotX 是我自己写的一个小代理,把常用的大模型能力转译成标准 API,平时 Claude Code 和各种小工具都接它,跑得很稳。它的来龙去脉我之前写过一篇专门的介绍——《一个晚上,把 GitHub Copilot 变成本地 API 服务器》,感兴趣可以翻翻,这里就不展开了。

这次轮到 Codex。它走的是 OpenAI Responses API(wire_api = "responses"),配置上只要在 ~/.codex/config.toml 里加个自定义 provider、指向我的代理就行——看起来天衣无缝。

翻车:stream closed before response.completed

结果一发请求就翻车:

stream closed before response.completed

Codex 还很执着地重连了 5 次,每次都是同样的结局。更诡异的是:

  • 简单一句 "hi" 有时能成功
  • 稍微复杂、带 reasoning 的请求必然失败
  • 失败时统计显示:消费 0 个事件、0 个 token

最让我挠头的是——我用 Python 的 httpx 手动打同一个流式接口,一切正常,事件、token 全都在。那到底是谁的问题?

定位:raw byte 探针

既然高层客户端都说"没问题",那就降到最底层,数原始字节。我写了个探针,直接抓 /v1/responses 的裸流,统计两个数字:

  • event: 行有多少个(事件总数)
  • \n\n(空行分隔符)有多少个

结果触目惊心:

events:      54
delimiters:   1

54 个事件,只有 1 个分隔符。 真相大白了。

真凶:被吞掉的 SSE 空行

这里要补一个 SSE(Server-Sent Events)的协议常识:

SSE 规定,每个事件块之间必须用一个空行(\n\n)分隔。空行不是可有可无的排版,它是事件的结束信号。

问题就出在 CopilotX 转发流的两个函数里。我用 httpx 的 aiter_lines() 逐行读上游的流,然后转发。罪魁祸首长这样:

async for line in resp.aiter_lines():
    if line:                      # ← 凶手在这
        yield (line + "\n").encode("utf-8")

aiter_lines() 在遇到空行分隔符时,会吐出一个空字符串 ""。而这句 if line: 直接把它当成"无用的空行"过滤掉了——于是所有事件被焊死成了一整块,中间一个分隔符都不剩。

那为什么 httpx 手测又没事?因为 httpx 的 SSE 解析很宽容,没有严格分隔符它也能连蒙带猜地把事件切出来。而 Codex 的客户端是 Rust + reqwest 写的严格解析器——没有空行分隔符,它一个事件都不肯派发,干等到流结束,最后只能抛出 stream closed before response.completed。

一句话教训:宽容的客户端会掩盖协议 bug,严格的客户端会把它原形毕露。 永远不要因为"我本地测过没问题"就下结论,换个更严格的消费者再验一遍。

修复:把空行还回去

修法非常简单——别把空行吞了,原样发回去:

async for line in resp.aiter_lines():
    # 保留空行:它是 SSE 事件分隔符,必须原样转发
    yield (line + "\n").encode("utf-8")

另一个做 ID 同步的包装函数 fix_responses_stream 里有同样的毛病,顺手一起修了:

if not line:
    yield b"\n"   # 之前是 continue,把分隔符丢了
    continue

有意思的是,处理 /v1/chat/completions 的那个函数早就是对的,注释里白纸黑字写着"空行是 SSE 分隔符,必须保留"。这次的修复,本质上只是让 Responses 这条路对齐了 Chat 那条早就正确的实现。

验证:从 1 到 75

部署到线上(git pull + systemctl restart)后,同一个探针再打一遍:

events:      75
delimiters:  75

75 个事件,75 个分隔符,完美一一对应。 紧接着 Codex CLI 端到端跑通,返回了真实答案,消费 296 个 token。再回到桌面端测 Computer Use——就是开头那段丝滑体验:

Codex 用 Computer Use 能力描述当前可见窗口,全程只观察、不点击、不输入
Codex 用 Computer Use 能力描述当前可见窗口,全程只观察、不点击、不输入

彩蛋:顺手给 Codex 上个工牌

修通之后,我想在 CopilotX 的统计里把 Codex 的流量单独标出来。好消息是 CopilotX 的 metrics 中间件早就支持按 X-Client-Id 请求头区分客户端(我其它几个客户端早就这么用了),所以服务端一行代码都不用改。

只要在 Codex 的 provider 配置里加一个静态请求头:

[model_providers.copilotx]
# ...
http_headers = { "X-Client-Id" = "codex" }

从此 Codex 的每一笔请求都会自报家门,request_log.client_id 里清清楚楚记成 codex。统计面板上它终于有了自己的名字。

反思

这次踩坑给我留下三条结论:

  1. 协议细节是魔鬼。 一个空行,足以让整条流式链路从"看起来能跑"变成"严格客户端一个字都收不到"。
  2. 测试要用最挑剔的消费者。 httpx 的宽容差点让我把 bug 放过去。如果不是 Codex 的 Rust 解析器这么"较真",这个隐患可能要潜伏很久。
  3. 好的架构让扩展几乎零成本。 加 X-Client-Id 统计这件事,服务端不用动,全靠当初把"客户端识别"做成了通用机制。

现在 Codex + Computer Use + CopilotX 这条链路彻底打通了。那个住在我桌面里的 Agent,终于可以放心地睁眼看世界了。

Comments