1. 数据管道里塞进大模型,到底在解决什么麻烦
很多团队第一次把大模型接进数据管道,脑子里想的还是"给数仓加个聊天窗口"。这个思路从根上就跑偏了。聊天窗口是给人看的,而数据管道是给下游系统、报表、特征工程、风控规则喂数据的。两者对"输出"的要求完全不同:聊天可以容忍模糊、可以来回追问,但数据管道要的是结构化、可校验、可回溯、可批量重跑的字段。
我见过太多项目卡在同一个地方:日志、工单、客服对话、合同扫描件、邮件正文这些非结构化文本,明明躺在对象存储里,却进不了数仓。传统做法是写正则、写规则、堆关键词字典,维护成本高得离谱,业务稍微换个说法就全废。大模型的出现,本质上是把"从自由文本里抽取结构化字段"这件事,从规则工程变成了语义工程。
但这里有个关键认知:大模型不是数据管道的目的,它只是管道里的一个算子。就像你不会把整个ETL都写成Python UDF一样,你也不该让大模型承担管道的调度、重试、幂等、血缘这些职责。它只负责一件事——把一段非结构化文本,映射成一组符合schema的字段。剩下的,交给成熟的数据工程体系。
这篇文章想聊的,就是怎么把这个"算子"稳稳地嵌进数据管道,让非结构化数据真正落地成数仓里可用的表。适合已经会用大模型API、但还没想清楚怎么把它工程化的数据开发、数据平台、以及做AI应用落地的同学。我会把踩过的坑、选型的逻辑、以及能直接抄的配置都摊开讲。
2. 为什么"直接调API写进表"这条路走不通
2.1 一次性调用的三个致命假设
最朴素的实现是这样的:读一条文本,拼个prompt,调一次模型,解析JSON,insert进表。跑通demo没问题,但一上生产就崩。因为它隐含了三个假设,而这三个假设在真实数据管道里全都不成立。
第一个假设是模型输出永远合法。实际上大模型会返回多余的解释文字、会漏字段、会把数字写成中文、会在JSON外面套一层markdown代码块。你如果直接json.loads,失败率能到两位数百分比。
第二个假设是每条数据独立处理就够了。但数仓要的是分区、要的是批次一致性。你今天跑一批、明天跑一批,同一份源数据可能因为模型版本更新而抽出不同结果,下游对不上账。
第三个假设是调用是免费的、无限速的。真实场景里你要考虑token成本、并发限流、超时重试。一条文本抽8个字段,和抽3个字段,成本差一大截。
2.2 把大模型当"函数"而不是"服务"
正确的定位是:把大模型封装成一个纯函数式的抽取算子。输入是文本+schema定义,输出是结构化记录或明确的失败标记。它不碰数据库、不管调度、不做重试——这些交给外层的管道框架。
这个定位一旦确立,很多设计就顺了。比如幂等性:同一条文本、同一个prompt版本、同一个模型版本,必须产出同一个结果。做不到怎么办?把prompt_version和model_version作为字段写进结果表,重跑时按版本比对。再比如可回溯:每条抽取结果都要能追到原始文本的存储路径和偏移量,否则出了问题你连原文都找不到。
提示:在管道里,大模型算子的输出必须是"确定性可复现"的。如果模型本身有随机性(temperature>0),要么设成0,要么把随机种子固定下来,否则你的数仓表每天都在变。
2.3 一个反直觉的结论:先做schema,再选模型
大部分人顺序反了——先挑个最强的模型,再想抽什么。正确顺序是先定义目标表的schema,再倒推prompt和模型选型。
原因很简单:数仓表的字段是下游契约。你抽出来的字段名、类型、是否可空,直接决定BI能不能用、特征工程能不能接。如果schema没定死,模型抽得再花哨也是白搭。我通常的做法是先和下游对齐一张"目标表DDL",把字段、类型、枚举值范围全部写清楚,然后才去设计抽取逻辑。
举个例子,假设你要从客服对话里抽工单信息,目标表可能长这样:
| 字段名 | 类型 | 是否可空 | 说明 |
|---|---|---|---|
| ticket_id | string | 否 | 源系统工单号,抽不到则整条丢弃 |
| category | string | 否 | 枚举:退款/物流/质量/其他 |
| urgency | int | 否 | 1-5,抽不到默认3 |
| summary | string | 是 | 一句话摘要,限200字 |
| extracted_at | timestamp | 否 | 抽取时间 |
| prompt_version | string | 否 | 用于回溯 |
有了这张表,prompt要抽什么、哪些字段必填、哪些可以兜底,一目了然。模型选型也有了依据:如果只是分类+短摘要,小模型足够;如果要抽多轮对话里的隐含诉求,才需要上大模型。
3. 抽取算子的核心设计:从prompt到结构化输出
3.1 prompt不是写作文,是写接口契约
很多人写prompt像写需求文档,一大段自然语言。这在抽取场景里是灾难。抽取prompt应该像接口文档:明确输入、明确输出格式、明确边界情况。
我的模板结构固定为四段:角色定义、任务说明、输出schema、few-shot示例。角色定义一句话带过,任务说明讲清楚"从什么文本抽什么",重点是输出schema——用JSON Schema描述,字段名、类型、枚举、必填全部写死。
{ "type": "object", "properties": { "category": { "type": "string", "enum": ["退款", "物流", "质量", "其他"] }, "urgency": { "type": "integer", "minimum": 1, "maximum": 5 }, "summary": { "type": "string", "maxLength": 200 } }, "required": ["category", "urgency"] }把这个schema直接塞进prompt,并明确告诉模型"只输出JSON,不要任何解释"。实测下来,配合few-shot示例,合法输出率能从60%提到95%以上。
3.2 输出解析必须做三层防御
即便prompt写得再好,也不能假设输出一定合法。解析层要做三层防御,缺一不可。
第一层是清洗。模型经常在JSON外面包json代码块,或者前面加一句"好的,以下是结果"。用正则把代码块标记剥掉,再定位第一个{和最后一个},截取中间部分。
第二层是校验。用JSON Schema校验器(Python里用jsonschema库)逐字段检查类型、枚举、范围。校验失败的字段单独标记,不要整条丢弃。
第三层是兜底。必填字段缺失时,按业务规则给默认值;枚举值不在范围内时,映射到"其他";数字超范围时截断到边界。兜底策略要写进配置,不能硬编码。
import json import re from jsonschema import validate, ValidationError def parse_llm_output(raw, schema, defaults): # 第一层:清洗 text = re.sub(r'```json|```', '', raw).strip() start, end = text.find('{'), text.rfind('}') if start == -1 or end == -1: return None, "no_json_found" try: data = json.loads(text[start:end+1]) except json.JSONDecodeError: return None, "json_decode_error" # 第二层:校验 try: validate(instance=data, schema=schema) except ValidationError as e: # 第三层:兜底 for field, default in defaults.items(): data.setdefault(field, default) return data, "ok"3.3 批处理与并发:别让限流拖垮整条管道
单条调用在生产里毫无意义。真实场景是几百万条文本要抽。这时候要考虑并发和限流。
我的做法是用生产者-消费者模型:一个读取器从源存储拉文本,塞进队列;多个worker从队列取任务调模型;结果写入一个中间层(比如Parquet文件或临时表)。worker数量根据API的QPS限制来定,通常设成限流的70%留余量。
关键细节是超时和重试。模型调用超时设30秒,失败重试3次,每次退避2倍。重试仍失败的,写入死信队列,不要阻塞主流程。死信队列每天人工或自动重跑一次。
注意:并发不是越高越好。我试过把并发拉到限流上限,结果大量请求被拒,反而比70%并发慢。留余量是为了应对突发和重试。
4. 让结果真正"进数仓":落地层的工程细节
4.1 中间层设计:为什么不能直接写目标表
抽取结果直接insert目标表,看起来省事,实则埋雷。一旦抽取逻辑要改、要重跑,目标表就被污染了。正确做法是加一个中间层——通常是对象存储上的Parquet分区文件,或者一张带batch_id的临时表。
中间层的好处有三个:一是可重放,原始抽取结果保留,重跑只覆盖中间层;二是可对比,新旧prompt版本的结果可以diff;三是可审计,每条记录都能追到源文本。
中间层的分区键我一般用dt(抽取日期)+source(数据源标识)。文件格式用Parquet,压缩用snappy,兼顾体积和读取速度。
4.2 从中间层到目标表:清洗、去重、合并
中间层到目标表这一步,才是真正的数仓工程。要做三件事:清洗、去重、合并。
清洗是处理抽取层的兜底残留,比如summary超长截断、category空值填充。去重按业务主键(比如ticket_id)做,同一主键保留最新extracted_at的记录。合并用MERGE INTO或INSERT OVERWRITE按分区覆盖。
这里有个容易忽略的点:主键冲突。如果源文本里同一个工单号出现多次,抽取结果可能不一致。我的处理是保留置信度最高的那条——置信度可以由模型返回,也可以用规则算(比如字段完整度)。
MERGE INTO dwd_ticket_info AS t USING ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY ticket_id ORDER BY extracted_at DESC, completeness DESC ) AS rn FROM stg_ticket_extract WHERE dt = '${bizdate}' ) AS s ON t.ticket_id = s.ticket_id WHEN MATCHED AND s.rn = 1 THEN UPDATE SET ... WHEN NOT MATCHED AND s.rn = 1 THEN INSERT ...4.3 血缘与版本:出了问题能倒查
大模型抽取最怕的是"结果不对但不知道哪一步错了"。所以血缘必须做全:源文本路径、prompt版本、模型版本、抽取时间、解析状态,全部作为字段落表。
我还会额外记录一个raw_output字段,存模型原始返回。虽然占空间,但排查问题时能救命。存储成本可以用冷分区控制,比如只保留最近30天的raw_output,更早的归档到低频存储。
版本管理上,prompt和模型版本用语义化版本号(如v1.2.0)。每次改prompt必须升版本,不能原地改。这样重跑时能精确指定用哪个版本,结果可复现。
5. 实测中绕不开的五个坑
5.1 坑一:模型"自作聪明"补全不存在的字段
模型有个坏习惯:文本里没提到的字段,它会根据上下文猜一个填上。比如文本没提紧急程度,它给你填个3。这在抽取场景里是致命的——你分不清哪些是抽出来的,哪些是编的。
解决办法是在prompt里明确要求"文本未提及的字段返回null,不要猜测"。同时在解析层做校验:如果某字段在原文里找不到对应证据,标记为低置信度。更严格的做法是让模型返回每个字段的原文片段作为证据,解析时校验片段是否真的在原文里。
5.2 坑二:长文本截断导致信息丢失
模型有上下文长度限制,长文本必须截断。但截断位置很讲究——截掉的部分可能正好包含关键信息。
我的策略是分段抽取+合并。把长文本按段落切成多个chunk,每个chunk独立抽取,最后按字段合并。合并规则:分类类字段取多数投票,数值类字段取加权平均,摘要类字段拼接后二次摘要。
分段时注意不要切断语义单元。按句号、换行符切,比按固定长度切效果好得多。
5.3 坑三:成本失控——token消耗远超预期
很多人算成本只算输入token,忽略了输出token和重试消耗。实测下来,输出token往往占大头,因为模型喜欢啰嗦。
控制成本的手段有三个:一是prompt精简,去掉冗余说明;二是限制输出长度,用max_tokens卡死;三是缓存,相同文本+相同prompt版本直接命中缓存,不重复调用。缓存用Redis或本地KV都行,key用文本hash+prompt版本。
5.4 坑四:模型版本升级导致结果漂移
模型服务方升级模型是常事,但对你来说可能是灾难——同一份文本,昨天抽出来是"退款",今天变成"其他"。
应对方式是锁定模型版本。如果服务方支持指定版本号,就写死;如果不支持,就定期做回归测试,用一批标注好的样本跑一遍,对比新旧结果。漂移超过阈值就告警,人工介入。
5.5 坑五:下游不认账——字段语义对不齐
技术上都跑通了,下游却说"这个字段不能用"。原因往往是语义没对齐。比如你抽的urgency是1-5,下游以为是1-3;你抽的category是中文枚举,下游要英文。
这类问题只能在schema定义阶段解决。我的做法是拉上下游一起评审目标表DDL,每个字段的语义、取值范围、空值含义全部确认签字。评审通过再动手,能省掉后期大量返工。
6. 一套可复用的落地清单
把上面的经验浓缩成一份清单,新项目可以直接照着走。
阶段一:定义契约
- 与下游对齐目标表DDL,字段名、类型、枚举、空值含义全部确认
- 确定业务主键和去重规则
- 确定哪些字段必填、哪些可兜底
阶段二:设计抽取算子
- 用JSON Schema定义输出结构
- prompt按"角色+任务+schema+few-shot"四段式写
- 解析层做清洗、校验、兜底三层防御
- 记录prompt版本和模型版本
阶段三:搭建管道
- 中间层用Parquet分区存储,保留原始输出
- 并发按限流70%设置,超时30秒,重试3次
- 死信队列单独处理,不阻塞主流程
阶段四:落地数仓
- 中间层到目标表做清洗、去重、合并
- 血缘字段全量落表,raw_output保留30天
- 版本号语义化管理,改prompt必升版本
阶段五:运维
- 定期回归测试,监控结果漂移
- 成本监控,token消耗按天统计
- 死信队列每日重跑
这套流程我在几个项目里跑下来,非结构化数据进数仓的成功率能稳定在95%以上,剩下的5%进死信队列人工处理。关键是每一步都有据可查,出了问题能快速定位。
最后分享一个小心得:别追求100%的抽取准确率。非结构化数据本身就有模糊性,强行追求完美只会让成本爆炸。把准确率目标定在业务可接受的阈值(比如90%),剩下的用人工审核或下游容错兜住,整体ROI反而更高。数据管道要的是稳定和可维护,不是学术意义上的最优。