最近 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 上个工牌
修通之后,我想在 CopilotX 的统计里把 Codex 的流量单独标出来。好消息是 CopilotX 的 metrics 中间件早就支持按 X-Client-Id 请求头区分客户端(我其它几个客户端早就这么用了),所以服务端一行代码都不用改。
只要在 Codex 的 provider 配置里加一个静态请求头:
[model_providers.copilotx]
# ...
http_headers = { "X-Client-Id" = "codex" }
从此 Codex 的每一笔请求都会自报家门,request_log.client_id 里清清楚楚记成 codex。统计面板上它终于有了自己的名字。
反思
这次踩坑给我留下三条结论:
- 协议细节是魔鬼。 一个空行,足以让整条流式链路从"看起来能跑"变成"严格客户端一个字都收不到"。
- 测试要用最挑剔的消费者。 httpx 的宽容差点让我把 bug 放过去。如果不是 Codex 的 Rust 解析器这么"较真",这个隐患可能要潜伏很久。
- 好的架构让扩展几乎零成本。 加
X-Client-Id统计这件事,服务端不用动,全靠当初把"客户端识别"做成了通用机制。
现在 Codex + Computer Use + CopilotX 这条链路彻底打通了。那个住在我桌面里的 Agent,终于可以放心地睁眼看世界了。

