从零搭建AI工程:短文本情绪识别模型的完整落地实践
发布时间:2026/10/4 9:26:15 作者:尧图编辑部 阅读量:1,286

做了这么久的东西我一直觉得“AI工程师”这个头衔被市场叫烂了。打开招聘软件一看十个岗位里有八个是“AI工程师”干的事却是调参、洗数据、调API、润色Prompt。不是说不重要而是这些只是冰山一角。真正让我觉得有底气拿“AI工程”来命名的工作是从零开始把一个模型从无到有地搬进生产环境。这个项目我起名叫 ai-engineering-from-scratch就是想用一次完整实操把“AI工程”里那些非玄学、可复制、要流汗的部分切成一块块拼图摆出来。这个项目最适合两类人一类是已经会用PyTorch或TensorFlow训练模型但没正经上过线不知道gradle和gunicorn怎么和模型沾上边的人另一类是后端工程师写接口很熟但对训练曲线、过拟合、数据漂移这些概念只有模糊印象想把AI这头大象完整摸一遍。我把整件事拆成了五个阶段系统边界与数据封闭、基线模型与训练策略、评估体系与迭代取舍、服务化与统一接口、可观测性与持续自动化。每一块都会结合一份我自己做过的“短文本情绪识别主题聚合”项目来讲工程细节全部可落地参数给的是能直接进notebook跑通的那种。1. 系统设计与数据闭环1.1 先画边界AI项目不是从模型开始的很多新手一上来就抄模型结构或者先决定“我要用BERT”。但我的习惯是先回答三个问题——处理什么输入、产出什么结果、跑在什么环境里。以我的短文本情绪识别为例输入是APP内用户反馈文本平均长度不超过50个字符输出是“正向/负向/中性”三分类环境是CPU密集型容器内存上限2GB时延容忍度是单条200ms以内。有了这些数字约束很多选择就被锁死了BERT-base会被淘汰因为108ms的P99延迟在纯CPU上冲到250ms太常见DistilBERT会成为主选因为同样条件下能跑到60ms。选型不是凭感觉是拿约束条件当筛子。边界画完之后立刻动笔写一条数据契约。不用写复杂文档一个schema文件就够# schema.py from pydantic import BaseModel, Field class Sample(BaseModel): text: str Field(..., min_length1, max_length200) label: int Field(..., ge0, le2) source: str Field(..., pattern^(app|web|api)$)这个文件的价值是让你在第一天、第100天回到项目时序列化逻辑不出偏差。数据从源头到模型、再到落库全部过这一个schema脏数据在入口就会被拦掉。1.2 数据采集与标注封闭数据集才是底线模型的效果上限不是模型结构决定的是你手里的数据决定的。我这次的采集策略有两条路并行现有用户评价库取近12个月数据按时间分层抽样防止只取到某次营销活动后的极端情绪样本。冷启动补采在反馈入口埋了一个轻量打点只多了一个字段“这条反馈让你觉得处理得怎么样”用户点击即成为弱监督样本。标注环节我踩过不少坑最后定下的流程是先由两个标注员独立标注同一批300条数据计算一致性系数低于阈值就拉到一起讨论分歧点直到统一标准后再铺量标注。这样看着多花了两天实际上避免了后期“重新标注一万条”这种地狱工作量。最终我拿到2.8万条标注数据其中负向占比46%正向39%中性15%。这里有个容易忽略的点类别不平衡不一定要采样平衡。我处理的场景是“情绪识别”负向比例高恰恰是产品事实所以我保留自然分布只在损失函数里加重了多数类的惩罚陷阱规避具体做法是后续改用带类别权重的交叉熵。1.3 数据清洗的策略要保守还是激进数据清洗有一个铁律能靠模型学到的不靠规则硬删靠规则删的一定是你确信它不是语言而是噪声。具体到我的文本数据我做了三步处理去掉HTML实体和短链但保留URL本身——因为URL在反馈里往往意味着“用户附了截图链接”这在情绪分析里有信号意义。统一中英文标点和半全角但对表情符号保留原样。和对情绪分类是强特征绝不能无脑过滤。不删重复样本而是引入“同文本多次出现”作为一个计数特征传给后续模型——这在反馈场景下本身就是“大量用户共同遇到同一问题”的信号。这套清洗逻辑做完我得出的五折交叉验证F1是0.83比“激进清洗版”去掉所有符号、小写化、去停用词的0.79高出一截。激进清洗适合传统机器学习对深度学习反而有害因为符号语境是特征不是噪音。2. 采样策略与训练流水线2.1 负样本挖掘从沙堆里挑真正的垃圾纯随机采样的负样本会让模型学习到“哪些句子看起来正常”而不是“哪些句子语义真实”。我做过对比实验随机负样本训练出的模型在测试集上会轻易把“非常满意”识别为“中性”因为训练时没见过太多强正向表达。所以我改成了“困难负样本挖掘”策略先用当前模型跑一遍未标注池取出预测概率大于0.6但实际非目标类别的样本。将这些样本人工复核后并入训练集。每训练一个里程碑版本重新挖一次。这个策略在第二轮迭代后把中性类别的召回率从0.68拉到了0.74。虽然听起来不多但放在线上就是每周少错判几千条用户反馈。2.2 基线模型到底怎么选从逻辑回归到DistilBERT我不建议一上来就上大模型先跑一个词频逻辑回归作为基线。它的意义不只是给后续模型“垫底”而是让你理解数据最基本的可分性。如果逻辑回归就能拿F1 0.72说明特征空间里存在明显的线性信号如果只有0.55那说明语义信息远大于表面词汇信息必须用表示学习。我的逻辑回归基线是F1 0.72TF-IDF特征维度限制在5万。这让我判断词汇层面的信号很强但“买回来发现是坏的但是客服很好”这种转折句逻辑回归会分错。这正是要上预训练语言模型的原因而不是因为“大家都在用”。最终训练用的是DistilBERT-base多语言版作为初始化权重序列长度卡到64。因为中文短文本95%以上不超过64字符再长就是浪费计算量。学习率用的是3e-5batch size 32warmup比例0.1梯度裁剪到1.0。这个组合在多次不同数据集上都很稳是标准的起步配方。2.3 训练流程的编排与断点续训训练永远不要在一个裸脚本里跑裸for循环。我用的是Ray Train做编排它在断点续训上的表现比在notebook里自循环强一大截。from ray.train import CheckpointConfig, RunConfig, ScalingConfig from ray.train.torch import TorchTrainer def train_func(config): # 模型初始化、DataLoader、optimizer等 pass scaling_config ScalingConfig(num_workers1, use_gpuFalse) run_config RunConfig( checkpoint_configCheckpointConfig( num_to_keep3, checkpoint_frequency1, ), storage_path/mnt/checkpoints, ) trainer TorchTrainer( train_functrain_func, scaling_configscaling_config, run_configrun_config, ) result trainer.fit()断点续训这件事我吃过一次亏——训练到第3个小时K8s节点重置全部日志没同步从头再来。后来我做了两件事checkpoint每一轮都落盘同时把原始数据放在对象存储上而不是训练机本地磁盘保证任何一个新节点都能无缝接入训练任务。2.4 收敛标准的判据不是“loss降了”很多人看到loss从0.6降到0.3就觉得模型变好了。其实要分开看——训练loss降、验证loss不降这是过拟合两者同降但业务指标F1不升这说明loss下降和你的目标函数不对齐。我这次项目的收敛判据是复合的验证F1不再提升连续3个epoch同时验证loss不再下降超过0.005才触发早停。早停不是终点早停后我会把历史上最好的checkpoint找出来而不是默认最后一个。经验值最后一个checkpoint往往已经过拟合0.5-1个点。3. 评估体系与迭代取舍3.1 不止是F1要建一张评估清单评估体系的建立原则是“先定好赛道再跑模型”。我建了一张多维评估表每次实验输出都往这张表里填指标数值如何计算宏平均F10.83sklearn f1_score(averagemacro)负向样本召回0.91负向标签的recall中性样本精确率0.58中性标签的precision预处理吞吐率2100条/s清洗编码全流程P99推理时延74msFastAPITriton实测表格里的每一项都对应一个线上担忧负向样本召回低会漏掉用户投诉中性精确率低会把中立反馈误伤成负面吞吐率决定要不要加机器。指标字段不是给论文看的是给监控看板和告警阈值用的。3.2 错误分析看错例比看loss有价值我最烦的一种汇报是“我的模型F1达到0.85”问一句“错在哪里”答不上来。你必须做错误分析我自己的固定动作是从测试集抽100条错例人工归类。一次典型的错误归因结果如下32%是标签噪声标注员把“快递太慢了但东西本身还行”标成“负向”因为看到了“太慢”。28%是语义转折模型没学会“虽然...但...”结构。18%是领域专名产品名、版本号干扰。22%是标注标准分歧两个标注员给的标签本身不一致。看到归因以后我的下一步动作就不是“调超参”了而是第一修订标注指南明确“转折后态度优先”第二针对“虽然但”句式扩充同义改写数据第三把无法达成共识的样本从训练集剔除。3.3 迭代取舍不是每个指标都要拉满很多人做迭代喜欢把所有指标都往上顶。但工程现实是中性类别的精确率每提升1个百分点可能需要牺牲负向召回2到3个百分点。这种取舍只有结合业务才能定。我这次的做法是给三类错误定了一个业务代价矩阵将负向误判为中性漏掉投诉的代价最高等价于召回失败将中性误判为负向的代价次之可能是客服点开一条普通反馈将正向误判为中性的代价最小因为用户即使收到确认也不会有太大反应。基于这个代价矩阵我在最后一次迭代时选择了一个在测试集上“宏F1不是最高、但加权业务代价最低”的checkpoint。这个模型宏F1是0.82但相比宏F1最高的版本漏投诉率下降了13%。AI工程不是打榜是给业务方程求最优解。4. 服务化与统一接口4.1 用一个标准接口包装所有模型服务化这里我只认一条原则任何模型都暴露成同一个HTTP接口。输入一律是{text: str, request_id: str}输出一律是{label: int, prob: float, version: str}。这样上游只对接一次后续模型升级、回滚、灰度都不动业务代码。我用FastAPI来做这个服务层原因很朴素异步支持好Pydantic原生集成文档页自动生成省掉一版手写API文档。from fastapi import FastAPI from pydantic import BaseModel app FastAPI() class PredictRequest(BaseModel): text: str request_id: str class PredictResponse(BaseModel): label: int prob: float version: str app.post(/v1/predict, response_modelPredictResponse) async def predict(req: PredictRequest): out model_service.predict(req.text) return PredictResponse(**out)这个接口上线以后前后端联调只花了半小时。对比之前见过的一个项目模型团队自己定了一套socket协议前端接不了后端又要多写一层适配完全是自找麻烦。4.2 模型推理优化CPU上如何省出3倍算力CPU推理的优化第一步永远是量化感知训练不是事后ONNX。我把DistilBERT先做量化感知训练QAT再导出成ONNX Runtime的int8格式。QAT比训练后量化PTQ多了两三个epoch的事但精度损失能控制在1%以内PTQ则常掉到3%以上。导出和推理的核心简化版如下import onnxruntime as ort import numpy as np sess_options ort.SessionOptions() sess_options.intra_op_num_threads 4 sess_options.graph_optimization_level ort.GraphOptimizationLevel.ORT_ENABLE_ALL session ort.InferenceSession(model_int8.onnx, sess_options) def predict(text: str): inputs tokenizer(text, return_tensorsnp, max_length64, truncationTrue) probs session.run( None, { input_ids: inputs[input_ids], attention_mask: inputs[attention_mask], } )[0] return softmax(probs[0])做了QATint8之后单条P99时延从74ms降到了31ms吞吐提升了快3倍。有人会问为什么不直接换更小的模型因为换模型等于重做一遍业务验收而量化只是部署优化流程不变、效果偏差在1分以内风险完全不是一个量级。4.3 批处理与缓存设计削峰填谷线上反馈量不是匀速的晚高峰往往是白天的5倍。如果每一条都单独实时推理峰值时CPU直接爆。我的做法是两条腿走路实时推理走同步接口超时阈值200ms负责高优场景。大批量清洗类反馈走离线批处理用Celery挂在RabbitMQ上消费端一次拉200条做动态padding和batch推理。动态padding值得多说一句同一batch内部按最大长度补齐而不是全batch都到64。只这一项离线批处理的吞吐就提升了一倍多因为90%的句子实际只有30个token左右。这是那种看起来不起眼、实际影响很大的工程细节。缓存方面我做了两层第一层是Redis短缓存相同文本5分钟内不重复推理第二层是“高置信度直接落库”——只要概率大于0.99且版本号没变就不再进人工复核队列。这套组合在高峰期帮我们拦截了约22%的重复请求。5. 可观测性与持续自动化5.1 记录一切从推理日志到灰度标记服务上线只是开始真正的工程体现在你能不能在凌晨两点回答“昨晚10点模型为什么把这批全分错”。我要求所有推理日志带上五个字段request_id、模型版本、输入文本、输出标签、各标签概率。每一条都落盘到数仓的日志表方便事后回放。这里有个实战做法不回传原始文本到日志系统时的脱敏困惑。但我们的场景是接收用户文本隐私合规要求高所以日志里记录的是文本的hash值以及一个“是否转人工”标记。正经做线上模型敏感信息处理比任何优化都优先。灰度标记也很关键同一个接口后面可能跑着v1和v2两版模型。我在响应里固定返回version字段就是方便监控按版本拆指标。否则黑盒上线一个新模型出问题你连错误面都圈不出来。5.2 数据漂移监控模型会默默变蠢一个常见的线上事故是模型上线时好好的三个月后指标悄悄掉了5个点。原因通常是数据漂移——用户表达方式变了、产品改版带来新词、运营活动催生了大量新句式。我的监控方案是在线预测分布与训练分布做对比每小时统计线上预测的三分类概率分布。与训练集的三分类分布做KL散度对比。超过阈值就触发告警并自动攒一批漂移区间内的样本进人工复核区。这套触发过两次一次是因为产品上线了新用户引导语大量的“我不会用这个”出现在反馈里模型把它归为负向但工程上这属于“求助类”而非情绪负向。第二次是客服改版后用户开始大量提到“工单号”这个词汇在训练集里出现极少。两次都被及时拦下没有造成声誉上的事故。监控不是锦上添花对AI服务来说它就是刹车系统。5.3 CI/CD模型版本不是一个文件是一组资产模型版本管理必须和代码版本管理一样严格。Git LFS只适合存小模型项目到了上百MB我会用DVC管理数据和模型文件每次训练的代码、数据版本、超参、评估结果、模型权重完整绑定成一个不可变快照。CI流水线大致分四步Lint和schema检验跑一遍pytest和Pydantic数据契约的单测。训练冒烟测试用1%的数据跑1个epoch确保训练脚本不炸。评估门禁在固定验证集上跑评估F1低于当前在线版本0.5个百分点则直接拦截。镜像构建把模型权重、tokenizer配置、推理代码打成OCI镜像推送到私有仓库然后K8s滚动发布。这个门禁很重要。有次我改了一版预处理逻辑F1涨了1.2个点但推理P99时延多了40ms门禁判定“整体代价超标”把版本打了回去。没有这种自动化的限制线上质量就是靠自觉靠自觉的工程系统注定会腐烂。6. 工具链选型与常见坑位6.1 为什么这套组合选型逻辑一览经常有人问“这个项目用XX框架行不行”。我一般回一句框架只是约束条件你先想清楚你要的组织架构和时间尺度。我这次的选型最终是环节工具为什么是它数据校验Pydantic类型即文档入口即可拦截脏数据实验记录MLflow追踪参数、指标、模型文件一条龙训练编排Ray Train断点续训和横向扩展能力成熟模型推理ONNX RuntimeCPU上优化能力强int8支持稳定服务框架FastAPI高并发异步、Pydantic原生、生态成熟批处理CeleryRabbitMQ老牌稳定文档多不出奇但不出错监控PrometheusGrafana指标采集和图表展示的标准组合数据版本DVC数据和模型与代码同等纳入版本控制这套组合的特点是“不追新、看稳”。每个组件都是各自领域的top级别选手组合在一起不打架文档能覆盖90%的坑。如果你要换先问自己为什么换性能瓶颈在哪个环节换完带来的复杂度是否可控这才是选型该有的姿势。6.2 实操期翻车最多的五个坑给后来者列几个我踩过的、让人觉得“不应该但确实发生了”的坑多卡训练时数据没用DistributedSampler重复采样或漏采样悄无声息发生训练出来的模型比单卡还差。所有DataLoader在DP/DDP下都要检查分布式采样器。checkpoint只落一台机器看似写了checkpoint文件实际只存在本地盘上容器一删就没了。解决方案就是落对象存储或共享存储卷。tokenizer没跟着模型版本走旧tokenizer和新模型混用导致解码乱码输出概率异常。tokenizer的版本必须和模型一起做快照绑定。时延优化后忘了重新做压测int8量化模型功能正确但并发一上来就超时。量化不只是看精度和时延还要做并发压测看response时间的分位点是否稳定。日志字段加了版本号但监控面板没拆维度数据是有了但Grafana面板还按整体聚合版本间差异还是看不到。监控设计要和日志设计同步做不然又变成“数据收集了但没法用”。6.3 一个关于工程思维的收尾最后聊点纯经验。做这趟项目我最大的感受是AI工程拼的不是谁的模型更炫而是谁的系统更不容易坏。训练出好模型的人很多能让模型在线上稳定跑三个月不烂掉的人才是团队抢着要的。我给自己的实践总结是三条数据诚实评估多维部署留痕。所谓数据诚实是不美化也不掩盖数据分布的缺点评估多维是永远用一张表而不是一个数字说话部署留痕是所有动静都落版本、有记录、能回滚。这三条做到你的AI项目就不太会在某个深夜里给你“惊喜”了。如果你准备自己开一个from-scratch项目我建议不要从零手写BERT也不要把时间花在刷SOTA上。挑一个真实的小场景哪怕是一个内部的文本分类走完数据清洗、训练、评估、部署、监控全流程比你在网上看二十篇教程都值。从零到一才是AI工程真正的地基。