镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

[Bug] 自动压缩请求把图片 base64 当纯文本序列化,压缩自身超 1M token 后会话永久卡死(v0.4.3) #378

Open

Description

@ATMxsp01

摘要

当会话中存在图片附件(ReadImage / read 产生的 contentParams,即 data:image/png;base64,...)时,自动压缩(compaction)会把 base64 当纯文本序列化进压缩请求。原本只花少量视觉 token 的图片,在压缩请求里被重新分词成几十万文本 token,导致压缩请求自身超过 1,048,576 上限并返回 HTTP 400;而压缩失败后 activeTokens 不更新、也不写入任何 compacted 标记或摘要,于是每次恢复会话都会重试同一个必失败的压缩,形成永久死锁。

环境

  • @vegamo/deepcode-cli 0.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,会话无法继续。

关键证据

  1. 该会话有 15 条带 contentParams 的用户消息(全部来自 ReadImage),base64 合计 1,852,105 字符,单张 3.2 万–35.7 万字符。

  2. 最后一次成功响应为 total_tokens = 524,527,只比阈值 524,288 高 239,因此下一轮迭代必然进入压缩分支。

  3. 下一次请求(即压缩)报 1,273,908 > 1,048,576。

  4. 按 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)。

  5. 由于「当前对话」的 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,历史体积不会有任何缩减。

死锁链路

  1. 恢复会话:activeTokens = 524,527 > 524,288 → 立即进入压缩;
  2. 压缩请求 1.27M token → HTTP 400;
  3. catch 只写 status: failed,消息、计数、compacted 标记全部不变;
  4. /continue 或再发一条消息 → 回到第 1 步。

因此这个状态是不可自愈的,只能手工改会话文件或放弃该会话。

复现方法

  1. 开一个长会话,多次使用 ReadImage(或 read 读取图片文件,参见 read tool inlines PDFs/images as base64 into history → context overflow (HTTP 400) on text-only models #181),累计若干张 100KB 级截图;
  2. 持续工作,直到 activeTokens 越过 contextWindow / 2;
  3. 下一轮触发压缩,getCompactPrompt() 把区间内所有图片 base64 文本化,请求超过 1,048,576;
  4. 观察 400,并且之后每次重试都失败,<session-id>.jsonl 中始终没有 compacted: true 和 isSummary 消息。

本例即 15 张截图、605 条消息、会话文件 4.3MB,稳定复现。

建议修复

  1. 压缩时不要把 contentParams 文本化:图片应转为 image_url part 放进多模态消息,或直接替换为 [image omitted] 占位符(推荐,摘要不需要像素);reasoning_content 建议剥离或截断。
  2. 压缩请求做预算:发送前估算 token,超过 contextWindow - 输出预留 时自动缩小 [startIndex, endIndex),或对单条超长消息截断。
  3. 压缩失败必须降级:至少把最老的一批消息标记 compacted: true(可写「摘要生成失败」占位),保证上下文一定能收缩,避免死锁。
  4. 修正统计口径:activeTokens 建议在请求构造后按实际消息重算(或记录 lastRequestTokens),失败时也能反映真实体积;成功后清理 failReason。
  5. 可考虑提供 /compact 手动命令或 --no-compact 逃生开关。

临时绕过(已在本地验证)

  • 把压缩区间内 9 条图片消息的 contentParams 替换为占位文本后,压缩 prompt 从 2,391,735 → 1,094,930 字符(≈ 36 万 token),会话即可正常压缩并继续。
  • 预防:把 autoCompactWindow 调低(如 "256K"),让压缩在历史更小、图片更少时发生,可显著降低触发概率。

关联 issue

如有需要,我可以提供脱敏后的会话片段与复算脚本。


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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions