不讲模型内部(那本已经写过),只讲它作为管道里的一个节点,怎么用、怎么算账、怎么不翻车
这份笔记怎么用
- 它假设你已经做过 ETL、写过 SQL、会一点 Python,但没在数据管道里接过模型。
- 每一节的顺序固定:这件事在业务里长什么样 → 代码 → 一个能让你返工的坑。
- 代码全部只依赖 numpy 与标准库,能直接跑;假模型是为了让你看清“批处理”和“评估”的形状,不是让你跑真模型。
- 第一、三、六、八章是主干,第四、五章是算账,剩下按需翻。
学完能到什么程度
- 给你一张百万行的表和一个模型接口,你能说出该切成多大 batch、要多少预算、瓶颈在哪。
- 别人说“模型上线后准确率 92%”,你会先问是哪一类的 92%、按什么分桶、基线多少。
- 能在 Spark / 批量任务里正确地把模型挂上去,而不是每行调一次接口。
一、先分清:数据开发用模型,和训模型是两件事
这两件事经常被混在一起谈,导致面试时说不清自己到底负责什么。先画一条线:
数据开发视角(这本笔记) | 算法视角 | |
输入 | 表、日志、文件流 | 样本、标注、loss |
产出 | 带标签/带向量的新表 | 权重文件 |
关心 | 吞吐、成本、按时产出、可回溯 | 指标、收敛、泛化 |
失败的样子 | 任务没跑完、分区缺失、成本超了 | 指标不涨、过拟合 |
典型工具 | SQL、Spark、airflow、调度、队列 | pytorch、训练框架、GPU 集群 |
你在这本笔记里站的位置是左边那一列。模型对你来说就是一个函数:吃一段文本、吐一个标签或一个向量。你要保证的是它每天按时、按量、按预算跑完,并且结果可以回溯到“用了哪个版本的模型”。
二、Transformer 会出现在管道里的四个位置
位置 | 业务长什么样 | 输出什么 | 难点 |
打标 / 分类 | 百万条日志要分登陆/查询/写入/报错 | 一个类别 + 置信度 | 类别漂移、长尾类别 |
抽取 | 从工单正文里抠出订单号、金额、时间 | 结构化字段 | 幻觉、格式不稳 |
向量化 | 把商品/文章/工单变成向量供检索 | 定长向量 | 换模型要全量重建 |
质量检测 | 找重复、找异常、找乱码 | 分数或标记 | 阈值怎么定 |
备注这四个位置里,只有“抽取”是真的需要生成能力;另外三个都能用很小的模型或 embedding 完成。新手最容易犯的错是:明明是分类任务,却上了一整套生成式调用,成本高十倍、结果还不稳。
三、把模型当成一个 UDF:批处理是第一个分水岭
同一个模型,逐条调用和攒批调用,吞吐能差一个数量级。这不是玄学,是 GPU/向量化单元被闲置的问题。
下面这段是实测(本机 numpy 假模型,384×384 一层,256 行):
import time, numpy as np
W = np.random.randn(384, 384).astype("float32") * 0.02
def infer(x): # x: (batch, 384) -> (batch, 384)
return np.tanh(x @ W)
rows = np.random.randn(256, 384).astype("float32")
t0 = time.perf_counter()
for i in range(len(rows)):
infer(rows[i:i+1]) # 一次一行
per_row_ms = (time.perf_counter() - t0) * 1000 / len(rows)
t0 = time.perf_counter()
infer(rows) # 一次 256 行
batch_row_ms = (time.perf_counter() - t0) * 1000 / len(rows)
实测结果:逐条 0.0115 ms/行,整批 0.0026 ms/行,快了约 4.3 倍。
换成真模型,差距只会更大——因为每条一次调用还会多出一次网络往返、一次调度、一次显存搬运。
3.1 batch 不是越大越好
- 显存:batch × 序列长度决定激活占用,32 条 × 512 token 大概是一个安全起点。
- 延迟:batch 越大,单条等待越久。离线任务随便大,在线接口通常控制在 8~32。
- 尾部:一批里最长的那条决定整批耗时,长短混排时先按长度分桶,能省 30% 以上。
# 按长度分桶,再组批——离线任务的标准做法
buckets = {}
for row in rows:
key = len(row["text"]) // 128 # 每 128 字一桶
buckets.setdefault(key, []).append(row)
for key in sorted(buckets):
for i in range(0, len(buckets[key]), 32):
run_batch(buckets[key][i:i+32])
四、离线批 与 在线推理:两套完全不同的约束
离线批(数据开发的主战场) | 在线推理(服务端) | |
目标 | 按时产出、单位成本最低 | 单次延迟、稳定性 |
可接受的延迟 | 分钟到小时 | 100 ms ~ 2 s |
重试 | 整分区重跑,必须幂等 | 快速失败 + 降级 |
扩缩容 | 按队列堆积调并发 | 按 QPS 预热 |
失败影响 | 分区缺失,下游报警 | 用户看到报错 |
关键设计 | 断点、分区覆盖、水位线 | 超时、限流、缓存 |
备注最常见的系统性错误:把在线接口那套“一条一请求”直接搬到离线任务里跑一千万行。结果不是慢一点,是慢 50 倍,且中途挂一次全白干。
五、算账:先把三个数算出来,再动手
这一节是全篇最实用的部分。任何一个“给百万行数据接模型”的需求,先算这三个数,再讨论架构。
5.1 token 总量
行数 × 每行平均 token = 总 token
1,000,000 行 × 120 token = 120,000,000 token = 120 M
5.2 成本
120 M token × 单价(举例 ¥1 / 百万 token)= ¥120
如果是每行调两次(一次分类、一次抽取),直接乘 2
备注便宜的地方在这里:分类任务根本不需要生成,用 embedding + 最近类心,成本能降到百分之一。“用大模型做分类”通常是过度设计。
5.3 时长
实测吞吐 T 条/秒(一定要自己压测,不要信文档峰值)
1,000,000 / T = 秒;再乘上并发数
例:T=40 条/秒、并发 4 -> 1000000/40/4 ≈ 6250 秒 ≈ 1.7 小时
如果算出来不对,按这个顺序调 | 能拿回多少 |
先加大 batch(在显存允许内) | 2~8 倍 |
按长度分桶,减少 padding | 1.3~1.5 倍 |
分类任务换 embedding,不跑生成 | 10~100 倍(成本) |
加并发 / 多进程 | 近似线性,直到被接口限流 |
前置过滤:明显无用的行先扔掉 | 看数据,常见能砍 20~40% |
六、embedding 与向量检索:数据开发要守的三条规矩
embedding 是这段管道里最像“数据”的一环:它产出的是向量列,要入库、要建索引、要重建。
6.1 余弦相似度,自己写一遍就懂了
import numpy as np
def cosine(a, b):
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))
def search(q, mat, k=3): # mat: (n, d)
qn = q / (np.linalg.norm(q) + 1e-9)
mn = mat / (np.linalg.norm(mat, axis=1, keepdims=True) + 1e-9)
scores = mn @ qn
idx = np.argsort(-scores)[:k]
return idx, scores[idx]
6.2 三条规矩
- 建库和查询必须同一个模型。换模型 = 全量重建,没有捷径——两个空间的向量不可比,余弦照样能算出 0.99,但那个数没有意义。
- 维度要写进表结构。384/768/1536 决定存储量:1536 维 float32 是 6 KB/条,一千万条就是 60 GB。
- 向量要带版本列。model_name + model_version + 建库时间,缺了这三个字段,半年后没人知道这批向量能不能用。
CREATE TABLE doc_vec (
doc_id BIGINT,
model_name TEXT, -- 必须
model_ver TEXT, -- 必须
dim INT, -- 必须
vec BLOB, -- 或 pgvector 的 vector(1536)
built_at TIMESTAMP
);
备注换模型时正确做法:新建一张 doc_vec_v2,双写一段时间,灰度读,跑一次对比评测,确认不劣化再切。直接原地更新是在给自己埋一颗定时炸弹。
七、数据质量:接模型之前先量三件事
模型出问题,一半的根因在数据侧。接之前先跑这三段检查,能省掉后面两天的扯皮。
7.1 长度分布与截断率
import statistics as st
lens = sorted(len(t) for t in texts)
def pct(p): return lens[int(len(lens) * p) - 1]
print("p50", pct(0.5), "p95", pct(0.95), "p99", pct(0.99), "max", lens[-1])
MAX = 512
over = sum(1 for L in lens if L > MAX)
print("截断率", round(over / len(lens) * 100, 1), "%")
截断率超过 5% 就要重新设计切片,不能再假装没看见——被截掉的部分往往是结论所在。
7.2 重复与近重复
# 精确去重:一行
uniq = list({t: t for t in texts})
# 近重复(MinHash 思路):把文本切成 3-gram,Hash 后取最小 k 个
def shingles(t, k=3): return {t[i:i+k] for i in range(len(t)-k+1)}
def sig(t, n=32):
hs = [hash(s) for s in shingles(t)]
return tuple(sorted(hs)[:n])
- 训练/评测数据被重复样本污染,指标会虚高;这是“离线 95%、上线 70%”的第一嫌疑人。
- 日志类数据天然大量重复,先去重再打标,标注成本能砍一半。
7.3 类别分布
先数一遍标签分布再动手。如果某一类占 95%,后面所有指标都要按类分开看——总体准确率会骗人。
八、评估:别信“准确率 92%”
这是数据开发接模型时最容易糊弄过去、也最容易被面试官戳穿的一节。
8.1 三段式:基线 → 模型 → 分桶
实测例子(假数据 200 条日志、4 类):关键词规则基线准确率 73.0%,类心模型 77.0%。
注意这个差距:模型只赢基线 6 个多点,这才会是真实项目里的样子。如果有人告诉你模型比规则高 30 个点,先查数据泄漏——通常是把标签写进了特征。
“报错”这一类的混淆矩阵:TP=38、FP=15、FN=12,得精确率 0.717、召回率 0.760、F1 0.738。
precision = TP / (TP + FP) # 说是报错里,真报错的占比
recall = TP / (TP + FN) # 真报错里,被抓出来的占比
f1 = 2 * p * r / (p + r)
备注为什么两个指标都要报:只报准确率时,模型可以“全部预测成多数类”拿到高分;只报召回时,模型可以“全部标成报错”拿满分。P 和 R 一起看,才堵住这两条路。
8.2 必须按桶再切一刀
分桶维度 | 为什么切 | 看到什么就该警惕 |
文本长度 | 短文本信息不足 | 短桶指标明显低 -> 补上下文 |
来源系统 | 不同系统日志格式差异大 | 某系统接近 0 -> 规则没覆盖 |
时间 | 模型/数据会漂移 | 最近一周掉点 -> 需要重训或换 prompt |
置信度 | 低置信度是主要错误来源 | 阈值以下占比高 -> 加人工复核 |
九、失败模式清单(照着排查)
症状 | 根因 | 怎么改 |
离线好上线崩 | 训练/评测同分布,线上下个月就漂了 | 加时间分桶评估 + 定期重跑基线 |
长文本效果差 | 被截断,结论在被切掉的后半段 | 分块 + 汇总,或换长上下文模型 |
同一输入两次结果不同 | 温度 > 0 或没固定 seed | 分类任务温度设 0 |
成本突然翻倍 | 重试放大了调用量,或 prompt 变长 | 统计调用数与平均 token,设预算告警 |
任务跑一半挂了全白干 | 没有断点,重跑从头开始 | 按分区覆盖写 + 水位线 |
结果没法回溯 | 没记录模型版本/prompt 版本 | 结果表里存 model_ver、prompt_ver |
接口超时雪崩 | 没有限流,失败重试一起打 | 指数退避 + 抖动 + 并发上限 |
重复行翻倍 | 重试写成了 append 而不是覆盖 | 分区级幂等:先删后写 |
十、挂到 Spark / 批量任务上的四个注意点
10.1 反例:每行一次调用
# 千万别这么写:一千万行 = 一千万次调用
df.rdd.map(lambda r: infer_one(r["text"])).toDF()
10.2 正例:mapPartitions 攒批
def batch_map(it, size=32):
buf = []
for row in it:
buf.append(row)
if len(buf) == size:
yield from infer_batch(buf) # 一次调 32 条
buf = []
if buf:
yield from infer_batch(buf)
df.rdd.mapPartitions(batch_map).toDF()
10.3 其余三条
- 闭包里别放客户端对象:它要能被序列化发到 executor,连接池会直接序列化失败。用 mapPartitions 里现建。
- 写结果用分区覆盖,不要 append。重跑一次 append 一次,第二天数据就翻倍了。
- 模型版本写进结果表,并且和任务参数一起记到调度日志里——出问题第一句要能答“用的是哪版”。
十一、串起来:一个最小可跑的日志分类管道
把前面几节合起来,就是一个完整的小项目:生成日志 → 规则基线 → 模型 → 评估 → 落库。
import sqlite3, numpy as np
# 1) 落库:结果表结构,版本列不能少
con = sqlite3.connect(":memory:")
con.execute("""CREATE TABLE log_label(
log_id INTEGER, text TEXT, label TEXT,
model_ver TEXT, score REAL)""")
# 2) 批量写入(不要逐条 execute)
rows = [(i, t, p, "v0-baseline", 1.0)
for i, (t, p) in enumerate(zip(texts, preds))]
con.executemany("INSERT INTO log_label VALUES (?,?,?,?,?)", rows)
con.commit()
# 3) 出报表:按标签统计
for lab, cnt in con.execute(
"SELECT label, COUNT(*) FROM log_label GROUP BY label ORDER BY 2 DESC"):
print(lab, cnt)
跑通这一遍,你就有了一个可以往真模型上替换的骨架:把 preds 换成真模型输出,其余一行不改。
十二、面试会被问到的十二个问题
Q 数据开发为什么要懂模型?
A 因为模型是管道里的一个节点。不懂它的吞吐和失败模式,就排不出靠谱的调度。
Q 一个百万行的打标任务,你怎么估时长?
A 先压测单条吞吐,再乘并发,加上重试和排队系数。数字要能当场算出来。
Q 为什么不能每行调一次接口?
A 网络往返和显存搬运占比过高,实测慢一个数量级,且失败重试成本翻倍。
Q batch 多大合适?
A 离线看显存与吞吐,在线看延迟;常见 32 左右,长短混排要先按长度分桶。
Q 准确率 92% 算好吗?
A 先问基线。规则基线可能就 88%,那模型只值 4 个点;再问按类、按桶的分布。
Q 为什么报 F1 不报准确率?
A 类别不平衡时准确率会被多数类支配,F1 同时约束精确率与召回率。
Q 模型升级了,历史数据怎么办?
A 向量要全量重建,标签要标版本;两个版本不能混在一张表里比较。
Q 向量库怎么选?
A 万级用 numpy 暴力算,十万以上再上 FAISS/pgvector,先别急着上专用库。
Q 数据漂移怎么发现?
A 固定一份黄金评测集定期跑;同时监控输入长度分布、类别分布、置信度分布。
Q 任务重跑为什么数据翻倍?
A 写成了 append。要按分区覆盖 + 幂等,重跑多少次结果都一样。
Q 长文本怎么处理?
A 先看截断率;能分块的按语义分块再汇总,不能分块的才考虑长上下文模型。
Q 你怎么保证成本不失控?
A 算 token 总量 × 单价,设日预算告警,统计每次调用的平均 token 与失败重试率。
附:一页速查
公式
要算的 | 公式 | 记住的量级 |
token 总量 | 行数 × 每行 token | 百万行 × 120 = 120 M |
成本 | token 总量 × 单价 | 按 ¥1/百万 = 几百元级 |
时长 | 行数 / 吞吐 / 并发 | 40 条/秒 × 4 并发 ≈ 1.7 小时/百万 |
精确率 | TP / (TP+FP) | — |
召回率 | TP / (TP+FN) | — |
F1 | 2PR / (P+R) | — |
余弦 | a·b / (|a||b|) | 只比方向不比长度 |
术语
- UDF:用户自定义函数,模型挂到数据管道上最朴素的形态。
- batch:一次送进模型的样本数,直接决定吞吐。
- 分桶:按长度/类别把样本分组,让一批里的样本尽量同质。
- 幂等:同一批数据重跑多少次,结果都一样。数据任务的底线。
- 水位线:标记“处理到哪了”,断点续跑的依据。
- 漂移:线上数据分布和建库/训练时不一样,指标会无声下滑。