DeepSeek Harness 报 transport failed 不是网络问题:附件缺失砖掉会话的定位与修复

故障排查发布于 2026-10-03作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSHTRANSPORTATTACHMENT_NOT_FOUND附件缺失内容寻址prepareImages会话砖化
会话历史引用的图片附件对象一旦被删除,该会话此后每个请求都会失败:报 transport failed、退避重试 5 次,但 HTTP 请求数为 0。真因是 ATTACHMENT_NOT_FOUND 被适配器的兜底 catch 误报成传输失败。

症状很骗人:会话每次发消息都失败,报 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)。

按下面四步确认:

  1. 看错误码与文案:code: TRANSPORT,message 为 DeepSeek Messages transport failed。
  2. 看有没有发出请求:如果 HTTP 请求数为 0,就说明失败发生在本地准备阶段。这是决定性的判据。
  3. 看失败有多快:本问题每次尝试 6–20ms 即失败(纯本地错误,无网络往返)。
  4. 看重试链:每个失败 step 在会话日志里留下 5 条 llm/retry,退避 0.5s→8s——重试同样失败。对一个「本地文件不存在」的错误重试 5 次,是纯粹的延迟。

最小复现只有三步(#7834):

  1. 在一个会话里让模型读过图——附件会被复制进 $DSH_HOME/attachments/v1/objects/<sha256 前两位>/<sha256>,会话历史以 attachmentId 引用。
  2. 删除这些对象文件。
  3. 在同一会话里继续发消息。

结果:请求在本地准备阶段抛错,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):

ts
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):

ts
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:

ts
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 再走包装:

ts
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):

ts
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 相应改成「读不出字节就记为不可用」,最后统一投影:

ts
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')

而「哪些错误算不可用」被收窄成一个白名单,只覆盖「存储字节产不出来」这一种:

ts
const UNAVAILABLE_ATTACHMENT_CODES: ReadonlySet<AttachmentErrorCode> = new Set([
  'ATTACHMENT_NOT_FOUND',
  'ATTACHMENT_READ_FAILED',
  'ATTACHMENT_CORRUPT',
])

这条边界很重要:只有「存储字节取不出来」才降级;其它附件错误仍以自身的 code 失败并在消息里点名附件(例如 INVALID_ATTACHMENT_REF),provider 不支持图片投影、超出限额等真故障也照抛——避免把真问题一起吞掉。

两个性质要记清楚:

  1. 投影是「请求内」的:会话日志保留原始引用,只有这一次请求的内容被投影。这与 text-only 模型的 projectImagesForTextModel 是同一性质,不是持久改写。也就是说,用户之后重新附上正确的图,历史里的引用依然有效。
  2. 效果是「图变成一行说明文字」:删除附件对象最多让那张图在被投影成 [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),要用:

    bash
    npm i -g @deepseek-ai/dsh@next
    
  • 两套补丁是递进关系,不是二选一。 修法一(保留原 code)让报错说实话、顺带消掉无谓重试;修法二(降级为占位文本)才真正消除「会话砖化」。只做前者,用户还是会看到会话发不出消息;只做后者,报错仍可能指向错误的方向——建议一起落。

来源:


这类故障给了一个很实用的教训:报错码会把人带到错误的地方,而真正能救命的是「这次请求到底有没有发出去」这种可观测事实。 如果你希望本地环境里的状态变化(插件增删、版本更新、配置变更)有迹可查,可以装一个 DSH Plugin Hub——DeepSeek Harness 桌面端内置的官方插件市场,用于浏览、安装、卸载与更新插件,并集中展示插件版本与更新时间,方便你在排查「环境里最近动了什么」时一眼看到变化:

DSH Plugin Hub 插件市场:展示插件版本、已安装与可更新徽标、分类与主题标签,以及安装/卸载操作

把环境变动集中管起来,比事后从日志里倒推要省事得多。

常见问题

报 DeepSeek Messages transport failed,是网络/代理出问题了吗?

先别往网络上想。判断方法只有一个:看这次失败有没有发出 HTTP 请求。本问题的实测特征是**HTTP 请求数为 0**、每次尝试 6–20ms 就失败(纯本地错误、没有网络往返),却仍然被退避重试 5 次。出现「TRANSPORT + 零请求」这个组合,基本可以直接排除网络、代理、证书与鉴权。

为什么删掉一个附件文件,整条会话都发不出消息了?

因为 prepareImages 在**每次请求**都会重新读取会话里引用的全部图片。只要历史里还有指向那个已删除对象的 attachmentId,解析就会抛 ATTACHMENT_NOT_FOUND,于是这条会话的每个请求都在本地准备阶段失败。新会话不受影响,因为它不引用这些附件。

附件删了还能找回来吗?

基本不能。附件是**内容寻址**存储的:路径形如 $DSH_HOME/attachments/v1/objects/<sha256 前两位>/<sha256>,文件名就是内容的哈希。删掉且没有备份时,无法从路径或会话历史反推出原始字节去重建——这也是为什么这个问题比「报错难懂」严重得多:它等于让这条会话**永久无法再发消息**。

为什么这个错误会被重试 5 次?

因为兜底 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

来源