☰
多模态大模型+Dify工作流:设备铭牌结构化识别方案
2026/10/26 21:18:14 网站建设 项目流程

一个做设备资产管理的朋友前段时间找我,说他手底下几百台设备的铭牌信息还靠人一张张拍照、一个个字往表格里敲,旺季招的临时工连"额定电流"和"整定电流"都分不清,错一行后面全得返工。他想用多模态大模型搞定这件事,问我能不能给个能跑起来的方案。这类需求在工业巡检、物业设备台账、电力运维里非常普遍——铭牌识别看起来只是"拍照转文字",真做起来会发现它是多模态落地里少有的、既有明确结构化目标又有大量现实噪声的场景。我后来用 Dify 搭了一套完整的工作流,从拍图到结构化字段入库,跑了几千张真实照片,把坑踩了个遍。这篇就把整个思路、节点设计、提示词写法、Dify 工作流 API 的调用代码和后处理校验逻辑完整摊开,代码可以直接抄,非算法岗的开发者照着搭也能跑通。

1. 铭牌识别这件事,难在哪儿

1.1 一张图里能被读错的字段类型盘点

很多人第一反应是"这不就是 OCR 吗"。如果铭牌是印刷体、正面平拍、光照均匀,传统 OCR 确实够用。但现场照片的真实分布是这样的:设备装在配电柜深处,人要侧着身子把手机探进去拍,镜头带一点点仰角;铭牌表面有油污、有锈迹、有施工时溅上的涂料点;不锈钢镜面铭牌在闪光灯下会整片过曝;有些老设备的铭牌是蚀刻凹字,字口里积了灰。这些条件下,OCR 出来的是一片乱码,而多模态模型的价值恰恰在于它能结合上下文"猜"出这里应该是"额定电压 380V"而不是"额定电压 38OV"。

更麻烦的是字段类型本身的复杂度。我把一个典型电气设备铭牌上的信息拆了一下,大致分成四类:

字段类别典型字段识别难点
标识类设备名称、型号、出厂编号型号里常混字母和数字,无规律
参数类额定电压、电流、功率、频率、转速数值+单位,容易与相邻字段串行
关系类制造日期、厂家、执行标准标准号形如 GB/T xxxx-xxxx,易错位
附属类重量、防护等级、接线方式防护等级如 IP54,字母数字紧邻

这四类字段对模型的要求完全不同。标识类要求逐字符精确,错一个字母整个型号就查不到对应手册;参数类要求数值和单位绑定不能散,同时还要求单位换算正确;关系类里的日期格式五花八门,有"2019.05"、"2019年5月"、"05/2019";附属类里 IP 防护等级这种,模型很容易把它当成一串普通数字。

我在第一批测试里就吃了这个亏。当时为了省事,让模型"把铭牌上的所有信息提取成 JSON",结果 200 张图里有 37 张出现了字段位置错位——比如把"额定电流 5.4A"识别成了"额定电压 5.4A",因为模型看到的是两行紧挨着的文字,它把数值和上一行的标签配了对。这种错法比直接识别失败更危险,因为它在结构上完全合法,下游系统照单全收,等到有人拿它去核算负载才发现不对劲。

1.2 为什么选 Dify 搭这层壳

纯粹追求性能的话,直接写个脚本调多模态 API 也能跑。但真实项目里,脚本之外的活儿占了七成工作量:图片怎么进、失败了怎么重试、提示词改了怎么快速回归测试、业务方想自己在界面上调一下字段顺序怎么办、跑出来的结果想人工抽查怎么操作。这些都是 Dify 这类应用平台存在的意义。我选它主要基于三点实际考虑。

第一是提示词和流程的分离。铭牌项目的提示词一定会反复改,因为你会不断发现新的错法。如果提示词硬编码在代码里,每改一次就要发一次版,跟业务方的沟通成本极高。Dify 的工作流把节点、提示词、模型参数都放在可视化界面上,改完保存即刻生效,开发只需保证调用侧的入参出参契约不变。我在项目中期最高频的操作就是打开画布,调两行提示词,然后在测试集上跑一遍。

第二是多模态链路天然支持。Dify 的开始节点可以声明一个文件类型变量,下游 LLM 节点直接把它当图片喂进去,中间不需要你自己做 base64 编码、MIME 类型判断这些琐碎事。它还支持在节点之间插入代码节点做轻量处理,比如把 OCR 结果里的全角字符转半角、把"Φ"统一成"φ"。这些逻辑写在代码节点里比塞进提示词靠谱得多。

