☰
Dify+多模态大模型:智能铭牌识别工作流实践
2026/10/26 14:07:45 网站建设 项目流程

1. 先说清楚:这个项目到底在解决什么问题

1.1 一次被骂出来的需求

我第一次接触“铭牌识别”这个场景,是在一个设备台账数字化的项目里。现场运维的人抱着一台机器,对着铭牌拍了张照,然后要在系统里手工敲进去十来个字段:型号、额定功率、电压、电流、出厂编号、生产日期、厂商、防护等级……一台机器两三分钟,一个厂区几千台设备,光录入就够一个小组干上两个月。更麻烦的是,手抄很容易出错,字母 O 和数字 0、字母 l 和数字 1,“3kW”写成“30kW”这种事我都见过。

于是很自然地想到了用技术解决。第一反应是上传统的 OCR:拍图、识别文字、正则抽取字段。实际跑下来问题很集中——铭牌上的文字排版千奇百怪,有的是表格,有的是竖排,有的字段名和值挤在一行,还有大量中英混排、符号缩写(比如“IP55”“Y2-132M-4”这类型号编码)。纯 OCR 只能把一堆字吐出来,谁是谁完全分不清,正则写到最后变成了一堆 if-else 的屎山,稍微换个厂商的铭牌就全线崩。

第二反应是直接调多模态大模型:把图丢进去,让它按 JSON 输出字段。效果确实好了一大截,模型能理解“额定功率”和“3kW”之间的对应关系,也能猜出被锈斑遮住半个字的字段名。但裸调模型也有它的麻烦:提示词散落在代码里、改一次要发一次版、不同场景想换模型要改代码、没有可视化调试、多个客户要用不同字段模板时管理混乱。

最后落地的方案是:**用 Dify 做应用编排,把图像预处理、多模态大模型抽取、规则后处理、字段校验串成一条可视化工作流,外层用一段轻量 Python 代码负责接入业务系统。**这就是标题里说的“基于 Dify 开发的多模态大模型应用-智能铭牌识别”。

这套东西解决的核心问题其实就三个:把非结构化的现场照片变成结构化字段;把提示词和模型配置从代码里抽出来,让非算法同学也能调;把识别结果的可信度量化出来,让人工只复核有疑问的那部分。适合谁参考?我觉得是三类人:做设备管理/资产管理数字化的开发;想入门多模态应用但不知道从哪下手的工程师;以及手里已经有一堆现场照片、苦于没法批量结构化的人。

1.2 为什么是“Dify + 多模态”这个组合

拆开来讲,Dify 在这条链路里承担的角色是编排层。它把“输入图片 → 调模型 → 拿结果 → 跑一段自定义代码 → 输出”这件事变成了可视化的节点流,好处是很实在的:提示词在界面上改,保存即生效,不用重新部署;模型可以在配置里切换,今天用云端多模态接口,明天换成本地部署的开源视觉模型,业务代码一行不动;每个节点的输入输出都能在日志里看到,出问题的时候能立刻定位是“图片没传对”还是“模型抽错了”还是“后处理把正确的值改坏了”。

多模态大模型承担的是语义抽取层。它跟 OCR 的根本区别在于,OCR 给的是“像素到字符”的映射,多模态模型给的是“图像到语义”的映射。铭牌上一行写着“额定功率 3 kW”,OCR 吐出来是四个孤立的文本块,多模态模型能直接告诉你“rated_power = 3kW”。这个差别在字段结构化任务上是决定性的。

中间我还加了一层轻量图像预处理,也就是在图片进模型之前做方向纠正、尺寸压缩、对比度增强。这一步看着不起眼,但对识别率的影响比我预想的大得多——同一批照片,光是做了 EXIF 方向纠正,竖拍照片的字段完整率就从七成出头拉到了九成以上。

2. 整体链路设计与关键决策

2.1 从一张现场照片到一条台账记录的完整链路

整条链路由四段构成,我在设计时特意把每一段的边界划清楚,因为这类项目最容易出问题的地方就是“不知道错误是哪一段引入的”。

