DeepSeek Harness 报 transport failed 不是网络问题:附件缺失砖掉会话的定位与修复
症状很骗人:会话每次发消息都失败,报 DeepSeek Messages transport failed(TRANSPORT),还会退避重试 5 次(0.5s→8s),看起来像网络或代理出了问题——但这个错误码指向的方向是错的。它的真实原因是本地附件对象被删除了:attachment-local 在文件系统报 ENOENT 时抛出精确的 ATTACHMENT_NOT_FOUND,却在适配器的兜底 catch 里被一律包装成 TRANSPORT;而 prepareImages 每次请求都要重读会话里引用的全部图片,于是一个缺失对象让这条会话的每一个请求都失败,且被重试 5 次。判断方法是看 HTTP 请求数**:本问题实测为 0,每次尝试 6–20ms 就失败。更严重的是,附件是内容寻址存储的,删掉且无备份就无法重建——这条会话会永久无法再发消息。**
先分诊:报的是 TRANSPORT,但它根本不是网络问题
结论先行:把「零 HTTP 请求」当成第一条判据,就能一步把网络、代理、证书、鉴权、配额全部排除。 上报里特别强调,这条证据应该放在正文最前面——否则 TRANSPORT 这个名字会先入为主,把你带向完全错误的方向(#7834)。
按下面四步确认:
- 看错误码与文案:
code: TRANSPORT,message 为DeepSeek Messages transport failed。 - 看有没有发出请求:如果 HTTP 请求数为 0,就说明失败发生在本地准备阶段。这是决定性的判据。
- 看失败有多快:本问题每次尝试 6–20ms 即失败(纯本地错误,无网络往返)。
- 看重试链:每个失败 step 在会话日志里留下 5 条
llm/retry,退避 0.5s→8s——重试同样失败。对一个「本地文件不存在」的错误重试 5 次,是纯粹的延迟。
最小复现只有三步(#7834):
- 在一个会话里让模型读过图——附件会被复制进
$DSH_HOME/attachments/v1/objects/<sha256 前两位>/<sha256>,会话历史以attachmentId引用。 - 删除这些对象文件。
- 在同一会话里继续发消息。
结果:请求在本地准备阶段抛错,turn/end 的 reason 为 {"kind":"error","error":{"code":"TRANSPORT","message":"DeepSeek Messages transport failed"}},日志里出现 5 条 llm/retry,而没有任何 HTTP 请求发出。新会话不受影响,因为它不引用这些附件。
根因:内容寻址的附件对象一旦缺失,就会被兜底 catch 误报成传输失败
两个点位的组合造成了这个误导:附件层抛出精确的 ATTACHMENT_NOT_FOUND,适配器的兜底 catch 把请求准备阶段的任何非 LlmError 一律当成传输失败包成 TRANSPORT。真正的错误码其实被 cause 完整保留了,只是没有出现在用户可见的那一层。
真实抛错点(packages/attachment/attachment-local/src/store.ts:443):
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND')
误报点(packages/llm/llm-deepseek/src/adapter.ts:66):
throw new LlmError('DeepSeek Messages transport failed', 'TRANSPORT', { cause: error })
注意 cause 是被保留了({ cause: error })——也就是说 ATTACHMENT_NOT_FOUND 并没有丢,只是被埋在了外层那句话下面。这一点既是问题所在,也是最小修法的落点:把 cause 的 code 透出来,排查就不会再被引向网络(#7834)。
放大机制在 packages/llm/llm-deepseek/src/images.ts 的 prepareImages:它每次请求都要重读会话里引用的全部图片(并按 attachmentId 去重缓存)。所以:
- 一个缺失对象 → 该会话的每个请求都失败,而不是只失败一次;
- 新会话不引用这些附件 → 完全正常;
- 由于
TRANSPORT在默认可重试集合里,每个失败 step 还会白白重试 5 次。
为什么这比「报错难懂」严重得多:附件是内容寻址的,路径形如 $DSH_HOME/attachments/v1/objects/<sha256 前两位>/<sha256>,文件名就是内容哈希。一旦被删且没有备份,用户无法自行重建——这条会话将永久无法再发消息。这是严重度论证的关键。同一个道理也适用于「附件库清理」:清理动作会带上一个隐藏后果,即删掉某个对象就废掉引用它的活跃会话。
修法一:让失败按附件自己的身份上报
第一步是小改:在读取点翻译附件错误,保留附件自己的 code,并在消息里点名是哪个附件。 这样报错准确、重试消失,但会话仍然会失败——它解决的是「误导」,不是「砖化」。
补丁落在 llm-deepseek/src/images.ts:新增一个 readRequestImage 包装,把 AttachmentError 翻译成携带原 code 的 LlmError:
async function readRequestImage(
attachments: AttachmentStore, ref: ImageAttachmentRef, target: ImageRequestTarget, signal: AbortSignal,
): Promise<RequestImageAttachment> {
try {
return await attachments.readImageRequest(ref, target, signal)
} catch (error) {
if (!(error instanceof AttachmentError)) throw error
throw new LlmError(
`DeepSeek Messages could not prepare image attachment ${ref.name ?? String(ref.attachmentId)}: ${error.message}`,
error.code,
{ cause: error },
)
}
}
调用点相应改成先算 target 再走包装:
if (!versions.has(ref.attachmentId)) {
const target = resolveRequestImageTarget(model, ref)
versions.set(ref.attachmentId, await readRequestImage(attachments, ref, target, signal))
}
上报者同时补了一个断言测试,验证三件事一起成立:code 为 ATTACHMENT_NOT_FOUND、消息里含附件名、cause 链保留原 AttachmentError,且 provider 未收到任何请求。验证结果:runtime.spec.ts + files.spec.ts 共 171 passed,store.spec.ts 12 passed,两处 tsc -b 均为 0 error(#7834)。
为什么保留原 code 就能顺带去掉重试:ATTACHMENT_NOT_FOUND 本身不在默认可重试集合内。把它从 TRANSPORT 恢复成它自己,重试策略自然就不会再对这种错误退避 5 次。
修法二:把不可用附件降级为占位文本,别砖掉整个会话
修法一让失败「报得准」,但没有改变行为:只要一个附件对象不在了,引用它的会话仍然每个请求都失败(resume 旧会话同样挂)。更彻底的做法是复用仓库里现成的「图片卸载成文本」机制,把不可用的那张图投影成一行占位文字,其余图片照常处理。
这套机制是现成的:packages/llm/llm/src/content.ts 里的 offloadedImageText(ref, access?)(占位文案形如 [image omitted to fit request image limits; <identity>. …])与 projectOffloadedImages(messages, placeholder)。上报者按同样的形状新增了两个函数(#7834):
export function unavailableImageText(ref: ImageAttachmentRef): string {
return `[image unavailable: its stored attachment object cannot be read; ${imageIdentity(ref)}. Ask the user to attach it again if needed.]`
}
export function projectUnavailableImages(
messages: readonly RequestMessage[],
unavailable: ReadonlySet<ImageAttachmentRef['attachmentId']>,
placeholder: (ref: ImageAttachmentRef) => string,
): readonly RequestMessage[] {
if (unavailable.size === 0) return messages
return messages.map((message) => {
const content = replaceUnavailableImages(message.content, unavailable, placeholder)
return content === message.content ? message : { ...message, content }
})
}
prepareImages 相应改成「读不出字节就记为不可用」,最后统一投影:
const unavailable = new Set<ImageAttachmentRef['attachmentId']>()
for (const message of messages) {
for (const ref of imageRefs(message.content)) {
if (versions.has(ref.attachmentId) || unavailable.has(ref.attachmentId)) continue
const target = resolveRequestImageTarget(model, ref)
const version = await readRequestImage(attachments, ref, target, signal)
if (version === undefined) unavailable.add(ref.attachmentId)
else versions.set(ref.attachmentId, version)
}
}
const retained = projectUnavailableImages(messages, unavailable, unavailableImageText)
assertImagesFit(retained, versions, connection, 'raw')
而「哪些错误算不可用」被收窄成一个白名单,只覆盖「存储字节产不出来」这一种:
const UNAVAILABLE_ATTACHMENT_CODES: ReadonlySet<AttachmentErrorCode> = new Set([
'ATTACHMENT_NOT_FOUND',
'ATTACHMENT_READ_FAILED',
'ATTACHMENT_CORRUPT',
])
这条边界很重要:只有「存储字节取不出来」才降级;其它附件错误仍以自身的 code 失败并在消息里点名附件(例如 INVALID_ATTACHMENT_REF),provider 不支持图片投影、超出限额等真故障也照抛——避免把真问题一起吞掉。
两个性质要记清楚:
- 投影是「请求内」的:会话日志保留原始引用,只有这一次请求的内容被投影。这与 text-only 模型的
projectImagesForTextModel是同一性质,不是持久改写。也就是说,用户之后重新附上正确的图,历史里的引用依然有效。 - 效果是「图变成一行说明文字」:删除附件对象最多让那张图在被投影成
[image unavailable: …],会话继续可用;不再出现「一个对象缺失 → 引用它的活跃会话报废」。
上报者给出的验证:content.spec.ts + runtime.spec.ts + files.spec.ts 全绿(36 / 159 / 13),tsc -b 两处与 oxlint 均 0 问题;并用产物自证——重建后 llm/lib/index.js 含 image unavailable 与 projectUnavailableImages(sha256 从 9132c8a8… 变为 7eac2052…),llm-deepseek/lib/index.js 含 ATTACHMENT_NOT_FOUND(c729db70… → b65bb2d1…),两份产物 node --check 通过(#7834)。
排查与注意事项
-
先看 HTTP 请求数,再看错误码。
TRANSPORT+ 零请求 = 本地准备阶段失败;这是一条能一步排除网络问题的判据,务必放在排查最前面。 -
把
cause翻出来。 真实错误码一直在LlmError.cause里(ATTACHMENT_NOT_FOUND),只是没被渲染到用户可见的层面。无论你打不打补丁,排查时都应该先读 cause。 -
附件是内容寻址的,删了就是删了。
$DSH_HOME/attachments/v1/objects/<sha256 前两位>/<sha256>的路径里没有可反推的原始信息,没有备份就无法重建;引用它的会话因此永久不可用。清理附件库之前请先确认没有活跃会话在引用。 -
自助出口应该是「丢弃该引用后继续」,而不是「整条会话不可用」。 既然
cause里已经有准确的 code,完全可以据此明确告知「哪个附件丢了」并允许丢弃该引用继续对话——这也是修法二在做的方向。 -
版本提醒:上报在
0.1.7-rc.2(含桌面端内置运行时)上复现,那已是当时的最新版,无需先升级。另外别把核换成 npm 的latest(仍停在更旧的0.1.5-rc.3),要用:bashnpm i -g @deepseek-ai/dsh@next -
两套补丁是递进关系,不是二选一。 修法一(保留原 code)让报错说实话、顺带消掉无谓重试;修法二(降级为占位文本)才真正消除「会话砖化」。只做前者,用户还是会看到会话发不出消息;只做后者,报错仍可能指向错误的方向——建议一起落。
来源:
这类故障给了一个很实用的教训:报错码会把人带到错误的地方,而真正能救命的是「这次请求到底有没有发出去」这种可观测事实。 如果你希望本地环境里的状态变化(插件增删、版本更新、配置变更)有迹可查,可以装一个 DSH Plugin Hub——DeepSeek Harness 桌面端内置的官方插件市场,用于浏览、安装、卸载与更新插件,并集中展示插件版本与更新时间,方便你在排查「环境里最近动了什么」时一眼看到变化:

把环境变动集中管起来,比事后从日志里倒推要省事得多。
常见问题
先别往网络上想。判断方法只有一个:看这次失败有没有发出 HTTP 请求。本问题的实测特征是**HTTP 请求数为 0**、每次尝试 6–20ms 就失败(纯本地错误、没有网络往返),却仍然被退避重试 5 次。出现「TRANSPORT + 零请求」这个组合,基本可以直接排除网络、代理、证书与鉴权。
因为 prepareImages 在**每次请求**都会重新读取会话里引用的全部图片。只要历史里还有指向那个已删除对象的 attachmentId,解析就会抛 ATTACHMENT_NOT_FOUND,于是这条会话的每个请求都在本地准备阶段失败。新会话不受影响,因为它不引用这些附件。
基本不能。附件是**内容寻址**存储的:路径形如 $DSH_HOME/attachments/v1/objects/<sha256 前两位>/<sha256>,文件名就是内容的哈希。删掉且没有备份时,无法从路径或会话历史反推出原始字节去重建——这也是为什么这个问题比「报错难懂」严重得多:它等于让这条会话**永久无法再发消息**。
因为兜底 catch 把它包成了 TRANSPORT,而 TRANSPORT 在默认重试集合里,于是每个失败 step 都会退避重试 5 次(0.5s→8s 的 llm/retry 链)。而真实错误码 ATTACHMENT_NOT_FOUND 本身**不在**默认可重试集合内——也就是说,只要让失败按附件自己的身份上报,重试就不会发生。对一个「本地文件不存在」的错误重试 5 次,是纯粹的延迟。
截至本文所引报告,这两处仍在最新版(含桌面端内置运行时)上:抛错点在 attachment-local/src/store.ts:443,误报点在 llm-deepseek/src/adapter.ts:66。上报者给了两套本地验证过的补丁:①(小改)让失败保留 ATTACHMENT_NOT_FOUND 并点名附件,报错准但会话仍会失败;②(行为改动)把「读不出存储字节」的附件降级为占位文本,让请求照常发出——这才是真正解决「会话报废」的那一步。注意别把核换成 npm 的 latest(仍停在更旧的 0.1.5-rc.3),要用 npm i -g @deepseek-ai/dsh@next。
相关术语
- 内容寻址存储(content-addressed)
- 附件对象不是按名字保存,而是按内容哈希保存:`$DSH_HOME/attachments/v1/objects/<sha256 前两位>/<sha256>`。会话历史只保留 `attachmentId` 引用。好处是天然去重、不可篡改;代价是——一旦对象被删除且没有备份,就无法从引用重建原始字节,引用它的会话也就永久无法再读取这张图。— https://github.com/deepseek-ai/deepseek-harness/discussions/7834
- ATTACHMENT_NOT_FOUND 与 TRANSPORT
- `ATTACHMENT_NOT_FOUND` 是附件层(`attachment-local`)在底层文件系统报 `ENOENT` 时抛出的自有错误码,含义精确、且**不在**默认可重试集合内。`TRANSPORT` 是 LLM 适配器的兜底错误码:当请求准备阶段抛出任何非 `LlmError` 时,它一律包成 `TRANSPORT`。于是「本地附件缺失」被贴上了「传输失败」的标签,把排查引向网络。— https://github.com/deepseek-ai/deepseek-harness/discussions/7834
- prepareImages
- `llm-deepseek/src/images.ts` 里把请求历史中的图片引用解析为可发送形态的函数,每次请求都会重新读取会话里引用的全部图片并按 `attachmentId` 去重缓存。正因为它按「每次请求」重读,一个缺失对象才会让该会话的**每一个**请求都失败,而不是只失败一次。— https://github.com/deepseek-ai/deepseek-harness/discussions/7834
- 图片卸载投影(offloaded image projection)
- 仓库里已有的机制:当图片超出请求的图像限额时,把它替换成一行确定性的占位文字(`offloadedImageText`)再做投影(`projectOffloadedImages`),而不是让请求失败。这套机制后来被复用来处理「附件读不出来」:新增 `unavailableImageText` 与 `projectUnavailableImages`,投影只在**请求内**生效,持久日志里的原始引用保持不变。— https://github.com/deepseek-ai/deepseek-harness/discussions/7834
来源
- #7834 — [Bug] 删除附件对象后会话每个请求都失败,并被误报为 "DeepSeek Messages transport failed"(附修复补丁)· deepseek-ai(GitHub Discussions)