用户上传一张图片后,一个好的图像解析系统要同时做到两件事:识别得准,响应得快。这两个目标各有难点,OpenClaw 用两套相互配合的分层设计分别应对。本文分上下两篇:上篇讲它如何保证识别准确,下篇讲它如何保证识别速度。
上篇 如何保证识别准确
涉及源文件:image-input-normalize.ts、input-files.ts、resolve.ts、defaults.ts、image.ts、runner.entries.ts
一个常见的误区是:图片能不能被准确识别,取决于用了多强的模型。但在工程实践中,单一模型再强,也会遇到格式不认、请求超时、厂商限流、只输出推理不输出正文、返回超长或乱码等问题。只要依赖单一模型,上述任何一种情况发生,都会直接把错误结果交到用户手里。
OpenClaw 的做法是把“准确”拆成相互独立的多层,每一层只解决一类问题:先把图片读对,再把模型选对,执行时把图片问对,输出时把结果规整对,最后再用容错层兜底。任何一层出问题,都不会直接让用户拿到错误结果。
一、输入层:先把图片读对
模型能否识别正确,第一前提是它拿到的是一张“它认识的、类型判断正确的、体积可控的完整图片”。如果这一步就错了,后面选再好的模型也没有意义,而且往往表现为“假性识别失败”(返回空或错误描述),难以排查。所以准确性必须从入口抓起。这一层做三件事。
1. 真实类型探测:不采信客户端声明的类型
要讲清楚这一步,先区分三个概念。
MIME 类型是一种标准化的字符串,用于标识文件或数据的格式,例如image/png、image/jpeg、application/pdf。
声明类型是上传或传输方在提供文件时一并给出的 MIME 值,来源通常是 HTTP 的 Content-Type头,或由文件后缀推算、由调用方填写。它与文件真实格式可能不一致,因为这些来源都不基于文件的实际字节——后缀可以被改名,渠道可能标错,软件也可能只给一个笼统的值。一个典型场景是:把一张图从微信保存到本地文件夹时,文件名(包括后缀)由应用自己决定,并不保证与图片实际编码一致,常见结果是后缀为 .png、声明类型被标成image/png,而文件内容实际是 JPEG 编码。
探测类型是代码读取文件实际内容后判断出的 MIME 值。它同样是一个 MIME 类型,区别只在于来源是“从字节验证得出”。具体做法是读取文件开头的若干特征字节(magic number),与各格式的已知特征比对:JPEG 以 FF D8 FF开头,PNG 以 89 50 4E 47开头,PDF 以 %PDF开头。它不受后缀或声明类型影响,通常被视为真实类型。
OpenClaw 的处理是:先用字节探测得出探测类型,再与声明类型做一致性校验。若声明类型是图片而探测类型不是图片,直接报错拒绝;若确认是图片,则以探测类型为准发送给模型,不采用声明类型。这样做的原因是,声明类型和后缀都不基于文件内容、都可能标错。若直接采信,会有两种后果:一是把实际并非图片的文件(如被改名的 PDF、文本)当作图片送入模型,只能得到空或错误的描述并浪费一次调用;二是即使确实是图片,若声明类型标错(如把 PNG 标成 JPEG),模型按错误类型解码也可能失败。以探测类型为准,才能确保送入模型的始终是格式判断正确、能被正确解码的图片。
2. 格式归一化:只把 HEIC/HEIF 转成 JPEG
OpenClaw 的格式归一化只在文件真实类型为 HEIC/HEIF 时,才将其转换为 JPEG;其余格式(PNG、WebP、GIF、普通 JPEG 等)一律原样透传,不做任何转码。判断依据是文件的真实类型,而非来源设备:HEIC 恰好是苹果设备的默认拍照格式,但如果 iPhone 传来的是 PNG,它不会被转;反过来,任何来源(安卓、下载、截图工具)产生的 HEIC 都会被转。
这样设计,是因为 HEIC/HEIF 是较新的高压缩格式,很多视觉模型和下游渠道并不接受,直接发送会报错或返回空结果,造成假性识别失败,因此必须转成通用格式;而 JPEG 兼容性最高,所以选它作为转换目标。相对地,PNG、WebP、JPEG 等是通用格式,下游普遍能够解码,再转码只会多一次 CPU 开销、可能损失画质,并多一个出错环节。所以只对模型不认的 HEIC/HEIF 做转码、对本就支持的格式放行。
3. 体积闸门:多道检查,转换后再查一次
maxBytes表示允许处理的图片最大字节数,即体积上限,单位是字节,它量的是图片原始(解码后)的字节数。图片默认上限是 10MB。为了保证真正送进模型的负载始终可控、避免因体积问题导致识别失败或服务不稳,检查分两处进行:第一处对所有图片都做,即在读入(下载或读取)时,原始图超过 maxBytes直接拒绝;第二处仅在发生 HEIC/HEIF 转 JPEG 后追加一次,检查转出来的 JPEG 是否超限(非 HEIC 图片不转码,因此不经过第二处)。
之所以要设上限、还要在转换后再查一次,有三点原因。其一,HEIC 是高压缩格式,一张不到 10MB 的 HEIC 解码再编码成 JPEG 后,体积可能反而变大,所以读入时查过一次还不够,必须对真正要发给模型的那张 JPEG 再查一次。其二,图片最终会以 base64 形式放进请求传输,编码后体积会再增加约 33%,因此需要在原始字节层面设上限,避免编码后超出模型的请求大小限制、导致整次请求失败。其三,解码超大图会占用大量内存和带宽,上限能防止单张巨图影响整个服务。
二、选型层:把模型选对
选错模型是准确性中较隐蔽的问题:一个不合适的模型会看起来在工作,却给出错误或空洞的描述。选型层的职责,是把每个模型的输入体积、输出长度、超时和提示词等上限提前解析好,让执行层有据可依、能够快速判负。
主要解析四个参数:输入体积上限maxBytes(优先级为模型条目、能力配置、全局默认依次生效);输出字数上限maxChars(图片默认 500 字);提示词处理 resolvePrompt(会给提示词拼上“最多 N 字符”的约束,音频除外);以及超时 resolveTimeoutMs(把配置的秒数换算成毫秒并设下限,最低 1000 毫秒)。
把这些上限在选型阶段就解析好,是为了让执行层有据可依、快速判负:体积超限的图能被立刻跳过,而不是发出去才失败;输出字数既控制成本,又让描述聚焦要点(过长的描述会稀释关键信息、拖慢下游);超时下限避免把上限配成 0 导致请求瞬间超时。参数与执行分离,也让同一套上限逻辑能被 provider 和 CLI 两条路径复用。
一个应用示例:用图片检测 5 项是否达标
如果场景是“根据图片检测 5 项是否达标”,这几个参数可以这样设置:
●maxBytes = 20MB:质检依赖细节,不要把正常高清图拒掉;它只做超限拒绝、不压缩,所以留足余量即可。
●maxChars = 1500:要放下 5 条“满足/不满足加理由”,默认 500 会被截断;若只要 5 个是/否、不带理由,300 也够。
●prompt:写成结构化清单,列出 5 项,要求按 1 到 5 编号逐项回答“满足/不满足加一句理由”,这样结果稳定、便于解析。
●timeoutSeconds = 120 秒:逐项推理比生成一句话描述慢,给足时间,避免没答完就超时切换到下一个模型。
三、执行层:把图片问对
选好模型后,怎么把图片和问题递给模型、怎么处理模型的回答,直接决定描述质量。同一张图、同一个模型,请求组织方式不对就可能报错或答非所问。
1. 统一的图片上下文构造
图片上下文由一个统一入口构造:把每张图的字节转成 base64,包成带类型和 MIME 的内容项,再统一装进一条 user 消息、带上时间戳,组成固定结构的对话上下文;其中提示词的位置按厂商规则决定——默认放进 systemPrompt,需要时则作为文本项和图片放进同一条 user 消息。这样无论图片来自哪条路径、是一张还是多张,送进模型的结构都一致、MIME 都带全。
这里 MIME 和结构都很重要。MIME 方面,base64 只是把字节转成文本,本身不带“这是什么格式”的信息,模型和接口必须靠 mimeType 才知道该按 JPEG 还是 PNG 去解码这段字节,缺失或标错会导致解码失败或被接口拒绝。结构方面,不同厂商对“提示词该放系统消息还是用户消息”“视觉请求要不要特殊请求头”的要求不一样,结构不对,接口要么报错(如 400),要么收到了却没真正把图片当输入去读,两种情况都等于识别失败。
2. 空响应重试:应对只推理、不出正文
部分带思维链的模型偶尔会“想了很多,但没有输出最终描述”,正文为空。如果直接判为失败,用户就会得到“识别不出”的结果,可实际上模型是有能力的。为此,执行层设计了一次针对性的重试,分四步:
第一步,正常发一次请求,并尝试从回复里抽取图片描述正文,抽到就直接返回。第二步,如果抽不到正文,判断是不是“只有推理内容、没有最终答案”造成的;判断标准有两条且必须同时成立——一是抽取正文时得到的确实是“没有正文”这一特定结果,而不是别的报错,二是这条回复里确实含有推理内容、但不包含可用的答案文本;两条都满足才认定属于“只推理”,否则直接报错、不重试。第三步,确认后用同样的图片和提示词重发一次,但在请求发出前把与推理相关的参数去掉,相当于要求模型这次不再花在思考上、直接给出结论。第四步,对重试的回复再抽一次正文,拿到就返回;且只重试这一次,若仍失败就报错、不再重试。
这样做,是把“被推理占满、本该拿到却没拿到”的描述重新取回,直接降低“识别为空”的概率。
四、输出层:把结果规整对
模型返回的原始文本可能超长,或在多字节字符中间被切断。输出层要保证最终落到下游的是长度可控、编码完整的文本,否则会污染后续的命令解析与展示。
输出层之所以在把“最多 N 字符”拼进提示词之后还要用代码再处理一次,是因为提示词里的字数要求只是对模型的软约束,模型并不一定严格遵守,实际返回的文字可能超长。因此代码会再做一道处理:先对文本去掉首尾空白,若长度未超过 maxChars就原样返回,一旦超过,则用按字符安全边界的方式截断到限额内。之所以用安全截断,而不是直接按字节或 UTF-16 码元硬切,是为了避免把一个中文字、emoji 或代理对从中间切开产生乱码。用提示词和代码两重约束,保证最终落到下游的始终是长度可控、编码完整的合法可读文本。
五、容错层:一个模型失败不等于识别失败
真实环境里会遇到网络卡顿、厂商限流、模型偶发故障或超大图,没有容错,任何一次偶发问题都会变成用户眼中的“识别失败”。容错层通过三个做法来兜底。
第一是双阶段超时加主动中止:把“建连准备”和“等待模型响应”分开计时,一旦超时立刻中止当前请求,并抛出可区分“准备慢还是响应慢”的错误,同时全程尊重外部的取消信号。第二是超限直接跳过:图片体积在读入阶段就用上限检查,超限的图根本不发出请求,直接判为失败、进入回退。第三是决策留痕:为每个模型的每次尝试生成一条结构化记录(成功或失败,以及原因),并汇总成状态里的一行摘要。
这样做的原因是:超时立刻中止而不是一直等待,能尽快释放资源、切换到下一个候选模型,把单个模型卡住的影响降到最低;超限在本地就判负,让失败的开销小、结束得快,从而有余地做更多回退尝试;每次尝试都留痕,既让外层回退循环有依据地决定要不要换下一个模型,也让使用者和运维能直观看到“用了哪个模型、为什么失败”,让整个过程可观测、可快速定位问题。
下篇 如何保证识别速度
涉及源文件:runner.ts、resolve.ts、image.ts、image-model-runtime.ts、defaults.ts
上篇解决的是“准得住”。同一套设计里,还有一批机制专门解决“跑得快”。速度问题大多不是“单次请求不够快”,而是做了本不该做的工作、卡在了本该早停的地方、重复解析了本可复用的东西,或串行等待了本可并行的任务。
OpenClaw 从这四类浪费入手,用四条策略叠加来提速:能省的调用直接跳过、注定失败或超长的请求尽早中止、只需算一次的东西缓存复用、多个任务并行但设上界。需要说明的是,其中体积上限、超时、输出长度等机制在上篇讲准确时已经出现过,这里从提速的角度再看一次。
一、少做:能省的调用直接跳过
最省时间的做法,是从源头去掉本不必要的工作。在runCapability中,如果当前处理的是图片、主回复模型本身原生支持视觉、且用户没有在配置里专门指定图片理解模型,OpenClaw 会在 activeModelSupportsNativeVision判定后直接跳过图片理解这一步,把原图直接交给主模型的上下文,不再单独发一次“描述图片”的模型调用。
这样做的原因是:如果主模型自己就能看图,再单独调一次模型生成文字描述就是重复劳动,白白多一次网络往返和等待。在识别出主模型原生支持视觉时直接跳过,可以省掉一整次模型调用,是最直接的提速方式。
二、早停:超限提前拦截,超时快速中止
已经开始的工作,如果注定失败或注定超长,越早停越好。这一层保证个别的坏情况不会拖着整体一直等待,具体有四处处理。
1. 体积上限提前拦截
resolveMaxBytes解析出输入体积上限(图片默认 10MB),超过上限的图在读入阶段就被判为失败,根本不发出模型请求。与其把超大图发出去、等模型端慢慢接收再拒绝(既慢又可能计费),不如在本地的上限检查处就快速判负。让失败的开销小且快,整体响应时间就不会被个别超大图拖垮。
2. 输出长度限制
resolveMaxChars解析输出字数上限(图片默认 500 字),resolvePrompt把“最多 N 字符”拼进提示词。模型生成的文字越长,耗时和 token 成本越高;把输出限制在必要长度内,既让模型更快返回,也让下游处理更快——描述聚焦要点即可,不需要长篇大论。
3. 双阶段超时与主动中止
withImageDescriptionTimeout把“准备”和“请求”两个阶段分别计时,一旦超时立刻中止当前请求,并抛出可区分“准备慢还是请求慢”的错误,同时全程尊重外部的取消信号。没有超时,单个卡住的请求会让整次识别无限期等待;超时立刻中止而不是一直等,能尽快释放资源、尽快切到下一个候选模型,把单个模型卡住对整体速度的影响降到最低。
4. 输出 token 上限保护
resolveImageToolMaxTokens取“请求值(默认 4096)与模型自身上限”的较小值,作为本次生成的 token 上限。给生成设一个合理上限,既避免模型输出过长而拖慢响应,也避免因为 token 参数越界导致请求被拒后又要重来。
三、复用:缓存避免重复解析
同样的模型元数据、鉴权和 provider 注册表,如果每张图都重新解析一遍,就是重复劳动。这一层用缓存把只需算一次的东西复用起来,分两个阶段。
1. 选型阶段:注册表缓存
在resolveDefaultRegistry中,没有传配置时走defaultRegistryCache,注册表只构建一次、全局复用;传了配置时走configRegistryCache,并按“配置快照加工作目录”为键区分缓存,最多同时存 32 份不同配置对应的注册表。这样同一份配置的注册表只需构建一次,之后重复调用直接命中缓存。
2. 执行准备阶段:快速静态解析与运行时缓存
把选中的那个具体模型解析成可直接调用的运行时(含鉴权),并用 WeakMap 按模型对象缓存,好处是模型对象被丢弃后缓存会自动清掉、不会泄漏;同时走跳过 provider 钩子的快速路径。每个 provider 或插件注册的运行时初始化步骤如果每次识别都跑一遍,会明显拖慢调用前的准备,所以能只靠静态元数据解析出模型时就走快速路径跳过它们,需要时再走完整路径。
四、并发:并行处理但设上界
OpenClaw 从配置项 tools.media.concurrency里读出用户设定的并发上限:如果是有效的正数就用它(取整),没配或无效就用默认值 2。串行处理会把多个任务的耗时相加,并发能让它们同时进行、缩短总时长;但并发不设上界又可能压垮下游厂商或占满内存,所以用一个受控的并发数在更快和稳定之间取平衡。
结语
把两篇合起来看,OpenClaw 的图像解析并不依赖单一模型的强弱,而是靠分层设计同时守住准确与速度。准确性靠五层:输入层把图片读对,选型层把模型选对,执行层把图片问对,输出层把结果规整对,容错层保证单个模型失败不拖垮整次识别。速度靠四条:少做、早停、复用、并发。其中体积上限、超时、输出长度等机制同时服务于这两个目标。归根结底,又准又快不是某一处的优化,而是每一层、每一条策略各司其职的结果。
夜雨聆风