dsh-payload-guard
Verifieddsh-payload-guard · v0.1.5 · MIT
DSH plugin: recover from HTTP 413 payload-too-large by replacing context images with local-path placeholders and retrying
Install
dsh plugin add dsh-payload-guard Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
dsh-payload-guard
DSH 插件:请求体超限(HTTP 413)自愈 —— 触发后,将上下文中最老的一批图片出现位置转换为文本占位符,然后重试。占位符包含附件身份信息,并在可访问时提供本地路径。
解决的问题
上游网关的请求体上限通常远小于 DSH 适配器的默认图片预算:
| 层 | 限制 |
|---|---|
llm-pi-ai 的 maxRequestImageBytes |
默认 20 MiB |
| 实测某中转网关的真实 body 上限 | 约 10 MB(十进制计量;实测 9.79 MB 通过、10.04 MB 返回 413) |
注:上表两行单位不同(MiB 是适配器的配置口径,MB 是实测字节数口径),不要混用换算。
于是图片一多,适配器的预算闸门永远不会响,直到上游直接拒绝。而 413 被适配器归类为 INVALID_REQUEST,不在 dsh-llm-retry 的 retryableCodes 里:
["EMPTY_RESPONSE", "RATE_LIMIT", "SERVER", "TIMEOUT", "TRANSPORT"]
结果就是:不重试、不降级、回合直接终止。而上下文里那堆图片删不掉 —— 之后每次发消息都必然再撞一次墙,会话就此永久卡死。
这个问题不是本插件臆想的。同类故障在开源生态里有大量公开记录,例如 Claude Code #43056(累积内联图片后连纯文本消息都发不出)、 #9269(413 后重启恢复仍失败)、 #24541(107 张图超媒体数量上限)。
插件做了什么
请求 ──▶ 上游超限 ──▶ payload-guard 认出「请求体过大」
│
├─ 扫出当前 surface 里所有未 offload 的图片(最老的在前)
├─ 挑出这一轮该移出的一批(默认剩余的一半)
├─ 追加一持久化 image/offload 决定
├─ 注入一条固定提示(提醒模型按需单张回读)
└─ 返回 { kind: "retry" }
│
▼
重新 deriveMessages() → 再发一次请求
│
▼
被选中的图片渲染成「文本占位符」,
其余图片照常发送 ──▶ 尝试重新请求;
仍超限时继续恢复,直到成功或达到预算上限
模型看到的占位文本长这样(DSH 内置 offloadedImageText() 的输出):
[image omitted to fit request image limits; "chest_crop.png" (sha256:e0fccfd8…).
Normalized copy (read-only; may be resized or re-encoded):
"C:\Users\...\.dsh\attachments\v1\objects\e0\e0fccfd8…" (520x360px, image/png).
Source dimensions, format, and byte size may differ.
Copy to a writable path ending in .png before editing.]
卸载不会主动删除附件文件,但被选中的历史图片出现位置会持续以文本占位符呈现,后续请求不会再发送该位置的图像字节。如果占位符包含当前执行环境可读取的本地路径,可以使用 read_image 按需回读;重新读取会产生新的图片出现位置。若无法解析路径,内置占位符只会给出附件身份,并不保证能重新读取。
安装
本插件尚未发布到 npm,请从 GitHub 安装。
方式一:从 GitHub 安装(推荐)
# 装到你 profile 的插件目录
npm install --prefix "$HOME/.dsh/profiles/web" \
https://github.com/XiAzir/dsh-payload-guard/archive/refs/tags/v0.1.5.tar.gz
然后在 profile 的 cordis.patch.yml 里登记:
- insert:
- id: payload-guard
name: dsh-payload-guard
config:
enabled: true
或直接克隆到本地任意位置,再热注入(免重启):
dev_inject_plugin(dir: "<你克隆到的路径>")
方式二:直接 git clone
git clone https://github.com/XiAzir/dsh-payload-guard.git
# cordis.patch.yml
- insert:
- id: payload-guard
name: dsh-payload-guard
config:
enabled: true
本插件为通用插件,不绑定任何工作区。
前置依赖:内置 @deepseek-ai/dsh-compaction-image-offload 必须已装载 —— 它提供
image/offload 事件的解释器。本插件不抢注该解释器;检测到它缺席时会 log 一条 warn 并
fail-closed(不挂监听、不写事件),而不是假装能工作。
版本兼容性
已在 DSH 0.2.1-rc.2 上通过真实 Agent Loop 集成测试。DSH 版本升级后请重跑
npm run test:integration(可用 DSH_TESTKIT_VERSION=<目标版本> 指定要验证的版本)。
本插件不做版本号硬拦截:版本号不能可靠代表事件语义,误停用比失效更糟。若未来 DSH
改变了 image/offload 的解释规则,可能出现「事件写得成功但模型看不到缩小后的请求体」
这类隐性退化 —— 届时请以集成测试结果为准,而不是靠版本号推断。
配置
| 键 | 默认 | 说明 |
|---|---|---|
enabled |
true |
主开关。false = 完全退回 DSH 原生行为(见下) |
offloadRatio |
0.5 |
每轮移出「剩余未 offload 图片」的比例 |
maxOffloadPerRetry |
12 |
单轮移出张数上限 |
maxRecoveriesPerTurn |
5 |
同一回合内最多自动恢复次数。耗尽后交回原错误 |
notice |
true |
是否注入「已发生卸载」的行为提示 |
配置值越界会被钳制而不是抛错。
总开关:enabled
enabled: false 时插件不挂任何监听、不注册任何投影、不注入任何提示,等价于没装:
- 413 照旧直接失败(
INVALID_REQUEST不在llm-retry的重试码里) - 图片预算仍是适配器默认的 20 MiB
- 内置
dsh-compaction-image-offload的既有能力完全不受影响
⚠️ 内置闸门(20 MiB)和本插件(超限救援)是两层独立保护。关掉本插件不会让 20 MiB 机制消失,它只是回到「20 MiB 闸门 + 超限直接失败」的原始状态。而问题恰恰 在于很多网关的真实上限远小于 20 MiB,那道内置闸门永远来不及响。
设计取舍
为什么被动触发,而不是提前估算?
仅当识别到请求体超限失败后才修改历史,尽量避免不必要的主动清理。各家网关的真实上限既不 公开也不一致(有 5 MB 的,有 10 MB 的,有几十 MB 的),提前猜阈值要么误伤、要么漏判。 需要明确的是:错误识别仍依赖 HTTP 状态码或错误文本,不保证零误判。
为什么渐进卸载,而不是一次全移出?
看起来「反正图片都在本机,全移出最省事」—— 但这是个陷阱。image/offload 保留附件引用,
却用文本占位符替代 image 块:模型必须再次调用工具才能重新看到画面。
于是全量卸载会造成功能退化,不只是效率降低:假设模型要比较最新的两张图 A、B,历史旧
截图导致超限。全量卸载会把 A、B 也变成占位符 → 模型 read_image 读回 → 这批又超限 →
再被卸载。结果是「工具读取成功,但模型始终看不到这批图」。
默认每轮尝试卸载剩余图片的一半、单轮最多 12 张,以尽量保留视觉上下文。是否一次重试即可 通过,取决于实际请求体大小和网关限制,不做保证。
为什么按回合计数,而不是按 step?
按 ${turn}/${step} 计数时,换一个 step 就重置预算,等于对「回读 → 超限 → 再卸载」这类
跨 step 循环完全不设限。按回合封顶才能真正收敛。
为什么自己注册解释器是错的?
image/offload 的解释器归内置插件所有。抢注会让官方插件随后注册时抛
projection "image/offload" is already registered;运行期动投影表还可能让已折叠的会话
重放失败。所以检测到缺席时正确做法是放弃,不是顶替。
已知代价(诚实记录)
修改历史会破坏提示缓存
卸载发生在历史中间时,该位置之后的前缀缓存会失效(之前的部分仍可能命中)。这是修改 历史的固有代价,即使只在失败之后触发也不是零成本 —— 失败请求之前那些成功请求可能已经 建立了很长的缓存前缀。
公开数据佐证:Claude Code #90716 报告旧图片被逐步淘汰后, 缓存读取从约 658,681 tokens 降到 26,314 tokens。Hermes Agent #113517(修复 PR #113953 已合并)记录了更精确 的账单:反复淘汰导致 707,180 / 1,145,702 tokens(约 62%)的缓存被重写。
本插件的缓解是:只在真的失败之后才动历史,不制造本来不需要的缓存失效。但请明确: 这是「减少不必要的失效」,不是「失败后没有代价」。
缓存不解决体积
Prompt cache 只省计算与费用,请求体该有多大还是多大(除非用文件引用或服务端有状态 接口)。缓存命中率再高,也绕不过网关上限。
提示是软约束
notice 注入的是 user-role 的运行时上下文,属于行为引导而非强制拦截。所以它不能替代
maxRecoveriesPerTurn 这道硬预算。
热注入场景需要重载
若本插件先加载、内置 image-offload 插件后加载,本插件会 fail-closed 停用。此时需重载
payload-guard 才能启用恢复。这是「初始化时检测一次」的固有代价,换来的是不去动运行期
投影表。
提示状态能从历史重建,但预算不能
恢复持久化会话、fork 分支或插件晚加载时,可从历史 image/offload 事件重建提示的激活
状态;实际注入仍取决于 notice 开关及 DSH Runtime Context 是否可用。
但 maxRecoveriesPerTurn 的计数是纯内存的:fork 出来的新会话会从零开始获得预算。
这是有意的 —— fork 是一个新分支,给它一份干净预算比继承父会话的消耗更合理。
明确不做的事
- 不做主动预防性清理(每次加新图就删最老一张)。主流项目踩过这个坑:逐张淘汰会
反复打破前缀缓存。若将来要做,应参考 Hermes Agent 的
image_eviction_policy.py:阈值触发、批量卸载、保护用户上传图片。 - 不做模型自主卸载工具。模型要调用工具卸载,前提是先成功完成一次请求 —— 请求已经 被拒时它根本没有机会发起工具调用。所以这只能作为正常工作阶段的优化,不能替代自动恢复。
- 不按图片大小选择(largest-first)。该策略会让「哪几张还能看见」变得难以推理,且 按大小跳跃删除比连续删除更难解释。
自检
node test/self-check.mjs # 31 组离线自检项;部分官方 projection 交叉验证在依赖缺失时跳过
node test/dead-session-dryrun.mjs # 用真实会话日志干跑
node test/integration/run.mjs # 真实 Agent Loop 集成测试(自动装隔离依赖)
离线用例覆盖
- imageIndex 语义与真实内置 projection 的交叉验证(差一位就会标错图或让 projection 抛错)
- 裸
INVALID_REQUEST绝不触发卸载(发布前必过的安全边界;误判会造成不可逆破坏) - 已知 HTTP 状态码否决文本匹配:
status: 400不会因为消息里出现 "413" 而误判 - 与 HTTP body 无关的
length limit exceeded不得误判 - 跨 step 不重置预算、新回合重置预算
- fail-closed:解释器缺席时不抢注、不挂监听
- 动态提示:未卸载时为空串、卸载后为固定文本(不含可变计数)、恢复/fork 后可从历史重建
- 历史扫描是真增量:无新增事件时
eventAt()调用次数必须为 0 - 渐进收敛轨迹、413 措辞识别与反例、无图会话、enabled 开关
真实 Agent Loop 集成测试
test/integration/ 用已发布的 npm 包(@deepseek-ai/dsh-agent-loop-testkit 等)
装配真实运行时,验证离线假件证明不了的三件事:
| 场景 | 断言 |
|---|---|
| HTTP 400 | 会话日志中没有任何 image/offload 事件 |
| HTTP 413 | 真的写入 image/offload、真的重发请求、后续请求的图片块被官方投影标记 offloaded |
| 历史重建 | offload 事件落在真实 Session 日志里,回扫能定位到 |
装配的是真实 Context / SessionStore / 官方 image-offload projection / AgentLoop,
只有 LlmAdapter 是脚本化的(不访问任何上游)。
插件仓库保持零 DSH 运行时依赖:这些包装在仓库外的隔离临时目录
(默认 <tmp>/dsh-payload-guard-itest),首次运行自动安装并锁定版本。
可通过环境变量控制:
| 变量 | 作用 |
|---|---|
DSH_TESTKIT_DEPS |
指定隔离依赖目录 |
DSH_TESTKIT_VERSION |
锁定 DSH 版本(默认 0.2.1-rc.2) |
SKIP_INSTALL=1 |
复用已装依赖,跳过安装 |
版本要与你实际部署的 DSH 对齐。集成测试验证的是发行包之间的兼容性, 这比跟着源码主分支更贴近真实部署环境。
dry-run 的证据边界
dead-session-dryrun.mjs中的图片体积统计来自附件元数据attachment.bytes, 并非适配器完成缩放、重新编码以及 Base64 序列化后的实际 HTTP 请求体大小。 干跑主要验证卸载目标的选择、官方 projection 的接受情况与收敛轨迹, 不代表已通过真实网关请求测试。
License
MIT