第一段是采集端。手机或手持终端拍照,图片通过接口上传。这里有个容易被忽略的点:上传前就要做一次尺寸约束,长边压到 1280 像素左右,质量 85~88。原因是现场照片动辄 4000×3000、三四兆一张,直接送进模型既慢又贵,而对铭牌这种主体占画面比例较大的场景,1280 长边已经足够模型看清每一个字符。实测下来,压缩前后的字段抽取准确率差异在 1 个百分点以内,但单张耗时能从七八秒降到三秒左右。

第二段是Dify 工作流。核心节点只有四个:开始节点接收图片变量;一个视觉大模型节点负责“看图说话”,按预设的 JSON Schema 输出字段;一个代码节点做规则清洗和数值归一化;结束节点把结构化结果吐出来。中间我还挂了一个条件分支,用来处理“一张图里有多个铭牌”的情况,这个后面细说。

第三段是业务侧后处理。Dify 返回的 JSON 并不是直接入库的,还要经过一层校验:必填字段有没有缺、数值有没有超出合理区间、型号编码的字符集对不对、跟历史台账比对有没有明显冲突。校验不通过的记录打上标记进入人工复核队列。

第四段是反馈闭环。人工复核的结果会被记录下来,定期回灌成提示词里的 few-shot 示例。这一步是整个系统能越用越准的关键,也是很多人做完就忘掉的一步。

2.2 为什么不走“纯 OCR 后接规则”的老路

我认真试过这条路,结论是:在铭牌这个特定场景下,纯 OCR 加规则的维护成本会随着厂商数量线性上升,而且是那种“越修越脆”的上升。

举个例子。某个厂商的铭牌是表格式排版,字段名在左列、值在右列,OCR 出来是“型号 | 额定功率 | 电压 | 电流”一行,下面一行是“Y2-132M-4 | 3kW | 380V | 6.8A”。这种情况下你需要靠坐标位置去配对,也就是得同时拿到 OCR 的文本框坐标,再写一套基于行列关系的匹配算法。换一个厂商,排版变成“字段名: 值”的横向列表,你的坐标算法就得重写。再换一个竖排的铭牌,坐标算法彻底失效。

多模态模型的好处是它天然理解“布局语义”,你不需要告诉它“左边是键右边是值”,它自己看得出来。代价是它可能看错,所以要用后处理去兜。用规则兜底模型,而不是用规则替代模型,这个思路上的转变是我在这个项目里最大的收获。

2.3 为什么选择在 Dify 里做编排而不是自己写脚本

坦白说,如果只是自己一个人用、只服务一个客户、字段模板永远不变,写个 Python 脚本调 API 是最快的,半天就能跑通。但我遇到的实际情况是:字段模板有四五套(不同设备类型字段不一样)、提示词在调试期一天改十几遍、后面还想换更便宜的模型试试效果。

这三件事在裸脚本里的成本分别是:字段模板靠参数传入,越传越乱;提示词改一次要重启服务;换模型要改 SDK 调用方式和返回解析逻辑。而在 Dify 里,这三件事分别对应“建多个应用/用变量控制”“界面上改完保存”“模型下拉框里换一个”。省下来的时间足够我把后处理规则打磨得更细。

还有一个隐性好处:工作流的运行日志是可视化的。给客户演示、给同事交接、自己排查问题,都能直接看到“这张图进来的时候是什么样、模型返回了原始什么、代码节点改了什么”。这比在日志文件里 grep 一堆 JSON 舒服太多。

3. 动手实现:从零搭一条可用的识别流水线

3.1 环境准备与 Dify 端的应用配置

Dify 的部署方式按官方文档来就行,本地用容器方式起一套,注意把存储卷挂出来,不然文件上传的记录重启就没了。起好之后进控制台,创建一个工作流类型的应用,不要建聊天助手类型——这两者的区别在于工作流是单次输入单次输出,更适合这种“一张图进、一条 JSON 出”的场景,也更好做批量。

工作流建好之后按下面的顺序搭节点:

第一个节点是开始节点,定义一个变量image,类型选单文件(file),限定只接受图片。这个变量的作用是把业务侧上传的文件 ID 传进来。

第二个节点是视觉大模型节点。模型选择上,云端的多模态接口和本地部署的开源视觉语言模型都能用,区别主要是成本和数据出域的问题。如果铭牌图片涉及敏感的设备信息,建议走本地部署;如果只是通用设备台账,云端接口的开发效率更高。这个节点里最关键的是提示词和输出格式约束,下一节展开。

