1. 为什么我决定从零手写一套 AI 工程体系
这两年 AI 相关的职位和项目铺天盖地,但说实话,大多数人在做的其实是“调用”而不是“构建”。调接口、套框架、改参数,确实能快速出活,但一旦遇到性能瓶颈、成本爆炸或者模型行为不可控,就发现手里全是别人的轮子,自己连拧螺丝的能力都没有。
我决定做 ai-engineering-from-scratch 这个项目的初衷,就是想把自己从“API 使用者”逼成“工程构建者”。这里的 from-scratch 不是让你从零去训练一个大模型,那既不现实也没必要。我的定义是:不依赖任何现成的 AI 应用框架,从数据结构设计、特征处理、模型训练管线、推理服务、评估体系到监控告警,每一个环节都自己动手搭一遍。哪怕最终用的还是开源模型和基础库,但每一层逻辑你都得亲手写过、踩过坑、调过优。
这个项目适合谁?两类人。一类是已经用 LangChain 这类框架写过 demo,但感觉始终隔着一层纱的开发者;另一类是对 AI 工程全链路感兴趣,想从“懂概念”进阶到“能落地”的工程师。如果你只是想快速调通一个聊天机器人,这篇内容对你来说可能太重了;但如果你想搞清楚 AI 系统在生产环境中到底是怎么运转的,我强烈建议你跟着走一遍。
整个项目我前后花了大概三个月业余时间,从零搭出了一套可以处理文本分类和结构化抽取任务的小型 AI 平台。这篇文章不吹架构多漂亮,只讲那些真正花过时间才明白的设计决策、踩坑实录和可以直接抄作业的代码思路。我把整个实现过程拆成六个部分来聊,每个部分都对应一套完整的技术方案。
2. 整体架构设计:先画清楚边界,再动手写代码
2.1 先搞清楚“自研”的边界在哪里
动手之前最重要的一件事,不是选技术栈,而是划定自研边界。很多人一激动就想什么都自己写,结果写了三个月还在写分词器。我的做法是分三层来界定:底层基础设施用现成的,中间层算法能力用开源模型,最上层的工程逻辑全部自己实现。
这个项目的技术栈是这样的:Python 3.11 作为主语言,PyTorch 作为深度学习框架,HuggingFace Transformers 负责加载和调用开源预训练模型,FastAPI 提供推理服务接口,PostgreSQL 存业务数据,Redis 做缓存和队列。向量检索用的 FAISS,但只在 RAG 模块里用到了它。整个项目没有引入任何重量级的 AI 应用框架,所有 Chain、Agent、Pipeline 的逻辑都是自己用 Python 写的。
这样做的好处是每一行代码你都能控制,出了性能问题可以精准定位到具体的模块。坏处也很明显,很多框架帮你封装好的边界情况你得自己处理,比如超时重试、请求合并、上下文管理。我的建议是:框架帮你解决了 20% 的通用问题,但剩下 80% 的业务适配才是真正体现工程能力的地方。
2.2 模块划分与数据流向设计
我的架构分五个核心模块:数据层、训练层、推理层、评估层、监控层。数据层负责样本采集和预处理,训练层负责模型微调和版本管理,推理层对外提供统一 API,评估层在发布前跑自动化评测,监控层负责线上推理质量和延迟的实时追踪。
模块之间通过明确的数据契约交互。数据层输出统一的 Dataset 格式,训练层产出模型 artifact 和配置文件,推理层加载 artifact 并提供服务,评估层消费推理日志生成报告,监控层把指标推送到 Prometheus。这些契约我是在项目第三天就定下来的,实践证明这个决策帮我省了大量返工的功夫。前期多花一小时设计接口,后期就能少花十小时改代码。
数据流向的核心是一条线上推理链路。用户请求进来后,先经过预处理模块做文本清洗和截断,然后交给模型推理核心,拿到原始输出后再做后处理,把模型 token 映射回业务字段,最后写进结果表和日志系统。这条链路里每一步都有对应的类实现,每一步都可以独立测试和替换。
2.3 为什么先做文本分类这个场景
我选择文本分类作为第一个落地场景,不是因为它简单,而是因为它具备一个完整的 AI 工程闭环所需的所有要素。文本分类需要处理样本不均衡问题,需要做标签体系设计,需要面对线上数据分布漂移,这些都是 AI 工程里最典型的挑战。
具体任务是对客服工单做自动分类,标签集合包含“账户问题”“订单问题”“技术故障”“咨询建议”“投诉维权”五个类别。这个任务看起来不复杂,但真实工单数据的噪声非常大。用户可能一边骂人一边描述问题,也可能在一个工单里同时提到两个完全不同的问题。这些边界情况逼着我设计了比较完整的预处理和置信度判断逻辑,这些经验放到其他 NLP 任务里同样适用。
3. 数据工程:AI 项目里最容易被低估的环节
3.1 样本采集与清洗的实测经验
数据是整个项目里花时间最多的部分,没有之一。我用了一个包含两万条真实客服工单的数据集,但原始数据的质量只能用“惨不忍睹”来形容。HTML 实体、URL、手机号、表情符号、重复字符、繁体简体混杂,这些噪声不处理干净,后面模型吃进去的就不是文本而是垃圾。
清洗流程我分成了五步,每一步都用独立的函数实现,方便单测。首先是编码修复,把所有文本统一转成 UTF-8,处理掉乱码字符。然后是基础清洗,去掉 HTML 标签、URL、邮箱、电话号码,替换成特殊占位符。第三步是去重,基于 MinHash 做近似去重,把重复和近乎重复的工单直接合并。第四步是长度过滤,小于 10 个字符和大于 1000 个字符的样本单独放一个桶,因为这两类样本在分类时的处理策略完全不同。最后一步是人工抽检,随机抽 200 条清洗后的数据,逐条确认标签和质量。
清洗算法这块有个关键参数值得展开。MinHash 去重我用的是 n-gram 大小为 5,签名字段数设置为 64。n-gram 太小会把不同语义的句子误判为相似,太大又容易漏掉真正的重复内容。64 个签名的 Jaccard 相似度估计误差大概在正负 0.05 以内,这个精度对去重场景足够用了。实测下来,两万条原始数据清洗后剩下一万八千条左右,去重率约 10%。
清洗环节最大的坑是过度清洗。我一开始把标点符号全部删掉了,结果模型在验证集上的 F1 分数直接掉了四个百分点。仔细分析后发现,标点符号对文本分类是有语义贡献的,比如感叹号往往意味着强烈的负面情绪,这对投诉维权类工单是个强信号。后来我改成保留标点,只是在分词层面做处理,效果马上回来了。这件事给我的教训是:清洗策略必须以实验数据为准,不能靠直觉拍脑袋。
3.2 标注体系设计与标签冲突处理
标注体系是数据工程的灵魂,但绝大多数人一开始都不重视。我吃过这个亏,第一版标注规则写得太模糊,结果三个人标注同一批数据的一致率只有 72%。后来我重新设计了标注规范,每个标签都写了正例、反例和边界情况说明,一致率才提升到 91%。
标签冲突处理是另一个容易翻车的地方。一个工单同时命中“账户问题”和“订单问题”怎么办?我的方案是引入优先级机制。在业务场景里,“投诉维权”优先级最高,其次是“技术故障”,然后是“订单问题”“账户问题”,最后才是“咨询建议”。如果模型输出的 top-1 概率低于 0.6,同时 top-2 概率差距小于 0.15,系统会自动标记为“需人工复核”,而不是硬给一个答案。
这里有一个我从实践中总结的规则:AI 系统在不确定的时候,应该主动承认不确定,而不是强行输出。线上运行两周的日志显示,大约 7% 的请求会进入人工复核流程,而这部分请求如果强行自动分类,准确率只有 41%。也就是说,与其让系统硬猜,不如把不确定的样本交给人来处理,这个策略直接把整体自动分类准确率从 88% 拉到了 93%。
3.3 数据版本管理的实用方案
AI 项目的数据集是会持续演进的,今天加了 500 条新样本,明天修了一批错误标签,如果不对数据集做版本管理,你根本无法复现任何一个历史实验结果。我见过太多团队因为数据集更新后忘记记录,导致实验结果对比完全失效的灾难场景。
我的方案比较轻量,不用 DVC 这类重量级工具,就是一套基于文件命名和元数据记录的约定。每个数据集版本是一个目录,目录名包含日期和版本号,例如 dataset_v20250101_v3。目录里除了数据文件本身,还强制要求包含一个 metadata.json,记录这个版本的样本总数、标签分布、来源、变更说明和创建人。训练脚本在跑实验之前必须加载 metadata.json 并写入实验报告,这样任何一次实验都能精确追溯到用的哪份数据。
这套方案看起来简单,但实际执行下来效果很好。有一次我发现模型性能下降,查了半天才发现是一个数据版本被错误覆盖了,因为当时没有记录校验和。后来我在数据加载函数里加了 SHA-256 校验,数据文件有任何改动都会直接报错。这个教训值五千个字,我只说一句:不加校验和的数据版本管理等于没有版本管理。
4. 模型选型与训练管线搭建
4.1 为什么选了 6 层小模型而不是大模型
模型选型可能是最让人纠结的环节。我的任务要求是低延迟、可部署、可解释,同时数据量只有一万八千条,这几条约束叠加下来,直接排除了所有大规模预训练模型。实测跑过一次 7B 参数的开源模型,单条推理延迟在 CPU 上要两秒多,GPU 上也要三四百毫秒,这在客服工单实时分类场景里根本不可接受。
最终我选择了 bert-base-chinese 的蒸馏版本,6 层 Transformer,隐层维度 768,参数量大约 110M。选择它的理由是:这个规模在 CPU 上的单条推理延迟能做到 80 到 120 毫秒,在 GPU 上可以压到 20 毫秒以内,完全满足线上需求。准确率方面,微调后在验证集上的 F1 值达到 91.7%,只比使用 BERT-Large 低约 1.2 个百分点,但推理速度却快了将近五倍。
这里我重点说一个问题:小模型不等于差模型。当你的任务足够聚焦、数据质量足够好时,小模型的性价比远超大模型。模型不是越大越好,而是在你的约束条件下表现最好的那个才好。我后来把“模型选型决策表”固定为项目模板,包含延迟预算、吞吐要求、显存限制、标注数据量、任务复杂度五个维度,每次选型都按表打分,不再凭感觉拍脑袋。
4.2 微调训练中的超参数配置详解
微调阶段的超参数配置,我是用网格搜索加人工经验调出来的。最终配置是:学习率 2e-5,batch size 32,训练轮数 5,warmup 比例 10%,权重衰减 0.01,最大序列长度 256。优化器用 AdamW,损失函数是 CrossEntropyLoss,没有加 label smoothing,因为实验显示加了之后 F1 反而下降了 0.3 个点。
序列长度这个参数很容易被忽视,但它对训练速度和显存消耗的影响非常大。我的工单文本平均长度是 132 个字符,但尾部有长尾分布。如果序列长度设成 512,训练时间会增加大约 1.8 倍,而绝大多数样本根本用不到那么多 token。我把最大长度设成 256,保留约 95% 的样本不受影响,牺牲掉的那 5% 长文本单独做截断策略处理。截断策略是保留开头 128 token 和结尾 128 token,中间部分如果超长就丢弃,因为对文本分类任务来说,开头和结尾往往携带更多信号。
训练过程我自己写了一个轻量训练循环,没有用 HuggingFace 的 Trainer。原因是我想完全掌控每个 step 的梯度累积、日志记录和 checkpoint 保存逻辑。训练循环里我加了梯度裁剪,max_grad_norm 设为 1.0,因为文本分类微调时偶尔会遇到 loss spike,裁剪能防止参数被异常梯度带偏。实际训练了 5 个 epoch,每个 epoch 大约 560 个 step,总训练时长在单张 A10 上约 25 分钟。显存峰值 11.2GB,刚好卡在 A10 的 24GB 显存的一半以下,给后续优化留了空间。
训练中的一个关键经验是关于学习率的:2e-5 看起来是 BERT 微调的标准值,但实际跑下来发现 3e-5 虽然收敛更快,最终 F1 却低了 0.6 个点。原因在小数据集上大学习率更容易导致过拟合。我后面对比了五组学习率,结论是 2e-5 到 2.5e-5 之间是比较稳的区域。如果你也用小数据集做微调,建议优先试这个范围,别一上来就搞动态学习率那套花活。
4.3 模型版本管理与回滚机制
训练产出的不只是一个模型文件,还有配套的 tokenizer、配置文件、评估报告和样本日志。这些必须打包在一起,形成可追溯的模型版本。我的做法是每个模型版本一个目录,包含 model.bin、tokenizer.json、config.json、eval_report.json、train_meta.json 五个文件,打包成 tar.gz 后上传到对象存储,同时记录到模型版本表里。
模型版本表的结构我设计成了这样:版本号、对应的训练代码 commit id、数据集版本号、超参数配置、验证集指标、发布时间、状态。这个表是模型管理的核心,线上回滚只需要切换状态字段,服务端会在下次请求到来之前加载新版本。这里要强调一个细节:GPU 上加载模型到显存需要时间,所以版本切换不能粗暴地重启进程,而是用双缓冲加载。新版本在后台加载完成后,原子替换当前指针,旧版本等待在途请求结束后再释放显存。这个机制帮我实现了秒级切换版本,线上无感知。
回滚机制我踩过一个记忆深刻的坑。有一次发布新版本后,监控发现“投诉维权”类别的精确率从 89% 掉到了 74%,但整体准确率只掉了 0.8%。如果只看整体指标,你根本不会发现这个异常。这就是为什么一定要按类别监控指标,而不能只看总量。自从加上类别级别的指标监控后,任何细粒度的模型退化都能第一时间被发现。版本回滚的决策依据也明确了:当任意核心类别的精确率下降超过 3 个点,且持续超过 10 分钟,就自动触发回滚。
5. 推理服务与线上部署的完整实现
5.1 FastAPI 服务架构与并发模型
推理服务我选择了 FastAPI,不是因为它火,而是因为它的异步支持和 OpenAPI 文档生成确实好用。服务端架构部署了三个实例,每个实例独立加载模型,前置 Nginx 做负载均衡。每个实例内部使用 model 加载单例,配合 Python 的 asyncio 实现请求并发处理。最关键的一点是,GPU 推理本身是同步阻塞的,如果直接在异步事件循环里跑推理,会把整个进程卡死,所以必须用独立线程池来执行推理调用。
我使用 ThreadPoolExecutor 作为推理执行器,线程数和 CPU 核数绑定,通常设置 4 到 8 个线程。每个请求进来后,FastAPI 的 async 函数会立刻把推理任务提交到线程池,然后让出事件循环。这样 Nginx 层和 API 层都能保持高吞吐,不会因为一个慢推理阻塞所有请求。实测单实例压测结果:QPS 达到 180,P95 延迟在 460 毫秒,这个表现对于文本分类任务来说是合格的。
压测这块我用的是 Locust,模拟 100 个并发用户持续打 15 分钟。让我意外的是性能瓶颈不在模型推理,而在后处理的数据序列化环节。模型推理一条大概 100 毫秒,但把 Python 字典转成 JSON 再返回竟然花了 180 毫秒。优化方案是放弃用 Pydantic 自动序列化,改成手写带类型标注的解析函数,同时使用 orjson 替代标准 json 库,序列化耗时直接降到 90 毫秒。这一优化让整体 QPS 提升了约 30%。
5.2 推理链路上的预处理与后处理逻辑
线上推理链路看起来就是“文本进、分类出”,但中间涉及很多细节。完整的请求处理流程是七步:请求校验、文本归一化、长度截断、tokenizer 编码、模型推理、概率解析、后处理映射。每一步都有独立的日志和耗时记录,这样任何一个环节出问题都能通过 tracing 定位到具体位置。
预处理环节有个特别容易忽视的运行时空洞:tokenizer 的 encoding 处理在 GPU 推理中常常占到整体延迟的 25% 到 30%。我用缓存优化缓解,对完全相同的历史请求文本直接缓存编码结果,命中率大约为 12%。实际上大多数请求虽然不完全相同,但结构非常相似,进一步对编码结果做组件级缓存,把 token 长度和 attention mask 缓存下来,命中率提升到了 34%。
后处理环节的置信度机制是保证线上质量的关键。模型输出的 softmax 概率不能直接当置信度用,因为它天然有校准偏差。我在验证集上做了一次温度缩放校准,找到一个最优的温度系数 T=0.86。校准之后,当模型输出 0.9 以上的置信度时,实际正确率大约在 0.93 左右,比校准前的 0.86 准确了不少。这个校准对“高置信度自动处理、低置信度转人工”的策略至关重要,没有校准就谈置信度阈值,等于在赌博。
5.3 容器化部署与资源配额
部署方案用的是 Docker 加 Docker Compose,没有上 Kubernetes,因为这个项目规模还不需要那么重的编排。Dockerfile 我做了多阶段构建,基础镜像用 python:3.11-slim,安装 PyTorch CPU 版和 GPU 版分两个 target。模型文件不打进镜像,而是挂在持久化卷上,这样模型更新时只需要替换文件,不用重新构建镜像。镜像大小控制在 2.3GB 左右,在不安装 CUDA 完整工具链的情况下可以进一步压到 1.6GB。
容器资源配额这里我必须多说一句:AI 推理容器的内存设置非常反直觉。我一开始按模型参数量估算,11亿参数的存储大约需要 440MB,就给了容器 2GB 内存限制,结果线上频繁 OOM。后来才发现 CUDA context、PyTorch 运行时和 cuDNN 的初始化内存远超模型本身,一个 110M 参数的模型在 GPU 推理时,CPU 内存峰值能达到 2.8GB。最终我把 CPU 内存限制调到 4GB,预留了 50% 的缓冲,才彻底解决了 OOM 问题。
Nginx 层配置了合理的超时参数。客户端请求超时设为 5 秒,代理转发超时设为 10 秒,这样即使下游模型卡住,也有一道兜底防线,不会让用户无限等待。健康检查接口 /healthz 会真正做一次小样本推理来确认模型是活的,而不只是返回“进程存在”。这个设计帮我抓过一个真实事故:模型加载后显存不足但进程没崩,表面上容器还活着,实际上所有请求都超时了。健康检查做真实推理,就能在流量进来之前发现问题。
6. 评估体系与监控告警的从零搭建
6.1 离线评估:从准确率到细粒度诊断
模型上线前必须经过离线评估,但准确率远不是唯一指标。我的评估流程分四个层次:基础指标、类别诊断、错误分析、业务验证。基础指标包括准确率、精确率、召回率、F1 和 AUC。类别诊断看每个类别的混淆矩阵,定位容易混淆的标签对。错误分析是抽样查看模型预测失败的案例,人工归纳失败模式。业务验证则是让业务方盲测一批样本,确认模型输出是否符合真实使用预期。
混淆矩阵分析帮我发现了一个很有价值的规律:模型经常把“咨询建议”误判成“账户问题”。原因是很多咨询性工单本身就包含账户相关关键词,比如“我的账户登录不了怎么办”实际上属于“技术故障”,但模型学到了“账户”和“问题”的共现关系,就倾向于往“账户问题”上偏。针对这个问题,我在训练数据里增加了 200 条相似的难例样本,通过难例挖掘的方式补充标注,这一招把 F1 从 89.2% 提升到了 91.7%。
评估里还有一个容易踩坑的地方:切分数据时如果随机切分,会因为标签分布不均衡导致评估结果虚高。我的做法是分层切分,保证训练集、验证集、测试集三类标签的比例和原始数据一致。同时做了一次时间序列切分的对照实验,用前 60% 时间的数据训练,后 40% 时间的数据做评估。这个模拟线上分布漂移后的 F1 只有 76.3%,比随机切分低了 15 个点。这个结果说明,模型在训练分布上的表现不一定能代表线上真实表现,通用评估指标可能会骗人。
6.2 线上指标监控:不只是看延迟和 QPS
很多人以为线上监控就是看 QPS、延迟和错误率,这些确实重要,但远远不够。AI 系统的核心监控对象是模型质量,也就是预测结果本身。我建立了三个维度的线上质量监控:分布监控、置信度监控、抽样审计。
分布监控是对输入文本的统计特征做实时感知,包括平均长度、高频词、标签预测分布。如果线上真实工单的平均长度突然从 130 个字符涨到 180 个,说明数据分布漂移了,模型的可靠性就要打问号。置信度监控是对模型输出的概率做流式计算,如果高置信度样本的比例突然下降,说明模型越来越不确定,很可能是因为数据分布变了。抽样审计则是按万分之一到千分之一的比例,随机抽取线上预测结果送给人工复核,持续积累反馈。
监控告警规则我设了三层阈值:警告、严重、紧急。警告级别是延迟 P95 超过 500 毫秒持续 5 分钟,或者某类预测比例偏离基线 10%。严重级别是错误率超过 1% 持续 10 分钟,或者低置信度比例超过 30%。紧急级别是核心类别精确率下降超过 3 个点持续 10 分钟,此时自动触发版本回滚。这些阈值不是拍脑袋定的,每一项都从历史告警数据里反推验证过。我不追求零告警,因为零告警通常意味着阈值设置太松,真出事了都不知道。
6.3 线上质量反馈闭环的工程实现
模型上线不是终点,而是一个新的循环起点。我的闭环机制是这样的:线上低置信度样本自动进入待复核队列,人工完成标注后写回数据库。每周做一次增量训练数据导出,把新标注的样本加上历史难例合并,触发一次增量微调,生成新版本模型。接着跑离线评估,达标后进入灰度发布,先在 10% 流量上跑一小时,观察监控指标无异常后,再逐步扩大到 50% 和 100%。
灰度发布是我认为整套系统里最能体现 AI 工程成熟度的环节。很多团队把模型当普通代码一样直接全量发布,出了问题再回滚,这在 AI 场景里代价非常高。因为你无法预判模型在新数据上的表现,尤其是数据分布漂移的情况下。灰度发布配合实时监控,可以让你在影响面可控的前提下验证模型表现,同时还能做 A/B 对比,确认新版本确实比旧版本在业务指标上更优。
这个反馈闭环跑了两个月后,模型 F1 从初版的 87.4% 缓慢爬升到 92.3%。增量收益虽然不大,但每一点提升都建立在实际线上数据的基础上,这是离线调参怎么都换不来的。我个人的体会是,增长最快的模型不一定是用最好的架构,而是拥有最完善反馈闭环的模型——它永远在从错误中学习。
7. 使用过程中遇到的坑与解决实录
7.1 序列长度截断导致的信息丢失问题
这个坑藏得很深。处理超长文本时我原本的做法是直接截断前 256 个 token,后来做错误分析时发现,有一条工单的关键信息全在末尾——用户在前面骂了一百多个字,最后一句说“我的订单号是 827362,帮我查一下物流”。截断之后,模型拿到的信息里完全没有订单号和物流关键词,自然就分类错了。
改成“开头加结尾”的截断策略后,这个问题缓解了很多。逻辑很简单,开头是用户描述问题的起点,结尾往往是诉求和关键信息。具体实现是:如果文本长度超过阈值,保留前 128 个 token 和后 128 个 token,中间部分在语义连贯处切断。要注意的是,切断点不能恰好落在一个词的中间,否则 tokenizer 会把这个词拆成两个无意义的子词,增加噪声。我之前没处理这个细节,调试时发现怎么都少几个 token,后来加了平滑切断逻辑才修复。
7.2 async 事件循环与 GPU 推理的冲突
这个坑让我一度怀疑人生。最初我把模型推理直接写进了 FastAPI 的 async 函数里,压测时发现并发一上来就大量超时,事件循环完全卡死。原因很简单:GPU 推理是阻塞操作,在事件循环里执行时不释放控制权,后面的请求全部排队等前面的推理完成,等于把异步变成了同步串行。
修复方案前面提过,用 ThreadPoolExecutor 把推理任务转到线程池执行,FastAPI 的 async 函数只负责接收请求和提交任务。这里有个更细节的问题:PyTorch 的 GPU 推理本身是持有 GIL 的吗?在大多数场景下,CUDA 调用会释放 GIL,所以多线程并发推理是可以实现的。实测 8 个线程时 GPU 利用率能到 90% 左右,再往上加线程利用率提升有限,反而增加上下文切换开销。
另一个相关的小坑是:不要用 uvicorn 默认的单 worker 模式跑 GPU 推理服务。多个 worker 意味着每个进程都要加载一份模型副本,显存翻倍,而并发提升并没有翻倍。正确做法是单 worker 加多线程,因为 PyTorch 推理的瓶颈不在 CPU 而在 GPU 计算,单进程多线程机制足够榨干 GPU 吞吐。
7.3 tokenizer 的编码一致性隐患
训练和推理如果用了不同版本的 tokenizer,后果是模型输出完全乱掉。这个问题的可怕之处在于它不会报错,只会让准确率神秘下降。我有一次升级了 transformers 库版本,训练时用的还是旧版本,推理却已经跑了新版本,结果新版本 tokenizer 对某些特殊字符的处理逻辑发生了变化,模型准确率直接掉了 8 个点。
从此我定了一条死规定:模型版本必须包含 tokenizer 文件,推理时必须加载模型包内的 tokenizer,绝不使用环境里默认安装的版本。同时把 transformers 库版本号固定写进 requirements.txt,升级必须走完整的回归测试流程。这条规定看起来基础,但真踩过坑才知道它有多重要。
还有一次比较隐蔽的问题是:训练时我在预处理里做了文本归一化,但推理时预处理脚本因为代码重构漏掉了这一步,导致同样的文本在校验时和实际预测时走了不同的处理路径。后来我把训练和推理共用的预处理逻辑抽成一个独立模块,用单元测试固定住行为,再遇到类似的问题,只需要跑一遍测试就能发现。
7.4 类目分布漂移的早期预警信号
线上数据分布漂移往往不是突然发生的,而是缓慢滑动的。我观察到的第一个信号发生在模型上线后的第三个星期,监控面板上“投诉维权”类别的预测比例从基线的 18% 悄悄爬到了 24%。当时看起来只是数据波动,没有触发告警。但第四周这个比例稳定在 25%,同时该类别的高置信度预测占比开始下降。
深入排查后发现原因是业务调整,有一批新的营销活动上线,引发了更多投诉类工单,输入数据分布确实变了。虽然模型整体准确率没有明显下降,但部分类别的置信度分布已经变化。这个案例说明了固定阈值的局限性:你要监控的不是静态指标,而是指标的变化趋势。后来我在告警系统里加了趋势检测逻辑,用滑动窗口对比当前 30 分钟和过去 7 天的基线,超过 2 倍标准差就预警。这套机制帮我提前发现了三次数据分布漂移,都赶在业务受影响之前完成了模型更新。
8. 项目复盘:这套自研体系的得与失
现在回看 ai-engineering-from-scratch 这个项目,我的评价是收益远大于成本。最直观的收获是,我现在对 AI 工程项目里每一个环节都有真实的体感,知道延迟瓶颈可能出现在序列化而不是推理,知道模型退化可能隐藏在整体指标背后,知道数据漂移和模型漂移是两件不同的事。这些体感没法靠看文档获得,只能靠亲手踩坑。
但我也想泼一盆冷水:不是所有项目都适合 from-scratch。如果你的核心目标是快速上线一个 demo,或者验证某个业务猜想,直接用现成框架可能是更合适的选择。自研框架的价值体现在长期迭代和深度定制的场景里,当你需要精细控制每一步行为时,这时候自研的每一行代码都是资产而不是成本。
这个项目后续我计划扩展三个方向。第一个是在文本分类的基础上增加序列标注任务,因为工单里实体抽取的需求很强烈。第二个是把单模型的推理链路升级为支持多模型路由,根据请求类型动态选择模型。第三个是补上完整的 A/B 测试框架,让模型评估从离线指标和线上监控进一步升级到真实业务漏斗效果对比。这些都是从当前体系里自然生长出来的下一步,不是拍脑袋想出来的功能。
如果你也想走一遍类似的路,我的建议是:别贪大,先选一个极小的业务场景跑通全链路,哪怕只是判断一条消息是“咨询”还是“投诉”也行。全链路跑通带来的认知提升,远大于在一个环节里做到极致。你在第一个小场景里踩过的每一个坑,都会成为后面做大场景时最值钱的经验。