Repository navigation
Deleting ~/.dsh/attachments makes image-bearing sessions fail permanently, misreported as TRANSPORT #7582
3387366837
started this conversation in
General
Replies: 1 comment
|
补充:同一个问题在 0.2.0-rc.2 上仍然原样复现,有两处关键点未变——
版本对照( |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
会话历史含图片时,DeepSeek 适配器在每次请求前都会从本地附件库重新读取图片字节,而不是把图片内联在请求中。一旦 ~/.dsh/attachments/v1 下的对象被删除,该会话历史中所有图片引用即变为悬空引用,于是:
该会话此后每一次请求都永久失败,重试无法恢复;
失败被归类为 TRANSPORT,触发 5 次自动重试(实测 44 次重试事件)全部无效;
界面仅显示 DeepSeek API stream from https://api.deepseek.com/ failed,把"本地附件缺失"误报为"网络故障";
/compact 同样失败(压缩也需重发含图历史),会话无法自救。
这是永久性错误被误分类为瞬时错误的健壮性缺陷。
环境
项目
值
@deepseek-ai/dsh
0.1.5-rc.2(内置 @deepseek-ai/* 子包同为 0.1.5-rc.2)
操作系统
Windows 11 (26100)
Node.js
v24.21.0
Provider / Model
deepseek-official / deepseek-flash,reasoningEffort: max
复现步骤
新建会话,把图片放进历史(上传,或让 Agent 生成图片作为附件);
确认 ~/.dsh/attachments/v1/objects 下已生成对象文件;
删除 ~/.dsh/attachments/v1/objects 与 .../request-images 下的全部文件(保留目录);
在该会话中发送任意消息。
实际结果:
界面报 DeepSeek API stream from https://api.deepseek.com/ failed
会话日志记录 llm/retry ×5,随后 turn/end 为 {"kind":"error","error":{"code":"TRANSPORT"}}
重发、切换模型、执行 /compact 均以同一错误失败
期望结果:报出明确的、不可重试的错误,例如
attachment sha256:xxxx is missing; cannot rebuild the image-bearing request,且不应触发重试。
证据
时间线(UTC,时区换算已与文件系统 mtime 交叉验证):
时间 (UTC)
事件
04:22:23.225
tool/call — Agent 发出附件库清理命令
04:22:24.069
文件系统:attachments/v1/objects 目录 mtime
04:22:24.098
文件系统:attachments/v1/request-images 目录 mtime
04:22:24.244
tool/result — 报告 objects\ 18 个文件 -> 0、request-images\ 16 个文件 -> 0、总计 0 MB
04:22:24.304
首次 TRANSPORT 失败(turn 47, step 3)
04:22:40 → 04:26:48
turn 48–53 连续失败,每轮 5 次重试
04:32:28
compaction/end — /compact 以同一错误结束
04:33:14
turn 55 失败,6 次尝试
删除动作与首次失败相隔仅 206–235 毫秒。
该会话历史含 24 个 image 块,引用 17 个不同附件。删除后全盘搜索,无任何副本存活。实测重试策略:
无语言
["normal", 5, ["EMPTY_RESPONSE","RATE_LIMIT","SERVER","TIMEOUT","TRANSPORT"]]
退避序列实测: 0.5s -> 1s -> 2s -> 4s -> 8s
重试机制完全按设计工作——只是它无法战胜一个永久性错误。
根因
node_modules/@deepseek-ai/dsh-llm-deepseek/lib/index.js:1690
JavaScript
js
const requestImages = attachments === void 0 || model === void 0
? new Map()
: await prepareRequestImages(requestOptions, attachments, model, signal);
prepareRequestImages(同文件 :1455-1462)为历史中每个图片引用读取字节:
JavaScript
js
async function prepareRequestImages(options, attachments, model, signal) {
const refs = new Map();
for (const message of options.messages) collectImageRefs(message.content, refs);
const policy = resolveRequestImagePolicy(model);
const orderedRefs = [...refs.values()];
const projected = await Promise.all(
orderedRefs.map((ref) => attachments.readImageRequest(ref, policy, signal))
);
return new Map(orderedRefs.map((ref, index) => [ref.attachmentId, projected[index]]));
}
后果:会话历史只存 attachmentId,真实字节依赖附件库。二者隐式强耦合,且无引用计数、无可达性 GC、无完整性保护。
同函数结构(:1691-1707):
JavaScript
js
const requestImages = ... await prepareRequestImages(...); // :1690 —— 在任何 try 之外
let representation = "file";
let fileAttempt = 0;
while (true) {
const usedFiles = [];
let body;
if (attachments === void 0) body = serializeRequest(...);
else if (representation === "base64") body = await serializeRequestWithImages(...);
else try { // 仅此处及以下有 try
body = await serializeRequestWithImages(...);
} catch (error) { ... }
}
:1690 的异常未被局部捕获,直接冒泡到 streamWithConnection 的兜底分支,同文件 :1641-1645:
JavaScript
js
} catch (error) {
if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== void 0)
throw new LlmError(
DeepSeek stream idle timeout after ..., "TIMEOUT", { cause: error });if (options.signal?.aborted)
throw new LlmError("DeepSeek request aborted by caller", "ABORTED", { cause: error });
if (error instanceof LlmError) throw error;
throw new LlmError(
DeepSeek API stream from ${connection.baseURL} failed, "TRANSPORT", { cause: error });}
任何不是 LlmError 的底层错误(文件读取失败通常不是)都会被无条件标为 TRANSPORT。
node_modules/@deepseek-ai/dsh-llm/lib/types/retry-policy.js:12-22
JavaScript
js
const DEFAULT_MAX_RETRIES = 5;
const DEFAULT_RETRYABLE_CODES = Object.freeze([
EMPTY_RESPONSE_CODE, 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT',
]);
dsh-llm-retry 仅依据 failure.code 判定(dsh-llm-retry/lib/index.js:160,164):
JavaScript
js
} else if (!policy.retryableCodes.includes(failure.code)) return next();
...
if (policy.mode === "normal" && previousRetry >= policy.maxRetries) return next();
建议修复
(a) 错误分类 —— 改动最小、收益最大
在调用点包裹,抛出不属于可重试码的明确 LlmError:
JavaScript
js
let requestImages;
try {
requestImages = attachments === void 0 || model === void 0
? new Map()
: await prepareRequestImages(requestOptions, attachments, model, signal);
} catch (error) {
throw new LlmError(
"cannot rebuild request images: a session-referenced attachment is missing or unreadable; " +
"restore the objects under ~/.dsh/attachments, or remove the image from the session history",
"ATTACHMENT_MISSING",
{ cause: error }
);
}
因为 streamWithConnection 会原样重抛已存在的 LlmError(:1644),它不会再被改标为 TRANSPORT。
(b) 在消息中保留底层 cause
当前 TRANSPORT 消息完全丢弃了 cause,这正是该缺陷在排查中被误判为网络问题的原因。附上 error.cause?.code(如 ENOENT、ECONNRESET)即可让此类故障可诊断:
JavaScript
js
const detail = error?.cause?.code ?? error?.code ?? error?.name ?? "unknown";
throw new LlmError(
DeepSeek API stream from ${connection.baseURL} failed (${detail}),"TRANSPORT", { cause: error }
);
(c) 附件生命周期保护
对附件对象做引用计数或可达性 GC;仍被任何会话历史引用时,拒绝删除(或改为墓碑标记)。
把 ~/.dsh/attachments 视为内部存储,通用删除操作不得触及——例如放置标记文件,或对 Agent 的删除类操作加路径守卫。
优雅降级:加载历史时若发现悬空图片引用,替换为文本占位,而不是让整个会话不可用。
影响面
触发条件:会话历史含图片 且 对应附件对象被删除。任何对 ~/.dsh/attachments 的清理(手工、脚本、或 Agent 工具调用)都会命中。
后果:该会话永久不可用;重试、切换模型、/compact 全部失败。
数据损失:取决于附件重要性。本例中 17 个附件全盘无副本。
误导性:报错文本指向"网络",令用户与支持人员沿错误方向排查。
已排除的假设(实测,供排查参考)
假设
排除依据
网络 / 链路故障
40/40 次连续流式请求成功(146 s);3/3 次复刻相同请求形态(deepseek-flash + reasoning_effort: max + max_tokens: 256000)成功,单次 19–28 s、接收 1.15–1.83 MB
DNS / TCP
解析正常;TCP 443 探测 10/10 成功
路径 MTU 黑洞
1472 字节载荷收到正确的 ICMP 需分片响应,PMTUD 工作正常
请求体过大
0.5/1 MB 成功;2–8 MB 返回明确 HTTP 400(上下文超限),非传输失败
API 密钥 / 路由
密钥有效;/models 与 /chat/completions 均正常
重试插件缺失 / 失效
44 条 llm/retry 事件,指数退避正确
上游 API 故障
失败窗口内(04:22–04:33)同一台机器的其他请求全部成功
该故障与网络、API 密钥、上游服务均无关。
代码位置速查
文件
行
说明
@deepseek-ai/dsh-llm-deepseek/lib/index.js
1455–1462
prepareRequestImages —— 从附件库读取图片字节
同上
1690
调用点,位于 try 之外 → 错误冒泡
同上
1691–1707
文件 / 内联序列化的 try 结构(未覆盖 1690)
同上
1641–1645
streamWithConnection 兜底 → 标记为 TRANSPORT
@deepseek-ai/dsh-llm/lib/types/retry-policy.js
12–22
maxRetries=5,TRANSPORT 可重试
@deepseek-ai/dsh-llm-retry/lib/index.js
151–174
recover() —— 依据 failure.code 决定重试
@deepseek-ai/dsh-agent-loop/lib/index.js
1081–1098
agent/request-error 瀑布 → 重试决策
All reactions