把一张截图拖进 DeepSeek harness 的对话框,进度条刚转一圈就弹出一行冷冰冰的红字——“当前模型不支持图片输入”,连请求都没发出去。这个场景我在给几个团队搭 agent 工作流的时候遇到过太多次:模型目录里那条能力声明写着纯文本,harness 的附件校验在组装请求前就把图片拦了下来,后面所有环节都没机会执行。这篇文章就专门讲这件事:为什么会被“硬性拦截”、这套能力声明机制在背后怎么运转、以及我在实际项目里试过的四种绕开方式,哪一种最稳、哪一种信息损耗最大、哪一种最适合多人协作的团队。如果你正在用 CLI 或 IDE 插件跑 DeepSeek 相关的工作流,被图片上传卡住,或者你是在维护一套自建的模型编排层、想搞清楚能力校验该怎么做,下面的内容可以直接抄作业,也可以当成排查手册用。
1. 拦截到底发生在哪一层:先把整条链路拆开
很多人一看报错就下意识去改模型名、换 API Key、重装插件,结果折腾一晚上问题还在原地。原因很简单:这个报错来自本地编排层的静态校验,跟你后面接的是谁、Key 有没有余额、网络通不通,一点关系都没有。不把链路拆开,你连“改哪里”都判断不了。
1.1 客户端、编排层、模型服务这三段式结构
一条图片请求从你手上到模型,中间至少过三道门。
第一道在客户端,也就是你看到的那个输入框。它决定“能不能选图片”,依据通常是一份本地缓存的模型能力清单,选中某个模型时就把上传按钮的可用状态切过去。这一层的限制最表层,绕开也最容易,但绕开它往往只是把错误往后推了一层。
第二道在编排层,也就是 harness 本体。它负责会话管理、附件落盘、上下文裁剪、工具注册、请求组装。图片在你点击发送的那一刻会被它接管:先算哈希做去重,再检查体积和像素上限,必要时压缩重编码,最后按模型声明的 modality 决定是塞进content数组还是直接抛错。绝大多数“硬性拦截”就发生在这一步,而且是在出站请求之前。
第三道在模型服务侧。请求真的发出去了,服务端按自己的协议解析,不认image_url这种 part 就回 400。这一层的错误信息和前两层长得很像,但性质完全不同——它是“别人不收”,不是“本地不让”。
把这三层分清楚,排查思路立刻就明确了:先确认请求有没有出站。有出站记录、有 4xx 响应,问题在第三层;日志里只有一条 capability 校验失败、出站请求数为零,问题在第二层。
1.2 三种典型报错与快速分辨方法
我在不同版本的 harness 上收集过几类高频报错,它们的措辞不一样,但指向的位置很集中。
| 报错特征 | 出现位置 | 真实含义 | 处理方向 |
|---|---|---|---|
capability_mismatch: modality image not allowed | 编排层日志,无出站请求 | 目录声明里该模型只支持 text | 覆盖目录声明或改走转换链路 |
attachment rejected: model does not accept image/* | 客户端提示条,秒回 | 前端拿到的能力清单没开图片 | 同步能力清单 / 换模型条目 |
400 invalid content type: image_url | 服务端响应体 | 请求已到达,上游协议不认多模态 part | 必须做模态转换,改本地声明无效 |
413 payload too large | 服务端或反向代理 | 图片 base64 膨胀后超出 body 限制 | 压缩、切分、改用引用式传图 |
| 无任何报错,模型答非所问 | 无 | 图片被静默丢弃,只留了文本 | 检查降级策略是否把附件吃了 |
分辨方法就一句话:看有没有出站。可以在 harness 启动时把日志级别调到 debug,找dispatch、request.payload这类关键字;如果你有反向代理,直接在代理侧看请求体大小和 content 结构,最直观。我自己的习惯是开一个本地抓包代理,把请求体 dump 到文件,一眼就能看出 content 数组里到底有没有 image 那一段。
注意:有些 harness 的“静默降级”比报错更坑。它检测到模型不支持图片,不报错,直接把附件从 content 里剔掉,只发文本。你得到的是一个看似正常但答非所问的回复,排查成本比直接报错高十倍。如果你的回复质量莫名下降,先怀疑这个。
1.3 为什么设计成硬拦截,而不是悄悄降级
一开始我也觉得这设计很烦,直到自己维护过一套类似的编排层才理解。
最直接的原因是用户预期管理。图片被悄悄丢掉,用户会认为“模型看过了”,基于这个假设继续追问,越问越偏,最后得出“这个模型不行”的结论,实际是链路的问题。硬拦截虽然粗暴,但至少诚实。
第二个原因是成本不可控。图片进入上下文之后,token 消耗是按图块算的,一张高分辨率截图可能顶几千个 token。如果编排层不提前拦住,一次误操作就可能把一整个会话的预算打穿。目录里那些max_images_per_request、resize_max_edge之类的字段,本质上都是成本闸门。
第三个原因是协议兼容性。不同的服务端对多模态 content 的支持程度差别很大,有的只认image_url加 URL,有的只认 base64,有的干脆只接受单张。混着用的时候,一张图能让整条请求 400,连带上百字的文本一起丢掉。与其让用户面对一个没有任何信息量的 400,不如在本地就把话说清楚。
理解了这三点,你就会明白:改目录声明只是把闸门抬起来,不代表下游真的有水。这也是后文四条路线的分水岭。
2. 模型目录的能力声明机制,到底在声明什么
要动这套东西,得先知道它在声明什么。模型目录(model catalog)不是简单的“模型名到地址”的映射表,它更像一份能力契约:编排层拿它来决定请求怎么组装、上下文怎么裁、工具要不要注册、附件收不收。
2.1 一条目录条目的关键字段
我把实际项目里最常打交道的字段列一下,不同的 harness 命名会有差异,但语义基本对应。
id:模型标识,也是你在命令里指定的那个名字。provider/endpoint:请求发往哪里,走什么协议。modalities.input/modalities.output:输入输出支持的模态集合,图片拦截就看这个字段。limits.max_images_per_request:单次请求最多几张图。limits.max_image_bytes/max_total_image_bytes:单图体积和总量上限,超过就在本地压缩或直接拒。image_policy.resize_max_edge:长边缩放阈值,这个值直接决定视觉编码后的 token 数量。image_policy.reencode/strip_exif:是否重编码、是否剥离元数据。context_window与tokenizer:上下文裁剪和 token 估算用,图片的估算规则通常单独挂在image_token_cost上。supports_tool_call:工具调用能力,跟图片无关但经常一起配。
一份最小的、能被校验通过的条目大概长这样:
models: - id: deepseek-chat provider: openai_compatible endpoint: https://api.example.com/v1 modalities: input: [text] output: [text] limits: context_window: 65536 max_images_per_request: 0 supports_tool_call: true注意max_images_per_request: 0这个写法,它比单纯在modalities.input里去掉 image 更“硬”。有些实现会先看数量上限,为 0 时直接短路返回,连后面的 mime 检查都不做。这也是为什么有些人改了 modalities 却不生效——数量闸门还卡在 0。
2.2 一次图片附件要过几道闸
按我见过的实现,一次附件提交大致会走这么一串判断,顺序很重要,因为报错只会报第一道不通过的闸。
- 模型存在性:
id在目录里找不到,直接拒,这个跟图片无关。 - 模态交集:附件的 mime 归一到
image,与modalities.input求交集,空集则拒。 - 数量上限:本次请求的图片张数对
max_images_per_request。 - 单图体积:读文件头或 stat,超
max_image_bytes先尝试压缩,压不下去再拒。 - mime 白名单:
image/png、image/jpeg、image/webp之外的格式(比如 HEIC、SVG、BMP)通常在本地转码或直接拒。 - 像素与长边:按
resize_max_edge缩放,同时算一下估算 token,超上下文预算就按策略裁剪或拒。 - 组装 content:把图片编码成 base64 data URL 或先上传取引用,塞进 content 数组对应位置。
知道这个顺序的实用价值在于:你改完配置不生效时,可以按顺序逐个排除。比如你确认 modalities 已经开了图,但还是被拦,那八成是第 3 步的max_images_per_request还写着 0,或者第 5 步的 mime 白名单没有你那个格式。
2.3 目录的加载顺序与覆盖优先级
这一点是踩坑重灾区。绝大多数 harness 的目录不是单一文件,而是分层加载、后者覆盖前者:
- 内置目录(随安装包分发,升级时会被整份替换)
- 用户级目录(家目录下的配置,跨项目生效)
- 项目级目录(仓库里的配置,跟着代码走)
- 环境变量指定的额外目录
- 运行时 API / 插件注入
优先级大致是从上到下递增。这就带来两个非常实际的结论。
第一,永远不要改内置目录。你改得再对,下次升级一覆盖,全部白干,而且这种“昨天还好好的今天又不行了”的问题最难查,因为没人记得自己改过什么。
第二,多人协作时把覆盖放在项目级目录里,并且提交进版本库。这样每个人的环境一致,新人拉下来就能跑,不用口口相传“你要去改那个 yaml”。
注意:有些实现对覆盖文件做严格 schema 校验,字段名写错一个字母,它不报错,静默回退到内置条目。你会觉得“我明明改了”。验证办法是打开 debug 日志看它实际加载了哪几个文件、最终生效的是哪条。找不到这种日志,就故意写一个非法字段,看它是否报错——不报错说明校验很松,你得更小心。
3. 四条可落地路线与选型对比
污染清楚了机制,方案就好选了。我按改造成本从低到高排,每一条都实际跑过,优缺点都写在里面。
3.1 路线一:本地覆盖目录声明
这是最直觉的做法:写一份覆盖文件,把modalities.input加上image,把数量上限调到合理值,再配好压缩参数。
# .harness/models.d/90-deepseek-vision.yaml models: - id: deepseek-chat extends: builtin/deepseek-chat modalities: input: [text, image] output: [text] limits: max_images_per_request: 6 max_image_bytes: 8388608 max_total_image_bytes: 20971520 image_policy: resize_max_edge: 1568 reencode: true strip_exif: true改完之后,本地那道闸确实开了,图片能进 content 数组,请求能发出去。
但这里有个必须说清楚的前提:这条路只在“上游真的支持图片、只是目录没更新”时有效。典型场景是自建服务、私有化部署、或者你前面挂了一个自己写的兼容网关——网关认图片,只是 harness 的内置目录还不知道。这种时候改声明是最省事的,五分钟搞定。
反过来的情况就很尴尬:如果上游是纯文本模型,你把声明开了,请求发出去,服务端返回 400,而这次的错误信息往往比本地拦截还难读懂,因为它可能只说invalid request body,你根本不知道是图片导致的。这是我见过最多的“改完更糟”的情形。
判断方法很简单:先手动构造一个带图片的最小请求,用 curl 直接打上游,看它认不认。认,走路线一;不认,往下看。
3.2 路线二:客户端前置转译成文本
思路是不跟多模态协议较劲:图片在进 harness 之前就变成文本,附件类型还是 text,所有闸门自然放行。
具体做法是在提交前跑一个预处理步骤,把图片转成结构化描述:
- 纯文字截图:走 OCR,输出带阅读顺序的纯文本。
- 表格截图:OCR 加版面分析,输出 Markdown 表格。
- 流程图/架构图:OCR 抽节点文字,再让一个视觉模型补一句整体描述。
- 照片类:直接交给视觉模型生成描述,OCR 意义不大。
优点非常明显:链路简单、没有协议风险、任何纯文本模型都能用、成本可控(文本 token 比图块便宜得多)。而且转译出来的文本可以落盘缓存,同一张图第二次用直接命中。
缺点同样明显:信息是有损的。颜色、相对位置、图标语义、手写体、模糊拍照,这些都会在转译过程中丢掉。做文档问答、报错截图分析这类任务,损失可以接受;做 UI 还原、图表趋势判断,损失就很大了。
我一般把这条路当默认方案,因为它的稳定性最好,出了问题也好定位——文本就在那儿,看一眼就知道转译对不对。
3.3 路线三:自建兼容网关做模态转换
路线二的升级版:把转译逻辑从客户端挪到一个独立的兼容网关里,对外仍然暴露标准的 chat completions 接口,内部把image_url这种 part 换成文本描述,再转发给上游文本模型。
这么改的好处是一次投入,所有客户端受益。你的 CLI、IDE 插件、网页端、脚本,全都不用动,只要把 endpoint 指过来。团队里有人用不同工具也不影响。
代价是多了一个需要维护的服务,以及多了一跳网络延迟。但如果你的团队本来就有内部网关,这层只是加个中间件,成本其实很低。
关键实现点有三个:一是要能识别 content 数组里的image_url,包括 base64 data URL 和 http URL 两种形式;二是转译结果要按图片哈希缓存,避免同一张图在长会话里被反复描述;三是转换后的文本要保留“这是第几张图”的位置标记,否则多图场景下模型分不清谁是谁。
3.4 路线四:双模型接力
质量最高的一条:先用一个视觉模型把图看清楚,输出一段结构化描述,再把描述拼进 prompt 交给文本模型做推理。
跟路线二的区别在于,这里的“看清楚”是由一个真正的多模态模型完成的,不是 OCR。它能看到“这个按钮是灰的”“进度条停在 80%”“弹窗上有个红色感叹号”,这些 OCR 一概抽不出来。
代价是链路更长、延迟更高、要维护两套凭据。我一般用在对准确性要求高的场景,比如根据报错截图定位问题、根据设计稿生成结构描述。
实操上的一个技巧是约束视觉模型的输出格式。别让它自由发挥写一大段散文,而是要求它按固定字段输出:界面类型、可见文本、控件状态、异常提示、布局关系。格式固定之后,下游拼 prompt 稳定,缓存也好做。
3.5 选型对照表与我的取舍
| 路线 | 改造成本 | 信息保真度 | 稳定性 | 适用场景 |
|---|---|---|---|---|
| 一、改目录声明 | 极低 | 取决于上游 | 上游支持时很稳,不支持时必挂 | 自建/私有化/自挂网关的多模态服务 |
| 二、前置转译 | 低 | 中,文字类高、图像类低 | 最稳 | 文档问答、报错截图、批量处理 |
| 三、兼容网关 | 中 | 同路线二 | 稳,且统一 | 团队多客户端、需要统一治理 |
| 四、双模型接力 | 中高 | 高 | 较稳,依赖两个服务 | 界面理解、复杂截图推理 |
我自己的默认组合是:路线一 + 路线三。先确认上游到底认不认图,认就在网关侧把图片按原样透传(此时目录声明也需要开);不认就在网关侧做转译。对客户端来说两种情况的接口完全一致,切换只改网关的一个开关。这套组合我在三个不同规模的团队里用过,迁移成本最低。
4. 完整实操:从被拦住到稳定跑通
下面按我实际的落地顺序走一遍,每一步都给出可以照着敲的内容。假设你已经确认上游是纯文本模型,走的是“网关转译”这条路。
4.1 第一步:确认生效中的目录来源
先别急着改文件。搞清楚当前生效的是哪一份、来自哪里,比改十次都管用。
# 列出所有模型及其能力声明 harness models list --verbose # 看某个条目的来源文件和最终合并结果 harness models show deepseek-chat --show-source # 环境变量方式指定额外目录(不同实现名字不同,先看 help) harness --help | grep -i catalog如果show输出里带了来源文件路径,直接去看那个文件。如果它只输出合并后的结果,那就用排除法:临时把项目级目录改名,重启,看能力声明是否变化。变了说明项目级生效,没变说明被更高优先级的覆盖了。
这一步还有一个副产品:你会看到max_images_per_request的真实值。很多“改了 modalities 还是不生效”的案例,根子都在这个字段是 0。
4.2 第二步:写一份不会被升级冲掉的覆盖文件
放在项目级目录,用extends继承内置条目,只覆盖需要改的字段。这样内置条目升级时,你的改动仍然有效,而且文件很小,一眼能看出改了什么。
# .harness/models.d/90-deepseek-gateway.yaml models: - id: deepseek-chat extends: builtin/deepseek-chat provider: openai_compatible endpoint: http://127.0.0.1:8787/v1 modalities: input: [text, image] output: [text] limits: max_images_per_request: 4 max_image_bytes: 6291456 max_total_image_bytes: 16777216 image_policy: resize_max_edge: 1280 reencode: true strip_exif: true preferred_format: image/jpeg几个参数的选择理由,值得说一下。
max_images_per_request设 4 而不是更大,是因为一次请求里图片越多,单张能分到的有效分辨率越低,而且转译出来的文本会迅速吃掉上下文。宁可分两轮问。
resize_max_edge设 1280,是权衡后的结果。截图类内容在 1280 长边下,中文小字基本还能辨认;再往上收益递减,但体积和 token 增长很快。如果你处理的是高密度代码截图,可以提到 1600,同时把preferred_format保持为 jpeg 质量 85 左右,体积能压下来一大截。
strip_exif一定要开。手机拍的截图带方向标记,不处理的话 OCR 出来是横着的。而且 EXIF 里可能带位置信息,多人协作场景下没必要留。
preferred_format: image/jpeg是针对照片类内容的。纯文字截图用 png 更清晰,但体积大,实际测试下来 jpeg 质量 85 对 OCR 准确率影响不到 1%,体积能小一半以上。
4.3 第三步:图片转结构化文本的处理脚本
这是整条链路的核心。我把它拆成“读取与归一化”“OCR 与版面”“输出结构化文本”三段,方便你按需替换中间那段。
# tools/image_to_text.py import base64 import hashlib import io import json from pathlib import Path from PIL import Image, ImageOps import pytesseract CACHE_DIR = Path(".cache/img2text") CACHE_DIR.mkdir(parents=True, exist_ok=True) MAX_EDGE = 1280 JPEG_QUALITY = 85 def normalize(path: Path) -> tuple[Image.Image, str]: """读取图片,修正方向,缩放,重编码,返回图像对象和内容哈希。""" img = Image.open(path) img = ImageOps.exif_transpose(img) # 修方向,必须放在缩放前 if img.mode not in ("RGB", "L"): img = img.convert("RGB") # 长边缩放,比例保持不变 w, h = img.size scale = MAX_EDGE / max(w, h) if scale < 1: img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS) buf = io.BytesIO() img.save(buf, format="JPEG", quality=JPEG_QUALITY, optimize=True) digest = hashlib.sha256(buf.getvalue()).hexdigest()[:16] return img, digest def ocr_with_layout(img: Image.Image) -> list[dict]: """按块输出文字与坐标,坐标用于还原阅读顺序。""" data = pytesseract.image_to_data( img, lang="chi_sim+eng", output_type=pytesseract.Output.DICT ) blocks = [] for i, text in enumerate(data["text"]): text = text.strip() if not text: continue blocks.append({ "text": text, "x": data["left"][i], "y": data["top"][i], "w": data["width"][i], "h": data["height"][i], }) return blocks def to_markdown(blocks: list[dict]) -> str: """按行聚类后再排序,避免多列排版读串行。""" if not blocks: return "" # 按纵坐标聚成行,容差取平均字高的一半 avg_h = sum(b["h"] for b in blocks) / len(blocks) tol = max(8, int(avg_h * 0.6)) blocks.sort(key=lambda b: (b["y"], b["x"])) lines, current, last_y = [], [], None for b in blocks: if last_y is None or abs(b["y"] - last_y) <= tol: current.append(b) else: lines.append(current) current = [b] last_y = b["y"] if current: lines.append(current) out = [] for line in lines: line.sort(key=lambda b: b["x"]) # 横向间距大的地方补空格,保留列结构 parts, prev_right = [], None for b in line: if prev_right is not None and b["x"] - prev_right > avg_h: parts.append(" ") parts.append(b["text"]) prev_right = b["x"] + b["w"] out.append("".join(parts)) return "\n".join(out) def image_to_text(path: Path, hint: str = "") -> dict: img, digest = normalize(path) cache_file = CACHE_DIR / f"{digest}.json" if cache_file.exists(): return json.loads(cache_file.read_text(encoding="utf-8")) blocks = ocr_with_layout(img) body = to_markdown(blocks) result = { "hash": digest, "size": img.size, "hint": hint, "text": body, "block_count": len(blocks), } cache_file.write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8") return result def build_attachment_text(items: list[tuple[Path, str]]) -> str: chunks = [] for idx, (path, hint) in enumerate(items, start=1): r = image_to_text(path, hint) header = f"【图{idx}】{hint or path.name}({r['size'][0]}x{r['size'][1]})" chunks.append(f"{header}\n{r['text'] or '(未识别到文字)'}") return "\n\n".join(chunks)几个实现细节值得单独拎出来说。
ImageOps.exif_transpose必须在缩放之前调用。反过来的话,缩放时用的是未旋转的宽高,方向修正后会变形。这个坑我在手机截图上踩过一次,OCR 出来全是乱码,查了半天才发现是顺序问题。
行聚类那段看着啰嗦,但不做的话多列排版会读串。最简单的例子是左右两栏的说明文档,按 y 排序再直接拼接,你会得到“左栏第一行+右栏第一行+左栏第二行”这种交替结果,完全没法读。按纵坐标聚行、行内再按 x 排,是成本最低的有效方案。
缓存键用归一化后的图像哈希,不是原文件哈希。这样同一张图换个格式另存、或者只是 EXIF 变了,仍然能命中缓存。
注意:如果同一张图在不同任务里需要不同的描述粒度(比如一次要全文,一次只要标题),缓存键必须带上描述配置的版本号,否则会复用旧结果。我给
hint加了个后缀进缓存文件名,就是为了这个。
4.4 第四步:网关层的模态转换实现
网关要做的事情很明确:接住标准请求,把 content 数组里的图片 part 换掉,转发给上游文本模型,再把响应原样返回。流式响应也要支持,否则前端的打字机效果就没了。
# gateway/main.py import base64 import hashlib import re import httpx from fastapi import FastAPI, Request from fastapi.responses import JSONResponse, StreamingResponse from tools.image_to_text import normalize, ocr_with_layout, to_markdown app = FastAPI() UPSTREAM = "http://127.0.0.1:9999/v1/chat/completions" UPSTREAM_KEY = "your-key" DATA_URL = re.compile(r"^data:(image/[a-zA-Z0-9.+-]+);base64,(.+)$", re.S) def describe_image_part(part: dict) -> str: """把一个 image_url part 转成文本段落。""" url = part.get("image_url", {}) raw = url.get("url", "") if isinstance(url, dict) else str(url) m = DATA_URL.match(raw) if not m: return "【图片】无法解析的图片引用,已跳过。" blob = base64.b64decode(m.group(2)) digest = hashlib.sha256(blob).hexdigest()[:16] cache_file = CACHE_DIR / f"{digest}.txt" if cache_file.exists(): return cache_file.read_text(encoding="utf-8") img = Image.open(io.BytesIO(blob)) img = ImageOps.exif_transpose(img) blocks = ocr_with_layout(img) text = to_markdown(blocks) out = f"【图片内容】\n{text or '(未识别到文字)'}" cache_file.write_text(out, encoding="utf-8") return out def rewrite_messages(messages: list[dict]) -> list[dict]: """遍历所有消息,把 image_url part 替换成 text part。""" new_messages = [] for msg in messages: content = msg.get("content") if not isinstance(content, list): new_messages.append(msg) continue parts, img_no = [], 0 for part in content: if part.get("type") == "image_url": img_no += 1 parts.append({ "type": "text", "text": f"[第{img_no}张图]\n{describe_image_part(part)}", }) else: parts.append(part) new_messages.append({**msg, "content": parts}) return new_messages @app.post("/v1/chat/completions") async def chat(request: Request): body = await request.json() body["messages"] = rewrite_messages(body.get("messages", [])) body["model"] = "deepseek-chat" # 强制路由到文本模型 headers = {"Authorization": f"Bearer {UPSTREAM_KEY}"} if body.get("stream"): async def gen(): async with httpx.AsyncClient(timeout=120) as client: async with client.stream( "POST", UPSTREAM, json=body, headers=headers ) as resp: async for chunk in resp.aiter_bytes(): yield chunk return StreamingResponse(gen(), media_type="text/event-stream") async with httpx.AsyncClient(timeout=120) as client: resp = await client.post(UPSTREAM, json=body, headers=headers) return JSONResponse(resp.json(), status_code=resp.status_code)有三个地方容易出问题,我提前说明。
第一,describe_image_part里的缓存用原始二进制的哈希,而脚本里用的是重编码后的哈希,两者不通用。这是我故意的:网关侧面对的是不同客户端送来的各种格式,用原始哈希更稳;脚本侧是本地批处理,用归一化哈希命中率高。你想统一也行,但那要保证两条链路的重编码参数完全一致,否则会出现“以为命中其实没命中”的诡异现象。
第二,流式分支里没有做错误处理。上游返回 4xx 时,aiter_bytes会把错误体当作正常流吐出去,前端拿到一堆解析不了的数据。生产环境要判断resp.status_code,非 200 就直接返回 JSON 错误,别进流式分支。
第三,body["model"]强制改写会覆盖客户端传来的模型名。如果你的网关要支持多个下游模型,改成映射表,别硬编码。
4.5 第五步:逐层验证的检查清单
改完之后别急着开香槟,按这个顺序走一遍,能省掉后面大量的“时好时坏”。
- 目录层:
harness models show deepseek-chat里modalities.input含 image,max_images_per_request大于 0。 - 客户端层:输入框的图片按钮可点,选图后能看到缩略图预览,不是灰的。
- 编排层:开 debug 日志,发一次带图请求,日志里能看到附件被接受、压缩参数、最终 content 结构。
- 网关层:网关日志里能看到重写前后的消息条数一致,image part 数量在重写后为 0。
- 上游层:上游收到的请求体里没有 image 相关字段,响应 200。
- 端到端:问一个只有看图才能答的问题(比如“截图里第三行写的是什么”),答案正确。
第 6 步很关键。前五步全绿但回答仍然错误,说明图片虽然转了文本,但内容没进到最终 prompt 里,或者被上下文裁剪吃掉了。这种情况我遇到过两次,一次是裁剪策略把小消息优先丢了,一次是转译文本被当成了系统消息的一部分导致权重不对。
5. 踩坑实录与问题速查
这部分是我从半年多的实际使用里攒下来的,很多是文档里不会写、但真能让人卡半天的问题。
5.1 高频问题速查表
| 现象 | 根因 | 处理 |
|---|---|---|
| 改完 yaml 没反应 | 字段名拼错,schema 校验静默回退 | 故意写个非法字段看是否报错,确认校验强度 |
| 改完生效,重启后又失效 | 改的是内置目录,被升级覆盖 | 移到项目级目录,用 extends 继承 |
| 报 413 | base64 膨胀 33% 后超出 body 限制 | 调大反向代理 body 上限,或先压缩 |
| 报 400 但不知道哪错了 | 上游不认 image part | curl 直接打上游验证,不要猜 |
| OCR 结果是横的 | EXIF 方向未处理,或处理顺序错 | exif_transpose放在缩放前 |
| 多列排版读串 | 只按 y 排序 | 先按 y 聚行,行内再按 x 排 |
| 同一张图描述不一致 | 缓存键没带描述配置 | 缓存文件名加配置版本后缀 |
| 长截图文字丢一半 | 缩放过度,小字糊了 | 提高 max_edge 或先切分再处理 |
| 多图时模型张冠李戴 | 转译文本没有位置标记 | 每段加【图N】前缀 |
| 流式响应前端解析失败 | 上游错误体被当流吐出 | 非 200 直接返回 JSON,不进流式 |
5.2 几个不写进文档的隐蔽坑
长截图是最大的坑。一整页的网页截图,长边可能有四五千像素,缩到 1280 之后小字就糊成一片,OCR 准确率断崖式下跌。这时候别硬调参数,应该先按内容切分:找横向留白多的行做切点,切成若干段分别处理,最后按顺序拼接。我在一个文档整理的项目里就这么干的,切分之后的识别准确率从六成多提到了九成以上。
图片数量上限和上下文预算是联动的。很多人只看max_images_per_request,不看总 token。四张图转成的文本可能顶几千 token,叠加历史对话之后直接触发裁剪,而裁剪策略往往优先丢最早的消息——包括你刚转译出来的图片描述。表现就是“第一张图描述得好好的,问第二个问题时它忘了”。
中文 OCR 的错字会污染检索。如果后续要做关键词匹配或者向量检索,OCR 出来的错字会直接导致召回失败。我的做法是对关键字段做一次模糊纠错,或者干脆把 OCR 结果和原图一起交给视觉模型做二次校对。别指望 OCR 一次到位。
统一走纯文本之后,图片顺序信息全靠你维护。原来的 content 数组是有序的,模型能靠位置区分。转成文本之后,如果你把几段描述拼接成了一个字符串而没加分隔标记,模型就分不清哪段是哪张图。加编号是最低成本的解法。
别用同一套参数处理所有类型的图。代码截图要清晰度,照片要体积小,表格要保留行列结构。我在网关里按图片尺寸和长宽比做了个简单分流:接近满屏的按截图处理(高分辨率、png),长宽比接近相机的按照片处理(jpeg 压缩),中间地带走默认。就这一个改动,平均 token 消耗降了两成,识别效果反而更好。
5.3 稳定跑了一段时间之后,我改掉的几个默认做法
最开始我把转译放在客户端,图省事。跑了两个月之后发现三个问题:不同客户端各写一套逻辑,行为不一致;有人本地环境缺依赖,转译直接失败但没报错;缓存散落在各人机器上,命中率很低。后来整体挪到网关,这三个问题一次性解决,代价只是多维护一个进程,非常划算。
还有就是我把resize_max_edge从最初的 1568 降到了 1280。当时觉得分辨率越高越好,实际跑下来发现 1568 对中文小字的提升很有限,但体积和 token 涨了不少,而且触发裁剪的概率明显上升。降下来之后整体体验反而更稳。这种参数没有标准答案,得按你的内容类型实测,别照抄别人的值。
最后一个改动是给转译结果加了“置信度”标记。OCR 出来文字特别少、或者版面块数量异常少的时候,我会在文本前面加一句“该图文字识别结果可能不完整”,让模型知道这段信息不可靠。这个小小的改动,让模型在遇到模糊图片时不再一本正经地编内容,而是会反问我一句“这张图是不是没截全”。
我个人在实际操作中的体会是:这类“能力被拦住”的问题,表面上是个配置问题,往下挖其实是链路设计问题。你今天把这道闸抬起来,明天就会撞上下一道。与其反复跟单点较劲,不如花半天时间把转译这一层做扎实,让图片在进入模型之前就变成它真正能理解的东西——文本。这一层做好之后,你换任何模型都不用再改一遍,这才是真正省事的地方。