第三是可观测性。每次运行的输入、每个节点的输出、token 消耗、耗时都能在日志里看到。排查"为什么这张图识别错了"的时候,你可以精确知道是预处理把图压糊了,还是模型这一版提示词退化了,还是后处理规则误删了正确字段。没有这层可观测性,调优基本靠猜。

需要说清楚的一点是:Dify 不是银弹,它解决的是"编排与迭代效率",不解决"模型识别能力"。如果你的铭牌图片质量实在太差,换什么平台都没用,得先解决拍摄规范问题。这一点我在后面的预处理章节会展开。

2. 工作流骨架:把"一张照片"拆成可调试的节点

2.1 节点职责划分与数据流

我最终落地的工作流是这样的节点序列,节点名和职责在画布上必须一眼能看懂,否则三个月后自己都认不出来:

  1. 开始节点:声明两个输入,image(文件类型,单文件,接受图片)和scene_hint(文本,可选,用来告诉模型这是电气铭牌还是机械铭牌)。
  2. 代码节点 A(图像体检):判断图片尺寸、长宽比,做基础校验,输出一个quality_flag。
  3. LLM 节点(视觉抽取):核心节点,选支持视觉输入的模型,输出严格 JSON。
  4. 代码节点 B(JSON 修复):模型偶尔会输出带 markdown 代码块围栏的 JSON,或者尾部多一个逗号,这里做容错解析。
  5. 条件分支:解析成功走正常路径,失败走兜底路径(降级为纯文本抽取 + 标记待复核)。
  6. LLM 节点(字段自检):可选,把抽取结果和原图再喂一次,让它只回答"哪些字段与图片不符",用于捕捉幻觉。
  7. 代码节点 C(规则校验):单位归一、数值区间、字段间一致性校验。
  8. 结束节点:输出结构化 JSON 加confidence和need_review标记。

这个设计里最关键的一点是把不确定性显式化。很多方案喜欢让模型一口气输出"完美结果",但模型永远会有不确定的时候,与其让它硬猜,不如让它在拿不准时输出 null 并附上原因,下游用规则和人工去兜。我在提示词里明确写了"看不清楚就填 null,不要猜",一开始担心这会拉低召回,实测下来字段准确率反而涨了 6 个百分点,因为模型不再用编造的值去填补模糊区域。

2.2 图像预处理:翻拍质量决定上限

这一步是纯传统图像处理,不涉及模型,但对最终准确率的影响大过任何一个提示词技巧。我最初跳过这步,直接把原图丢给模型,结果在 300 张测试集上字段级准确率只有 71%。加上预处理后升到 86%,同一套提示词、同一个模型。

具体做了三件事。第一是长边限制在 1600 像素左右。手机原图动辄 4000×3000,直接上传一方面是传输慢、token 贵,另一方面很多视觉模型内部还会再缩放,缩放算法不可控,反而不如自己先缩一次用高质量插值。实测 1600 像素是个甜点,再往上对铭牌小字的提升非常有限。第二是自动旋转矫正。手机拍的图常带 EXIF 方向信息,某些链路上这个信息会丢,导致模型看到一张躺倒的铭牌,横排文字变成竖排,识别率断崖下跌。我读取 EXIF 的 Orientation 并物理旋转像素。第三是对比度拉伸。对灰度图做一次 1% 到 99% 的百分位拉伸,对蚀刻字和油污铭牌的提升很明显。

from PIL import Image, ImageOps, ImageEnhance import io def preprocess(img_bytes: bytes, max_side: int = 1600) -> bytes: img = Image.open(io.BytesIO(img_bytes)) # 1. 按 EXIF 修正方向 img = ImageOps.exif_transpose(img) # 2. 转 RGB,丢弃 alpha 通道避免后续编码报错 if img.mode != "RGB": img = img.convert("RGB") # 3. 长边缩放 w, h = img.size scale = max_side / max(w, h) if scale < 1: img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS) # 4. 轻度对比度与锐度增强 img = ImageEnhance.Contrast(img).enhance(1.15) img = ImageEnhance.Sharpness(img).enhance(1.2) buf = io.BytesIO() img.save(buf, format="JPEG", quality=88, optimize=True) return buf.getvalue()

这段代码在 Dify 的代码节点里跑不了(代码节点默认只有受限的 Python 环境,Pillow 不一定可用),所以我把它放在业务侧的上传服务里,进 Dify 之前就已经是处理好的图。这个边界要划清楚:凡是依赖第三方图像库的重活,放在 Dify 外面;Dify 里只做逻辑判断和字符串处理。

还有一条经验:如果现场条件允许,最好在拍摄端就做约束。我后来给业务方做了个小程序页面,拍照时强制显示一个引导框,要求铭牌四角落在框内,并且检测模糊度(用拉普拉斯方差)太低就不让提交。这一条把废片率从接近两成压到了 5% 以下,比后端任何算法优化都管用。

