搭一套可复用的 AI 数据处理工作流

这类问题长什么样

先看一行真实的输入:

code
BBS-125KT11  KTM DUKE200(14-17年) DUKE390(14-17年) RC390 (14-21年) Duke 125 250 390

这是业务同事在 Excel 里手写的一行「商品适配清单」,要变成 6 条结构化记录:

code
DUKE200   2014-2017DUKE390   2014-2017RC390     2014-2021Duke125   —Duke250   —Duke390   —        ← 和上面那条其实是同一台车,最后会合并成一个

再拿每一条去年型库里查出对应的车系 ID。

这类问题有个固定的形状:

  • 输入是给人看的,格式随心情,缩写、俗称、中英混写混在一起
  • 输出是给机器用的,必须唯一、必须能对上既有主数据
  • 中间隔着一层理解 —— 而这一层,正好是大模型擅长的

同样的形状在很多地方会出现:把客服工单里的产品描述对到 SKU 库、把发票上的费用项对到会计科目、把简历里的技能对到能力字典。只要输入是自由文本、输出要落到一张已有的标准表上,就属于这一类。

下面这套工作流就是为这个形状设计的。


工作流分四层

code
┌─ 输入层 ────────────────────────────────┐│  Excel 读取:sheet / 表头行 / 列位置       ││  全部可配 → 换表不用改代码                 │└────────────────┬────────────────────────┘┌─ 理解层(AI)───────────────────────────┐│  把自由文本拆成结构化条目                   ││  缩写展开 / 年款归属 / 品牌组切分            │└────────────────┬────────────────────────┘┌─ 对齐层(代码)─────────────────────────┐│  品牌 → 车系 → 年款,三层收敛               ││  归一化 / 别名 / 俗称 / 品牌组 四个补丁      │└────────────────┬────────────────────────┘┌─ 输出层 ────────────────────────────────┐│  结果表 + 逐条 reason + 状态染色            │└─────────────────────────────────────────┘

关键在中间那条线:理解交给 AI,对齐交给代码。这条线不是拍脑袋定的,是被一次翻车逼出来的 —— 见第三节。


第 1 层 · 输入:全部可配

这类表最大的特点是没有固定格式。今天表头在第 1 行,下个月同事给你一张表头在第 3 行、还带合并单元格的;列名从「车型描述」变成「适配车型」变成「商品名称」。

所以第一件事是把所有定位信息变成配置

yaml
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

真实清单里的缩写比想象中花样多。整理下来是四种形态:

code
① 空格数字序列   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

② 和 ③ 的判别方法是:看共同部分出现在哪一段。在最前是前缀共享,在最后是后缀共享。

这些东西用正则写会写到崩溃,交给模型就是一句话的事 —— 这里它是真的好用。

但提示词要覆盖全,否则模型的边界就是你的例子

我最初写 ③ 的时候只举了数字的例子:

code
AFR/USR/VX/UCR/VH125  →  AFR125、USR125、VX125、UCR125、VH125

测试时发现 大道/公路/旗舰滑翔 被拆成了 大道公路旗舰滑翔 —— 前两个少了两字,去库里查全部零命中。

我才意识到:模型学到的不是"后缀共享"这个抽象规则,而是"最后一段带数字时,把数字补到前面几段"。我举的例子是数字,它学会的就是数字。

把中文后缀也写进规则之后,那一行从 8/10 命中变成 10/10。

这条经验对所有写提示词的人都成立:模型不做对的多数时候不是它不行,是你没教全。


第 3 层 · 对齐:交给代码

这是整套工作流里最需要克制的部分。

先说为什么不交给 AI

最直觉的做法是把车系列表塞进上下文,让模型直接返回 ID。跑起来是这样的:

清单里有 KTM Duke 125,模型返回:

json
{"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 DUKEDuke 125 是两台车。

零命中是可以接受的,人工补一下就行。错配是数据事故,错误数据会流进下游。

代码这一侧:三层 + 四个补丁

code
品牌对齐 ── 品牌对不上,后面全白搭车系对齐 ── 在【该品牌下的车系集合】里找年款对齐 ── 清单给了就听清单的,没给就取主数据的兜底区间

但真实的主数据会逼着你打补丁。四个补丁,全是被具体的数据问题打出来的:

① 归一化 + token 排序 —— 库里存 200 DUKE,清单写 DUKE200。这不是别名问题,是词序问题。把名字拆成 token、排序、拼接,两边就一致了。

② 俗称映射 —— 杜卡迪的「怪兽」,在库里是 Monster 950Monster 1200Monster 797Monster 821 四个独立车系。业务上这条商品对四个都适配,所以正确做法是四个都列出来交给人工确认,而不是当成歧义丢掉,也不能替业务决定。

③ 品牌组 —— 这个最反直觉。库里没有叫「本田」的品牌,只有四个合资品牌:HONDA WING / DREAMWING / WUYANG / Sundiro。而 CB400F 挂在 WING 下、PCX160 挂在 WUYANG 下。所以清单写「本田」时,得在组内所有品牌下一起找。

这个补丁后来又补了一次:上游表格里写的是英文 Honda,它既不等于组名「本田」,也不属于组内任何成员,触发不了品牌组,结果整行品牌零命中 —— 8 行车系白白丢掉。

④ 默认挂靠 —— 兜底规则。命中会明确写进判定说明「按默认挂靠规则采用」,人工可以复核。

这些补丁怎么变成"可复用"的一部分

四个补丁没有一个写在代码里,全部外置成一张别名表(synonyms.yaml):

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:

code
CB400X  →  CB400X(329)  已匹配   车系命中唯一(basic);年款取车系主区间兜底CBR250R →  未匹配        未匹配   车系「CBR250R」在品牌组「本田」下零命中,需人工补主数据500RR   →  未匹配        未匹配   品牌「张雪」在库里零命中

人工复核的时候不需要从头判断一遍 —— 看一眼 reason 就知道该去改清单(品牌写错了)、还是去补主数据(库里确实没有这个车型)。

如果换成让模型直接输出一个 ID,你拿到的只有一个数字和一句"置信度 0.9",出了问题无从查起。

结果表还按状态做了染色:已匹配绿、待确认黄、未匹配红,直接筛颜色就能定位问题行。


三个支撑「可复用」的支点

前面讲了四层怎么搭。但真正让这套东西能被复用、而不是只跑一次的,是另外三件事。

支点一:AI 的边界用代码锁死,不靠提示词自律

光在提示词里写"不要编造"是没用的。四条约束写死在代码里,不能通过配置关掉

  1. 模型返回的 ID 必须出现在候选集里,否则直接判拒绝并记日志
  2. 候选集为空就不调 AI —— 零候选下让它判断,它必然编造
  3. 自评置信度打 85 折,且永不自动通过
  4. 每条决策留 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 拉出来看,几乎全是原始清单本身的问题

code
500RR  → 品牌「张雪」在库里零命中          主数据里没有这个品牌310    → 车系「310」在品牌「宝马」下零命中   裸数字,清单就没写全

这就是可解释性的价值:能明确区分"是数据的问题"还是"是程序的问题"。 前者推回业务方改清单或补主数据,后者才需要动代码。

整套东西跑了 132 项自动化测试。


三个印象最深的坑

1. 最大的坑是数据模型错了,不是提示词

第一版跑完,62% 的记录提取不出品牌

我怀疑是提示词写得不好,反复调了两轮毫无改善。把失败样本拉出来才反应过来 —— 我把品牌当成了行级字段,而真实清单一行经常含多个品牌组:

code
春风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 无关,但最阴。生成结果表时写了:

python
cell._style = body_style        # ← 错

看起来没问题,但 _style按引用共享的 —— 12 个单元格指向同一个 StyleArray 对象。之后任何一格改填充色,会连带把其余全部改掉。

结果是整张表 351 行被染成同一种颜色,连根本没设过填充的前 5 列也一起变色。

python
cell._style = copy(body_style)  # ← 每格一份

这套工作流能搬到哪

回到开头那个形状:输入是自由文本,输出要落到一张已有的标准表上

场景 理解层(AI) 对齐层(代码)
客服工单 → 产品 SKU 从描述里抽产品名、规格 对到 SKU 主数据
发票费用项 → 会计科目 抽取费用名目、备注 对到科目表
简历技能 → 能力字典 抽取技能写法 对到标准能力项
商品适配清单 → 车型库 拆解车系、展开缩写 对到车系 ID

每一层的换法都一样:改配置、换别名表。代码只负责流程,知识全部外置。


最后

这套工作流真正的设计重心,不是"怎么让 AI 更准",而是怎么让 AI 的错误不致命

三条原则:

  1. AI 只做理解,不做判定 —— 拆解、归类、抽取交给它;需要唯一答案的查询交给代码
  2. 边界用代码锁死 —— 不指望提示词自律,约束写进代码且不可配置关闭
  3. 知识外置 —— 别名、规则、表结构全部放进配置文件,让变化的部分不用改代码

这样搭出来的工作流,换一个领域照样能用 —— 因为你复用的不是某个提示词,而是一套分工