最近在用 Coding Agent 配合 Blender 做 3D 建模与贴图调整的工作流。整个闭环非常直观:Agent 通过脚本或 MCP 修改网格与材质参数,驱动 Blender 渲染出正、侧、背及局部特写的多角度预览图,再调用看图工具检查模型表面细节,决定下一步调整。
这种高频视觉反馈的工作流本来效率极高,但跑着跑着,手头的几个 Codex Thread 开始接二连三地暴毙——总是干到一半就彻底卡死,界面没有任何输出,一卡就是十几分钟,最离谱的一个 Thread 甚至单轮原地卡了 2 小时 7 分钟。
更绝望的是,当你试图敲 /compact 去压缩上下文自救时,连 /compact 自己也一起卡死在半路。
扒开 Codex 的底层 SQLite 日志和会话 JSONL 文件之后,我发现这是一个极其典型、在官方仓库里挂了将近半年却迟迟没人修的 Bug。这篇文章记录一下问题的完整故障链、官方 Issues 的现状、Claude Code 的处理方式,以及我们最终是如何用 Pi 彻底解决它的。
故障解剖
先说结论:卡死的直接原因是历史预览图的 Base64 数据在上下文中无限累积,叠加 WebSocket 断线后单向降级到 HTTP 全量重传,最终陷入 408 Request Timeout 的死循环。
去查 ~/.codex/logs_2.sqlite 和 ~/.codex/sessions 下的 rollout JSONL 文件,可以看到非常清晰的犯罪现场。
Base64 历史膨胀
在 Blender 视觉反馈流程中,每次 Agent 调用看图工具检查渲染出的 PNG 预览图,客户端都会把图片编码成 Base64 Data URL,作为 input_image 塞进模型上下文:
{
"type": "input_image",
"image_url": "data:image/png;base64,..."
}一张 1.5 MB 的 Blender 渲染图,经过 Base64 编码后会膨胀约 1/3(接近 2 MB)。当一个会话经历了二十多轮“修改模型 → 渲染多视角预览 → 看图检查”之后,上下文里已经躺了二十多张高清原图。
我们排查的几个卡死 Thread,本机序列化出来的模型上下文体积分别是:
| 会话 | 累计预览图 | 序列化上下文体积 | zstd 压缩后单次请求体 | 最终表现 |
|---|---|---|---|---|
| Thread A | 多轮局部特写 | 18.46 MB | 13.70 MB | WebSocket 断线后切 HTTP,反复发送失败 |
| Thread B | 28 张渲染预览图 | 82.08 MB | 61.35 MB | 连续报 408 Request body read timed out,单轮卡死 2 小时 7 分钟 |
| Thread C | 多轮贴图预览 | 31.28 MB | 23.11 MB | 连续 Broken pipe,切 HTTP 后持续失败,等待 17 分钟后被迫中断 |
| Thread D | 多轮建模预览 | 52.29 MB | ~38 MB | 长时间无响应被迫中断 |
WebSocket 降级重放
你可能会问:既然上下文有几十 MB,为什么前面十几轮没卡死,偏偏干到一半突然暴毙?
因为正常情况下,Codex 使用 WebSocket 模式连接服务端,同一条连接内具备状态缓存,每轮只需要增量发送新增的消息和工具输出(OpenAI WebSocket Mode 文档)。
但只要长连接在某一刻遇到网络抖动、代理超时或服务端主动断开(日志里表现为 websocket closed by server before response.completed、Broken pipe (os error 32) 或 tls handshake eof),客户端在快速重试 5 次失败后,就会打印出这两行宣判死刑的日志:
non-incremental request reason="connection_closed"
falling back to HTTP来看一段 Thread B 真实的底层日志节选:
21:22:43 stream disconnected - retrying sampling request (1/5 in 198ms)... sampling_error=websocket closed by server before response.completed
...
21:23:45 stream disconnected - retrying sampling request (5/5 in 3.343s)... sampling_error=websocket closed by server before response.completed
21:24:00 falling back to HTTP
21:24:00 Compressed request body with zstd pre_compression_bytes=82055100 post_compression_bytes=61344719 compression_duration_ms=104
21:25:31 Request failed method=POST url=https://chatgpt.com/backend-api/codex/responses error=error sending request
21:41:25 Request completed method=POST url=https://chatgpt.com/backend-api/codex/responses status=408 Request Timeout
21:41:25 stream disconnected - retrying sampling request (1/5 in 219ms)... sampling_error=unexpected status 408 Request Timeout: {"detail":"Request body read timed out"}
21:41:25 Compressed request body with zstd pre_compression_bytes=82055100 post_compression_bytes=61344720 compression_duration_ms=84
21:56:40 Request completed method=POST url=https://chatgpt.com/backend-api/codex/responses status=408 Request Timeout一旦降级到 HTTP,连接内的增量上下文瞬间失效,客户端开始在每一次请求中全量重放整条会话历史。
82 MB 的原始 JSON,即便经过 zstd 压缩也还有 61.35 MB。客户端试图通过 HTTP POST 把这 61 MB 的请求体一次性推给服务端,上传十几分钟后被服务端直接以 408 Request Timeout ("Request body read timed out") 拍死;紧接着重试机制立刻在几百毫秒后再次把这 61 MB 的巨型包重传一遍。五次重试叠加多个回合,直接把一轮对话拖成了两个多小时的静默卡死。
Compact 救援死锁
这里有两个非常讽刺的机制冲突:
- Token 计数与字节体积严重背离:视觉模型里,一张图片折算的视觉 Token 往往只有几百到上千个,但它的 Base64 字符串却占用了几 MB 的网络字节。因此,当请求体已经膨胀到 80 MB 时,总 Token 数还远没达到触发自动 compact 的阈值。
/compact本身也是个带全量上下文的请求:当你发现不对劲、手动输入/compact时,客户端为了生成摘要,照样要把当前这 61 MB 的压缩请求体先 POST 到远端。大象已经卡在门框里了,你让大象自己缩骨,前提居然是先把大象塞进门里。
为了把这几个正在干活的旧 Thread 救回来,我们最后不得不停掉进程,写了个离线修复脚本:把 JSONL 里的 230 张去重原图抽离保存到本地磁盘、生成 <1024px 的 JPEG 缩略图,再把 JSONL 里的 Base64 替换成本地文件路径。
更坑的是,Codex 桌面端用 SQLite(state_5.sqlite 和 thread_history_1.sqlite)维护了基于字节偏移量(Byte Offset)的分页历史索引。你甚至不能直接删掉 JSONL 里的 Base64 字符串——一旦文件字节长度变小,SQLite 里的分页偏移量全部错位,整个历史记录直接损坏。我们必须在替换成短路径之后,再用空白字符把每一行精确填充回原来的字节长度,才算完成了这场“外科手术”,把四个会话的模型上下文从 18~82 MB 压回了 0.54~0.87 MB。
官方 Issues 现状
排查完之后去翻了一圈 openai/codex 的 GitHub Issues,血压直接拉满——这根本不是什么冷门 Corner Case,社区早就把问题根因、复现路径甚至修复补丁全都喂到嘴边了,结果官方挂了小半年就是不修。
随便列几个目前依然处于 Open 状态的 Issue:
- #23665:WebSocket fallback should recover after transient transport failures(5 月 20 日提出,至今 Open) 只要遇到一次瞬时网络抖动降级到 HTTP,整个会话余生就被永久钉死在全量 HTTP POST 模式,没有任何探测恢复 WebSocket 的机制。
- #28316:Codex should not resend large base64 image tool outputs in subsequent context(6 月 15 日提出,至今 Open) 精准指出了看图工具返回的巨型 Base64 在后续每一轮都被无脑重传。甚至有社区开发者直接在 评论区 贴出了完整的自用补丁(限制看图最大分辨率与字节数、按图片累积数量强制触发 compact),官方依然无动于衷。
- #28531 / #32128 / #32766(6~7 月提出,至今 Open)
会话 JSONL 无上限内嵌 Base64 导致桌面端打开多图会话直接卡死或 OOM,CLI
resume多图会话时死锁无响应。 - #35458 / #41806 / #46761 / #52456(7~10 月持续爆发,至今 Open)
每次 compact 居然把完整 Base64 图片在 rollout JSONL 里重新持久化一遍,加上 subagent fork 继承,有用户的
~/.codex/sessions目录直接涨到了 165 GiB(其中 95%~99.6% 全是重复的旧 Base64 图片);哪怕手动抢救回来,继续用一会儿预览图又重新堆积卡死。
老实说,看到这一串从 5 月排到 10 月的 Issue 列表,真的很难不吐槽。
作为一个主打多模态看图、桌面端长程任务、频繁拿前端截图和视觉验证当卖点的旗舰级 Coding Agent,“限制单次请求体的总字节预算”和“对历史旧图做降采样或滑窗淘汰”,难道不是最基础的客户端工程兜底吗?
退一步讲,就算官方核心团队忙着堆新特性没空改上下文管理,开放一个发送前的 Context Filter Hook 让用户自己写插件裁剪也行啊。结果去查了 Codex 现有的 Hooks 文档(OpenAI Docs: Hooks),目前只提供了 PreToolUse 和 PostToolUse,而且 updatedMCPToolOutput 还不生效,压根没有暴露“每次请求发送前重写完整上下文历史”的接口。
也就是说:官方自己不修历史图片淘汰,也不给插件留改写历史上下文的口子;WebSocket 断一次就永久退化回全量 HTTP;JSONL 把几十兆的 Base64 跟文本混写在一起还拿字节偏移量建 SQLite 索引。这几个设计凑在一起,简直是给多图工作流挖了一个完美的连环坑。
Claude Code 的解法
在排查过程中,我也顺手对比了一下 Claude Code 是怎么处理同一问题的。
首先需要澄清一个常见的误区:Claude Code 每一轮同样需要发送当前有效上下文,Prompt Cache 节省的是服务端的重复计算(KV Cache 命中),并不会变成网络层只传增量(见 Claude Code: How the cache is organized)。
那为什么在同样的高频看图场景下,Claude Code 很少出现这种把会话彻底卡死的情况?因为人家老早就把这个问题当成正经的客户端工程问题解掉了:
- 内置图片总量预算 + 批量淘汰最旧图片 在 Claude Code 的官方文档 Accumulating many images 中写得非常清楚:客户端会同时跟踪上下文中图片和 PDF 的总数量与总字节体积。当下一轮请求即将超限时,客户端会自动从发送上下文中批量移除最旧的图片。 注意这里的细节是批量移除(in batches),而不是超一张删一张——因为如果每轮都删掉最老的一张图,每次请求的前缀都会发生变化,导致该位置之后的 Prompt Cache 每轮全部失效。攒到阈值一次性批量淘汰旧图,是兼顾请求体积上限与缓存命中率的经典工程设计。
- 超限自动剥离并重试 早在 v2.1.126 版本中,Claude Code 就上线了自动缩图以及“当请求因大图超限失败时,自动剥离历史超限图片并重试”的恢复机制;后来在 v2.1.299 中,又专门修复了桌面端、VS Code 和 SDK 在任务执行中途追加图片时偶发绕过缩图发原图的漏洞。
整个过程由客户端在底层静默兜底,既不需要用户在 Prompt 里战战兢兢地叮嘱 Agent “看图前请先用命令行把图压小”,更不需要在卡死后去手搓 Python 脚本修补二进制偏移量。
用 Pi 解决问题
既然 Codex 指望不上,我们转头评估了自己日常主用的另一个轻量、高度可扩展的 Coding Agent —— Pi(pi-coding-agent)。
Pi 原生机制
翻看 Pi 的源码可以发现,它在底层设计上比 Codex 干净得多:
- 默认单图自适应缩放:
images.autoResize默认开启,底层通过 Photon(编译为 WASM 的 Rust 图像处理库) 在本地 worker 线程中将单张图片限制在 2000×2000 及约 4.5 MiB Base64 预算以内。 /compact绝不携带图片 Base64:Pi 的 compaction 逻辑在生成摘要前,会先把历史消息序列化成纯文本表示,结构化图片的 Base64 根本不会进入摘要请求体。因此就算历史图片再多,执行/compact也永远不会像 Codex 那样死锁在路上。- 但原生同样缺少“历史图片总量上限”:在走 OpenRouter 或标准 Chat Completions 路径时,每轮依然会重发有效上下文中的所有图片。如果单张图压完还有 1.5 MB,攒够 20 张照样会变成 30 MB 的大请求。
不过,Pi 最大的优势在于它极其克制且开放的扩展架构:它把本地持久化的会话记录(Session)与每轮真正发给大模型的上下文(Context)完全解耦,并直接向扩展暴露了每次请求前的 context 拦截钩子。
引入 pi-image-budget
在社区里,我们已经能找到专门针对这个痛点的开源扩展:pi-image-budget。在将它固定版本(v0.2.0)部署到生产环境前,我们对它的源码做了完整的安全审计与行为验证:
pi install git:github.com/Aaalice233/pi-image-budget它的核心工作流非常契合多图场景:
- 多维硬性预算:默认限制单次上下文最多携带 12 张图、所有图片 Base64 合计不超过 16 MiB、整个序列化后的 JSON 请求体不超过 24 MiB,并且支持按不同模型或网关单独配置上限。
- 非破坏性改写(Non-destructive):它只在每轮组装请求时修改发给模型的消息副本,绝不篡改本地磁盘上的原始会话记录和源图片文件。
- 纯本地 WASM 压缩 + 淘汰兜底:直接复用 Pi 自带的 Photon(Rust/WASM)在本地 worker 线程做 PNG/JPEG 重编码与降采样(不调任何外部服务、无隐私外传风险)。如果压缩后总体积依然超标,就从最旧的历史图片开始批量省略,替换成简短的文字占位说明。
- 网关错误自适应恢复:当遇到中转站缓冲区溢出或 HTTP 413 请求过大错误时,自动下调当前会话的体积预算并触发重试。
缓存策略调优
在细读 pi-image-budget 源码并用测试用例验证时,我们也发现了一个值得深究的工程取舍差异:
| 维度 | pi-image-budget 默认行为 |
Claude Code 公开机制 |
|---|---|---|
| 历史图片变更时机 | 图片一旦滑出“最近 2 张”窗口,即便总体积远低于预算,也会立即触发有损重压缩 | 只有当图片总数量或总体积即将突破上限时,才触发处理 |
| 对 Prompt Cache 的影响 | 每新增一张图,倒数第 3 张图的内容就会被改写,导致该位置之后的缓存每轮失效 | 平时保持历史字节完全不变;逼近上限时一次性批量移除旧图,最大化缓存命中率 |
| 超限硬兜底 | 受保护的“最近 2 张图”如果本身就超过极小预算,告警后仍可能放行 | 超限自动剥离并重试 |
用编码器替身跑一轮测试就能证实:在默认开启主动重压缩(compress: true)时,哪怕整个请求只有不到 3 MiB(远低于 16 MiB 预算),连续加入第 3、第 4 张图时,扩展都会去改写倒数第 3 张图的 Base64 内容。这虽然省下了几百 KB 的上传带宽,但在高频多轮交互中却破坏了消息前缀的字节稳定性,得不偿失。
因此,在 Pi 上最理想的工程实践是把两者的优点结合起来:
- 入口控制:Blender 渲染预览图或看图工具读取时,优先通过单图缩放把分辨率控制在合理范围(例如 1024px~1280px 的 JPEG 预览,单张体积直接从 1.43 MB 降到 70 KB 左右,缩减 95%),需要查看法线或接缝细节时再按需裁剪局部高清图。
- 关闭激进的逐轮重压缩(
compress: false):让已经进入上下文的图片保持字节不变,保护 Prompt Cache 前缀稳定。 - 硬性总预算与批量淘汰兜底:依靠
pi-image-budget守住 16 MiB 图片总量和 24 MiB JSON 请求体的红线,一旦逼近上限或遇到网关拒收,自动批量剥离最旧的历史图片。
小结
回顾这次排障,一个看似神秘的“Agent 干活干到一半随机卡死两小时”,剥开来其实是三个非常朴素的工程问题叠在了一起:
- 多模态上下文只进不出:把几 MB 的 Base64 原图永久留在对话历史里;
- 脆弱的传输层降级:WebSocket 断线后单向退化为全量 HTTP POST,既不尝试重连恢复,也不做请求体大小检查;
- 封闭的上下文管线:既没有内置的字节级预算淘汰(如 Claude Code),也没有向插件开放发送前的上下文改写钩子(如 Pi)。
对于日常需要让 Agent 高频看图、跑视觉反馈闭环(无论是 Blender 3D 建模、游戏渲染验收还是前端 UI 调试)的开发者来说,如果你也在用 Codex 并且频繁遇到莫名其妙的请求卡死,不妨去查一眼 ~/.codex/logs_2.sqlite 里的 pre_compression_bytes。
至于官方什么时候能把那堆挂了半年的 Issue 合掉——与其干等,不如早点给自己的工作流加上一层真正可控的 Context Budget。