这类问题长什么样
先看一行真实的输入:
BBS-125KT11 KTM DUKE200(14-17年) DUKE390(14-17年) RC390 (14-21年) Duke 125 250 390
这是业务同事在 Excel 里手写的一行「商品适配清单」,要变成 6 条结构化记录:
DUKE200 2014-2017DUKE390 2014-2017RC390 2014-2021Duke125 —Duke250 —Duke390 — ← 和上面那条其实是同一台车,最后会合并成一个
再拿每一条去年型库里查出对应的车系 ID。
这类问题有个固定的形状:
- 输入是给人看的,格式随心情,缩写、俗称、中英混写混在一起
- 输出是给机器用的,必须唯一、必须能对上既有主数据
- 中间隔着一层理解 —— 而这一层,正好是大模型擅长的
同样的形状在很多地方会出现:把客服工单里的产品描述对到 SKU 库、把发票上的费用项对到会计科目、把简历里的技能对到能力字典。只要输入是自由文本、输出要落到一张已有的标准表上,就属于这一类。
下面这套工作流就是为这个形状设计的。
工作流分四层
┌─ 输入层 ────────────────────────────────┐│ Excel 读取:sheet / 表头行 / 列位置 ││ 全部可配 → 换表不用改代码 │└────────────────┬────────────────────────┘ ▼┌─ 理解层(AI)───────────────────────────┐│ 把自由文本拆成结构化条目 ││ 缩写展开 / 年款归属 / 品牌组切分 │└────────────────┬────────────────────────┘ ▼┌─ 对齐层(代码)─────────────────────────┐│ 品牌 → 车系 → 年款,三层收敛 ││ 归一化 / 别名 / 俗称 / 品牌组 四个补丁 │└────────────────┬────────────────────────┘ ▼┌─ 输出层 ────────────────────────────────┐│ 结果表 + 逐条 reason + 状态染色 │└─────────────────────────────────────────┘
关键在中间那条线:理解交给 AI,对齐交给代码。这条线不是拍脑袋定的,是被一次翻车逼出来的 —— 见第三节。
第 1 层 · 输入:全部可配
这类表最大的特点是没有固定格式。今天表头在第 1 行,下个月同事给你一张表头在第 3 行、还带合并单元格的;列名从「车型描述」变成「适配车型」变成「商品名称」。
所以第一件事是把所有定位信息变成配置:
input: excel_file: "..." sheet: { mode: name, name: "Sheet1" } header: { mode: row_number, row_number: 1 } columns: raw_text: { header: "商品名称", required: true } product_code: { header: "货号" }
同理还有主数据表结构 —— 换一个数据库,只改 lookup_schema 里的表名和列名,匹配逻辑一行不动。
这一步是整套工作流"可复用"的地基。如果定位信息写死在代码里,这套东西就只能跑一次。
第 2 层 · 理解:交给 AI
真实清单里的缩写比想象中花样多。整理下来是四种形态:
① 空格数字序列 Duke 125 250 390 → Duke125、Duke250、Duke390② 前缀共享(共同部分在最前面) 300AC/GY/DS → 300AC、300GY、300DS③ 后缀共享(共同部分在最后面) AFR/USR/VX/UCR/VH125 → AFR125、USR125、VX125、UCR125、VH125 大道/公路/旗舰滑翔 → 大道滑翔、公路滑翔、旗舰滑翔④ 组合展开(前缀集 × 后缀集) 790/890/1290Adv/Duke → 790Adv、790Duke、890Adv、890Duke、1290Adv、1290Duke
② 和 ③ 的判别方法是:看共同部分出现在哪一段。在最前是前缀共享,在最后是后缀共享。
这些东西用正则写会写到崩溃,交给模型就是一句话的事 —— 这里它是真的好用。
但提示词要覆盖全,否则模型的边界就是你的例子
我最初写 ③ 的时候只举了数字的例子:
AFR/USR/VX/UCR/VH125 → AFR125、USR125、VX125、UCR125、VH125
测试时发现 大道/公路/旗舰滑翔 被拆成了 大道、公路、旗舰滑翔 —— 前两个少了两字,去库里查全部零命中。
我才意识到:模型学到的不是"后缀共享"这个抽象规则,而是"最后一段带数字时,把数字补到前面几段"。我举的例子是数字,它学会的就是数字。
把中文后缀也写进规则之后,那一行从 8/10 命中变成 10/10。
这条经验对所有写提示词的人都成立:模型不做对的多数时候不是它不行,是你没教全。
第 3 层 · 对齐:交给代码
这是整套工作流里最需要克制的部分。
先说为什么不交给 AI
最直觉的做法是把车系列表塞进上下文,让模型直接返回 ID。跑起来是这样的:
清单里有 KTM Duke 125,模型返回:
{"series_id": 523, "series_name": "250 DUKE", "confidence": 0.9}
而库里根本没有 125 DUKE —— KTM 的 DUKE 系列只有 200 / 250 / 390 / 690 / 790 / 890 / 1290。模型面对一堆长得差不多的选项,它必须选一个,于是选了最近的,还给了 0.9 的置信度。
这是最危险的一类错误:不是"我不知道",而是"我很有把握地选错了"。结果看起来完全正常 —— ID 存在、名字合理 —— 只有逐条核对才能发现 250 DUKE 和 Duke 125 是两台车。
零命中是可以接受的,人工补一下就行。错配是数据事故,错误数据会流进下游。
代码这一侧:三层 + 四个补丁
品牌对齐 ── 品牌对不上,后面全白搭 ▼车系对齐 ── 在【该品牌下的车系集合】里找 ▼年款对齐 ── 清单给了就听清单的,没给就取主数据的兜底区间
但真实的主数据会逼着你打补丁。四个补丁,全是被具体的数据问题打出来的:
① 归一化 + token 排序 —— 库里存 200 DUKE,清单写 DUKE200。这不是别名问题,是词序问题。把名字拆成 token、排序、拼接,两边就一致了。
② 俗称映射 —— 杜卡迪的「怪兽」,在库里是 Monster 950、Monster 1200、Monster 797、Monster 821 四个独立车系。业务上这条商品对四个都适配,所以正确做法是四个都列出来交给人工确认,而不是当成歧义丢掉,也不能替业务决定。
③ 品牌组 —— 这个最反直觉。库里没有叫「本田」的品牌,只有四个合资品牌:HONDA WING / DREAMWING / WUYANG / Sundiro。而 CB400F 挂在 WING 下、PCX160 挂在 WUYANG 下。所以清单写「本田」时,得在组内所有品牌下一起找。
这个补丁后来又补了一次:上游表格里写的是英文 Honda,它既不等于组名「本田」,也不属于组内任何成员,触发不了品牌组,结果整行品牌零命中 —— 8 行车系白白丢掉。
④ 默认挂靠 —— 兜底规则。命中会明确写进判定说明「按默认挂靠规则采用」,人工可以复核。
这些补丁怎么变成"可复用"的一部分
四个补丁没有一个写在代码里,全部外置成一张别名表(synonyms.yaml):
brand_groups: # 同一厂商在库里的多个品牌 本田: brands: [HONDA WING, HONDA DREAMWING, HONDA WUYANG, HONDA Sundiro] aliases: [Honda, 本田摩托] # 只用来触发进组series: # 一个写法 → 库内标准名 "NSS350": [佛沙350, FORZA 350]series_groups: # 一个俗称 → 库内多个车系 怪兽: [Monster 950, Monster 1200, Monster 797, Monster 821]
业务方遇到新的写法,自己加一行就行,不用找开发。 这是这套工作流能持续跑下去的关键 —— 数据是活的,代码是死的,把变化的部分推出去。
第 4 层 · 输出:每条结果都要能解释自己
结果表除了匹配到的 ID,还带一列 reason:
CB400X → CB400X(329) 已匹配 车系命中唯一(basic);年款取车系主区间兜底CBR250R → 未匹配 未匹配 车系「CBR250R」在品牌组「本田」下零命中,需人工补主数据500RR → 未匹配 未匹配 品牌「张雪」在库里零命中
人工复核的时候不需要从头判断一遍 —— 看一眼 reason 就知道该去改清单(品牌写错了)、还是去补主数据(库里确实没有这个车型)。
如果换成让模型直接输出一个 ID,你拿到的只有一个数字和一句"置信度 0.9",出了问题无从查起。
结果表还按状态做了染色:已匹配绿、待确认黄、未匹配红,直接筛颜色就能定位问题行。
三个支撑「可复用」的支点
前面讲了四层怎么搭。但真正让这套东西能被复用、而不是只跑一次的,是另外三件事。
支点一:AI 的边界用代码锁死,不靠提示词自律
光在提示词里写"不要编造"是没用的。四条约束写死在代码里,不能通过配置关掉:
- 模型返回的 ID 必须出现在候选集里,否则直接判拒绝并记日志
- 候选集为空就不调 AI —— 零候选下让它判断,它必然编造
- 自评置信度打 85 折,且永不自动通过
- 每条决策留 reason,落盘 + 进结果表,支持事后抽检
第 2 条最容易被忽略。很多人会把"找不到候选"也丢给模型问"你觉得是哪个"。这时候它没有任何依据,只能凭训练数据里的印象编一个 —— 而那个印象可能来自完全不同的年份或市场。没有候选就是没有候选,这一步不该问 AI。
支点二:拆解结果出来之后,用代码再查一遍
提示词写得再清楚也不能假设模型永远遵守。所以 AI 的输出不是直接采信,而是过一遍确定性校验:
| 检查 | 抓什么 |
|---|---|
year_attachment |
年款被顺延给了后面的车系 |
abbreviation |
该展开的缩写没展开 |
coverage |
原文里的数字没出现在任何车系名里 |
empty / overflow |
没拆出 / 拆出一大堆(模型失控) |
年款那条值得单说:DUKE200(14-17年) DUKE390 里的括号只能属于 DUKE200,模型很容易理解成"整行的年款"。代码校验的做法是把所有年款的位置取出来,逐个检查它后面紧邻的是不是它该属于的那个车系。
只有 error 级才降级成「待人工」,warning 级只记日志 —— 启发式检查有假阳性,全降级会把大量正常行拖进人工队列,人工成本等于没做自动化。
支点三:换表、换库、离线都能跑
- 换表:改
config.yaml的 sheet / 表头行 / 列 - 换库:改
lookup_schema的表名列名 - 离线测试:匹配是纯逻辑,主数据一次性拉进内存,全程零数据库往返 —— 979 个车系全内存查找是毫秒级,也让整套逻辑能脱离数据库跑测试
- 重跑不花钱:AI 结果按内容哈希缓存,改配置重跑不会重复计费
跑起来是什么样
拿一份真实的适配表跑一遍:221 行清单,去重后 65 条不同的原文,拆出 351 条车系记录。
未匹配的 99 条看着不少,但把 reason 拉出来看,几乎全是原始清单本身的问题:
500RR → 品牌「张雪」在库里零命中 主数据里没有这个品牌310 → 车系「310」在品牌「宝马」下零命中 裸数字,清单就没写全
这就是可解释性的价值:能明确区分"是数据的问题"还是"是程序的问题"。 前者推回业务方改清单或补主数据,后者才需要动代码。
整套东西跑了 132 项自动化测试。
三个印象最深的坑
1. 最大的坑是数据模型错了,不是提示词
第一版跑完,62% 的记录提取不出品牌。
我怀疑是提示词写得不好,反复调了两轮毫无改善。把失败样本拉出来才反应过来 —— 我把品牌当成了行级字段,而真实清单一行经常含多个品牌组:
春风250SR/NK CLX250;无极250RR Q250;本田CM300 CBR250R└──── 春风 ────┘ └──── 无极 ────┘ └─── 本田 ───┘
一行三个品牌,行级只能填一个。把品牌下沉到每条记录上之后,问题直接消失。
提示词调不动的问题,八成不在提示词。
2. 模型默认开思考模式,把输出额度吃光了
复杂一些的输入会返回空内容,finish_reason=length。查下来是模型默认开启思考模式,reasoning_tokens 把 4000 的 max_tokens 全消耗完了,正式输出一个字都没轮到。
关掉之后,单次调用从 18.9 秒降到 2.1 秒,失败数从 2 变成 0。
3. openpyxl 的样式对象是共享的
这个跟 AI 无关,但最阴。生成结果表时写了:
cell._style = body_style # ← 错
看起来没问题,但 _style 是按引用共享的 —— 12 个单元格指向同一个 StyleArray 对象。之后任何一格改填充色,会连带把其余全部改掉。
结果是整张表 351 行被染成同一种颜色,连根本没设过填充的前 5 列也一起变色。
cell._style = copy(body_style) # ← 每格一份
这套工作流能搬到哪
回到开头那个形状:输入是自由文本,输出要落到一张已有的标准表上。
| 场景 | 理解层(AI) | 对齐层(代码) |
|---|---|---|
| 客服工单 → 产品 SKU | 从描述里抽产品名、规格 | 对到 SKU 主数据 |
| 发票费用项 → 会计科目 | 抽取费用名目、备注 | 对到科目表 |
| 简历技能 → 能力字典 | 抽取技能写法 | 对到标准能力项 |
| 商品适配清单 → 车型库 | 拆解车系、展开缩写 | 对到车系 ID |
每一层的换法都一样:改配置、换别名表。代码只负责流程,知识全部外置。
最后
这套工作流真正的设计重心,不是"怎么让 AI 更准",而是怎么让 AI 的错误不致命。
三条原则:
- AI 只做理解,不做判定 —— 拆解、归类、抽取交给它;需要唯一答案的查询交给代码
- 边界用代码锁死 —— 不指望提示词自律,约束写进代码且不可配置关闭
- 知识外置 —— 别名、规则、表结构全部放进配置文件,让变化的部分不用改代码
这样搭出来的工作流,换一个领域照样能用 —— 因为你复用的不是某个提示词,而是一套分工。