dsh-im-ops/docs/方案/入站图片非视觉模型文件回退方案.md

14 KiB
Raw Blame History

入站图片非视觉模型文件回退方案

状态:已实施(P0);Ubuntu CI、包产物验证与真实宿主非视觉模型端到端验收通过

日期:2026-09-01

关联:出站图片原生呈现落地方案、渠道原生能力建设方案

1. 结论

IM 对话中,非视觉模型收到图片时当前是准入即硬失败:图片字节在宿主 session.prompt 准入检查处被整体丢弃,LLM 收不到任何内容;而发送文件(如 zip)却能成功。本方案不改宿主、不新建媒体框架,只做一件事:

当宿主以 MODEL_DOES_NOT_SUPPORT_IMAGES 拒绝带图片的 prompt 时,dsh-im 自动把同一批图片字节转入现有入站文件管线(落盘到 Session 工作区 + <dsh_im_files> 清单),替换掉多模态内容块后重发一次 prompt。

效果:

  1. 非视觉模型不再对图片报错;图片以工作区文件形式到达 Agent,Agent 可用 run_code/pwsh(读字节、EXIF、OCR、图像库等)"以其他方式识图"。
  2. 视觉模型行为完全不变(继续原生多模态内容块)。
  3. 其他图片错误(超大、格式不支持、下载失败等)继续走现有报错,不回退。
  4. 收口在 harness-client.mjs 的 ask() 单点,九渠道 Bridge 无需各自改动。

与出站方案同样的原则:复用现有 inbound-file 生命周期(落盘、清单、turn 结束清理),不新增配置开关、不新增产物类型。

1.1 实施结果(2026-09-01)

本方案已按上述最小边界落地:

  • 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 新增 9 个用例:转换/判定辅助函数、完整回退(复用 rpcId、字节一致、清单正确、turn 后清理)、混合消息合并清单、非模型原因不回退、落盘失败保留原错误、落盘期间保留调用方取消语义、重试失败不再重试、纯文本路径不变。
  • 回归:Windows 本机以逐文件直跑方式执行全部 137 个测试文件,失败集与改动前基线(git worktree 对照)完全一致(23 个均为仓库既有的 Windows/沙箱环境性失败,CI 在 ubuntu 上通过);npm run build 通过;verify-package 的可执行位检查在 Windows 上为既有环境性失败。
  • 端到端:在真实 DSH 宿主进程内通过生产 apiProxy 与 fileIngressExecutor 链路向文本模型注入入站 PNG,验证了宿主拒绝、图片落盘、纯文本重试与模型工具分析的完整路径。

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.mjs ask() 内 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-apiproxy prompt 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. 设计原则

  1. 图片字节必须到达 Session 工作区,而不是被丢弃——这是"非视觉模型以其他方式识图"的前提。
  2. 不修改宿主。@deepseek-ai/dsh-host-apiproxy 是安装的 npm 包,改了会被升级覆盖;宿主侧改进作为上游建议另行反馈(见 §7)。
  3. 复用入站文件管线,不创建第二条图片生命周期:同样的目录结构、权限(0600)、turn 结束清理语义。
  4. 单点收口:回退逻辑只存在于 harness-client.mjs ask() 内,所有渠道自动获益。
  5. 不新增配置开关:对 MODEL_DOES_NOT_SUPPORT_IMAGES 的回退严格优于硬失败,默认启用;其余错误原因维持现有行为。
  6. 至多重试一次,防止错误码异常时的循环。

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

  1. 保留 append 文件清单之前的 basePrompt(当前代码在 ask() 内先 prompt = appendInboundFilesToPrompt(prompt, stagedInboundFiles) 再发 prompt;回退需从原始 prompt 重建,避免出现两个 <dsh_im_files> 块)。
  2. 新 content = 原 text 内容块 + 回退提示文本 + 合并后的单个 <dsh_im_files> 清单(原 staged 文件 + 图片文件,一次 appendInboundFilesToPrompt 生成)。
  3. 提示文本(走 t(),中英双语):
当前会话模型不支持直接接收图片输入。用户发送的图片已作为文件保存到工作区(见下方清单)。
请使用可用工具分析这些图片文件后回答,例如 run_code/pwsh 读取字节、解析元数据、
调用图像处理或 OCR 库;不要假设自己能直接看到图片内容。
  1. 再次调用 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)

  1. 首次 session.prompt 返回 attachment-error/MODEL_DOES_NOT_SUPPORT_IMAGES → 断言:调用了第二次 prompt;第二次 content 无 image part;含 <dsh_im_files> 清单与回退提示文本;文件以正确扩展名写入 executor 收到的 sources;仅重试一次。
  2. 混合消息(图片+文件)→ 断言只出现一个合并后的 <dsh_im_files> 块。
  3. 其他拒绝原因(IMAGE_TOO_LARGE、TOO_MANY_IMAGES 等)→ 不回退,原错误冒泡,用户文案不变。
  4. 落盘失败时保留原始模型拒绝错误;落盘期间取消时保留调用方的取消原因。
  5. 回退 prompt 再次失败 → 仅报错一次,不继续重试。
  6. 纯文本/纯文件消息 → 不触发任何回退路径(回归)。
  7. turn 正常结束后 staged 图片文件被 cleanup(复用 test/inbound-file.test.mjs 的断言模式)。

6.2 回归与手工验收

  • npm run check 全量通过。
  • 真实宿主端到端:文本模型收到图片后走完“模态拒绝 → 工作区文件落盘 → 纯文本重试 → 工具分析”链路并正常回复;视觉模型与普通文件消息的原有行为保持不变。

7. 上游反馈(非本仓库范围)

向 DSH 宿主(@deepseek-ai/dsh-host-apiproxy)反馈两个改进建议:

  1. prompt 准入遇到非视觉模型时,将 image part 降级为持久化附件路径文本(对应方案 B),使所有 IM/API 客户端天然获得该能力;
  2. 模型目录响应增加 inputModalities(对应方案 C),让客户端可预判并在 /models 中展示视觉能力标记。

8. 分期

  • P0(本期):方案 A 全部内容 + 单测 + 真机验收。
  • P1:回退触发时的用户可见一次性提示;<dsh_im_files> 清单 description 中补充"非视觉会话请用工具分析图片"的指引(依赖 C 落地后可精化为按模型分流)。
  • P2:上游 B/C 跟进结果回流后,评估是否保留响应式回退作为兜底(预期保留)。