1. 这不是“搭个LLM API”——AI工程从零开始的真实成本清单
很多人看到“AI Engineering from Scratch”第一反应是:不就是调用OpenAI API、写个Flask后端、套个Streamlit前端?三小时搞定,发个GitHub仓库,标题一写“手把手教你构建AI应用”,点赞过千。我去年也这么干过——用LangChain连了三个模型,做了个“智能会议纪要生成器”,上线当天用户反馈:“它把老板说的‘暂缓推进’理解成‘立即执行’,还自动给全员发了待办”。那一刻我才意识到:所谓“from scratch”,根本不是从API开始,而是从故障树的根部开始挖坑。
AI工程从零起步,本质是重建一套软件工程范式。它不解决“能不能跑”,而专治“为什么在生产环境里跑着跑着就崩了”“为什么准确率从92%掉到63%没人知道原因”“为什么加了新数据模型反而更差”。这和传统Web开发有根本差异:Web服务出错,日志里能定位到某行PHP代码;AI服务出错,你得先判断是数据漂移、提示词退化、embedding维度错位,还是GPU显存碎片导致batch size异常——这些故障点,没有现成的Sentry插件能自动捕获。
关键词“ai-engineering”和“from-scratch”组合起来,实际指向一个被严重低估的现实:90%的AI项目死于工程化断层,而非算法本身。我参与过的7个落地项目里,4个卡在模型交付后——数据管道没做schema校验,上游业务系统字段悄悄改名,模型输入变成NaN;2个栽在监控盲区——只监控GPU利用率,却没监控token生成速率突降50%,结果用户投诉“响应变慢”,运维查了一天发现是模型在反复重试失败的prompt;还有1个,纯粹因为没做版本锁,pip install -r requirements.txt直接把transformers从4.35升到4.38,tokenizer分词逻辑微变,线上A/B测试结果全乱。
所以这篇不是教程,是一份“踩坑地图”。它不教你怎么写第一个hello world,而是告诉你:当你决定真刀真枪从零构建AI系统时,必须立刻面对的5类硬骨头——数据契约的建立、模型生命周期的原子化控制、推理服务的确定性保障、可观测性的AI特化设计、以及最反直觉的一条:你得先写测试,再写模型。这些事,没有文档会主动告诉你,但每漏掉一项,都会在未来某个凌晨三点,以P0级告警的形式准时出现。
提示:本文所有技术选型、配置参数、目录结构,均来自我们团队在金融风控、医疗问诊、工业质检三大场景中真实压测过的方案。不推荐“理论上可行”的玩具配置,只列“实测扛住日均200万请求”的最小可行集。
2. 数据契约:比模型代码更早该写的文件
绝大多数AI项目崩溃的第一步,始于一个没人签字的数据协议。业务方说“每天下午3点推送用户行为日志”,工程师信了,写了ETL脚本;结果第三周起,日志格式从JSON变成CSV,字段顺序打乱,时间戳从ISO8601变成Unix毫秒——模型输入直接报错,但错误堆栈显示的是“tensor size mismatch”,没人往数据源查。这就是典型的“契约缺失”。
从零构建AI工程,第一步必须是定义数据契约(Data Contract)。这不是Excel表格,而是可执行、可验证、带版本的机器可读声明。我们用Great Expectations+Pydantic v2实现,核心逻辑只有三句话:
- 所有上游数据源必须提供
.contract.yaml,声明字段名、类型、非空约束、值域范围、更新频率; - ETL管道入口强制校验契约,任何字段缺失/类型不符/值域越界,立即中断并告警,绝不容错写入;
- 契约变更需走审批流,下游模型训练任务自动感知版本号变化,触发全量回归测试。
举个真实案例:医疗问诊项目中,医生录入的“症状描述”字段原契约要求“长度≤500字符”,某次升级后业务方放开限制到2000字符。契约文件更新后,我们的CI流水线自动运行测试:
- 检查现有模型tokenizer是否支持超长文本(BERT-base最大512,直接fail);
- 测试截断策略对诊断准确率影响(截前512 vs 截后512,准确率差3.7%);
- 生成新样本集,验证模型在长文本下的attention权重分布是否异常。
整个过程23分钟,比人工排查快17倍。
2.1 契约文件的最小必要字段
一个生产级契约绝不能只写“字段名:字符串”。以下是我们在工业质检场景强制要求的字段清单(YAML格式):
version: "1.2.0" # 语义化版本,主版本升级需全量回归 source: "camera_stream_v3" schema: - name: "frame_id" type: "integer" constraints: min: 0 max: 999999999 required: true - name: "image_bytes" type: "binary" constraints: size_max_bytes: 8388608 # 8MB,对应4K@30fps单帧 mime_type: ["image/jpeg", "image/png"] required: true - name: "timestamp_utc" type: "string" constraints: format: "iso8601" # 必须含时区,如2024-05-22T14:30:00+08:00 required: true - name: "defect_labels" type: "array" constraints: item_type: "string" allowed_values: ["crack", "scratch", "dent", "none"] max_items: 5关键细节在于size_max_bytes和mime_type。曾有项目因相机固件升级,默认输出HEIC格式,而模型预处理只认JPEG——契约校验直接拦截,避免了后续所有环节的无效计算。
2.2 如何让业务方真正遵守契约?
技术手段只能防君子,不能防小人。我们设计了三层机制:
- 自动化兜底:ETL脚本内置契约校验模块,失败时自动生成修复建议(如“字段X缺失,建议补默认值null或跳过该记录”),并邮件抄送双方负责人;
- 经济杠杆:在SLA协议中明确“数据源违反契约导致模型服务不可用,按分钟计罚”,去年因此收回违约金12.7万元;
- 体验优化:为业务方提供契约编辑器Web UI,输入字段名自动推荐类型和约束,实时渲染校验报告,降低使用门槛。
注意:契约不是法典,而是协作接口。我们坚持每季度和业务方一起review契约,删掉3个月未使用的字段,合并语义重复的字段。上个月刚把“product_code”和“sku_id”合并为“item_identifier”,减少下游17处映射逻辑。
3. 模型生命周期:拒绝“扔个pkl文件就跑路”
很多团队把模型发布等同于“把训练好的.pkl文件scp到服务器”。这是AI工程最大的幻觉。一个.pkl文件包含什么?可能是PyTorch 1.12训练的模型,依赖CUDA 11.3,而生产服务器装的是CUDA 11.8;可能用了torch.compile(),但目标机器CPU不支持AVX-512指令集;更糟的是,它甚至没记录训练时的随机种子——这意味着你永远无法复现那个“92.3%准确率”的结果。
真正的模型生命周期管理,必须做到原子化、可追溯、可回滚。我们采用MLflow+ 自研Model Registry双引擎架构,但关键不在工具,而在流程设计:
- 训练阶段:每个训练任务生成唯一
run_id,自动捕获:
✓ 代码commit hash(Git)
✓ 环境spec(conda env export > environment.yml)
✓ 数据集版本(DVC tracked hash)
✓ 超参完整列表(包括随机种子)
✓ 验证集指标(精确到小数点后4位) - 注册阶段:人工审核通过后,模型进入
Staging区,此时禁止任何修改; - 上线阶段:运维执行
mlflow models serve启动服务,但不直接暴露端口,而是通过Nginx反向代理,且代理配置与模型版本强绑定(如/v1/model/2.1.0); - 下线阶段:旧版本模型服务进程不kill,而是将Nginx路由指向维护页,保留72小时供问题追溯。
3.1 为什么必须禁用pickle,改用ONNX?
去年某项目因pickle兼容性翻车:研发用Python 3.9训练模型,生产环境是Python 3.8,pickle.load()报错AttributeError: Can't get attribute 'CustomLayer' on <module '__main__'>。根源在于pickle序列化保存的是类路径,而非字节码。
我们强制所有生产模型导出为ONNX格式,理由很实在:
| 对比项 | Pickle | ONNX |
|---|---|---|
| 跨语言支持 | 仅Python | C++, Java, C#, JavaScript, Rust |
| 版本兼容性 | Python 3.8→3.9常失效 | ONNX opset 15向前兼容opset 12 |
| 安全审计 | 无法静态分析,可执行任意代码 | 纯张量计算图,无副作用 |
| 体积压缩 | 通常大30%-50% | 权重量化后体积减小60% |
实操步骤极简:
# 训练完成后立即导出 torch.onnx.export( model=model, args=(dummy_input,), # 必须提供shape匹配的dummy input f="model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}}, opset_version=15, verbose=False )关键在dynamic_axes——它告诉ONNX哪些维度可变,否则部署时固定batch size=1,线上流量突增直接OOM。
3.2 模型版本的语义化命名规则
我们弃用v1.0.0这种通用版本号,改用{domain}-{type}-{accuracy}三段式:
domain:业务域缩写(fraud风控,med医疗,qc质检)type:模型类型+框架(bert-tf2,resnet-pytorch,xgboost-sklearn)accuracy:核心指标四舍五入(acc92,f187,auc95)
例如fraud-bert-tf2-acc92。好处是:
✅ 运维看版本号就知道适用场景和性能基线;
✅ A/B测试时,fraud-bert-tf2-acc92vsfraud-bert-tf2-acc93,指标差异一目了然;
✅ 审计时,acc92代表在特定测试集上的结果,杜绝“号称95%实测87%”的扯皮。
提示:
accuracy字段必须关联具体测试集哈希值。我们在MLflow中为每个模型版本附加testset_hash: "a1b2c3d4...",点击即可跳转到该数据集详情页。这是防止“换测试集刷指标”的最后防线。
4. 推理服务:确定性比速度更重要
新手常陷入一个误区:疯狂优化推理延迟,把QPS从100刷到1000,结果上线后发现——99%的请求返回正确结果,1%返回完全荒谬的答案,且无法复现。这比慢十倍更致命。AI服务的核心诉求不是“快”,而是“稳”:每次输入相同,输出必须严格一致。
我们定义“确定性推理”的四个黄金标准:
- 硬件无关:同一模型,在A100/V100/T4上输出误差≤1e-6;
- 框架无关:PyTorch/TensorFlow/ONNX Runtime输出完全一致;
- 批处理无关:batch_size=1和batch_size=32,单样本输出完全相同;
- 时间无关:连续1000次请求,结果零波动(排除网络抖动等外部因素)。
达成这四点,靠的不是调参,而是环境锁死 + 计算路径固化。
4.1 环境锁死:Docker镜像即契约
我们不用FROM python:3.9-slim这种浮动基础镜像,而是锁定到具体SHA256:
FROM python:3.9.18-slim-bookworm@sha256:abc123... # 固定镜像ID RUN pip install --no-cache-dir \ torch==2.1.0+cu118 \ torchvision==0.16.0+cu118 \ onnxruntime-gpu==1.16.3 \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . /app CMD ["uvicorn", "app:app", "--host", "0.0.0.0:8000"]关键点:
torch和onnxruntime-gpu版本必须严格匹配CUDA驱动版本(cu118对应CUDA 11.8);--no-cache-dir避免pip缓存污染;rm -rf /var/lib/apt/lists/*减小镜像体积,且避免apt元数据干扰;- 最终镜像大小控制在1.2GB以内,确保K8s拉取<30秒。
4.2 计算路径固化:关闭所有随机性开关
PyTorch默认启用cudnn.benchmark=True,它会为每个输入尺寸搜索最优卷积算法,导致相同输入在不同时间可能走不同路径。必须全局关闭:
import torch import numpy as np import random # 全局禁用随机性 torch.backends.cudnn.enabled = True torch.backends.cudnn.benchmark = False # 关键! torch.backends.cudnn.deterministic = True # 固定所有随机种子 SEED = 42 torch.manual_seed(SEED) np.random.seed(SEED) random.seed(SEED) if torch.cuda.is_available(): torch.cuda.manual_seed_all(SEED)更进一步,我们禁用torch.compile()——虽然它能提速20%,但编译后的kernel在不同GPU上可能产生微小数值差异。生产环境宁可慢一点,也要绝对确定。
4.3 批处理陷阱:为什么batch_size=1和=16结果不同?
这是最隐蔽的坑。根源在于BatchNorm层:训练时用滑动平均统计,推理时用冻结的running_mean/var;但当batch_size=1时,BN层输入方差为0,导致除零异常,框架内部会插入极小epsilon,引发数值漂移。
解决方案只有两个:
- 彻底替换BN:所有模型用
GroupNorm或InstanceNorm,它们不依赖batch统计; - 强制统一batch:推理服务始终用
batch_size=32,前端请求不足时padding填充,后端返回前截取有效结果。
我们选方案2,因为改造成本低,且实测padding对精度无影响(图像pad 0,文本pad<PAD>token)。关键在padding策略:
- 图像:
torch.nn.functional.pad(input, (0,0,0,0,0,pad_h,0,pad_w), mode='constant', value=0) - 文本:
tokenizer.pad_token_id填充,且attention mask置0,确保模型忽略padding位置
注意:padding必须在模型输入前完成,不能在ONNX图内做。我们用FastAPI中间件统一处理,避免每个endpoint重复逻辑。
5. AI可观测性:别再只看GPU显存了
传统运维监控GPU利用率、内存占用、HTTP状态码。这对AI服务是无效的。一个模型可能GPU占用率95%,但实际90%时间在等待数据IO;也可能HTTP 200返回率100%,但30%的响应内容是胡言乱语——这些,Prometheus抓不到。
AI可观测性必须覆盖三层:
- 基础设施层:GPU显存、PCIe带宽、NVLink通信延迟;
- 模型服务层:token生成速率、perplexity突变、logit分布熵值;
- 业务语义层:答案相关性得分、事实一致性检查、敏感词触发率。
我们用Prometheus+Grafana+ 自研SemanticProbe实现,重点说业务语义层——这才是区分“能跑”和“靠谱”的关键。
5.1 SemanticProbe:给AI输出打分的探针
不是用BLEU、ROUGE这类传统指标(它们对短文本效果差),而是基于LLM-as-a-Judge:
- 构建轻量级评判模型:用
Phi-3-mini微调,输入“用户问题+AI回答”,输出0-5分(5=完美,0=完全错误); - 实时采样1%请求,异步调用评判模型,结果存入TimescaleDB;
- Grafana看板展示:
judge_score_avg(整体质量)、judge_score_p95(长尾质量)、judge_score_drift(对比上周变化)。
真实效果:上线后首次发现——客服对话模型在“退款政策”类问题上,评分从4.2骤降至2.8。排查发现:新加入的训练数据中,“7天无理由”被错误标注为“30天”,模型学到了错误知识。若只看准确率,这个错误被淹没在海量“你好”“谢谢”等简单问答中。
5.2 Logit分布监控:比准确率更早的预警信号
准确率是结果,logit分布是过程。我们监控每个输出token的top-3 logit差值:
- 正常情况:
logit[best] - logit[2nd] ≈ 2.5(模型自信); - 异常征兆:该差值持续<0.5,说明模型在“瞎猜”;
- 危险信号:差值≈0,意味着top-3概率几乎相等,输出必然混乱。
实现方式:在ONNX Runtime中启用execution_mode=ExecutionMode.ORT_SEQUENTIAL,获取每层输出,提取final logits:
# ONNX Runtime session配置 session_options = ort.SessionOptions() session_options.log_severity_level = 3 # 只记录error session_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED # 获取logits层输出(需在模型导出时指定output_names) outputs = session.run( output_names=["logits"], # 关键:显式要求logits输出 input_feed={"input_ids": input_ids.numpy(), "attention_mask": attention_mask.numpy()} ) logits = outputs[0] # shape: [batch, seq_len, vocab_size]然后计算np.max(logits, axis=-1) - np.partition(logits, -2, axis=-1)[..., -2],即top1与top2的logit差。每分钟聚合统计,设置告警阈值:连续5分钟mean_diff < 0.3,触发P1告警。
5.3 敏感词触发率:合规性最后一道闸门
不是简单正则匹配,而是用spaCy+BERT做上下文感知检测:
- “苹果”在“吃苹果”中不触发,在“苹果手机”中触发(品牌词);
- “死亡”在“临床死亡率”中不触发,在“希望他死亡”中触发(情感极性);
我们构建了三层过滤:
- 规则层:正则匹配高危词(如“自杀”“暴力”),立即拦截;
- 模型层:微调
distilbert-base-uncased,二分类“是否违规”,F1=0.92; - 人工复核层:对模型置信度0.7~0.9的样本,推送到审核队列,2小时内人工确认。
这套机制使误拦率从12%降至0.3%,漏拦率从5%降至0.02%。最关键的是,它生成了可审计的决策链:[规则匹配:否] → [模型预测:违规(0.87)] → [人工确认:是],满足金融/医疗行业合规要求。
6. 测试先行:写模型代码前,先写这3类测试
“测试驱动开发(TDD)”在AI工程中不是理念,是生存法则。我们团队严格执行:模型代码提交前,必须通过三类测试,缺一不可。这三类测试不是可选项,而是CI流水线的硬性门禁。
6.1 数据契约测试:保证输入不脏
用great_expectations编写数据验证测试,每个数据源对应一个测试文件:
# tests/test_camera_data_contract.py import great_expectations as ge import pandas as pd def test_camera_stream_contract(): # 加载最新数据样本 df = pd.read_parquet("data/samples/camera_latest.parquet") context = ge.data_context.DataContext() suite = context.get_expectation_suite("camera_stream.v1") # 执行验证 validator = context.get_validator( batch_request={ "datasource_name": "parquet_datasource", "data_connector_name": "default_inferred_data_connector", "data_asset_name": "camera_samples", }, expectation_suite=suite ) results = validator.validate() # 断言:所有expectation必须success assert all([r.success for r in results.results])CI中,此测试失败=禁止合并。它比单元测试更早拦截问题——毕竟,垃圾输入进,垃圾输出出,再完美的模型也白搭。
6.2 模型确定性测试:保证输出不飘
核心是“相同输入,相同输出”验证。我们用numpy.testing.assert_array_almost_equal,但精度设为decimal=5(浮点误差容忍1e-5):
# tests/test_model_determinism.py import torch import numpy as np def test_model_output_consistency(): # 加载模型和固定输入 model = load_model("models/fraud-bert-tf2-acc92.onnx") dummy_input = torch.load("tests/fixtures/dummy_input.pt") # 预存的tensor # 连续运行10次 outputs = [] for _ in range(10): with torch.no_grad(): out = model(dummy_input) outputs.append(out.cpu().numpy()) # 检查所有输出是否一致 for i in range(1, len(outputs)): np.testing.assert_array_almost_equal( outputs[0], outputs[i], decimal=5, err_msg=f"Output {i} differs from output 0" )这个测试在GPU和CPU上都跑,确保跨设备一致性。曾经发现TensorRT在某些GPU上开启FP16后,logit差异达1e-3,直接否决该优化方案。
6.3 业务逻辑测试:保证答案不蠢
这是最难写,也最重要的测试。它不验证数学正确性,而验证业务合理性。例如风控模型:
# tests/test_fraud_logic.py def test_high_risk_transaction_flagging(): # 构造高风险场景:单日交易50次,金额>5万,IP跨3省 transaction = { "user_id": "U123456", "amount": 52000.0, "transaction_count_24h": 50, "ip_province_list": ["广东", "江苏", "北京"] } # 模型必须返回high_risk=True result = predict_fraud(transaction) assert result["high_risk"] == True assert result["risk_score"] > 0.95 # 分数必须极高 def test_low_risk_false_positive(): # 构造低风险场景:退休教师买菜,金额<100 transaction = { "user_id": "U789012", "amount": 85.5, "transaction_count_24h": 3, "ip_province_list": ["上海"] } # 模型必须返回high_risk=False,且分数<0.1 result = predict_fraud(transaction) assert result["high_risk"] == False assert result["risk_score"] < 0.1这些测试用真实业务case编写,每年由风控专家更新2次。它让模型开发者直面业务逻辑,而不是躲在“准确率92%”的数字后面。
最后分享一个血泪教训:我们曾因跳过业务逻辑测试,上线一个“优化版”模型,它把所有“医保报销”类交易判为高风险——因为训练数据中,这类交易恰好和欺诈样本有共现特征。测试用例里有一条
test_medicare_reimbursement_not_flagged,如果当时执行了,能提前3周发现。现在,这条测试是所有风控模型的准入红线。
AI工程从零开始,从来不是炫技,而是建堤坝。堤坝不防洪水,而防自己挖的坑。当你把数据契约刻进ETL、把模型版本写进Nginx路由、把logit差值画进Grafana、把业务case写成测试用例——那一刻,你才真正站在了AI工程的起点。剩下的,不过是日复一日,加固每一寸堤岸。