第三个节点是代码节点,语言选 Python,负责清洗、归一化、补默认值。这个节点的入参就是上一个节点的文本输出。

第四个节点是结束节点,把代码节点的输出映射成最终的结构化字段。

配完之后在界面上用“运行”按钮试几张图,看看中间每个节点的输入输出,这一步别跳过,很多问题在这里就能看出来。比如我就遇到过一次,模型节点输出的 JSON 外面包了一层 markdown 代码块标记,代码节点解析直接报错,在日志里一眼就能看到。

3.2 提示词与输出结构的设计要点

提示词这块我踩过的坑最多,总结下来有几条是必须写进去的:

第一,只输出 JSON,不要任何解释性文字。这一点必须明确写,否则模型很爱在 JSON 前面加一句“根据图片内容,识别结果如下”。虽然可以在后处理里用正则把 ```json 剥掉,但从源头掐掉更省事。

第二,字段缺失时填 null,绝对不允许编造。这是最重要的一条。多模态模型有个坏习惯,看到“额定功率”这四个字但值看不清,它会根据型号猜一个填上去。对台账系统来说,猜错比空着危险得多。所以提示词里要反复强调“无法确认的字段一律输出 null,不要推测”。

第三,单位和格式的约定写在提示词里。比如功率统一输出瓦特数值(3kW 输出 3000),电压统一输出伏特整数,日期统一YYYY-MM-DD,型号保留原始大小写和连字符。这些约定如果只写在代码里,模型返回的还是带单位的字符串,你就得在代码里再写一堆解析逻辑,不如让它一步到位。

第四,给两三个示例。铭牌的字段抽取是个典型的格式敏感任务,给两三个不同排版的示例(一个是表格排版、一个是列表排版),准确率提升非常明显。示例不用多,多了反而占 token。

输出结构我用的是一份固定字段的 JSON,大致长这样:

{ "model": "设备型号,字符串,看不清填 null", "rated_power_w": "额定功率,单位为瓦特的数字,看不清填 null", "rated_voltage_v": "额定电压,单位为伏特的数字", "rated_current_a": "额定电流,单位为安培的数字", "serial_no": "出厂编号,字符串,保留原始大小写与连字符", "manufacture_date": "生产日期,格式 YYYY-MM-DD,只有年月则补 01", "protection_level": "防护等级,如 IP55", "manufacturer": "厂商名称,字符串", "confidence": "0 到 1 之间的数字,表示整体识别把握程度" }

最后那个confidence字段看起来有点玄学,但它确实有用——让模型自己给一个把握程度,配合后处理的规则,可以粗略地把“这张图大概率要人工看一眼”的记录筛出来。它的绝对值不准,但相对排序是有参考价值的,我一般会把低于 0.6 的丢进复核队列。

3.3 图像预处理:那几行容易被跳过的代码

预处理这块的代码不多,但每一行都有它的理由。先说结论:方向纠正、尺寸压缩、对比度与锐度轻微增强,这三件事做完,识别率能有一个明显提升,而且耗时和成本都下降。

from PIL import Image, ImageOps, ImageEnhance def preprocess(src_path, dst_path, max_side=1280, quality=88): img = Image.open(src_path) # 关键一步:按 EXIF 信息把照片转正,手机竖拍的图不带这步会横过来 img = ImageOps.exif_transpose(img) w, h = img.size scale = max_side / max(w, h) if scale < 1: img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS) # 轻微增强,参数不要贪大,过犹不及 img = ImageEnhance.Contrast(img).enhance(1.15) img = ImageEnhance.Sharpness(img).enhance(1.20) img.convert("RGB").save(dst_path, "JPEG", quality=quality, optimize=True) return dst_path

方向纠正为什么要单独强调?因为手机拍的照片里,实际像素方向和显示方向经常不一致,靠 EXIF 里的 Orientation 标记来纠正。如果不处理,竖着拍的铭牌到了模型眼里就是躺倒的,横排文字变成竖排,识别率掉得厉害。这个问题在本地看图片时完全发现不了(看图软件会自动转正),只有传到服务端才会暴露。

对比度和锐度的增强系数我一直控制在 1.1~1.2 之间。试过 1.5,结果噪点也被放大了,模型反而更容易把锈斑看成字符。参数这东西,够用就行。

如果你的场景里铭牌经常是斜着拍的、有明显的透视变形,可以再加一步透视矫正:先用边缘检测找到铭牌的四个角点,做一次四点透视变换把它拉正。这一步对纯 OCR 帮助很大,对多模态模型来说收益没那么明显,因为它对轻微变形本身就有一定鲁棒性。所以我一般把它作为可选项,只有在某些特定批次的照片上效果不好时才启用。

3.4 调用 Dify 工作流的完整代码

业务侧要做的就两件事:把图传上去拿到文件 ID,然后带着这个 ID 调工作流。下面这段是能直接跑的:

import os import requests DIFY_BASE = os.environ.get("DIFY_BASE", "http://127.0.0.1/v1") API_KEY = os.environ.get("DIFY_APP_KEY", "app-xxxxxxxxxxxxxxxx") USER_ID = "plate-recognizer" def upload_file(path, timeout=30): """上传本地图片,返回 Dify 侧的文件 ID""" name = os.path.basename(path) with open(path, "rb") as f: resp = requests.post( f"{DIFY_BASE}/files/upload", headers={"Authorization": f"Bearer {API_KEY}"}, files={"file": (name, f, "image/jpeg")}, data={"user": USER_ID}, timeout=timeout, ) resp.raise_for_status() return resp.json()["id"] def run_workflow(file_id, timeout=90): """阻塞模式跑一次工作流,返回 outputs 字典""" payload = { "inputs": { "image": { "transfer_method": "local_file", "upload_file_id": file_id, "type": "image", } }, "response_mode": "blocking", "user": USER_ID, } resp = requests.post( f"{DIFY_BASE}/workflows/run", headers={ "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json", }, json=payload, timeout=timeout, ) resp.raise_for_status() data = resp.json() if data.get("data", {}).get("status") != "succeeded": raise RuntimeError(f"workflow failed: {data}") return data["data"]["outputs"] def recognize(path, workdir="/tmp/plate"): os.makedirs(workdir, exist_ok=True) fixed = os.path.join(workdir, os.path.basename(path).rsplit(".", 1)[0] + "_pre.jpg") preprocess(path, fixed) fid = upload_file(fixed) return run_workflow(fid)

几个实操上的注意点。response_mode我用的blocking,也就是同步等结果,适合单张交互式场景。如果是批量跑几千张,建议改成streaming或者干脆用批处理接口,同步模式下并发一高就容易超时。超时时间给到 90 秒是留了余量的,正常情况下三五秒就返回了,但遇到模型侧排队或者网络抖动,太短的超时会导致大量无谓的重试。

文件上传接口里那个user字段不是可选的,Dify 用它来区分会话和统计用量,随便传个固定字符串就行,但别传空。另外,上传的文件是有生命周期的,别指望传一次能反复用,每次识别都老老实实传一遍最稳妥。

3.5 代码节点里的清洗与归一化

Dify 工作流中间那个代码节点,是整个链路的“除错层”。模型返回的东西看着规整,实际用起来你会发现它总能在意料之外的地方给你惊喜:功率给了个“3 kW”带空格的字符串、日期给了“2023年5月”、型号里的连字符被识别成了破折号、该填 null 的地方给了空字符串。

import json import re NULL_TOKENS = {"", "null", "none", "n/a", "na", "-", "--", "未知", "不详"} def _to_null(v): if v is None: return None s = str(v).strip() return None if s.lower() in NULL_TOKENS else s def _to_number(v, unit_scale=None): """从字符串里抠出数字,支持 3kW / 3,000W / 3000 这几种写法""" s = _to_null(v) if s is None: return None m = re.search(r"[-+]?\d[\d,\.]*", s) if not m: return None num = float(m.group(0).replace(",", "")) low = s.lower() if unit_scale: for unit, factor in unit_scale.items(): if unit in low: num *= factor break return num def _norm_date(v): s = _to_null(v) if s is None: return None m = re.search(r"(\d{4})\D{0,2}(\d{1,2})(?:\D{0,2}(\d{1,2}))?", s) if not m: return None y, mo, d = m.group(1), int(m.group(2)), int(m.group(3) or 1) if not (1 <= mo <= 12): return None if not (1 <= d <= 31): d = 1 return f"{y}-{mo:02d}-{d:02d}" def _norm_model(v): """型号里的常见误识别:破折号统一、去空格、全角转半角""" s = _to_null(v) if s is None: return None trans = str.maketrans({"–": "-", "—": "-", "-": "-", " ": " ", "O": "O", "0": "0"}) return re.sub(r"\s+", "", s.translate(trans)).upper() def main(image_result: str) -> dict: try: raw = json.loads(image_result) except json.JSONDecodeError: # 模型偶尔会包一层 ```json 围栏,兜一下 cleaned = re.sub(r"^```(?:json)?|```$", "", image_result.strip(), flags=re.M).strip() raw = json.loads(cleaned) power = _to_number(raw.get("rated_power_w"), {"kw": 1000, "w": 1}) voltage = _to_number(raw.get("rated_voltage_v"), {"kv": 1000, "v": 1}) result = { "model": _norm_model(raw.get("model")), "rated_power_w": power, "rated_voltage_v": voltage, "rated_current_a": _to_number(raw.get("rated_current_a"), {"a": 1}), "serial_no": _to_null(raw.get("serial_no")), "manufacture_date": _norm_date(raw.get("manufacture_date")), "protection_level": (_to_null(raw.get("protection_level")) or "").upper() or None, "manufacturer": _to_null(raw.get("manufacturer")), "confidence": _to_number(raw.get("confidence")), } missing = [k for k, v in result.items() if v is None and k != "confidence"] result["_missing_fields"] = missing return result

