[Bug] 删除附件对象后会话每个请求都失败,并被误报为 "DeepSeek Messages transport failed"(附修复补丁) #7834
Replies: 3 comments
补充建议:把"附件不可用"降级成文本占位,而不是让整轮请求失败上面的补丁让失败报得准,但没有改变行为:只要会话历史里引用的某个附件对象不在了, 更彻底的做法是复用仓库里现成的"图片卸载成文本"机制,把不可用的那张图投影成一行占位文字,其余图片照常处理:
说明:我们本机只落地了上面那条报错补丁(小改动);这条降级属于行为改变,交给维护者判断是否值得做。 |
已按上面的思路落地:附件不可用降级为占位文本(附实现补丁与验证)我们把这条件议在本机实现了,做法就是复用"图片卸载成文本"的投影机制,不新造机制:
边界与性质:
验证:
效果:删除附件对象最多让那张图在请求里变成一行说明文字,会话继续可用;不再出现"一个对象缺失 → 引用它的活跃会话报废"。 diff --git a/packages/llm/llm-deepseek/src/images.ts b/packages/llm/llm-deepseek/src/images.ts
index 01c8a654cc..fbae9f2ec8 100644
--- a/packages/llm/llm-deepseek/src/images.ts
+++ b/packages/llm/llm-deepseek/src/images.ts
@@ -1,8 +1,8 @@
/** Deterministic Messages image preparation for Files references and bounded inline fallback. */
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
-import type { AttachmentStore, ImageAttachmentRef, ImageRequestTarget, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
-import { contentHasImage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, offloadedImageText, projectOffloadedImages, requiredImageOffload } from '@deepseek-ai/dsh-llm'
+import type { AttachmentErrorCode, AttachmentStore, ImageAttachmentRef, ImageRequestTarget, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
+import { contentHasImage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, offloadedImageText, projectOffloadedImages, projectUnavailableImages, requiredImageOffload, unavailableImageText } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, ImageAttachmentAccessResolver, RequestMessage } from '@deepseek-ai/dsh-llm'
import type { DeepSeekConnectionOptions as Connection } from './types.ts'
import { resolveRequestImageTarget } from './request-pricing.ts'
@@ -27,14 +27,28 @@ function* imageRefs(blocks: readonly ContentBlock[]): Generator<ImageAttachmentR
}
}
-/** Read one retained image version, keeping an attachment failure's own code and naming its object. */
+/** Attachment codes naming stored bytes this request cannot produce, so the occurrence degrades to text. */
+const UNAVAILABLE_ATTACHMENT_CODES: ReadonlySet<AttachmentErrorCode> = new Set([
+ 'ATTACHMENT_NOT_FOUND',
+ 'ATTACHMENT_READ_FAILED',
+ 'ATTACHMENT_CORRUPT',
+])
+
+/** Read one retained image version; unreadable stored bytes yield undefined so the remaining request still sends.
+ * @param attachments - mounted attachment store.
+ * @param ref - durable normalized attachment for this occurrence.
+ * @param target - request-image dimensions and byte budget for this occurrence.
+ * @param signal - request cancellation.
+ * @returns the prepared version, or undefined when its stored object cannot be read.
+ */
async function readRequestImage(
attachments: AttachmentStore, ref: ImageAttachmentRef, target: ImageRequestTarget, signal: AbortSignal,
-): Promise<RequestImageAttachment> {
+): Promise<RequestImageAttachment | undefined> {
try {
return await attachments.readImageRequest(ref, target, signal)
} catch (error) {
if (!(error instanceof AttachmentError)) throw error
+ if (UNAVAILABLE_ATTACHMENT_CODES.has(error.code)) return undefined
throw new LlmError(
`DeepSeek Messages could not prepare image attachment ${ref.name ?? String(ref.attachmentId)}: ${error.message}`,
error.code,
@@ -66,16 +80,19 @@ export async function prepareImages(
if (messages.some(message => message.role !== 'user' && message.role !== 'tool' && contentHasImage(message.content))) {
throw new LlmError('DeepSeek Messages supports images only in user messages and tool results', 'UNSUPPORTED_CONTENT')
}
+ const unavailable = new Set<ImageAttachmentRef['attachmentId']>()
for (const message of messages) {
for (const ref of imageRefs(message.content)) {
- if (!versions.has(ref.attachmentId)) {
- const target = resolveRequestImageTarget(model, ref)
- versions.set(ref.attachmentId, await readRequestImage(attachments, ref, target, signal))
- }
+ 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)
}
}
- assertImagesFit(messages, versions, connection, 'raw')
- return { messages, versions }
+ const retained = projectUnavailableImages(messages, unavailable, unavailableImageText)
+ assertImagesFit(retained, versions, connection, 'raw')
+ return { messages: retained, versions }
}
/** Require logged offload before retrying images that exceed the inline budget.
diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts
index cecbfba938..c8e2712fea 100644
--- a/packages/llm/llm/src/content.ts
+++ b/packages/llm/llm/src/content.ts
@@ -116,6 +116,15 @@ export function offloadedImageText(
return `[${identity}${normalizedAccessText(ref, access)}]`
}
+/**
+ * Stable per-image placeholder for an attachment whose stored object cannot be read.
+ * @param ref - durable normalized attachment whose bytes this request cannot produce.
+ * @returns deterministic identity and the recovery instruction for the user.
+ */
+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.]`
+}
+
/**
* True when typed model content contains an image block. This is the one image
* walk shared by every image policy (capability gating, text-only
@@ -240,6 +249,45 @@ function replaceOffloadedImages(
return next ?? blocks as ContentBlock[]
}
+/** Replace every occurrence whose stored bytes are unavailable with its placeholder. */
+function replaceUnavailableImages(
+ blocks: readonly ContentBlock[],
+ unavailable: ReadonlySet<ImageAttachmentRef['attachmentId']>,
+ placeholder: (ref: ImageAttachmentRef) => string,
+): ContentBlock[] {
+ let next: ContentBlock[] | undefined
+ for (const [index, block] of blocks.entries()) {
+ if (block.type === 'image' && unavailable.has(block.attachment.attachmentId)) {
+ next ??= blocks.slice(0, index)
+ next.push({ type: 'text', text: placeholder(block.attachment) })
+ continue
+ }
+ next?.push(block)
+ }
+ return next ?? blocks as ContentBlock[]
+}
+
+/**
+ * Project occurrences whose stored bytes are unavailable into deterministic
+ * text for one request. The unavailable set is request-local: the durable
+ * history keeps its reference, and only this request's content is projected.
+ * @param messages - derived request history.
+ * @param unavailable - attachment ids whose stored object this request cannot read.
+ * @param placeholder - build the model-visible replacement for one unavailable attachment.
+ * @returns the original list when nothing is unavailable, otherwise shallow message copies with placeholders.
+ */
+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 }
+ })
+}
+
/**
* Project the surface's offloaded occurrences into deterministic text for one
* request. The offloaded set is a durable surface fact, so every route sends |
你给的两个位置我都在当前最新版上核到了——逐字一致1. 真实抛错点与误报点// 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 })⇒ 你写的"真实原因是本地附件缺失、却被包成 2. 你"HTTP 请求数为 0"这条证据很关键它把"是不是网络问题"一步排掉了。建议放在正文最前面——否则 同时请保留"重试也白费"这一点: 3. 两点建议
4. 版本提醒你在 一条边界我确认的是这两处代码在最新版上的现状(抛错点、包装点、 |
Uh oh!
There was an error while loading. Please reload this page.
Summary
会话历史里引用的图片附件对象一旦在磁盘上缺失(例如
$DSH_HOME/attachments/v1/objects/下的对象被用户清理或误删),该会话此后每一个模型请求都会失败,报DeepSeek Messages transport failed(code: TRANSPORT),默认重试策略再退避重试 5 次且每次同样失败——会话彻底无法继续发消息。除了"会话砖掉",这个报错还会把排查带偏:真实原因是本地附件缺失,与网络无关,但错误指向传输层(本次实测 HTTP 请求数为 0)。
Reproduction
$DSH_HOME/attachments/v1/objects/<sha256 前两位>/<sha256>,会话历史以attachmentId引用)。结果:请求在本地准备阶段就抛错,
turn/end的 reason 为{"kind":"error","error":{"code":"TRANSPORT","message":"DeepSeek Messages transport failed"}};每个失败 step 在会话日志里留下 5 条llm/retry(0.5s→8s 退避),而没有任何 HTTP 请求发出。Current behavior
packages/attachment/attachment-local/src/store.ts:443→AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND')。packages/llm/llm-deepseek/src/adapter.ts:66的兜底 catch 包成TRANSPORT(兜底把请求准备阶段的非LlmError一律当作传输失败)。packages/llm/llm-deepseek/src/images.ts的prepareImages每次请求都要重读会话里的全部图片,所以一个缺失对象会让该会话的每个请求都失败(新会话不受影响,因为它不引用这些附件)。Expected behavior
附件缺失应按其自身身份上报:保留
ATTACHMENT_NOT_FOUND(该 code 不在默认重试集合内,本就不应重试),并在消息里点名是哪个附件;不要伪装成TRANSPORT。Environment
dsh-v0.1.7-rc.2(master commit
477b4f4205),macOS,providerdeepseek-official。Proposed fix(本地补丁,已跑测试)
在读取点翻译附件失败,保留附件自己的 code 并点名附件:
验证(均在
477b4f4205之上):vitest run packages/llm/llm-deepseek/tests/runtime.spec.ts packages/llm/llm-deepseek/tests/files.spec.ts→ 171 passed。新增用例断言:code 为ATTACHMENT_NOT_FOUND、消息含附件名、cause链保留原AttachmentError,且 provider 未收到任何请求。vitest run packages/attachment/attachment-local/tests/store.spec.ts→ 12 passed。tsc -b packages/llm/llm-deepseek与tsc -b tsconfig.host.json均为 0 error。补充:CONTRIBUTING 说明当前暂不接受外部 PR,所以先以 Discussion 形式反馈;如果之后开放外部贡献,我们可以直接提供分支/PR。
All reactions