2.3 视觉模型节点的选型与参数

Dify 的 LLM 节点里选模型时有个坑:必须选明确支持视觉输入的模型,选错了图片会被静默丢弃,模型只拿到一句"请识别这张图片上的铭牌信息",然后它就开始编。这个错误我第一次遇到时排查了很久,因为日志里看不出任何异常,只有结果全是瞎编的。

选型上我对比过几类:通用大模型的视觉版本,长于理解整体语义和版式,对模糊、倾斜的容错好,但逐字符精度一般;专门的 OCR 类服务,逐字精度高,但对"额定电压"和"380V"的配对关系理解弱,输出的是一堆带坐标的文本块;还有一些垂直文档解析模型,对表格和键值对友好,但对非标准版式泛化差。

我的结论是:主体用通用视觉大模型做结构化抽取,把"配对"和"语义"交给它,把"逐字符精确"用规则和后校验来兜。这个组合性价比最高,因为铭牌里真正需要逐字符精确的只有型号和出厂编号这两个字段,而这两个字段恰好有很强的模式特征,可以用正则和型号库做二次确认。

参数上,温度调到 0 或者接近 0 的值。这不是为了"稳定输出格式"(格式靠提示词约束),而是因为铭牌识别是一个确定性任务,同一个图两次跑出不同结果的唯一解释就是模型在瞎猜。最大输出 token 给到 1500 左右足够,铭牌字段再多也就二三十个。另外要注意,有些模型的视觉输入 token 消耗是按图片分辨率分档计费的,把图压到 1600 长边除了提准确率,还顺带把单张成本压下来了一截。

3. 结构化抽取的提示词工程

3.1 字段 schema 的颗粒度怎么定

字段定得太粗,下游没法用;定得太细,模型记不住也填不满。我踩过的具体坑是:一开始把"额定电压"和"额定电压范围"当成两个字段,结果模型遇到"380V±10%"这种写法时会同时填两个字段,或者随机填一个,导致下游去重逻辑崩掉。后来我改成"一个字段 + 一个原文附录"的结构,也就是主字段只存归一化后的值,原文写在raw子字段里。

最终我用的 schema 大概是这样,字段数量控制在 20 个以内:

{ "device_name": {"value": null, "raw": null}, "model": {"value": null, "raw": null}, "serial_no": {"value": null, "raw": null}, "manufacturer": {"value": null, "raw": null}, "rated_voltage_v": {"value": null, "raw": null}, "rated_current_a": {"value": null, "raw": null}, "rated_power_kw": {"value": null, "raw": null}, "frequency_hz": {"value": null, "raw": null}, "speed_rpm": {"value": null, "raw": null}, "protection_class": {"value": null, "raw": null}, "insulation_class": {"value": null, "raw": null}, "weight_kg": {"value": null, "raw": null}, "manufacture_date": {"value": null, "raw": null}, "standard_code": {"value": null, "raw": null}, "extra_fields": [] }

这个结构有两个好处。一是每个字段自带raw,人工复核时能直接对照原文,不用再回头翻图;二是extra_fields作为数组,把非标准铭牌上的自定义字段(比如某些进口设备的"Made in"、"Order No.")兜住,不会因为 schema 里没定义就被模型扔掉。实际跑下来,extra_fields里确实捞到过不少有价值的信息,比如"订货号"和"批次号",这些在追溯场景里很有用。

字段命名上我建议带单位后缀,比如rated_voltage_v、rated_power_kw。这看起来啰嗦,但能显著降低模型混淆单位的概率——模型看到字段名里写着_kw,就不太会往里填"3000"这种瓦数。这是一个小技巧,成本几乎为零,收益很实在。

3.2 输出约束:JSON、枚举与"不确定就留空"

提示词里关于输出格式的部分,我写得很啰嗦,几乎是手把手教。原因很简单:格式错误是成本最高的一类错误,因为它在解析阶段直接炸掉,整条数据作废,而模型自己不会意识到。