这段代码里我最看重的两处:一是_to_null把各种“看起来是空但又不是 null”的东西统一收口,避免空字符串和 null 混在数据库里;二是_missing_fields这个附加字段,它让下游一眼就能知道这条记录缺了啥,缺三个以上直接进复核队列,缺一两个的可以先入库再补。这个小设计在后面的运营阶段省了很多沟通成本——业务方看到_missing_fields就知道该查什么,不用来问我“为什么这条没记录”。

3.6 批量场景下的并发与限流

单张跑通之后,下一个需求一定是批量。这里我给个能用的骨架,核心是控制并发数,别把模型接口打崩:

from concurrent.futures import ThreadPoolExecutor, as_completed def batch_recognize(paths, max_workers=4): results, errors = [], [] with ThreadPoolExecutor(max_workers=max_workers) as pool: future_map = {pool.submit(recognize, p): p for p in paths} for fut in as_completed(future_map): p = future_map[fut] try: results.append({"path": p, "data": fut.result()}) except Exception as e: errors.append({"path": p, "error": str(e)}) return results, errors

max_workers设 4 是我这边实测下来比较稳的值。设到 8 的时候开始出现接口限流错误,重试虽然能兜住但整体耗时反而更长。这个值跟你的模型侧配额强相关,没有通用答案,建议从 2 开始往上试,盯着错误率看,一旦错误率上去了就往回收。

