14 KiB
入站图片非视觉模型文件回退方案
状态:已实施(P0);Windows 本机全量回归通过,真机验收待各渠道补做
日期:2026-06-14
1. 结论
IM 对话中,非视觉模型收到图片时当前是准入即硬失败:图片字节在宿主 session.prompt 准入检查处被整体丢弃,LLM 收不到任何内容;而发送文件(如 zip)却能成功。本方案不改宿主、不新建媒体框架,只做一件事:
当宿主以 MODEL_DOES_NOT_SUPPORT_IMAGES 拒绝带图片的 prompt 时,dsh-im 自动把同一批图片字节转入现有入站文件管线(落盘到 Session 工作区 + <dsh_im_files> 清单),替换掉多模态内容块后重发一次 prompt。
效果:
- 非视觉模型不再对图片报错;图片以工作区文件形式到达 Agent,Agent 可用
run_code/pwsh(读字节、EXIF、OCR、图像库等)"以其他方式识图"。 - 视觉模型行为完全不变(继续原生多模态内容块)。
- 其他图片错误(超大、格式不支持、下载失败等)继续走现有报错,不回退。
- 收口在
harness-client.mjs的ask()单点,九渠道 Bridge 无需各自改动。
与出站方案同样的原则:复用现有 inbound-file 生命周期(落盘、清单、turn 结束清理),不新增配置开关、不新增产物类型。
1.1 实施结果(2026-06-14)
本方案已按上述最小边界落地:
src/channels/shared/image-prompt.mjs新增IMAGE_FILE_FALLBACK_PROMPT(模型侧指引,含英文翻译)、imageFileSourcesFromContent()(图片内容块 → 文件源,含扩展名映射与文件名清洗)、contentWithoutImages()、isModelImageRejection()。src/channels/shared/harness-client.mjs的ask()将原入站文件落盘逻辑抽为#stageWorkspaceFiles();session.prompt被以MODEL_DOES_NOT_SUPPORT_IMAGES拒绝且 content 含图片块时,把同一批字节经该管线落盘、重建纯文本 prompt(原文本 + 指引 + 合并后的单个<dsh_im_files>清单)并复用同一promptRpcId重试一次;重试不再回退,落盘失败时保留原始错误文案。staged 批次统一进入既有 turn 结束清理。test/image-fallback.test.mjs新增 8 个用例:转换/判定辅助函数、完整回退(复用 rpcId、字节一致、清单正确、turn 后清理)、混合消息合并清单、非模型原因不回退、落盘失败保留原错误、重试失败不再重试、纯文本路径不变。- 回归:Windows 本机以逐文件直跑方式执行全部 137 个测试文件,失败集与改动前基线(git worktree 对照)完全一致(23 个均为仓库既有的 Windows/沙箱环境性失败,CI 在 ubuntu 上通过);
npm run build通过;verify-package的可执行位检查在 Windows 上为既有环境性失败。
2. 现状与根因
2.1 入站文件链路(zip 能成功)
message.files ──► harness.ask(…, { files })
└► fileIngressExecutor(宿主进程内执行)
└► stageInboundFiles() 写入 <workspace>/.dsh-im/inbound/turn-XXXX/NN-名字(0600)
└► appendInboundFilesToPrompt() 在 prompt 末尾追加 <dsh_im_files> JSON 清单(工作区相对路径)
└► session.prompt(纯文本内容块)
- 代码:
src/channels/shared/inbound-file.mjs(stageInboundFiles、appendInboundFilesToPrompt)、src/channels/shared/harness-client.mjsask() 内inboundFiles分支、plugin-src/host/harness-session-coordinator.mjs的createFileIngressExecutor。 - 纯文本 prompt 不依赖模型输入模态,任何模型都能收到路径清单,再用工具处理文件。
2.2 入站图片链路(被拦截)
message.images ──► promptContentForMessage()(Bridge 内,含大小/数量/魔数校验)
└► content = [{type:'text'}, {type:'image', mediaType, data: base64}]
└► session.prompt RPC
└► 宿主 dsh-host-apiproxy prompt 准入:
resolveModelInfo(provider, model).inputModalities 不含 'image'
──► 返回 attachment-error / MODEL_DOES_NOT_SUPPORT_IMAGES
(发生在 createUserMessage 之前,未产生任何 durable 消息)
└► HarnessRpcError 冒泡回 Bridge
└► imagePromptUserMessage() 翻译为用户文案:
"当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。"
- 代码:
src/channels/shared/image-prompt.mjs(promptContentForMessage、HOST_ATTACHMENT_USER_MESSAGES);宿主侧为@deepseek-ai/dsh-host-apiproxyprompt handler(hasImage→resolveModelInfo检查 →err)。 - 六个图片构建点(共享
text-harness-bridge.mjs及飞书/QQ/钉钉/微信/企业微信各自 Bridge)全部经harness.ask()收口,因此回退只需改一处。
2.3 不对称的根源
| 维度 | 文件(zip) | 图片 |
|---|---|---|
| 交付形态 | 工作区路径文本 | 多模态内容块 |
| 对模型能力的要求 | 无 | inputModalities 含 image |
| 失败时的降级 | 不适用 | 无(直接丢弃字节) |
另一个限制:session.models / llm.models 返回的模型目录只有 id/name/description/reasoning,不含输入模态,dsh-im 无法预判当前模型是否支持图片;唯一的信号就是宿主拒绝时的 MODEL_DOES_NOT_SUPPORT_IMAGES 错误码——这也正是回退的触发器。
3. 设计原则
- 图片字节必须到达 Session 工作区,而不是被丢弃——这是"非视觉模型以其他方式识图"的前提。
- 不修改宿主。
@deepseek-ai/dsh-host-apiproxy是安装的 npm 包,改了会被升级覆盖;宿主侧改进作为上游建议另行反馈(见 §7)。 - 复用入站文件管线,不创建第二条图片生命周期:同样的目录结构、权限(0600)、turn 结束清理语义。
- 单点收口:回退逻辑只存在于
harness-client.mjsask()内,所有渠道自动获益。 - 不新增配置开关:对
MODEL_DOES_NOT_SUPPORT_IMAGES的回退严格优于硬失败,默认启用;其余错误原因维持现有行为。 - 至多重试一次,防止错误码异常时的循环。
4. 方案设计
4.1 触发条件
在 ask() 中包装 session.prompt 调用,捕获 HarnessRpcError,当且仅当:
error.code === 'attachment-error'且error.details.reason === 'MODEL_DOES_NOT_SUPPORT_IMAGES'且- 本次
content含{type:'image'}内容块
进入回退;否则原样抛出。
4.2 图片 → 文件源转换
从被拒 content 的 image 内容块直接构造文件源(字节已在内存,无第二次网络下载):
// image-prompt.mjs 新增 helper(图片知识内聚;stageInboundFiles 无需改动)
const IMAGE_EXTENSIONS = new Map([
['image/png', '.png'],
['image/jpeg', '.jpg'],
['image/gif', '.gif'],
['image/webp', '.webp'],
]);
export function imageFileSourcesFromContent(content) {
return content
.filter((part) => part?.type === 'image')
.map((part, index) => ({
name: part.name ?? ('image-' + (index + 1) + (IMAGE_EXTENSIONS.get(part.mediaType) ?? '.img')),
mediaType: part.mediaType,
data: Buffer.from(part.data, 'base64'),
}));
}
stageInboundFiles的loadedFile()已接受{data: Buffer, name, mediaType}形态,无需改动即可落盘。storageName()已做文件名清洗;扩展名映射保证下游工具能按扩展名识别格式。- 大小限制天然满足:
promptContentForMessage已按单张 5MB / 总量 20MB / 20 张校验过,转换不放大。
4.3 重发 prompt
- 保留 append 文件清单之前的
basePrompt(当前代码在 ask() 内先prompt = appendInboundFilesToPrompt(prompt, stagedInboundFiles)再发 prompt;回退需从原始 prompt 重建,避免出现两个<dsh_im_files>块)。 - 新
content= 原 text 内容块 + 回退提示文本 + 合并后的单个<dsh_im_files>清单(原 staged 文件 + 图片文件,一次appendInboundFilesToPrompt生成)。 - 提示文本(走
t(),中英双语):
当前会话模型不支持直接接收图片输入。用户发送的图片已作为文件保存到工作区(见下方清单)。
请使用可用工具分析这些图片文件后回答,例如 run_code/pwsh 读取字节、解析元数据、
调用图像处理或 OCR 库;不要假设自己能直接看到图片内容。
- 再次调用
session.prompt(此时内容为纯文本,宿主不再走图片准入分支,也不受serializeImageAdmission串行化影响)。
4.4 生命周期与安全
- 幂等:宿主检查发生在
createUserMessage之前,被拒的 prompt 未入队、未产生 durable 消息,重发不会造成重复消息或空 turn。 - 清理:图片文件与现有入站文件同语义——turn 结束后由既有
stagedInboundFiles.cleanup()一并删除(回退实现需把图片 staged 结果并入同一清理集合;重试仍失败时立即清理)。 - 降级失败:若
#fileIngressExecutor不可用(inbound-file-ingress-unavailable)或落盘失败,则不重试,按现有链路向用户报错(保留MODEL_DOES_NOT_SUPPORT_IMAGES的既有文案作为兜底,可追加"图片转文件失败"说明)。 - 旧宿主兼容:错误码不存在时(旧版本宿主不检查模态或行为不同)回退不触发,行为与今天一致。
4.5 用户可见行为
- 回退成功后不再报错,Agent 正常处理并回复;可选(P1):通过现有
onUpdate流在开头推一条一次性提示"当前模型不支持直接识图,已将图片保存为工作区文件进行工具分析",让用户知情。 /models文案与HOST_ATTACHMENT_USER_MESSAGES映射保留:仅在回退自身失败时作为兜底呈现。
4.6 改动清单
| 文件 | 改动 | 量级 |
|---|---|---|
src/channels/shared/harness-client.mjs |
ask() 内 session.prompt 包回退:触发判断、保留 basePrompt、图片落盘、清单合并、重试一次、清理集合 |
~80 行 |
src/channels/shared/image-prompt.mjs |
新增 imageFileSourcesFromContent()、扩展名映射 |
~30 行 |
src/channels/shared/inbound-file.mjs |
(若需要)导出合并两个 staged 清单的 helper | ~10 行 |
src/channels/shared/i18n-en/*.mjs |
回退提示文本英文翻译 | 若干 |
test/image-fallback.test.mjs(新增)、test/inbound-file.test.mjs |
见 §6 | ~150 行 |
5. 备选方案对比
| 方案 | 说明 | 取舍 |
|---|---|---|
| A. 客户端响应式回退(本方案) | 收到 MODEL_DOES_NOT_SUPPORT_IMAGES 后转文件重发 |
✅ 不改宿主、单点收口、旧宿主兼容;代价是多一次被拒往返 |
| B. 宿主准入降级 | apiproxy 在准入处把 image part 换成附件路径文本而非拒绝 | 效果最好(所有 API 客户端获益),但改的是安装的 npm 包,升级即丢;建议作为上游 issue 反馈,dsh-im 不依赖它 |
C. 模型目录暴露 inputModalities |
宿主在 session.models/llm.models 增加模态字段 |
可让 dsh-im 预判、直接走文件路径省一次往返,/models 可加视觉标记;需宿主支持,旧宿主下仍靠 A 兜底;作为上游建议一并反馈 |
| D. 纯文案优化 | 不回退,只改报错文案教用户切换模型 | 不解决"非视觉模型识图可能性"被阻断的问题 |
推荐:本期实施 A;B、C 作为上游反馈(对 DSH 宿主仓库提 issue),A 在 C 落地后仍作为旧宿主兜底保留。
6. 测试与验收
6.1 单元测试(新增 test/image-fallback.test.mjs,fake rpc/executor)
- 首次
session.prompt返回attachment-error/MODEL_DOES_NOT_SUPPORT_IMAGES→ 断言:调用了第二次 prompt;第二次 content 无 image part;含<dsh_im_files>清单与回退提示文本;文件以正确扩展名写入 executor 收到的 sources;仅重试一次。 - 混合消息(图片+文件)→ 断言只出现一个合并后的
<dsh_im_files>块。 - 其他拒绝原因(
IMAGE_TOO_LARGE、TOO_MANY_IMAGES等)→ 不回退,原错误冒泡,用户文案不变。 - 重试再失败(如 executor 抛
inbound-file-ingress-unavailable)→ 报错且 staged 文件被清理。 - 纯文本/纯文件消息 → 不触发任何回退路径(回归)。
- turn 正常结束后 staged 图片文件被 cleanup(复用
test/inbound-file.test.mjs的断言模式)。
6.2 回归与手工验收
npm run check全量通过。- 真机:绑定非视觉模型的会话发一张图 → 收到 Agent 基于工具分析的回复而非报错;视觉模型会话发图 → 行为与现状一致(原生多模态);发 zip → 行为不变。
7. 上游反馈(非本仓库范围)
向 DSH 宿主(@deepseek-ai/dsh-host-apiproxy)反馈两个改进建议:
- prompt 准入遇到非视觉模型时,将 image part 降级为持久化附件路径文本(对应方案 B),使所有 IM/API 客户端天然获得该能力;
- 模型目录响应增加
inputModalities(对应方案 C),让客户端可预判并在/models中展示视觉能力标记。
8. 分期
- P0(本期):方案 A 全部内容 + 单测 + 真机验收。
- P1:回退触发时的用户可见一次性提示;
<dsh_im_files>清单 description 中补充"非视觉会话请用工具分析图片"的指引(依赖 C 落地后可精化为按模型分流)。 - P2:上游 B/C 跟进结果回流后,评估是否保留响应式回退作为兜底(预期保留)。