EAMX 是一个 AI-Native 的设备资产管理系统。其中"拍一张铭牌照片,系统自动提取字段并填入设备台账"是用户感知最强的 AI 能力之一。本文从工程实现角度,完整拆解这一能力的技术架构、两阶段管线、动态表单构造,以及一个曾让管线延迟达到 130 秒的 Qwen3-VL 思维链陷阱及修复过程。
1. 问题定义
设备铭牌是一台设备的"身份证"——品牌、型号、序列号、额定功率、制造日期等关键信息都刻在上面。传统工作流中,设备管理员需要手动对照铭牌逐字段录入系统,一台设备耗时 3-10 分钟,且序列号这类高密度字符串极易录入错误。
目标:用户拍摄铭牌照片 → 系统自动提取结构化字段 → 返回预填好的设备台账表单,用户仅需核对确认。
对 AI 管线有三个核心要求:
| 要求 | 说明 |
|---|---|
| 忠实性 | 看到什么提取什么,不推断、不编造、不遗漏 |
| 性能 | 设备建档是高频操作,延迟必须控制在 3-8 秒以内 |
| 可靠降级 | 图片质量差或不含铭牌时,不展示空表单误导用户 |
2. 整体架构
管线分为两阶段 + 动态表单构造:
用户拍照上传
│
├─ [意图识别] IntentAgent 判断是否为"设备建档"意图
│
├─ [Stage 1] Qwen-VL 视觉模型:忠实提取铭牌上所有键值对 → RawField[]
│ 模型:AI_VISION_MODEL (Qwen/Qwen3-VL-32B-Instruct)
│ Prompt:工业铭牌OCR专用提示词,要求输出标准JSON
│
├─ [Stage 2] 纯代码查表:FIELD_MAP 将 RawField 分类为 A/C 两类
│ 精确匹配 → 模糊匹配 → 未命中进 technicalSpecs
│
└─ [Stage 3] prepare_equipment_form:构造 DynamicForm UI Card
A 类(铭牌来源)+ B 类(历史推断)+ C 类(技术参数)
每字段带置信度、来源标签、视觉着色
两阶段分离是刻意设计——Stage 1 只管"读",完全不理解台账 Schema;Stage 2 只管"映射",完全不依赖 LLM。 这种"提取-映射分离"带来了三个好处:
- Stage 1 的 Prompt 可以专注于 OCR 准确性,不涉及台账业务逻辑
- Stage 2 是纯代码查表(<1ms),无网络 IO,无 token 消耗
- 两者独立优化:换更好的视觉模型不改 FIELD_MAP,新增字段类型不改 Prompt
3. Stage 1:Qwen-VL 忠实提取
3.1 模型选择
选用 Qwen/Qwen3-VL-32B-Instruct,通过 OpenAI 兼容 API 调用。关键优势:
- 中文 OCR 准确率高,对工业铭牌上的中英文混合标注("型号/Model"、"额定功率/Rated Power")识别良好
- 32B 参数规模在精度与延迟之间取得平衡
- 支持
enable_thinking: false禁用思维链,大幅降低首 token 延迟
3.2 核心实现
// nameplate-extractor.ts — extractFromNameplate()
const VISION_MODEL = (process.env.AI_VISION_MODEL ?? AI_CHAT_MODEL).trim();
export async function extractFromNameplate(
imageBase64: string,
mimeType: string,
sourceName: string,
): Promise<RawField[]> {
const resp = await openai.chat.completions.create({
model: VISION_MODEL,
max_tokens: 1024,
temperature: 0,
messages: [{
role: "user",
content: [
{
type: "image_url",
image_url: {
url: `data:${mimeType};base64,${imageBase64}`,
detail: "high",
},
},
{
type: "text",
text: `你是工业铭牌OCR助手。请忠实转录这张铭牌/标牌图片上每一个可见的键值对。
【规则】
1. 看到什么写什么,不推断、不补全、不解释
2. 每个字段一条记录(label=铭牌上的字段名原文,value=字段值原文含单位)
3. 铭牌顶部/最大字体的设备名称若无明显键名,label填"设备名称"
4. 遇到模糊字符如实填写并将confidence设为0.4-0.6
5. 宁多勿少:电压、电流、频率、压力、转速、防护等级等每一个有数值的参数都要提取
【输出格式】
只输出JSON,不要任何解释文字:
{ "fields": [
{ "label": "字段名", "value": "字段值含单位", "confidence": 0.9 }
] }`,
},
],
}],
});
const raw = resp.choices[0]?.message?.content ?? "";
const parsed = extractJsonObject(raw);
return parsed.fields.map(f => ({
label: String(f.label), value: String(f.value),
confidence: typeof f.confidence === "number" ? f.confidence : 0.7,
source: sourceName,
}));
}
3.3 Prompt 设计要点
这套 Prompt 经过多次迭代,几个关键设计决策:
① "看到什么写什么" — 这是整个管线的根基。Qwen-VL 作为视觉模型,天然倾向于对图片内容做理解和推理("这是一台空压机"),但 Stage 1 需要的是原始键值对转录,不做语义理解。Prompt 第一句就锁定了这个方向。
② 模糊字符的诚实处理 — confidence: 0.4-0.6 而非盲目给高置信度。这直接影响 Stage 3 动态表单的着色——模糊字段会被标记为黄色,提示用户核对。
③ 输出格式锁定为 JSON only — 要求"只输出 JSON,不要任何解释文字"。Qwen-VL 有思维链输出习惯,不加这句会输出大量推理过程。
④ "宁多勿少" — 工业铭牌常有 20+ 参数。LLM 做结构化提取时容易漏掉"看起来不重要"的参数(如防护等级 IP54、绝缘等级 F),但设备台账的 technical_specs JSONB 列专门存储这些。所以 Prompt 明确要求每个数值参数都要提取。
3.4 JSON 提取容错
不同版本的 Qwen-VL 对纯 JSON 输出的遵从度不完全一致。有的会在 JSON 前后附加解释文字,有的会输出 Markdown 代码块包裹。因此 Stage 1 不依赖 response_format: { type: "json_object" },而是用 extractJsonObject() 做容错解析:
// llm-json-utils.ts
export function extractJsonObject(raw: string | null | undefined) {
if (!raw) return null;
const start = raw.indexOf("{");
const end = raw.lastIndexOf("}");
if (start === -1 || end === -1 || end <= start) return null;
try {
const obj = JSON.parse(raw.slice(start, end + 1));
if (typeof obj === "object" && obj !== null && !Array.isArray(obj))
return obj;
} catch { /* ignore */ }
return null;
}
思路:找到第一个 { 和最后一个 },裁剪中间内容尝试 JSON.parse。这样无论模型在 JSON 前后附加了什么文字,都能正确提取。
4. Stage 2:FIELD_MAP 纯代码分类
Stage 1 输出的 RawField[] 是"铭牌原文键值对",例如 { label: "额定功率", value: "37 kW", confidence: 0.88 }。Stage 2 的任务是把它们分类为 A 类(台账有专列)和 C 类(台账无专列)。
4.1 静态映射表
FIELD_MAP 是约 60 条字符串映射,覆盖工业铭牌全部常见标注:
const FIELD_MAP: Record<string, StandardField> = {
// ── 设备名称 ──
"设备名称": "name", "产品名称": "name", "品名": "name",
// ── 型号/规格 ──
"型号": "model", "规格": "model", "Model": "model",
"Model No.": "model", "Spec": "model", "Type": "model",
// ── 序列号(别名最多,最容易漏识)──
"出厂编号": "serialNumber", "序列号": "serialNumber",
"S/N": "serialNumber", "SN": "serialNumber",
"Serial No.": "serialNumber", "Product No.": "serialNumber",
// ── 额定功率 ──
"额定功率": "ratedPower", "功率": "ratedPower",
"Power": "ratedPower", "Rated Power": "ratedPower",
// ── 制造商 ──
"制造商": "manufacturer", "生产厂家": "manufacturer",
"Manufacturer": "manufacturer", "Mfr": "manufacturer",
// ... 约 60 条,覆盖 16 个标准字段
};
设计原则:
- 维护成本极低 — 新增设备类型只需追加别名条目,无需修改任何 Prompt
- 中英混合覆盖 — 同时支持中文铭牌("额定功率")、英文铭牌("Rated Power")、混合铭牌
4.2 两级匹配策略
function findFuzzyMatch(label: string): StandardField | null {
const normalize = (s: string) =>
s.replace(/[::。,,.、\s\-_()()[\]【】]/g, "").toUpperCase();
const normalizedLabel = normalize(label);
// 1. 去标点后精确匹配
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (normalize(key) === normalizedLabel) return field;
}
// 2. 子串匹配
for (const [key, field] of Object.entries(FIELD_MAP)) {
const nk = normalize(key);
if (nk.length < 2) continue;
if (nk.includes(normalizedLabel) || normalizedLabel.includes(nk))
return field;
}
return null;
}
先精确匹配(去标点后完全一致),再子串匹配(处理 OCR 误差导致的轻微偏差)。
4.3 分类逻辑与多源冲突处理
export function classifyFields(rawFields: RawField[]): ClassifiedFields {
const standard: ClassifiedFields["standard"] = {};
const technicalSpecs: Record<string, string> = {};
for (const field of rawFields) {
const val = field.value?.trim();
if (!val) continue;
const mappedKey = FIELD_MAP[field.label] ?? findFuzzyMatch(field.label);
if (mappedKey) {
// 多源冲突处理:同一字段取置信度更高的值
const existing = standard[mappedKey];
if (!existing || field.confidence > existing.confidence)
standard[mappedKey] = { value: val, source: field.source,
confidence: field.confidence };
} else {
// 未命中 → C 类技术参数
if (!(field.label in technicalSpecs))
technicalSpecs[field.label] = val;
}
}
return { standard, technicalSpecs, sourceMap: {} };
}
当同一字段有多个数据源(铭牌 + 合同)提供值时,取置信度更高的那个。例如铭牌上的"制造商"(置信度 0.95)优先于合同上的"制造商"(置信度 0.78)。
性能亮点
整个 Stage 2 纯内存计算,耗时 <0.5ms,无任何网络 IO。
5. Stage 3:DynamicForm 构造
classifyFields 的输出交给 prepare_equipment_form,构造一张分三类字段的动态表单:
5.1 A 类字段:铭牌/文档直接提取
// equipment.agent.ts
{ key: "name", label: "设备名称", confidence: 0.95,
source: "铭牌", required: true },
{ key: "model", label: "型号/规格", confidence: 0.92,
source: "铭牌", required: false },
{ key: "serialNumber", label: "出厂序列号", confidence: 0.92,
source: "铭牌", required: false },
// ...
这些字段的置信度直接来自 Stage 1 的 Qwen-VL 输出。高置信度(≥90%)字段前端渲染为绿色,用户可以"闭眼过"。
5.2 B 类字段:AI 基于历史推断
// 查询同型号历史设备,推断 category / department / location
const similar = await db.select({ category, department, location, grade })
.from(equipmentsTable)
.where(and(eq(tenantId), ilike(model, `%${modelVal}%`)))
.limit(5);
// 取出现最频繁的值作为建议
bSuggestion = {
category: freq(similar.map(s => s.category)),
department: freq(similar.map(s => s.department)),
// ...
};
B 类字段的 source 显示 "AI建议 · 同租户历史设备",置信度通常 0.65-0.72,前端渲染为黄色。用户需要确认这些推断值。
5.3 C 类字段:技术参数 JSONB
铭牌上所有不属于台账列的参数(额定电压、工作压力、防护等级、主轴转速等)全部进入 technicalSpecsJson,存为 JSONB:
{
"额定电压": "380V",
"额定电流": "72A",
"频率": "50Hz",
"工作压力": "0.7MPa",
"防护等级": "IP54",
"绝缘等级": "F"
}
前端以可编辑技术参数区域展示,每条带 [铭牌] 来源标签。
5.4 UI Card 构造
return {
type: "UI_CARD",
cardType: "dynamic_form",
formTitle: "设备台账确认",
taskId: "create_equipment",
description: "请核对AI提取的设备信息,确认无误后点击「提交确认」写入台账。
红色字段为必填或置信度低,建议优先检查。",
fieldGroups: [
{ id: "classA", title: "A类 — 基本信息(从文档提取)",
description: "AI直接从铭牌/合同/发票中识别的字段,绿色表示高置信度" },
{ id: "classB", title: "B类 — 管理属性(AI建议,请核对)",
description: "台账管理所需但文档未记录的字段" },
],
fields: [...classAFields, ...classBFields],
technicalParams, // C 类
};
前端收到 UI_CARD 后,按 fieldGroups 分组渲染,每个字段根据 confidence 值自动着色(绿/黄/红),附上 source 标签("铭牌"/"合同"/"AI建议")。
6. 与 AI Chat 系统的集成
6.1 两阶段管道(明确建档意图)
当用户上传图片且消息中包含"建档/新建设备/录入设备"等关键词时,coordinator 走两阶段快速管道,绕过 LLM Tool Calling:
// coordinator.agent.ts
const EQUIPMENT_CREATE_KEYWORDS = [
"新建设备", "设备建档", "建档", "设备台账", "新增设备",
"录入设备", "添加设备", "登记设备", "创建设备"
];
const hasEquipmentCreateIntent =
imageBase64 && EQUIPMENT_CREATE_KEYWORDS.some(kw => userMessage.includes(kw));
if (hasEquipmentCreateIntent && imageBase64) {
// Stage 1: Qwen-VL OCR
const rawFields = await extractFromNameplate(imageBase64, mimeType, "铭牌");
// Stage 2: 字段分类
const classified = classifyFields(rawFields);
// 有效性检查:无有用数据时走 LLM 降级路径
if (rawFields.length === 0 || (standardCount === 0 && techCount < 2)) {
// → 用视觉模型描述图片 → 主LLM解释原因
}
// Stage 3: 直接 prepare_equipment_form,不走 LLM
const formParams = flattenToFormParams(classified);
const formResult = await executeEquipmentAction(
"prepare_equipment_form", formParams, ctx
);
return { fullContent: briefing, uiCard: formResult };
}
6.2 LLM 路径(无明确关键词)
当用户上传图片但未使用建档关键词(如纯图片上传、或图片附带"帮我看看这个"这类含糊描述),管线不走两阶段快速通道,而是交由主 LLM 自行判断:
// 视觉模型描述图片后拼入文本,主 LLM 只接收纯文字
const imgDesc = await describeImageForCoordinator(imageBase64, mimeType);
llmUserText = `${userMessage}\n\n[图片内容摘要]\n${imgDesc}`;
// → 主 LLM 根据描述自行选择 Tool(prepare_equipment_form/create_fault_report/...)
6.3 有效性检查与降级
如果铭牌照片质量差(模糊、反光、遮挡),Stage 1 提取不到有效字段(标准字段 0 个且技术参数少于 2 个),不展示空表单:
const hasUsefulData = rawFields.length > 0 && (standardCount > 0 || techCount >= 2);
if (!hasUsefulData) {
// 走 LLM 降级:让 LLM 看图解释原因
// "图片不是设备铭牌" / "铭牌模糊请重拍" / "这是入库单,应该用入库功能"
}
设计哲学
这是对用户信任的保护——宁可说"我没识别出来"并给建议,也不展示一张半空的不可靠表单。
7. 性能优化:从 130s 到 3-8s
7.1 Qwen3-VL 思维链陷阱
最初实现时使用 Vercel AI SDK 的 generateText() 调用视觉模型:
// ❌ 旧实现 — 延迟 130s+
const result = await generateText({
model: provider.chat(VISION_MODEL),
messages: [...],
providerOptions: { openai: { enable_thinking: false } }, // 意图禁用
});
问题:Vercel AI SDK 的 providerOptions.openai.enable_thinking 参数在转发给 SiliconFlow(Qwen3-VL 的运行平台)时未被正确传递。Qwen3-VL 以默认的全思维链模式运行——先推理数万 token 再输出结果,单次调用延迟达到 130 秒以上。
7.2 修复:切换为 OpenAI 原生客户端
// ✅ 新实现 — 延迟 3-8s
const resp = await openai.chat.completions.create({
model: VISION_MODEL,
max_tokens: 1024,
temperature: 0,
messages: [{
role: "user",
content: [
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${imageBase64}` } },
{ type: "text", text: PROMPT },
],
}],
// enable_thinking 通过 FAST_OPTS 的 extra_body 传递
});
关键改动:
- 放弃 Vercel AI SDK 的
generateText,改回openai.chat.completions.create - 通过
extra_body: { enable_thinking: false }传递禁用思维链参数 temperature: 0保证确定性输出max_tokens: 1024限制输出长度(铭牌字段通常在 500 tokens 以内)
修复后实测延迟降至 3-8 秒,满足了"设备建档"场景的用户体验要求。
7.3 FAST_OPTS 机制
// client.ts
export const FAST_OPTS = {
...(process.env.AI_DISABLE_THINKING !== "false"
? { extra_body: { enable_thinking: false } } as any
: {}),
} as const;
FAST_OPTS 对 Qwen3 系列模型通过 extra_body 禁用思维链;对非思考型模型(GPT、豆包)该参数会被忽略,无副作用。
8. 多源扩展设计
extractFromNameplate 的 sourceName 参数预留了多源扩展能力:
// 铭牌
const nameplateFields = await extractFromNameplate(base64, "image/jpeg", "铭牌");
// 采购合同 PDF(将页面截图传入)
const contractFields = await extractFromNameplate(contractBase64, "image/png", "合同");
// 发票
const invoiceFields = await extractFromNameplate(invoiceBase64, "image/jpeg", "发票");
// classifyFields 自动处理多源冲突(取置信度更高的值)
const allFields = [...nameplateFields, ...contractFields, ...invoiceFields];
const classified = classifyFields(allFields);
classifyFields 的多源冲突处理逻辑(取高置信度值)使得铭牌字段(置信度 0.95)自动优先于合同字段(置信度 0.78)。前端 DynamicForm 的 source 标签也会显示每个字段来自哪个文档。
9. 总结
整个铭牌 OCR 建台账方案的技术关键点:
| 设计决策 | 价值 |
|---|---|
| 两阶段分离(提取/映射) | Stage 1 Prompt 不涉业务逻辑;Stage 2 纯代码零延迟 |
| Qwen3-VL 32B 视觉模型 | 中文 OCR 准确率高,工业铭牌中英混合适应好 |
| 静态 FIELD_MAP(约 60 条) | 新增字段只需追加入口,无需修改 Prompt |
OpenAI 原生客户端 + enable_thinking: false | 延迟从 130s 降至 3-8s |
JSON 容错解析 (extractJsonObject) | 不依赖模型输出格式一致性 |
| DynamicForm A/B/C 三分组 + 置信度着色 | 用户 3 秒判断哪些字段可信任 |
| 有效性检查 + LLM 降级 | 图片无效时不展示半空表单 |
| 多源支持(铭牌/合同/发票) | 同一设备可从多个文档提取,自动取高置信度值 |
这套管线已在 EAMX 项目中稳定运行,支撑了设备台账建档的核心 AI 体验。核心文件:
artifacts/api-server/src/agents/nameplate-extractor.ts— Stage 1 视觉提取(171 行)artifacts/api-server/src/agents/field-classifier.ts— Stage 2 字段分类(232 行)artifacts/api-server/src/agents/equipment.agent.ts— Stage 3 DynamicForm 构造(prepare_equipment_form 约 230 行)artifacts/api-server/src/agents/coordinator.agent.ts— 两阶段管道集成与降级artifacts/api-server/src/lib/llm-json-utils.ts— JSON 容错解析lib/integrations-openai-ai-server/src/client.ts— OpenAI 客户端与 FAST_OPTS