另一个建议是给批量任务加个本地缓存,以图片内容的哈希值为键。现场采集经常有重复照片(同一个人拍了两遍、或者同一个设备被不同人拍了),缓存能省掉这部分开销,在几千张的批次里能砍掉一两成的调用量。

4. 踩坑记录:那些文档里不会写的问题

4.1 识别不准时的排查顺序

遇到识别不准,最忌讳上来就改提示词。我总结了一个从外往里的排查顺序,按这个走,八成问题十分钟内能定位。

**第一步看图片本身。**把预处理之后的图打开看一眼,是不是糊了、是不是太暗、是不是铭牌只占了画面十分之一。很多时候问题根本不在模型,是采集端的问题。我遇到过一次批量识别率暴跌,最后发现是采集的兄弟为了省事,用广角模式拍,铭牌在画面角落里只有几百像素,模型当然看不清。

**第二步看模型原始输出。**在 Dify 的运行日志里看模型节点吐了什么。如果它把字段名和值都识别出来了只是配对错了,那是提示词的问题;如果它整个字段都没提,那是提示词里没定义这个字段,或者图片里确实看不到。

**第三步看代码节点。**清洗逻辑是有可能把正确的值改坏的。比如我那个_norm_model里做了大写转换,结果某个厂商的型号本身就带小写且大小写有含义,被我改坏了。这种问题很隐蔽,因为报错不会有,只是数据悄悄错了。

