1. 为什么偏偏选 Dify 来做铭牌识别
先说场景。做过设备台账、资产盘点、工厂巡检这类活的人都知道,最烦的从来不是"识别"这件事本身,而是那堆贴在电机、水泵、配电柜、空压机上的金属铭牌。巴掌大一块铝板,密密麻麻印着型号、额定电压、额定电流、功率、频率、防护等级、出厂编号、生产日期、执行标准,还有一堆认证标志。现场人员举着手机拍一张照片,回去之后要么手打录入到 Excel 或者资产系统里,要么就堆在相册里没人管。一个中型厂区上千台设备,一个人一天能录两百条已经算手快了,还特别容易把0打成O、把380V写成38OV。
基于 Dify 开发的多模态大模型应用-智能铭牌识别,做的就是把这件"拍照—录入—校验"的重复劳动压缩成一次上传。核心思路很直接:借助 Dify 这个可视化的大模型应用编排平台,把"图像理解"和"结构化输出"两件事串成一条工作流,前端传一张铭牌照片,后端直接吐出干净的 JSON 字段,能对上台账就直接入库,对不上的打回人工复核。它解决的是非结构化图像到结构化数据的最后一公里问题,适合三类人参考:一是想给内部业务系统加个"拍照录入"能力的后端开发;二是手上有一堆图片数据要批量处理的实施或数据岗;三是刚开始接触大模型应用、想找一个完整小项目练手的同学。
我前后在这套链路上折腾了小半年,从最早的"直接把图片丢给模型让它读"的粗暴做法,一路迭代到现在带预处理、带校验、带人工回流的三段式流程。中间踩的坑比想象中多:模型读得出来但格式每次都不一样、单位五花八门、反光铭牌直接瞎编、批量跑起来把额度烧光。下面把我这套东西完整拆开讲,包括代码和那些文档里不会写的细节。
2. 需求拆解与技术选型:为什么不是传统 OCR
动手之前得先想清楚一件事:铭牌识别到底难在哪里。想明白这个,选型基本就定了,不用纠结。
2.1 铭牌识别真正的难点不在"看清字"
很多人第一反应是"这不就是 OCR 吗"。如果铭牌都是标准印刷体、正面平拍、光照均匀,那确实传统 OCR 一骑绝尘,又快又便宜。但真实现场是这样:铭牌是金属拉丝面,闪光灯一打一片白;设备用了七八年,字迹被油污和锈蚀吃掉一半;安装位置刁钻,只能斜着仰拍,透视变形严重;同一个参数区,有的是表格排版,有的是"参数名:值"竖排,有的干脆印成一行小字;单位混用,功率有的写 kW 有的写 HP,电压有的标 220~240V 有的标单相 230V。
更要命的是语义层面的混乱。传统 OCR 给你的是一个文本框列表,它认得"A"和"1",但它不知道"A"才是型号的一部分,也不知道"1.5"后面跟着 kW 才是功率。要把这些散落的文本重新拼成"型号=XXX、功率=1.5kW"这种带语义的键值对,得再写一堆规则和模板,每来一个新品牌的铭牌就得加一条正则,维护成本是个无底洞。
这就是我认为多模态大模型在这个场景里真正有价值的地方:它输出的不是文字,而是理解后的结构。你告诉它"提取型号字段",它能在一堆噪声文字里挑出型号,甚至能根据上下文补全被遮挡的半个字符——当然这个能力也是双刃剑,后面会讲它瞎编的时候怎么防。
2.2 传统 OCR + 多模态大模型的分工
我最后的方案不是二选一,是分工。判断逻辑其实简单:
| 维度 | 传统 OCR | 多模态大模型 | 我的用法 |
|---|---|---|---|
| 字符级准确率(清晰图) | 极高 | 较高 | 清晰图优先 OCR |
| 版面理解与字段归属 | 无 | 强 | 靠模型做 |
| 遮挡、污损补全 | 无 | 有(但有幻觉风险) | 仅做参考不落库 |
| 单位换算与归一化 | 需硬编码 | 可用提示词约束 | 模型给原始值,代码做归一化 |
| 单张成本 | 极低 | 有成本 | 大批量先过滤 |
| 多语言混排 | 一般 | 好 | 靠模型 |
实际的链路是这样的:先用图像质量评分做一道筛子,太糊的直接打回重拍,不浪费模型调用;清晰图走 OCR 拿原始文本,同时把图片和 OCR 文本一起喂给多模态模型,让它基于"图像 + 文本"双通道做字段抽取。双通道的好处是互相印证——模型看错了但 OCR 认对了,文本能兜住;OCR 被反光干扰了,模型还能凭图像语义猜个大概。实测下来比单走任何一路都稳。
2.3 Dify 在这个方案里到底承担什么角色
有人会问,这套东西我直接用模型 API 写个脚本不就完了,为什么要套一层 Dify。我的理由是三条,都很实际。
第一,流程编排可视化。铭牌识别这条链路天然是有分支的:图像预处理、质量判定、抽取、校验、兜底重试、人工复核。用纯代码写,这些 if-else 很快就把函数写成面条;在 Dify 里就是画几个节点连起来,条件分支用现成的 IF/ELSE 节点,后面调整顺序、加一个"低置信度走二次抽取"的分支,改两下连线就行,不用重构代码。
第二,提示词和模型解耦。多模态模型迭代很快,今天这个便宜明天那个准,业务上经常要换。如果提示词硬编码在代码里,换一次模型就得改一遍代码、发一次版。Dify 里应用配置和提示词是配置项,换模型只改一个下拉框,提示词还能在界面上做版本对比,A/B 试哪个效果好,这个对非算法岗的同事特别友好。
第三,天然的 API 出口。工作流发布之后自动给你一个 HTTP 接口,前端传图片拿 JSON,不需要自己写服务、部署、搞鉴权。对内部工具类需求,这一条直接省掉一周工作量。
当然边界也要说清楚:Dify 不是万能的。它的代码节点运行环境有限制(依赖不能随便装,超时时间也不长),复杂的图像预处理(比如透视矫正、去反光)我是在外部先做掉再上传的,不指望在节点里跑 OpenCV 全套。另外它的并发和排队策略是平台侧的,真要跑几十万张的批量任务,得自己在外面做队列和限流,不能一把梭往里塞。
3. 整体架构:一条四段式流水线
架构定下来之后再写代码,会省很多返工。
3.1 四个环节的职责划分
我把整条链路切成四段,每段职责单一,出问题好定位:
- 接入层:负责图片接收、格式校验(jpeg/png/webp,单张不超过限制)、大小压缩、EXIF 方向纠正、生成任务 ID。这一层在业务系统侧,不进 Dify。
- 预处理层:图像质量打分(清晰度、亮度、倾斜角),低分直接给出"请重拍"的提示。清晰图可选做自动裁边和对比度增强。
- 抽取层:Dify 工作流主体。上传文件 → 多模态模型抽取 → 代码节点做字段归一化与合法性校验 → 条件分支决定直出还是二次抽取。
- 落地层:结果写库,置信度低于阈值的进人工复核队列,人工改完的样本回流成评估集。
这个切法最大的好处是成本可控。质量分这道筛子能挡掉大概两成的废片,二次抽取只对疑难图触发,真正烧额度的只有中间那一小撮。我算过账,比起无脑全量调模型,这套下来调用量降了三分之一还多。
3.2 输出字段设计:先定 Schema 再写提示词
这一点是我最早的教训。第一版我图省事,直接让模型"把铭牌信息整理成 JSON",结果它每次给的字段名都不一样,有时叫model,有时叫型号,有时嵌套一层spec,下游解析代码天天改。后来我把字段表定死,提示词里强制要求,代码节点再做一次 schema 校验,才算稳定。
核心字段清单如下,具体业务可以按需裁剪:
{ "device_name": "设备名称,如三相异步电动机", "model": "型号,原样保留不做翻译", "serial_no": "出厂编号/序列号,去空格", "rated_voltage": "额定电压,统一为 V,多值时保留原文", "rated_current": "额定电流,统一为 A", "rated_power": "额定功率,统一为 kW", "frequency": "频率,统一为 Hz", "protection_level": "防护等级,形如 IP55", "manufacturer": "制造厂商", "manufacture_date": "生产日期,归一化为 YYYY-MM", "standard": "执行标准号", "confidence": "整体置信度,0~1", "uncertain_fields": ["不确定的字段名列表"], "raw_text": "铭牌上的全部可见文字,按阅读顺序" }两个设计细节值得单独说。一是uncertain_fields,让模型主动标出它没把握的字段,比只给一个总置信度有用得多——总置信度 0.9 但型号那项是猜的,这种图落库就是个雷。二是raw_text一定要留,这是后面人工复核和问题排查的唯一依据,也是迭代提示词时最有价值的素材。
3.3 工作流还是对话流
Dify 里两种应用形态都能做,但铭牌识别我强烈建议用工作流(Workflow),不要用对话助手(Chatflow)。原因很实在:这个任务是无状态的、一次性的、输入输出格式固定的,不需要多轮上下文。用对话流的话,历史消息会带上,模型容易受上一张图干扰,而且每次调用都要传会话 ID,批量处理时管理麻烦。工作流模式输入即输出,接口干净,做批量脚本时直接循环调用就行。
4. 在 Dify 里把工作流搭起来
这部分是实操重点,我按节点顺序一个个说,包括每一处为什么这么配。
4.1 前置准备:模型选择与变量定义
先建应用,类型选工作流。进去之后第一件事是定义开始节点的输入变量,这里我定义了三个:
plate_image:类型 File(单文件,限制 image),必填。这是主输入。ocr_text:类型 文本,选填。如果外部已经跑过 OCR,把文本带进来,模型判断会更准;没有就留空。strict_mode:类型 文本,选填,默认off。开启后模型只输出铭牌上明确存在的字段,拒绝任何推理补全。
选模型这块,原则是能看清小字 + 输出结构稳定。我试过几款不同量级的多模态模型,体感是:参数量大一些的模型在密集小字上明显更稳,尤其是那种参数区一行挤七八项的铭牌;小模型速度快、便宜,但容易把相邻两行的值串到一起。建议正式跑之前,自己造 30 张有代表性的测试图(清晰、反光、锈蚀、斜拍、英文铭牌各几张),把候选模型都跑一遍,看字段级准确率而不是整体观感。我当初就是被几张"看起来很准"的样例骗了,全量跑起来才发现型号字段错得离谱。
提示:模型选择不要只看单次效果,一定要看输出格式的稳定性。有些模型内容对得很,但 JSON 偶尔漏个引号或者多句解释性文字,你的解析代码就崩了。
4.2 图像预处理节点的替代方案
前面说过,Dify 代码节点的运行环境不适合跑重型图像处理。我的做法是把预处理放在外部,工作流内只做一个轻量的"质量判定":
如果业务系统侧已经把图片处理好(压缩到长边 1600px 左右、纠正方向、必要时裁掉背景),工作流里其实可以直接进抽取节点。非要在这里做判定的话,可以用一个 LLM 节点做视觉质量评估,提示词大致是"判断这张铭牌照片是否适合识别:文字是否清晰可辨、是否严重反光或遮挡、是否倾斜过大。仅输出 PASS 或 FAIL 加简短理由"。这个判定不追求精确,作用是挡掉明显废片。代价是多一次模型调用,所以如果你的图源质量本来就不错(比如都用固定工装拍摄),这一步可以省掉,我后来的版本就砍了。
4.3 抽取节点的提示词怎么写才不飘
这是整个应用的核心,我前后改了七八版。先给完整版,再逐条解释为什么这么写。
你是工业设备铭牌信息抽取工具。你的唯一任务是从给定图片(以及可能提供的 OCR 文本)中,抽取结构化字段,并严格按指定 JSON 格式输出。 【输入】 图片:铭牌照片 OCR 文本(可能为空):{{#start.ocr_text#}} 严格模式:{{#start.strict_mode#}} 【抽取规则】 1. 只提取铭牌上真实存在的文字信息,不要根据常识推断、补全或翻译。 2. 型号、出厂编号、执行标准号必须逐字符原样保留,包括大小写、连字符、斜杠、小数点。不要规范化,不要纠正"看似笔误"的字符。 3. 数值字段保留数字与单位;若铭牌标注多个值(如 220~240V、50/60Hz),原样保留该表达,不做换算。 4. 无法辨认的字符用 ? 占位(例如 SN-202?0345),不要猜。 5. 铭牌上不存在的字段,值填空字符串 "",并在 uncertain_fields 中加入该字段名。 6. 若图片模糊、反光或遮挡导致某个字段只能靠推测,即使推测值合理,也必须把字段名加入 uncertain_fields。 7. 严格模式为 on 时,任何需要推理才能得出的值一律留空并标记 uncertain。 【输出要求】 仅输出一个 JSON 对象,不要输出任何解释文字、不要使用 Markdown 代码块包裹、不要在 JSON 前后添加任何字符。 JSON 结构必须严格为: { "device_name": "", "model": "", "serial_no": "", "rated_voltage": "", "rated_current": "", "rated_power": "", "frequency": "", "protection_level": "", "manufacturer": "", "manufacture_date": "", "standard": "", "confidence": 0.0, "uncertain_fields": [], "raw_text": "" } 字段说明: - confidence:对本次抽取整体准确性的自评,0 到 1 的小数。 - uncertain_fields:字符串数组,列出你不确定的字段名,没有则给空数组。 - raw_text:把铭牌上所有可见文字按从上到下、从左到右的顺序拼接,用换行分隔;辨认不清的字符用 ? 代替。几个关键设计点,都是踩坑换来的:
规则 2 里的"不要规范化"。最开始我写的是"型号请规范化为大写",结果模型把MTR-3a/2改成MTR-3A/2,台账里跟历史数据对不上了。工业场景里型号就是主键,一个字符都不能动。
规则 4 的?占位。这一条效果出乎意料地好。以前模型遇到半个字被锈吃掉,会硬猜一个,猜错了你完全看不出;现在它会输出SN-202?0345,代码节点一看到?就知道要转人工,成本很低但拦住了大量脏数据。
规则 7 的严格模式。这个开关是给不同业务线用的。资产入账要求零差错,开严格模式;巡检初筛只看个大概,关掉能提高召回。一个应用覆盖两种诉求,比维护两套提示词省事。
关于"仅输出 JSON"的反复强调。多模态模型特别喜欢在 JSON 前面加一句"好的,以下是提取结果:",后面还跟个代码块标记。加了"不要使用 Markdown 代码块包裹"这一句之后,干净了很多,但没根治。所以后面代码节点里我还加了一层容错解析,属于双保险。
4.4 代码节点:字段归一化与合法性校验
模型给的是"原文",落到业务系统要的是"规范值"。这一步交给代码节点,用 Python 写,主要干四件事:JSON 容错解析、单位归一化、格式合法性校验、置信度重算。
import json import re def main(extract_result: str) -> dict: # ---------- 1. 容错解析 ---------- text = (extract_result or "").strip() # 去掉可能的代码块包裹 if text.startswith("```"): text = re.sub(r"^```[a-zA-Z]*\s*", "", text) text = re.sub(r"\s*```$", "", text) # 截取第一个 { 到最后一个 } 之间的内容 start, end = text.find("{"), text.rfind("}") if start == -1 or end == -1: return { "ok": False, "reason": "no_json_found", "payload": {}, "need_review": True } try: data = json.loads(text[start:end + 1]) except Exception as e: return { "ok": False, "reason": f"json_parse_error: {e}", "payload": {}, "need_review": True } # ---------- 2. 缺字段补空 ---------- fields = [ "device_name", "model", "serial_no", "rated_voltage", "rated_current", "rated_power", "frequency", "protection_level", "manufacturer", "manufacture_date", "standard", "raw_text" ] for f in fields: if f not in data or data[f] is None: data[f] = "" # ---------- 3. 数值与单位归一化 ---------- def pick_number(s): m = re.search(r"\d+(?:\.\d+)?", str(s)) return float(m.group()) if m else None # 功率统一 kW p_raw = str(data["rated_power"]).upper().replace(" ", "") p_kw = None n = pick_number(p_raw) if n is not None: if "HP" in p_raw or "马力" in p_raw: p_kw = round(n * 0.7355, 3) elif "MW" in p_raw: p_kw = n * 1000 elif "W" in p_raw and "KW" not in p_raw: p_kw = round(n / 1000, 4) else: p_kw = n data["rated_power_kw"] = p_kw # 电压统一 V v_raw = str(data["rated_voltage"]).upper().replace(" ", "") n = pick_number(v_raw) data["rated_voltage_v"] = n # 电流统一 A i_raw = str(data["rated_current"]).upper().replace(" ", "") data["rated_current_a"] = pick_number(i_raw) # 频率统一 Hz f_raw = str(data["frequency"]).upper().replace(" ", "") data["frequency_hz"] = pick_number(f_raw) # ---------- 4. 格式合法性校验 ---------- problems = [] # 序列号:不允许出现 ? 占位 if "?" in str(data["serial_no"]): problems.append("serial_no_incomplete") # 防护等级:必须形如 IP + 两位数字 + 可选字母 ip = str(data["protection_level"]).upper().replace(" ", "") if ip and not re.fullmatch(r"IP\d{2}[A-Z]{0,2}", ip): problems.append("ip_format_invalid") else: data["protection_level"] = ip # 生产日期:归一到 YYYY-MM d = str(data["manufacture_date"]) m = re.search(r"(19|20)\d{2}", d) if m: year = m.group() mm = re.search(r"(?<!\d)(0[1-9]|1[0-2])(?!\d)", d.replace(year, "")) data["manufacture_date"] = f"{year}-{mm.group()}" if mm else year elif d: problems.append("date_unrecognized") # 型号:不允许空 if not str(data["model"]).strip(): problems.append("model_missing") # ---------- 5. 置信度重算 ---------- try: conf = float(data.get("confidence", 0)) except Exception: conf = 0.0 uncertain = data.get("uncertain_fields") or [] if not isinstance(uncertain, list): uncertain = [] # 每出现一个不确定字段,扣 0.08;每个格式问题扣 0.1 conf = max(0.0, conf - 0.08 * len(uncertain) - 0.10 * len(problems)) # 关键字段缺失直接判定需复核 critical = ["model", "rated_power_kw"] need_review = bool(problems) or conf < 0.7 or any( data.get(k) in (None, "") for k in critical ) or len(uncertain) > 0 return { "ok": True, "payload": data, "confidence": round(conf, 3), "problems": problems, "need_review": need_review }这段代码有几个地方值得单独强调。
rated_power_kw这类派生字段单独存,不要覆盖原值。原始值rated_power保留着,归一化结果另开字段,原因是万一换算规则错了(比如某些特殊设备的 HP 定义不同),你还能回溯原始数据重新算。我吃过这个亏,第一版直接覆盖,后来发现一批国外设备的马力换算系数用错了,原始值没了,只能重新调模型识别一遍。
need_review的判定逻辑要写死,不要只信模型。模型自评置信度普遍偏高,尤其是它"觉得"自己看对了的时候。所以我在代码里加了硬规则:关键字段缺失、格式校验不过、有不确定字段,任意一条命中就进复核。宁可多复核,不可错入账。
关于返回值结构。代码节点的输出会成为后续节点的变量,所以我在外面包了一层ok和reason,出问题时下游分支能直接拿到原因字符串,日志里一眼看懂卡在哪一步。
4.5 条件分支与二次抽取
抽取节点之后接一个 IF/ELSE 分支,判断依据是代码节点输出的need_review。
走"需要复核"那一支,可以再接一个二次抽取的 LLM 节点,这次把strict_mode强制设为 on,同时把第一次的raw_text一起塞进提示词,让它"基于原文逐字比对,只保留确凿存在的内容"。实测二次抽取能把一部分第一次因为联想而错的字段纠正回来,尤其是型号和编号这类字符级错误的修正效果不错。
二次抽取之后还要再挂一个代码节点做同样的校验,然后进结束节点。这种"重试一次"的设计,比一上来就把提示词写得无比严格更实用——第一次宽一点保召回,第二次严一点保精度,两次结果一致的可信度最高。
5. 用 API 把能力接进业务系统
工作流在界面上跑通只是第一步,真正的落地一定是代码调用。
5.1 接口形态与鉴权
工作流发布后,Dify 会给一个接口地址,路径通常是/v1/workflows/run,鉴权用应用密钥,放在Authorization: Bearer app-xxxx这个头里。调用有两种响应模式:blocking同步等结果,适合单张、交互式;streaming流式拿,适合前端要显示"处理中"的过程感。铭牌识别是短任务,一般几秒到十几秒,我全程用blocking,简单。
图片的传法有两种:一是先调/v1/files/upload上传拿到upload_file_id,再在 inputs 里引用;二是直接传公网可访问的 URL。前者适合内部系统(图片在内网),后者适合图片本来就在对象存储里的场景。我选上传,因为可控,而且能复用文件。
5.2 Python 调用完整示例
import time import requests BASE_URL = "https://your-dify-host/v1" API_KEY = "app-xxxxxxxxxxxxxxxx" HEADERS = {"Authorization": f"Bearer {API_KEY}"} def upload_image(path: str, user: str = "plate-bot") -> str: """上传本地图片,返回 upload_file_id""" with open(path, "rb") as f: resp = requests.post( f"{BASE_URL}/files/upload", headers=HEADERS, files={"file": (path.split("/")[-1], f, "image/jpeg")}, data={"user": user}, timeout=60, ) resp.raise_for_status() return resp.json()["id"] def recognize_plate(path: str, strict: str = "off", ocr_text: str = "") -> dict: """识别单张铭牌,返回结构化结果""" file_id = upload_image(path) payload = { "inputs": { "plate_image": { "type": "image", "transfer_method": "local_file", "upload_file_id": file_id, }, "ocr_text": ocr_text, "strict_mode": strict, }, "response_mode": "blocking", "user": "plate-bot", } resp = requests.post( f"{BASE_URL}/workflows/run", headers={**HEADERS, "Content-Type": "application/json"}, json=payload, timeout=180, ) resp.raise_for_status() body = resp.json() # 工作流输出在 data.outputs 里,字段名取决于结束节点的配置 outputs = body.get("data", {}).get("outputs", {}) return { "task_id": body.get("workflow_run_id"), "status": body.get("data", {}).get("status"), "result": outputs.get("result", {}), "confidence": outputs.get("confidence", 0), "need_review": outputs.get("need_review", True), } if __name__ == "__main__": t0 = time.time() r = recognize_plate("./samples/plate_001.jpg") print(f"耗时 {time.time() - t0:.1f}s") print(r)有几处容易出问题,提前说:
user字段必须传,而且不要全用同一个值。这个字段在平台侧用来区分调用方,做统计和限流。我最早图省事全写死一个字符串,后来想看具体是哪个业务线在猛调,傻眼了。建议按业务线或终端 ID 生成,比如plant-a-terminal-07。
inputs里的变量名必须和开始节点定义的一模一样,大小写敏感。这个坑我踩过,变量名写错了不报错,模型收到空输入,输出一堆空字段,排查了半小时才发现是拼写问题。
超时时间要给够。多模态推理本来就慢,遇到大图或者二次抽取,二三十秒很正常。我一开始设 30 秒,跑批的时候大量超时,改到 180 秒才稳。
5.3 批量处理:并发与限流要自己管
批量跑几千张图,几个原则:
并发别开太大。我实测下来并发 4~6 是比较舒服的区间,再往上收益就不明显了,反而超时率飙升。用线程池控制就行:
from concurrent.futures import ThreadPoolExecutor, as_completed def batch_run(paths, max_workers=4): results = [] with ThreadPoolExecutor(max_workers=max_workers) as pool: future_map = {pool.submit(recognize_plate, p): p for p in paths} for fut in as_completed(future_map): p = future_map[fut] try: results.append({"path": p, **fut.result()}) except Exception as e: results.append({"path": p, "status": "error", "error": str(e)}) return results一定要断点续跑。批量任务跑到一半挂了,不能从头再来。我的做法是每处理完一张就往 SQLite 或者 CSV 里追加一条,重跑时先读已完成清单,跳过已有的。这个看起来是小事,但真到跑一万张的时候能救命。
另外失败要分类。网络超时、平台限流、图片格式错误,这三种的重试策略完全不一样:超时可以退避重试,限流要降并发,格式错误重试一万次也没用。我在外层加了个简单分类,只有超时类才做最多三次的指数退避重试。
5.4 结果落库与人审回流
落库这块没什么花哨的,关键是字段对齐和留痕。我建了两张表:一张主表存结构化结果(含task_id、原始识别值、归一化值、置信度、复核状态),一张明细表存每一版的raw_text和模型返回的完整 JSON。后者看起来冗余,但复盘时候特别有用——想搞清楚某类铭牌为什么总识别错,直接翻历史原始返回,比重新跑一遍快得多。
人审环节我用的是最简单的做法:一个网页,左边显示原图,右边是可编辑的表单,字段预填识别结果,不确定字段标黄。审完提交,把这个"人工修正后的值"和"模型原始值"一起存下来,形成标注对。积累到几百条,就有了自己的评估集和微调素材,比什么都值钱。
6. 实测效果与常见问题排查
聊点实在的数据和坑。
6.1 各环节耗时与效果分布
我拿一批约 600 张真实铭牌照片做过统计,包括手机随手拍、巡检工装拍、翻拍纸质记录的扫描件:
| 指标 | 结果 |
|---|---|
| 一次抽取成功率(返回合法 JSON) | 约 96% |
| 关键字段(型号、功率)全对率 | 约 82% |
| 触发二次抽取的比例 | 约 18% |
| 最终需人工复核比例 | 约 11% |
| 单张平均耗时(含上传) | 8~15 秒 |
| 单张成本相对人工录入 | 降到原耗时的 1/10 左右 |
关键字段全对率 82% 这个数字,说实话一开始我不满意,但配合人审之后,端到端效率提升还是很明显的,因为人只需要复核那 11%,剩下的直接过。而且这个数字随着提示词迭代和样本积累在往上走,第二个月就到了 88% 左右。
6.2 常见问题速查
这张表是我自己攒的,基本覆盖了跑这套流程能遇到的大多数问题:
| 现象 | 大概率原因 | 处理办法 |
|---|---|---|
| 模型在前面加"好的,以下是..." | 提示词约束不够强 | 提示词末加强制指令 + 代码节点做截取容错 |
| 型号被自动"纠错"(大小写、连字符变了) | 模型默认做了规范化 | 规则里明确写"逐字符保留,不纠正" |
| 功率数值对但单位乱(HP/kW/W 混) | 模型直接搬运原文 | 归一化放代码里做,别指望模型算 |
出厂编号变成一串? | 图片过曝或划痕 | 转人工,不要用模型猜的值;同时在前端提示重拍 |
| 同一张图两次调用结果不一致 | 模型采样随机性 | 业务上要求确定性时温度设 0;关键字段做二次比对 |
| 大批量跑到一半全超时 | 并发过高触发限流 | 降到 4 并发,加指数退避重试 |
| 学习过的品牌铭牌突然识别变差 | 提示词改版引入了副作用 | 保留评估集,每次改提示词全量回归一次 |
| 上传成功但输入为空 | inputs 变量名拼写不一致 | 对照开始节点变量名逐字检查 |
| 日期识别成"有效期限"的年份 | 铭牌上多个日期,模型串了 | 提示词中明确"生产日期字段通常紧跟 MFD 或制造年月字样" |
6.3 一个典型的排查过程
有段时间我发现,某几类配电柜铭牌的生产日期错误率异常高。翻了几十条原始数据才看明白:这些铭牌上有三个日期——生产日期、出厂检验日期、合格证有效期,格式完全一样,都在同一片区域。模型有时候会抓错。
改法分两步。第一步在提示词里加一句定位线索:"生产日期通常标注为 MFD、MANUFACTURED、制造年月、生产日期 等字样,紧邻该字样的日期为生产日期;若无任何字样标注,该字段留空并标记 uncertain。"第二步在代码节点加了个日期合理性校验:如果识别出的日期晚于当前日期,或者早于 1980 年,直接判为异常转人工。
改完之后这类错误基本消失。这个案例给我的启发是:模型的错误往往不是"看不清",而是"没说清该看哪里"。补充空间位置和标识词的线索,比反复强调"要准确"有用得多。
7. 几条只有真跑过才明白的经验
最后分享几个不太适合放进系统设计、但确实影响落地效果的心得。
一定要先做 30 张图的黄金测试集,再做别的。选题材的时候要有意识覆盖:正面清晰、强反光、锈蚀严重、斜拍、中英混排、纯英文、参数表排版、竖排。这 30 张图是你后面所有提示词改动的回归基准,没有它,你根本不知道自己是改好了还是改坏了。我最初的版本就是因为没有基准集,改到第七版反而比第三版差,白白绕了弯路。
图片质量决定上限,提示词只能逼近上限。这个话说得有点绝对,但在铭牌场景是实话。同一张糊到看不清的图,你换十个模型、写二十版提示词也救不回来。所以我在接入层加了一道硬性约束:长边低于 800px 的直接拒,模糊度超阈值的提示重拍。前端加一句"请正对铭牌、避免闪光灯直射、尽量填满画面",能省掉后面一大半的麻烦。
别迷信置信度这个数字,要信规则。模型自评的 confidence 只能当参考,真要靠它做分流,迟早出错。我现在的做法是:规则校验(关键字段非空、格式合法、无?占位)为主,置信度只作为辅助排序——同样的图里,先复核置信度低的那些。
留着 raw_text,它是你未来的资产。很多人只存结构化字段,把原始文本扔了。等到你想分析"为什么型号总错",或者想训练一个专用的小模型,才发现没有原始语料。这个字段几乎不占成本,价值却随时间是复利增长的。
换模型的时候,别只看准确率,还要看稳定性。我换过一次模型,字段准确率涨了 3 个点,但输出格式的抖动明显增加,JSON 缺引号的比例从 1% 涨到 5%,结果代码节点的容错逻辑被反复触发,整体人工复核率反而上升了。后来的评估标准就变成了"准确率 × 格式合格率"这个组合指标,单看任何一个都会误导。
这套东西现在的形态依然不算完美,人工复核那 10% 左右短期内估计消不掉,涉及锈蚀和遮挡的图,模型确实没辙。接下来我打算做两件事:一是把人工复核的修正数据攒起来,做一个轻量的字段纠错模型,专门处理型号和编号这两类字符级错误;二是给前端加上实时取景框,拍照时就提示对齐和光照,从源头减少废片。这两条哪条收益大,还得跑一段时间数据才知道。