一次 184MB Codex 会话把 CPU 打满:Agent 前端不要把截图当聊天记录渲染

这次抓到一个很有意思、也很有启发性的 Agent 前端问题:同一个 Codex Desktop,只要打开某个历史 session,CPU 就会狂飙;换到别的 session 又正常。最后定位下来,它不是模型推理慢,也不是后台 app-server 在忙,而是前端/桌面壳在加载一份被视觉工具日志撑爆的历史记录。

现象

本机打开 session 019e1263-2ff1-79c0-a274-f25b42b8f015 时,进程表现大概是:

  • Codex.app/Contents/MacOS/Codex 主进程约 199%~219% CPU
  • WindowServer 被带到 50%+ CPU
  • codex app-server 基本空闲
  • CC Switch、Claude Code 都不是主要负载来源

这说明问题不在模型端,也不在工具真正执行,而在桌面客户端打开历史时的本地解析、内存搬运和渲染。

关键证据

这个 session 的本地 JSONL 文件大小是 184MB,但只有 2626 行。也就是说它不是普通意义上的“很长对话”,而是少量记录里塞了巨大的 payload。

按类型粗分:

  • response_item/function_call_output: 91.1MB
  • event_msg/mcp_tool_call_end: 88.5MB
  • function_call_output 里有 155 条带 data:image 的记录
  • 全文件命中 data:image/png;base64 / input_image / Computer Use state 相关内容约 304
  • 最大单行接近 2.9MB

进一步看来源,主要是 Computer Use 相关:

  • computer-use/get_app_state: 31.8MB
  • computer-use/click: 24.3MB
  • computer-use/press_key: 18.0MB
  • computer-use/set_value: 11.8MB

这些记录里包含截图、accessibility tree、工具调用结果,而且很多截图被以 data:image/png;base64 内联进 JSONL。更糟的是,同一类信息同时出现在 response_item/function_call_outputevent_msg/mcp_tool_call_end 两套日志里,天然放大了一倍左右。

CPU sample 也对得上:热路径集中在 Electron/Node/V8 侧的 Buffer / ArrayBuffer 分配、memmovebzero,并出现 PNG 解码路径。换句话说,打开这个 session 相当于让前端一次性读入一个 184MB 的 JSONL,再解析大字符串、解码图片、恢复 UI 状态、交给渲染层。

相关 issue:

对 Agent 前端开发的启发

这件事很像一个“AI Agent 前端会踩的新坑”:传统聊天应用的历史消息主要是文本,最多加少量图片;但 Agent 应用的历史记录会混入工具调用、终端输出、截图、浏览器状态、无障碍树、DOM 快照、MCP 结果、调试日志。它们如果都被当作普通聊天消息无脑持久化和渲染,很快会把桌面端拖死。

我觉得至少有几条设计原则值得单独拿出来:

1. 不要把截图当聊天文本存

截图、录屏、DOM snapshot、accessibility tree 都应该是 artifact,不应该作为大段 base64 内联进 transcript。

更合理的做法是:

  • 二进制资源单独落盘或对象存储
  • 用 content hash 去重
  • transcript 里只存引用、尺寸、mime、摘要和缩略图信息
  • UI 默认显示缩略图或折叠卡片,用户点开再加载原图

尤其是 Computer Use 类工具,每一次 click / key / get_app_state 都可能带截图。如果每步都内联全尺寸 PNG,几十步就能堆出一个巨型 session。

2. transcript、event log、debug trace 要分层

Agent 前端至少有三种历史:

  • 用户真正想回看的对话 transcript
  • Agent 运行所需的结构化状态
  • 调试/审计用的低层 event trace

这三者不应该在 UI 打开历史时被同等加载。用户点开一个旧 session,第一屏需要的是可读对话,而不是完整工具事件流和所有截图原图。

可以考虑:

  • 默认只 hydrate transcript summary
  • event trace 延迟加载
  • 大工具输出默认折叠
  • 只有进入“调试模式”才加载完整 MCP / tool trace
  • 对重复事件做引用,而不是复制 payload

这次的问题里,response_itemevent_msg 同时保存大图,就是一个典型的放大器。

3. 前端必须有“历史大小预算”

Agent 历史不是无限小的文本流。建议每个 session 至少有几类预算:

  • 单条消息最大可直接渲染大小
  • 单个工具输出最大内联大小
  • 单个 session 首屏最大 hydrate 字节数
  • 单个 session 的图片/附件总量提示
  • 超限后自动外置 artifact 或折叠

比如超过 256KB 的工具输出就不再直接塞进消息树;超过 1MB 的图片只保留缩略图;超过 20MB 的 session 打开时先进入“瘦身视图”。

4. UI 列表必须虚拟化,图片必须懒解码

即便底层存储没有优化,前端也不应该一次性把整个 session 的所有 rich content 渲染出来。

Agent session UI 应该默认:

  • virtual list,只渲染可见消息
  • 图片 lazy decode
  • 折叠长工具输出
  • 大 JSON / 大日志用 preview + download/view raw
  • 滚动到某个位置才取对应附件
  • 对历史消息的 Markdown / syntax highlight 做按需渲染

否则一个 session 里几百张截图,就足够让 Electron / WebView / WindowServer 一起忙起来。

5. 工具输出要有“人类摘要”和“机器原文”两套视图

对用户来说,get_app_state 最有价值的是:当前窗口、关键控件、动作结果。完整 accessibility tree 和截图原图通常只是调试材料。

更好的 UI 可以这样分层:

  • 默认显示:工具名、动作、成功/失败、关键摘要
  • 展开一级:可读的控件树摘要
  • 展开二级:截图缩略图
  • 调试入口:完整 JSON、原图、base64、调用耗时

这样既保留可审计性,也不让普通历史回放承担全部成本。

6. 对 Agent 来说,“可回放”不等于“全量重渲染”

很多 Agent 产品会想做 replay:重放浏览器、重放终端、重放每一步工具调用。但历史回放应该是时间轴式的、分段加载的,而不是把所有原始 payload 当聊天消息一次性恢复。

理想状态是:

  • transcript 负责叙事
  • timeline 负责步骤
  • artifact store 负责大对象
  • trace viewer 负责深度调试
  • renderer 只处理当前视口附近的内容

这次 Codex 的问题,本质上就是 trace 和 transcript 混在一起,且大对象以 base64 直接进入了主历史文件。

一个实用判断标准

如果一个 Agent 前端打开旧 session 时 CPU 飙升,可以先问几个问题:

  • session 文件是不是异常大?
  • 大小是不是集中在少数几行?
  • 有没有大量 data:image/*;base64
  • 工具输出是不是同时记录在 message 和 event 两套结构里?
  • app-server / backend 是否空闲,而 Electron / renderer / WindowServer 忙?
  • sample 里是否是 Buffer、memmove、image decode、syntax highlight、Markdown render?

如果答案是 yes,这大概率不是“模型慢”,而是 Agent 前端的历史载入架构问题。

结论

Agent 应用不能再照搬传统聊天产品的历史记录模型。只要引入 Computer Use、浏览器自动化、截图、多模态工具,历史数据就会从“文本聊天”变成“文本 + trace + binary artifact + UI state”的混合体。

前端如果没有 artifact 外置、懒加载、虚拟列表、折叠工具输出、去重和大小预算,迟早会出现这种奇怪现象:模型没在跑,后台没在忙,只是打开一个历史 session,就能把本地 CPU 拉满。

这对做 Agent 前端的人挺有价值:真正难的不是把工具调用显示出来,而是知道哪些东西不该默认显示、哪些东西不该内联、哪些东西不该在首屏加载。