Repository navigation
[Bug] 自动压缩请求把图片 base64 当纯文本序列化,压缩自身超 1M token 后会话永久卡死(v0.4.3) #378
Description
摘要
当会话中存在图片附件(ReadImage / read 产生的 contentParams,即 data:image/png;base64,...)时,自动压缩(compaction)会把 base64 当纯文本序列化进压缩请求。原本只花少量视觉 token 的图片,在压缩请求里被重新分词成几十万文本 token,导致压缩请求自身超过 1,048,576 上限并返回 HTTP 400;而压缩失败后 activeTokens 不更新、也不写入任何 compacted 标记或摘要,于是每次恢复会话都会重试同一个必失败的压缩,形成永久死锁。
环境
@vegamo/deepcode-cli0.4.3(npm 全局安装)- Node v24.16.0,Linux x86_64
- 模型
deepseek-flash,thinkingEnabled: true,reasoningEffort: max - 未配置
contextWindow/autoCompactWindow,即默认1,048,576/524,288
现象
| 项目 | 数值 |
|---|---|
| 会话消息数 | 605(tool 298 / assistant 276 / user 27) |
最后一次成功响应 total_tokens |
524,527 |
| 自动压缩阈值 | 524,288(contextWindow / 2) |
| 触发压缩后的请求 | 1,273,908 tokens → HTTP 400 |
会话文件中 compacted: true 条数 |
0 |
会话文件中 meta.isSummary 消息 |
0 |
失败后无论 /continue 还是继续发消息,都会重复报同一个 400,会话无法继续。
关键证据
-
该会话有 15 条带
contentParams的用户消息(全部来自ReadImage),base64 合计 1,852,105 字符,单张 3.2 万–35.7 万字符。 -
最后一次成功响应为
total_tokens = 524,527,只比阈值 524,288 高 239,因此下一轮迭代必然进入压缩分支。 -
下一次请求(即压缩)报
1,273,908 > 1,048,576。 -
按
compactSession()的切片规则复算:非系统消息前 2/3 为[2, 405),共 403 条;用与getCompactPrompt()相同的字段序列化后长度 2,391,735 字符,其中:- 图片 base64:1,297,531 字符(9 张)
reasoning_content:511,221 字符- tool 输出
content:326,146 字符 - tool 调用参数:148,892 字符
按 1.88 字符/token 换算 ≈ 1.27M token,与 API 报数吻合(2,391,735 / 1,273,908 = 1.877)。
-
由于「当前对话」的 524,527 token 是能成功的,说明超限的不是对话请求,而是压缩请求;两者相差的正是被文本化的 base64。
根因
1. 压缩 prompt 把 contentParams 当字符串序列化
getCompactPrompt()(cli.js:58741)对每条消息执行:
JSON.stringify({
id, role, content, contentParams, messageParams, createTime
})而 contentParams 里存的是:
[{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo..."}}]对比正常请求路径 convertMessage()(cli.js:79546)会把 contentParams 转成 image_url part(视觉 token 很少)。同一份历史,两条路径的 token 成本相差约一个数量级。
另外 messageParams.reasoning_content 也被完整写入压缩 prompt(该会话压缩区间内 511,221 字符),进一步放大体积。
2. 压缩请求没有预算与降级
compactSession()(cli.js:81614)在发送前:
- 不估算
compactPrompt的 token; - 不为模型输出预留空间;
- 请求失败直接
throw,不会缩小区间(如 1/2、1/3)、不会截断单条超长消息。
3. activeTokens 只在成功时更新
触发条件是 session.activeTokens > autoCompactWindow(cli.js:81425),而 activeTokens 只在请求成功时用响应 total_tokens 覆盖(cli.js:81542、81566、81668)。压缩失败 → 计数不变 → 下一轮仍然超阈值。
4. 没有失败兜底
压缩失败时既不标记 compacted、也不写占位摘要、也不清 failReason,历史体积不会有任何缩减。
死锁链路
- 恢复会话:
activeTokens = 524,527 > 524,288→ 立即进入压缩; - 压缩请求 1.27M token → HTTP 400;
catch只写status: failed,消息、计数、compacted标记全部不变;/continue或再发一条消息 → 回到第 1 步。
因此这个状态是不可自愈的,只能手工改会话文件或放弃该会话。
复现方法
- 开一个长会话,多次使用
ReadImage(或read读取图片文件,参见 read tool inlines PDFs/images as base64 into history → context overflow (HTTP 400) on text-only models #181),累计若干张 100KB 级截图; - 持续工作,直到
activeTokens越过contextWindow / 2; - 下一轮触发压缩,
getCompactPrompt()把区间内所有图片 base64 文本化,请求超过 1,048,576; - 观察 400,并且之后每次重试都失败,
<session-id>.jsonl中始终没有compacted: true和isSummary消息。
本例即 15 张截图、605 条消息、会话文件 4.3MB,稳定复现。
建议修复
- 压缩时不要把
contentParams文本化:图片应转为image_urlpart 放进多模态消息,或直接替换为[image omitted]占位符(推荐,摘要不需要像素);reasoning_content建议剥离或截断。 - 压缩请求做预算:发送前估算 token,超过
contextWindow - 输出预留时自动缩小[startIndex, endIndex),或对单条超长消息截断。 - 压缩失败必须降级:至少把最老的一批消息标记
compacted: true(可写「摘要生成失败」占位),保证上下文一定能收缩,避免死锁。 - 修正统计口径:
activeTokens建议在请求构造后按实际消息重算(或记录lastRequestTokens),失败时也能反映真实体积;成功后清理failReason。 - 可考虑提供
/compact手动命令或--no-compact逃生开关。
临时绕过(已在本地验证)
- 把压缩区间内 9 条图片消息的
contentParams替换为占位文本后,压缩 prompt 从 2,391,735 → 1,094,930 字符(≈ 36 万 token),会话即可正常压缩并继续。 - 预防:把
autoCompactWindow调低(如"256K"),让压缩在历史更小、图片更少时发生,可显著降低触发概率。
关联 issue
- 换新版本报Input length 1805012 exceeds the maximum length 1048566错误。 #344 同样报 1M 超限,维护者提到可能与图片有关、压缩算法不够健壮
- 长会话上下文超限时自动压缩未触发,请求发送远超活跃 token 数,持续 HTTP 400 #269 自动压缩未触发 / 持续 400(本 issue 补充了精确根因与复算数据)
- read tool inlines PDFs/images as base64 into history → context overflow (HTTP 400) on text-only models #181
read工具把 PDF/图片 base64 写入历史
如有需要,我可以提供脱敏后的会话片段与复算脚本。
English summary
When a session contains base64 image attachments (contentParams written by ReadImage/read), getCompactPrompt() serializes them with JSON.stringify into a single text message. Images that normally cost a few vision tokens are re-tokenized as base64 text (~1.3M chars in this session), so the compaction request itself exceeds the context window (1,273,908 > 1,048,576) and returns HTTP 400. Because activeTokens is only updated on successful responses and no compacted flags or summary are written on failure, every resume retries the same failing compaction — a permanent deadlock. Suggested fixes: serialize images as image_url parts or omit them, budget the compaction prompt, and fall back to dropping the oldest messages when summarization fails.
Activity