我用的约束手段有三层。第一层是在提示词里直接给出完整的输出样例,不是文字描述而是真实 JSON 文本,并且明确写"只输出 JSON,不要输出任何解释、不要用 markdown 代码块包裹"。第二层是用 Dify 里模型的"JSON 模式"或者响应格式参数(如果所选模型支持),强制输出为合法 JSON。第三层是代码节点做容错解析,把 ```json 围栏剥掉、把尾部逗号去掉、把中文引号换成英文引号。

枚举字段要特别处理。protection_class看着简单,模型却能给你填出 "IP 54"、"IP54"、"ip54"、"IP-54" 四种写法。我在提示词里明确写了"防护等级统一写成 IP+两位数字,不带空格和连字符"。insulation_class同理,绝缘等级只能是 A/E/B/F/H 这几个,我直接列出允许值。凡是取值集合有限的字段,一律列出全部合法值,不给模型自由发挥空间。

"不确定就留空"这条要写得更具体。光写"不要猜"没用,模型不知道什么算猜。我会加上这样的描述:如果某个字符因为反光、污渍、折痕无法辨认,该字段填 null,并在raw里保留你能看到的部分字符,用"?"替代看不清的字符。这样传回来的raw里可能长这样:"Y2-1?0L-4",人工一看就知道是第二个字符被遮挡了,补全的成本很低。

3.3 单位归一化的硬规则清单

这一步必须在提示词里写死,不能指望后处理。因为单位一旦在模型那一层就乱了,后处理很难分辨"3000"到底是 3000W 还是 3000kW。

我列的规则大致有这些:

  • 电压统一为伏特(V),"kV"换算成 V。
  • 功率统一为千瓦(kW),"W"换算成 kW,"HP"(马力)换算成 kW 并保留两位小数。
  • 电流统一为安培(A),"mA"换算成 A。
  • 频率统一为赫兹(Hz),"kHz"换算成 Hz。
  • 重量统一为千克(kg),"g"换算成 kg。
  • 转速统一为转每分钟(rpm)。
  • 日期统一为YYYY-MM-DD,只精确到月的补-01,并在raw保留原始写法。
  • 数值一律不带单位符号,只放数字;单位体现在字段名里。
  • 范围值取中值还是保留区间:我选择保留区间,写成"380±10%"或"0.5~1.5"这样的字符串,不做强行转换。

最后这一条有争议,我的理由是:额定参数的区间信息往往有工程含义(比如电压允许波动范围),强行取中值会丢掉这个信息。但如果是"额定电流 4.2~4.8A"这种标注本身不规范的情况,保留原样也比编一个中值好,让人来判断。

另外提醒一句,涉及三相电机的功率计算,我在后处理里做过一个交叉校验:铭牌上如果同时有电压、电流、功率,可以用 P ≈ √3 × U × I × cosφ 反推。比如 380V、5.4A,按 cosφ 取 0.85 算,P ≈ 3.02kW,如果铭牌标的是 3kW,这就是自洽的;如果标的是 30kW,那基本可以判定小数点或者单位读错了。这类交叉校验是抓错的一把好手,后面代码章节我会给出实现。

3.4 少样本示例的写法

我在提示词里放了两组示例,一组是标准印刷体铭牌,一组是模糊的蚀刻铭牌。示例的作用不是教模型"铭牌长什么样",而是固化输出格式和判断标准。所以示例里的关键部分不是图片,而是输出 JSON 的完整形态,尤其是 null 字段怎么写、extra_fields怎么组织、raw里怎么用"?"表示遮挡。

我现在会把示例精简到两组。加太多示例会把提示词撑得很长,token 成本上去了,收益递减,而且模型有时会"过拟合"示例的字段值,出现把示例里的型号照抄到真实结果里的情况——这个坑我遇到过,测试集里有两张图的型号和一个示例的型号完全一样,就是这个原因。所以示例里的型号、厂家这些标识类字段,我全部换成了明显虚构的值,比如"model": "DEMO-XXXX",一眼就能看出是示例填错的。

4. 代码落地:把工作流接进业务系统

4.1 单张调用的最小可用封装

Dify 的工作流发布后可以通过 API 调用。多模态场景要注意文件怎么传:如果图片已经在对象存储上有可访问的 URL,用remote_url方式最省事;如果是本地文件或者内网地址,就得先走文件上传接口拿到文件 ID,再用local_file方式引用。

这里有个必须提前确认的点:如果你用的是云端模型服务,它需要能访问到你传的图片 URL。内网地址、带鉴权的签名 URL(有时效)都可能出问题。我踩过一次很隐蔽的:签名 URL 有效期 5 分钟,批量任务排队时有些图轮到处理时链接已经过期,模型端拉不到图,然后它就基于文件名开始编。所以我的建议是统一走上传接口,不依赖 URL 可达性。

import requests from typing import Any class PlateRecognizer: def __init__(self, base_url: str, api_key: str, timeout: int = 60): self.base_url = base_url.rstrip("/") self.api_key = api_key self.timeout = timeout self.session = requests.Session() self.session.headers.update({ "Authorization": f"Bearer {api_key}" }) def upload(self, filename: str, content: bytes) -> str: """上传图片,返回 Dify 内部文件 id""" resp = self.session.post( f"{self.base_url}/files/upload", files={"file": (filename, content, "image/jpeg")}, data={"user": "plate-bot"}, timeout=self.timeout, ) resp.raise_for_status() return resp.json()["id"] def run(self, file_id: str, scene_hint: str = "") -> dict[str, Any]: payload = { "inputs": { "scene_hint": scene_hint, "image": { "type": "image", "transfer_method": "local_file", "upload_file_id": file_id, }, }, "response_mode": "blocking", "user": "plate-bot", } resp = self.session.post( f"{self.base_url}/workflows/run", json=payload, timeout=self.timeout, ) resp.raise_for_status() body = resp.json() # 工作流内部异常也会返回 200,必须检查状态字段 if body.get("data", {}).get("status") != "succeeded": raise RuntimeError(f"workflow failed: {body}") return body["data"]["outputs"]

这段代码里有个特别容易忽略的地方:工作流内部节点报错时,HTTP 状态码仍然是 200,错误信息藏在data.status和data.error里。我最早的版本只判断了raise_for_status(),结果一批 500 张图里有一百多张静默失败,直到下游对账才发现。所有调用 Dify 工作流的地方,都建议把状态字段检查做成一等公民。

4.2 批量与并发:限流、重试、幂等

批量处理的核心矛盾是:并发太低跑不完,并发太高会被限流或者把模型端打爆。我在实测中发现,一个并发度在 4 到 8 之间的线程池是比较稳的区间(具体取决于你的配额),再往上错误率明显上升,而且错误多是 429 和超时。

重试策略我分了两类错误处理。可重试的包括网络超时、429、5xx,用指数退避重试 3 次,间隔 2s、5s、12s。不可重试的包括图片下载失败、文件格式不支持、工作流返回明确的参数错误,这些重试多少次都一样,直接落到失败队列。区分这两类的意义在于避免无谓的重试把队列堵死。

幂等性这块,我的做法是给每张图算一个内容哈希(图片字节的 SHA256),以哈希作为任务的唯一键。同一个哈希已经有成功结果的,直接复用不重复调用。这在现场补拍、重复上传的场景里省了大量的钱——业务方补拍时经常把之前的图一起再传一遍。

import hashlib, time from concurrent.futures import ThreadPoolExecutor, as_completed RETRYABLE = (requests.Timeout, requests.ConnectionError) def task_key(content: bytes) -> str: return hashlib.sha256(content).hexdigest() def process_one(rec: PlateRecognizer, item: dict, store, max_retry: int = 3): key = task_key(item["content"]) cached = store.get(key) if cached: return {**cached, "from_cache": True} backoff = [2, 5, 12] last_err = None for attempt in range(max_retry): try: fid = rec.upload(item["filename"], item["content"]) out = rec.run(fid, scene_hint=item.get("scene_hint", "")) store.put(key, out) return out except RETRYABLE as e: last_err = e time.sleep(backoff[min(attempt, len(backoff) - 1)]) except Exception as e: # 不可重试错误,直接抛出 raise raise RuntimeError(f"retry exhausted: {last_err}") def run_batch(rec, items, store, workers: int = 6): results = [] with ThreadPoolExecutor(max_workers=workers) as pool: futures = {pool.submit(process_one, rec, it, store): it for it in items} for fut in as_completed(futures): item = futures[fut] try: results.append({"file": item["filename"], "ok": True, "data": fut.result()}) except Exception as e: results.append({"file": item["filename"], "ok": False, "error": str(e)}) return results

注意store这里我是抽象的,实际可以用 Redis 也可以用一张表。关键是它要能在多机部署时共享,否则幂等只在单机上生效,多实例跑起来还是会重复处理。

4.3 后处理校验层:用规则兜住幻觉

再好的提示词也拦不住模型偶尔的幻觉,尤其是铭牌上有"2008"这样的年份时,模型有时候会把年份关联到型号里。后处理层的任务是做"物理常识检查",凡是违反常识的直接降级为待复核。

我实现的校验规则大概有这些:

校验项规则违规处理
电压区间6V ~ 35000V标记待复核
电流区间0.01A ~ 5000A标记待复核
功率区间0.01kW ~ 10000kW标记待复核
频率枚举50 / 60 / 400归一或标记
防护等级正则^IP[0-9]{2}[A-Z]?$不匹配则置 null
日期合理性1970-01-01 ~ 今天越界置 null
型号字符集^[A-Z0-9\-\/\.]{2,32}$不匹配则保留 raw 待复核
三相功率自洽|√3·U·I·cosφ - P| / P < 0.25超差标记待复核

三相功率自洽这条规则挽回过好几次错误。有一次一批水泵的铭牌,模型把"额定功率 15kW"读成了"额定功率 1.5kW",看着完全合理,因为 1.5kW 也是常见规格。但它的额定电流是 30A,380V 三相下 30A 对应的功率大概就是 15kW 量级,这条校验直接把这张图标了出来。

import re, math from datetime import date def normalize_and_check(d: dict) -> tuple[dict, list[str]]: flags = [] v = _to_float(d.get("rated_voltage_v", {}).get("value")) i = _to_float(d.get("rated_current_a", {}).get("value")) p = _to_float(d.get("rated_power_kw", {}).get("value")) if v is not None and not (6 <= v <= 35000): flags.append("voltage_out_of_range") if i is not None and not (0.01 <= i <= 5000): flags.append("current_out_of_range") if p is not None and not (0.01 <= p <= 10000): flags.append("power_out_of_range") # 三相功率交叉校验 if v and i and p and v > 200: est = math.sqrt(3) * v * i * 0.85 / 1000.0 # kW if est > 0 and abs(est - p) / p > 0.25: flags.append(f"power_mismatch(est={est:.2f},declared={p})") ip = d.get("protection_class", {}).get("value") if ip: if re.match(r"^IP[0-9]{2}[A-Z]?$", ip): pass else: d["protection_class"]["value"] = None flags.append("ip_format_invalid") dt = d.get("manufacture_date", {}).get("value") if dt: try: y, m, _ = [int(x) for x in dt.split("-")] if not (1970 <= y <= date.today().year and 1 <= m <= 12): d["manufacture_date"]["value"] = None flags.append("date_unreasonable") except Exception: d["manufacture_date"]["value"] = None flags.append("date_unparseable") return d, flags def _to_float(x): try: return float(x) except (TypeError, ValueError): return None

这套规则不是越多越好。我一开始加了二十多条,后来发现其中一半从来没触发过,反而因为规则太严把正确的数据也拦下来了——比如把"型号里带空格"当成非法格式,结果误伤了一批进口设备。规则要一条一条加,加一条就在测试集上验证一次,确认它拦下来的确实是错数据,而不是正确数据。

4.4 置信度与人工复核队列

模型自评的置信度不可信,这是我在多个项目里反复确认的结论。让模型给自己打分,它给出的分数分布往往集中在 0.9 附近,几乎没有区分度。所以我用的是一套组合信号来算复核优先级:

  • 关键字段(型号、出厂编号、额定电压、额定功率)为 null 的,权重最高。
  • 触发后处理校验规则的,按严重程度加权。
  • 关键字段的raw里含有"?"的,说明存在遮挡,加权。
  • 二次识别比对不一致的,加权。

二次识别比对是我后期加的一个手段,只在关键字段上做:把原图再喂一次,提示词微调(比如换一种提问顺序),两次结果做字段级比对,不一致的字段标为不确定。这会让成本翻倍,所以我只对"型号"和"出厂编号"这两个字段做,而且只对首次识别置信度中等的那部分做。实测下来这一招对型号的准确率提升大概在 3 到 5 个百分点,考虑到型号错了整个设备就关联不上手册,这个投入是值的。

复核队列的界面不用做太复杂,一个表格:左边显示铭牌缩略图(最好是能放大看原图),右边显示抽取结果,高亮标出待确认字段,操作员可以直接改。这里有个提升效率的小设计——把同一批设备、同一厂家的结果放在一起复核,因为同厂家铭牌版式一致,错法往往也一致,操作员看几张就能掌握规律,比随机顺序快很多。

5. 实测踩坑记录

5.1 云端模型拉不到内网图片

这个坑前面提了一句,但值得单独说,因为它的表现形式极其隐蔽。当时的情况是:业务系统部署在内网,图片存内网对象存储,后端拿到了一个内网可访问的 URL 传给 Dify,Dify 调用云端视觉模型。结果部分图片识别结果完全是编的,而且编得很像真的——因为模型读到了文件名里的信息(文件名里恰好带了设备编号),就把文件名当成了铭牌内容的一部分。

排查过程走了不少弯路。先怀疑提示词,改了三版没用;再怀疑图片质量问题,抽了十几张"识别错误"的图人工看,图其实很清晰;最后是打开 Dify 的单次运行日志,看到 LLM 节点的输入里图片那一栏是空的,才定位到问题。

结论很简单:不要假设模型端能访问到你传的 URL。两种稳妥做法,一是走上传接口用local_file,二是把图片转成 base64 内联(如果模型接口支持)。第一种更通用,我推荐这个。

5.2 型号里的字母数字被"顺"成参数

这个问题属于模型的语言先验在捣乱。铭牌上如果型号是Y2-160L-4,紧挨着下面写着"额定功率 15kW",模型有时会把160L-4和后面的数字混在一起,输出成Y2-160L-4变成Y2-1601-4,或者功率变成160kW。原因是模型在"顺"文本的时候,把相近的数字串了起来。

应对办法有两个层次。提示词层面,我在示例里特意放了一个字母数字混排的型号,并且在规则里写"型号字段只从铭牌上标注为'型号'或'Model'的那一行取值,不跨行拼接"。代码层面,用一个型号正则做格式校验,同时如果业务方有型号库(这个项目里恰好有),做一次模糊匹配,匹配不上就标记待复核。型号库这一步效果非常明显,因为型号的合法取值是有限集合,模糊匹配能把大部分错位纠正回来。

5.3 多铭牌同框与"只看一眼"

现场拍摄时,一台设备上可能同时有主铭牌和辅助标签(比如合格证、检修记录贴纸),有时一张图里框进了两台设备。模型在这种情况下会出现两种错法:一是把两张铭牌的信息合并,二是只认了其中一张,另一张完全忽略。

合并的问题更严重,因为合并出来的结果看起来字段齐全,实际是张三李四拼起来的。我的处理方式是在提示词里明确要求"如果图中存在多个铭牌,只识别面积最大、最居中的那一个,并在 extra_fields 里记录 multi_plate=true"。同时在上传端做引导,要求一次只拍一个铭牌。

但仅靠提示词还不够,我在后处理里加了一条辅助判断:如果同一批次里连续多张图的serial_no高度相似(比如只差一两位),或者不同设备图返回了完全相同的serial_no,就报警排查。这条规则抓到过一次批量处理的问题——某个文件夹里的图被重复上传了,模型返回的是同一套结果,如果不查会被当成"设备批量重复"。

5.4 表格式铭牌的跨行读取

有些设备的铭牌是表格形式,参数排成两列甚至四列,比如左边"电压 380V 电流 5.4A",右边"功率 3kW 频率 50Hz"。这种版式对模型是个考验,因为它需要理解二维布局,而不仅仅是从上到下的文本流。

我试了几种办法。最有效的是在提示词里说明"铭牌可能是表格布局,请按行优先的顺序读取,注意每个数值紧邻左侧或上方的标签"。另外我在预处理里加了一步可选操作:对明显是表格布局的图,做一次轻微的透视矫正,把矩形铭牌拉正。这个矫正不需要很精确,能改善一点是一点。

还有一个低成本的办法:如果业务方允许,在拍摄时把手机横过来,让表格的行方向与手机长边一致。这个土办法的效果比很多算法都直接。

5.5 成本与延迟的实测账

给一组我实测的量级参考(具体数字随模型和图片大小浮动很大,这里只说结构):

单张图的输入 token 里,图片部分占绝大多数,压到 1600 长边后大约是几百到一千多 token 不等,取决于模型的分辨率分档策略;文本部分(提示词)大概一千出头,这部分是固定成本,提示词越长越亏。输出大概 300 到 600 token。所以整体的成本瓶颈在提示词长度上,这也是我后来把提示词从 3000 字砍到 1200 字的主要原因——砍完之后准确率几乎没掉,但成本降了将近一半。

延迟方面,单张端到端大概 4 到 10 秒,其中模型推理占大头。批量跑 1000 张,6 并发,大概 20 到 30 分钟。这个速度对"晚上跑批、白天复核"的节奏是够的,但如果要做实时识别(比如巡检 App 拍完立刻出结果),就得考虑换更小的模型或者做图片预裁剪。

6. 效果评估与迭代节奏

6.1 黄金测试集怎么建

评估这件事最忌讳拍脑袋。我最开始的做法是"看着差不多",结果就是每次改完提示词,感觉这张好了那张坏了,完全没法判断整体是进步还是退步。

黄金测试集的建法我推荐这样:从真实数据里随机抽 200 到 300 张,覆盖不同的设备类型、不同的拍摄质量、不同的铭牌版式。关键是必须包含足够多的"坏样本"——模糊的、反光的、倾斜的、多铭牌同框的、蚀刻字的,各类至少十几张。如果测试集全是清晰正面照,准确率会有 95% 以上,但这个数字毫无意义,上线就崩。

标注工作量大,但只做一次。我自己标了大概一天半,后面所有迭代都基于这套标注。标注的时候要注意:不仅要标正确值,还要标"这个字段在这张图上确实看不清",这类样本的正确期望输出是 null,而不是某个猜出来的值。这一点很重要,否则模型学会的是"无论如何都要填点什么"。

6.2 按字段算准确率

整体准确率(整条 JSON 完全正确)这个指标在小数据集上会低得吓人,而且没有指导意义。比如一条数据 20 个字段对了 19 个,整条算错,但你从中学不到任何东西。

我用的指标是字段级的:

指标含义目标值
关键字段准确率型号、出厂编号、额定电压、额定功率完全正确率≥ 92%
普通字段准确率其余字段完全正确率≥ 85%
幻觉率输出值在原图不存在且非 null 的比例≤ 1%
漏填率原图存在但输出 null 的比例≤ 8%
待复核率进入人工队列的比例≤ 20%

幻觉率要单独盯,因为它比漏填危险得多。漏填了人工补一下就行,幻觉出来的值会一路流到下游系统里。我的经验是幻觉主要集中在"厂家名称"和"制造日期"这两个字段上,因为模型对这类信息有很强的先验,看到某个型号就想填对应的厂家。对策是在提示词里对这两个字段加严,要求"厂家名称必须逐字来自铭牌,不要根据型号推断"。

待复核率也要控制。如果 50% 的数据都要人工看,那这套系统的价值就剩一半了。我一般把它压到 20% 以内,也就是 80% 的数据能直接自动入库。

6.3 知识库补位:型号库与厂家库的用法

这个项目有个天然优势——业务方手里有一份历史型号清单和厂家清单。我把这两份数据做成了字典,在 Dify 里也可以用知识库节点做检索增强,但说实话,对于型号这种短字符串的精确匹配,用知识库的语义检索反而不如直接在代码节点里做模糊匹配(编辑距离、前缀匹配)来得准。

具体做法是:模型输出的型号先做字符串清洗(去空格、统一大小写、把 O 和 0 的混淆做双向尝试),然后在型号库里查。完全匹配的直接采用;编辑距离 ≤ 2 的,给出候选并标记待确认;完全查不到的,标记待复核并保留原文。这一步把型号字段的准确率从 88% 拉到了 94% 左右。

知识库真正有价值的地方在"参数解释"上,而不是"字段校正"。比如识别出型号Y2-160L-4之后,可以在型号库里查到它的标准参数(功率 15kW、转速 1460rpm),如果铭牌识别的结果和标准参数严重不符,就是一个强信号——要么识别错了,要么这台设备被改装过。这个交叉验证比单纯的字段校验有价值得多。

6.4 迭代优先级

跑完一轮完整的评估,你会拿到一堆待改进项,这时候最忌讳的是平铺直叙地全改一遍。我的排序逻辑是:

先修格式类问题。JSON 解析失败、字段缺失、枚举值不规范,这些属于确定性错误,改提示词或者加代码节点就能解决,投入产出比最高。

再修系统性识别错误。比如所有表格式铭牌都错、所有蚀刻铭牌都错,这类问题的错误模式一致,可以通过针对性的提示词补充或者预处理改进来解决。

最后才考虑模型升级或者微调。这一条我放在最后是因为成本最高,而且很多问题在提示词和预处理层面就能解决。我见过不少团队一上来就想着微调,结果把数据标注完发现,问题出在图片被压缩糊了。

我在这个项目上的迭代顺序大致是:格式修复(两天)→ 图片预处理(三天)→ 提示词重构(一周,改了三版)→ 后处理规则(断断续续两周)→ 型号库接入(两天)。真正花在"模型本身"上的时间几乎为零,选型定下来之后就没换过。

有一个细节值得单独提:每次改完必须跑回归。我吃过一次亏,为了解决某个特殊排版铭牌的识别问题,在提示词里加了一段针对性的说明,结果那批铭牌好了,另一批常规铭牌的准确率掉了 4 个百分点。原因是新加的说明占了注意力,让模型对常规版式的判断发生了偏移。后来我定了个规矩——提示词每改一次,跑完整测试集,关键指标不接受任何一项下降,哪怕只是 1%。这个规矩看着死板,但省了很多返工。

后续如果要继续扩展,我觉得有两个方向比较有意思。一是把识别结果和设备的运维记录关联起来,形成"设备档案",识别只是入口,真正的价值在后面;二是把工作流复用到其他类型的标牌上,比如配电柜面板标签、管道介质流向标识、安全警示牌,这套工作流的骨架基本不用改,只需要换提示词里的字段 schema,边际成本很低。我在另一个场景里试过用同一套流程识别消防设施标签,改了两处提示词就跑通了,这大概就是把这层壳抽象出来的最大好处。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询