**第四步看校验规则。**有些“识别错误”其实是校验规则写得太严。比如防护等级我一开始限定了必须是IP加两位数字,结果遇到IP5X(X 表示未指定防尘等级)就被判为无效。

4.2 常见问题速查表

下面这张表是我这两年里实际碰到过的问题,按出现频率排的:

现象大概率原因处理办法
竖拍照片识别率明显低于横拍EXIF 方向未纠正预处理里加exif_transpose
字段名识别出来了但值全是 null图片分辨率太低或压缩过度检查长边是否低于 1000px,提高 JPEG 质量
型号里的 0 全变成 O,1 变成 l字符形近,模型视觉混淆型号字段做字符集白名单校正,结合历史型号库模糊匹配
功率出现 30000 这类离谱值单位误读,kW 当成 W提示词强制要求换算成瓦特,代码里加区间校验
一张图返回了两个设备的字段画面里有多台设备提示词明确“只识别画面中最大的铭牌”,或在采集端加裁剪
返回内容不是合法 JSON模型自作主张加了说明文字提示词强约束 + 代码里做围栏剥离兜底
同一张图两次识别结果不一致模型采样随机性温度参数调到 0 或 0.1,关键字段做双次比对
单张耗时超过 15 秒图片过大或模型侧排队压缩尺寸、检查配额、改异步模式

4.3 关于“双次比对”的一个小技巧

上面表格里提到的那条,值得单独展开。多模态模型即使在温度设为 0 的情况下,返回结果也不保证完全确定,尤其是在字段边界模糊的时候。我用的办法是:对关键字段(型号、额定功率、电压、出厂编号)跑两次,两次结果一致就直接采信,不一致就取置信度高的那次并把记录标记为“需复核”。

这个做法会增加一倍的调用成本,所以我只在关键字段上做,而且只对置信度低于 0.8 的记录做。实际算下来,大概只有两成多的记录会触发二次调用,成本增加不到三成,但关键字段的准确率能提升好几个百分点。这笔账在台账类系统里是划算的,因为错一条数据带来的返工成本远高于多调一次接口。

还有一个小细节:二次调用时我会在提示词里加一句“请特别注意校验型号和功率数字”,这个“定向提示”对提升第二次的准确率有点作用,虽然不严谨,但实测有效。这算是提示词工程里比较玄学但确实管用的东西。

5. 上线之后的一些体会和还能继续做的事

这套东西上线跑了几个月,最大的感受是:**识别准确率不是这个项目的核心指标,人工复核率才是。**一开始我死盯着准确率,从 82% 调到 91%,觉得很有成就感。但业务方关心的是“我要看一眼的比例有多高”,这个指标从最初的六成降到了两成多,他们的工作量才真正降下来。而推动人工复核率下降的,不完全是模型变准了,更多是后处理规则和校验逻辑越来越贴合实际业务——很多错误被规则提前拦住了,根本走不到人工那一步。

如果让我重做一遍,有两件事我会更早做。

一是更早地建立型号库。设备型号这个字段看着简单,但形近字太多,纯靠模型识别效果有天花板。后来我拿历史台账里已有的几千个型号做了一份白名单,识别结果先跟白名单做模糊匹配,匹配度高的直接采用白名单里的标准写法,准确率一下就上去了。这件事应该在做模型之前就做。

二是更早地把提示词纳入版本管理。Dify 界面上改提示词确实方便,但方便的另一面是容易改乱,改了以后效果变差还回不去。后来我养成习惯,每次改提示词都在应用描述里记一笔改了什么、为什么改、改完在固定测试集上的表现变化。这个习惯听起来很笨,但它救过我两次。

后面还能扩展的方向也挺多。最直接的是把识别结果直接对接设备台账系统,做成“拍照即入库”;稍远一点的是把这套工作流复用到其他类似的场景,比如仪表盘读数识别、产品标签识别、证书信息抽取,这些场景的链路结构几乎是一样的,只是提示词和字段表不一样。我试过把同一个工作流改个提示词就去做压力表的读数识别,一天之内就能出个能演示的版本,这正是把编排和业务代码分开的价值所在。

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

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

立即咨询