Skip to content

feat: 压缩超限旧图,避免会话被 413 卡死 - #28

Open
Yilmhi wants to merge 1 commit into
fish2lab:mainfrom
Yilmhi:feat/oversized-image-history
Open

Yilmhi wants to merge 1 commit into
fish2lab:mainfrom
Yilmhi:feat/oversized-image-history

Conversation

@Yilmhi

@Yilmhi Yilmhi commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Codex 每轮都会把整段历史重发一遍,而图片是以 base64 存在历史里的,从来不会变小。所以一段看得够多截图或渲染图的会话,迟早会越过网关约 47 MB 的请求体上限;从那一轮开始,之后每一轮收到的都只有网关回的那页 HTML 413 Request Entity Too Large,重试没有意义——请求体只会更大。

而且字节这道墙来得比 token 那道早:实测一段会话在 86.5 万 / 100 万 token 时就撞上了。也就是说 Codex 自己的上下文压缩根本来不及救场,真正能动的只有图片占的那部分字节。

做了什么

出站请求超过 44 MB 时,路由器按这个顺序处理:

  1. 最新的 4 张原样不动 —— 正在回答的这一轮该看到的图,一张都不降质;
  2. 更老的图全部转成有损 WebP(长边 1024、质量 75);
  3. 只有缩完仍然放不下,才把最老的图换成一条 input_text 记录,写明它是第几张、什么类型、多大。

丢图是最后手段,而且一定留痕,不会留下无声的空洞。

数字是怎么定的

取一段真实的 44 张图会话(其中 42 张是 PNG,真实图片数据 35.94 MB):

图片占用 整个请求 图片去向
改前 47.92 MB 50.67 MB 44 张原图
改后 10.1 MB 12.88 MB 44 张全在,最新 4 张原图

选 WebP 而不是 JPEG 是量出来的,不是偏好:同样花掉 1.18 MB,JPEG 得退到 q37,中位 PSNR 低 3.7 dB。另外那 44 张里有 22 张是带 alpha 的渲染图,WebP 留得住,JPEG 只能拍扁。

可读性按唯一算数的办法验:同一张 1860x1240 的二十面骰渲染图,改前 2182 KB、改后 12 KB,模型读出的骰子面数和上面的数字一致。

给 reviewer 的复现说明:三种格式做了两轮对照,一轮是"同样字节预算"(把对手的质量二分到花掉相同字节再比保真度),一轮是"同样质量档位";另外算图像质量时只统计不透明像素——把全透明像素也算进去,会让有损 WebP 显得比实际差得多。

有一件事想请作者定

编码器是找到就用,不是依赖。这个包对外仍然只声明 ws 一个运行依赖,npm install 不会多装任何东西。sharp 走 createRequire 懒加载(和路由器现在加载 ws 是同一套写法),解析顺序是 webp_encoder_dir → ~/.codex/dscodex/encoders → checkout 自己的 node_modules;一个都找不到就打一行 WebP encoder unavailable 并退回记录方式,所以全新克隆只是慢一点,不会坏。

没用 optionalDependencies 的原因是:那样 npm install 就会把它装上,"只依赖一个 ws"这句承诺就不成立了。如果作者更希望写成 optionalDependencies: sharp,或者觉得不该引入一条依赖外部编码器的路径,缩图逻辑就一个模块(src/image-compaction.mjs),降级路径也已经在位,两种改法都不费事,说一声就行。

配置

五个旋钮:max_upstream_bytes(设成 0 直接关掉这个保护)、keep_recent_images、image_max_side、webp_quality、webp_encoder_dir。它们按"进程环境变量 → ~/.codex/dscodex/config.json → 内置默认值"解析;自启动的路由器读不到 shell 里临时设的变量,所以以配置文件为准。

AGENTS.md 第 7 条把这个例外写成了正式条款,免得后来的 agent 把它当成 bug 删掉。

测试

node --test:154 项,152 通过,0 失败,2 项 Windows 专属在这台机器上跳过。新增用例覆盖:缩图阶梯和"从最老的开始"的顺序、受保护窗口逐字节不变、没有编码器时退回记录、缓存复用、单张图编码失败不影响其余,以及一个真实编码器的往返用例(校验 alpha 保留、最新一张原样不动)。

转码结果按内容哈希缓存在 ~/.codex/dscodex/image-cache,同一张图只编码一次:那段会话冷缓存 2.3 秒、热缓存 0.24 秒。尺寸统计改成了增量计算,因为每处理一张图就把 50 MB 的请求体重序列化一遍是 O(n²)。

Codex resends the whole transcript on every turn and images travel inside it as
base64 that never shrinks, so a picture-heavy session eventually passes the
gateway's ~47 MB request-body ceiling. From then on every turn answers with the
gateway's HTML 413 and no retry clears it. The byte ceiling lands before the
token ceiling (measured at 865k of 1M tokens), so the client's own context
compaction never fires early enough - the only lever is the picture bytes.

Above a 44 MB outbound budget the router now:

  1. leaves the newest 4 pictures byte-identical, so the turn being answered
     keeps its pictures at full fidelity;
  2. re-encodes every older picture to lossy WebP (long side 1024, quality 75);
  3. only if the body still does not fit, replaces the oldest pictures with an
     input_text record naming their position, media type, and size. Deleting is
     the last resort and never leaves a silent hole.

Measured on a real 44-image session: image payload 47.92 MB -> 10.1 MB, whole
request 50.67 MB -> 12.88 MB, nothing deleted, and DeepSeek still read the same
content out of a 2182 KB render re-encoded to 12 KB. WebP was chosen over JPEG
on measurement: allowed the same 1.18 MB, JPEG has to drop to q37, which is
3.7 dB worse at the median. WebP also keeps the alpha channel that 22 of those
44 RGBA renders rely on, which JPEG would flatten.

Transcodes are cached by content hash under ~/.codex/dscodex/image-cache, so a
picture is encoded once: 2.3 s cold versus 0.24 s warm on that session. The
size accounting is incremental, because re-serialising a 50 MB body per picture
is quadratic.

The encoder is found, never required. This package still declares exactly one
runtime dependency (`ws`) and npm install fetches nothing extra. Encoders are
resolved lazily through createRequire - the same idiom the router already uses
for `ws` - from webp_encoder_dir, then ~/.codex/dscodex/encoders, then the
checkout's own node_modules, and a machine with none falls back to the record
path rather than failing.

Five knobs - max_upstream_bytes, keep_recent_images, image_max_side,
webp_quality, webp_encoder_dir - resolve from the process environment and then
from ~/.codex/dscodex/config.json, because the autostarted router never inherits
a shell variable. max_upstream_bytes: 0 disables the guard entirely.

Tests: 147 cases, covering the shrink ladder and its oldest-first order, the
protected window staying byte-identical, the no-encoder fallback, cache reuse,
an unshrinkable picture being left alone, and a real-encoder round trip that
pins alpha preservation and the newest picture staying untouched.
@Yilmhi Yilmhi changed the title feat: shrink oversized image history instead of losing it feat: 压缩超限旧图,避免会话被 413 卡死 Oct 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant