从零构建AI工程能力这件事我前前后后折腾了差不多两年。最开始的时候我和大多数人一样觉得搞AI就是调包、跑模型、看指标能复现个开源项目就算入门了。直到真正接手一个需要从数据采集一路做到线上推理的完整系统才发现中间隔着的不是一两个库的距离而是一整套工程思维的鸿沟。ai-engineering-from-scratch这个方向说白了就是把这套鸿沟填平的过程——它不是教你某个框架怎么用而是让你理解一个AI系统从想法到落地每一步为什么这么做、不这么做会出什么问题。这篇文章适合那些已经会写Python、跑过几个demo但一到真实项目就不知道从哪下手的人也适合想系统梳理自己知识体系的中级工程师。我会按照一个完整项目的生命周期来拆从环境搭建、数据处理、模型训练、评估验证到部署推理把每个环节里最容易踩的坑和最关键的设计决策讲清楚。1. 为什么“从零”不等于“从轮子造起”1.1 重新理解“from scratch”的真实含义很多人看到from scratch第一反应是什么都自己写不用现成框架。这个理解不能说错但放在AI工程这个语境里它其实是个误导。真正的from scratch指的是你理解每一层的抽象在做什么而不是拒绝使用抽象。你可以用PyTorch但你得知道autograd是怎么工作的你可以用HuggingFace的Trainer但你得清楚它帮你封装了哪些训练循环的细节。我见过太多人模型跑不通就换框架换了三四个还是跑不通根本原因不是框架不好而是他不知道问题出在哪一层。打个比方这就像学开车。你可以开自动挡但如果你连离合器是干嘛的都不知道车一抛锚你就只能叫拖车。AI工程里的“从零”练的就是这种知道车底下发生了什么的能力。具体来说你需要理解的是数据是怎么变成张量的、梯度是怎么回传的、显存是怎么被占用的、推理延迟是从哪来的。这些问题的答案不会因为你用了高级API就自动消失。我在实际项目里总结了一个判断标准如果你能用纸笔把某个环节的数据流和计算图画出来那你就真的理解了这一层。画不出来说明你还在“调包”阶段。这个标准听起来简单但能筛掉一大半自称“会AI”的人。1.2 工程视角和学术视角的分水岭学术视角关心的是“这个模型在标准数据集上能涨几个点”工程视角关心的是“这个模型在我的场景下能不能稳定跑、跑多快、出错了我能不能定位”。这两个视角的差异决定了你从第一天起就应该怎么组织代码、怎么管理实验、怎么设计接口。举个很具体的例子。学术代码里数据加载经常是写死的路径、固定的batch size、没有异常处理。工程代码里数据可能来自多个源、格式不统一、有缺失值、有脏数据你必须设计一套健壮的pipeline。我刚开始做项目的时候直接把论文的data loader拿来用结果线上跑了三天就崩了原因是某天数据源里混进了一条空记录整个batch的shape对不上程序直接挂掉。后来我加了数据校验层每条数据进模型之前都过一遍schema检查才彻底解决。所以从零构建AI工程能力第一步不是学某个新模型而是转变视角你写的每一行代码都要考虑它在真实环境里会遇到什么。这个转变比学任何框架都重要。1.3 一个可复用的能力地图我把AI工程能力拆成五个层次从下往上依次是基础设施层、数据处理层、模型训练层、评估验证层、部署推理层。每一层都有它核心要解决的问题和对应的工具链。层次核心问题典型工具常见坑基础设施环境一致、资源管理Docker、conda、CUDA版本冲突、显存泄漏数据处理数据质量、吞吐量pandas、Arrow、WebDataset内存爆炸、格式不一致模型训练收敛性、可复现PyTorch、Lightning随机种子、梯度异常评估验证指标可信、过拟合sklearn、自定义指标数据泄漏、指标误读部署推理延迟、吞吐、稳定性ONNX、Triton精度损失、批处理这张表不是让你按顺序学而是让你在遇到问题时能快速定位到是哪一层出了毛病。我自己的经验是大部分“模型不work”的问题根因都不在模型层而在数据层或基础设施层。有了这张地图排查问题的效率会高很多。2. 环境搭建那些让你少走三天弯路的细节2.1 依赖管理的核心矛盾AI项目的依赖管理有个天然矛盾框架更新快但你的代码需要稳定。我试过直接用pip install装最新版结果第二天框架发了个小版本API变了代码跑不了。也试过把所有版本锁死结果想用个新功能发现依赖冲突升级一个包要动全身。后来我固定了一套做法用conda管理Python版本和CUDA相关的底层依赖用pip管理纯Python包并且所有版本都写进requirements.txt精确到patch版本。conda的好处是它能处理CUDA、cuDNN这些非Python依赖pip搞不定这些。而pip在纯Python包上更灵活安装速度也快。具体操作上我会先创建一个干净的conda环境conda create -n ai-eng python3.10 conda activate ai-eng然后安装PyTorch时一定去官网查对应的CUDA版本命令不要凭记忆写。比如CUDA 11.8对应的命令是pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118装完之后立刻验证import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))这三行输出能帮你确认三件事PyTorch版本对不对、CUDA能不能用、显卡认没认出来。我见过有人装完发现cuda.is_available()返回False排查半天发现是conda环境里装了个CPU版本的PyTorchpip和conda混用导致的。所以记住一个原则同一个包要么全用conda装要么全用pip装不要混。2.2 容器化不是可选项而是必选项如果你只在本地跑跑demo容器化确实可以省。但只要涉及团队协作或者线上部署Docker就是必选项。原因很简单你本地能跑不代表别人机器上能跑更不代表服务器上能跑。环境差异导致的问题能占到部署失败的六成以上。我的做法是从项目第一天就写Dockerfile哪怕暂时不用。写Dockerfile的过程本身就是对你依赖的一次梳理。一个典型的AI项目Dockerfile大概长这样FROM nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04 RUN apt-get update apt-get install -y python3.10 python3-pip WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD [python, serve.py]这里有个细节基础镜像选runtime而不是devel因为devel版本大了好几个G里面全是编译工具线上根本用不到。但如果你需要在容器里编译CUDA扩展那就得用devel。这个取舍要根据实际需求来。还有一个坑是时区和编码。基础镜像默认是UTC时区如果你的日志需要本地时间得手动设置。编码问题更隐蔽有些镜像默认不是UTF-8读中文数据会乱码。我一般会在Dockerfile里加上ENV TZAsia/Shanghai ENV LANGC.UTF-8这两行能省掉后面很多莫名其妙的bug。2.3 显存管理的第一课显存不够是AI工程里最常见的报错没有之一。但很多人对显存的理解停留在“模型太大”这个层面实际上显存占用分好几块模型参数、梯度、优化器状态、激活值、临时缓冲区。训练时这五块加起来可能是模型参数量的四到五倍。举个例子一个1亿参数的模型FP32下参数占400MB但训练时加上梯度、Adam优化器的两个状态再加上激活值实际占用可能到2GB以上。如果你用FP16混合精度参数和激活能减半但优化器状态通常还是FP32所以省不了那么多。我常用的显存排查手段是torch.cuda.memory_summary()它能告诉你当前显存被什么占用了print(torch.cuda.memory_summary(deviceNone, abbreviatedFalse))输出里会分allocated、reserved、active等几个指标。allocated是实际被张量占用的reserved是PyTorch向CUDA申请的缓存。有时候你发现显存没释放其实是reserved没还回去这时候可以用torch.cuda.empty_cache()手动清理。但注意这个操作有性能开销不要频繁调用。另一个实用技巧是梯度累积。当你显存不够但又想用大batch size时可以分多次前向传播累积梯度后再更新accumulation_steps 4 for i, (inputs, labels) in enumerate(dataloader): outputs model(inputs) loss criterion(outputs, labels) / accumulation_steps loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()这样等效于batch size放大了4倍但显存占用不变。代价是训练速度会慢一些因为多了几次前向传播。3. 数据处理决定项目成败的隐形战场3.1 数据管道的设计原则数据管道是AI工程里最不起眼但最要命的部分。模型差点顶多效果不好数据管道有问题整个项目直接崩。我总结了几条设计原则都是踩坑踩出来的。第一条数据加载和模型训练必须解耦。很多人图省事在训练循环里直接读文件、做预处理结果GPU利用率常年低于30%因为GPU在等CPU读数据。正确的做法是用DataLoader的num_workers参数开多进程加载让CPU和GPU并行工作。num_workers设多少合适一般设成CPU核心数但也不是越多越好太多会导致进程切换开销。我一般从4开始试看GPU利用率调整。第二条预处理结果要缓存。图像resize、文本tokenize这些操作如果每次训练都重做浪费大量时间。我习惯在第一次运行时把处理好的数据存成内存映射格式后续直接读。比如用numpy的memmap或者Arrow格式import numpy as np data np.memmap(processed.dat, dtypefloat32, moder, shape(100000, 768))这样加载几乎是瞬时的而且不占内存。第三条数据校验要前置。不要等到模型报错了才发现数据有问题。我一般会写一个独立的校验脚本检查每条数据的shape、类型、取值范围把异常数据提前过滤掉。这个脚本在数据更新时跑一遍能省掉后面无数调试时间。3.2 处理不平衡数据的实战策略真实场景的数据几乎都是不平衡的。比如做故障检测正常样本可能占99.9%异常样本只有0.1%。这时候如果你直接用准确率做指标模型全预测正常也能到99.9%但毫无意义。处理不平衡数据有几种常见手段我按效果排序说一下。最直接的是重采样对少数类过采样或者对多数类欠采样。过采样容易过拟合欠采样会丢信息。折中方案是SMOTE在少数类样本之间插值生成新样本。但SMOTE对高维数据效果一般而且可能生成不合理的样本。我更常用的是损失函数加权。给少数类的损失乘一个权重让模型更关注它们class_weights torch.tensor([1.0, 100.0]) # 少数类权重高 criterion nn.CrossEntropyLoss(weightclass_weights)权重的设定有个经验公式权重和类别频率成反比。但具体数值还是要根据验证集效果调。还有一种方法是focal loss它让模型聚焦在难分类的样本上。公式里有个gamma参数控制聚焦程度gamma越大对易分类样本的抑制越强。我在目标检测任务里用过效果比简单加权好但调参麻烦一些。不管用哪种方法评估指标一定要换。不平衡场景下看AUC、F1、召回率不要看准确率。我一般会同时看几个指标避免被单一指标误导。3.3 数据版本管理的必要性数据版本管理是很多人忽略的环节。你改了数据清洗逻辑重新跑了一遍模型效果变了但你不知道是模型改动的功劳还是数据改动的功劳。没有数据版本实验就不可复现。我的做法是用DVC或者简单的文件哈希来管理数据版本。每次数据处理脚本跑完记录输入数据的哈希和输出数据的哈希存到一个manifest文件里。训练时指定数据版本这样任何一次实验都能追溯到具体的数据快照。dvc add data/processed git add data/processed.dvc git commit -m update processed data v2DVC的好处是它把大文件存在本地或远程存储git里只存指针不会把仓库撑爆。如果不想引入DVC至少也要在文件名里带上版本号和日期比如train_20240115_v2.parquet。这个习惯看起来麻烦但当你需要回滚或者对比实验时会感谢自己当初多做了这一步。4. 模型训练从能跑到跑得好的关键跨越4.1 可复现性是训练的底线训练不可复现是所有实验问题的根源。你跑出一个好结果但第二次跑不出来了那这个结果就没法用。可复现性涉及好几个层面随机种子、数据顺序、CUDA算子、多卡通信。随机种子要设全不只是Python的random和numpy还有PyTorch的import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False注意最后两行。deterministicTrue让cuDNN用确定性算法benchmarkFalse关闭自动调优。这两个设置会降低一点速度但能保证结果可复现。如果你追求极致速度可以设benchmarkTrue但结果会有微小差异。数据顺序也要固定。DataLoader的shuffleTrue时如果不设种子每次顺序都不一样。设了种子之后顺序就固定了。但多进程加载时每个worker的种子也要设否则还是会有差异。PyTorch的DataLoader有个worker_init_fn参数可以在这里给每个worker设种子。多卡训练时可复现性更难保证。因为梯度同步的顺序可能影响结果。如果对可复现性要求极高建议单卡跑或者用deterministic的通信算法。但大多数场景下微小的数值差异可以接受只要整体趋势一致就行。4.2 学习率调度的实战经验学习率是训练里最重要的超参数没有之一。设大了不收敛设小了收敛慢。我见过很多人用默认的1e-3跑所有任务结果有的任务震荡有的任务半天不降。我的经验是学习率要和batch size挂钩。有个经验规则batch size翻倍学习率也翻倍。但这个规则不是绝对的还要看优化器和任务。Adam系列对学习率不那么敏感SGD就敏感得多。调度策略上我最常用的是warmup加余弦退火。warmup是在训练初期让学习率从很小线性增长到设定值避免一开始就大步长导致震荡。余弦退火是让学习率按余弦曲线下降到接近零帮助模型收敛到更平坦的极小值。from torch.optim.lr_scheduler import LambdaLR import math def lr_lambda(step): if step warmup_steps: return step / warmup_steps progress (step - warmup_steps) / (total_steps - warmup_steps) return 0.5 * (1 math.cos(math.pi * progress))warmup_steps一般设总步数的5%到10%。余弦退火的周期设成总步数训练结束时学习率刚好降到最低。还有一个实用技巧是学习率find。用一个很小的训练跑一遍逐步增大学习率观察loss什么时候开始上升那个点就是学习率的上限。实际使用时取上限的十分之一左右。fastai库里有现成的实现但自己写也不难。4.3 梯度问题的诊断与处理梯度异常是训练不稳定的常见原因。表现有三种梯度爆炸、梯度消失、梯度为NaN。诊断方法很简单在backward之后、step之前打印梯度的范数total_norm 0 for p in model.parameters(): if p.grad is not None: param_norm p.grad.data.norm(2) total_norm param_norm.item() ** 2 total_norm total_norm ** 0.5 print(fGradient norm: {total_norm})如果范数在几百以上可能是梯度爆炸用梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)如果范数接近零可能是梯度消失检查网络深度和激活函数。ReLU在负半轴梯度为零深层网络容易死神经元可以换LeakyReLU或GELU。如果出现NaN先检查数据里有没有NaN或inf再检查loss计算有没有除零。混合精度训练时FP16容易溢出用GradScaler自动处理scaler torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): outputs model(inputs) loss criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()GradScaler会自动放大loss避免下溢更新时再缩回来。这个机制能解决大部分混合精度的数值问题。4.4 训练监控该看什么训练监控不是只看loss曲线。loss降了不代表模型变好了可能是过拟合了。我一般同时监控这几个指标训练loss、验证loss、学习率、梯度范数、GPU利用率、显存占用。训练loss和验证loss的差距能看出过拟合。如果训练loss一直降但验证loss开始升说明过拟合了该加正则化或早停。学习率曲线能帮你确认调度器工作正常。梯度范数能提前发现训练不稳定。GPU利用率和显存占用能帮你判断是不是数据加载拖了后腿。我用TensorBoard或者Weights Biases来记录这些指标。WB的好处是能自动记录系统指标还能做超参数搜索。TensorBoard更轻量本地跑跑够用了。from torch.utils.tensorboard import SummaryWriter writer SummaryWriter(runs/experiment_1) writer.add_scalar(Loss/train, train_loss, step) writer.add_scalar(Loss/val, val_loss, step) writer.add_scalar(LR, current_lr, step)记录的时候注意验证指标不要每个step都记太频繁了曲线看不清。一般每个epoch记一次或者每N个step记一次。5. 评估验证别被自己的指标骗了5.1 数据泄漏的几种隐蔽形式数据泄漏是评估里最危险的问题因为它让你的模型看起来很好但上线就崩。泄漏的形式很多有些很隐蔽。最常见的是预处理泄漏。比如你做归一化时用了全量数据的均值和方差而不是只用训练集的。这样验证集的信息就泄漏到了训练过程。正确做法是只用训练集算均值和方差然后应用到验证集和测试集。# 正确做法 train_mean train_data.mean() train_std train_data.std() train_normalized (train_data - train_mean) / train_std val_normalized (val_data - train_mean) / train_std另一种是时间泄漏。如果你的数据有时间维度随机划分训练集和验证集会泄漏未来信息。比如用1月到12月的数据随机划分模型可能学到12月的模式去预测1月这在真实场景里不可能。正确做法是按时间划分用前面的预测后面的。还有一种是特征泄漏。某个特征在预测时根本拿不到但训练数据里有。比如预测用户会不会购买你用了“购买时间”这个特征那模型当然准但预测时这个特征不存在。这种泄漏要靠对业务的理解来发现没有通用方法。5.2 交叉验证的正确打开方式交叉验证不是简单地把数据分成K份轮流做验证。有几个细节决定了它是否有效。第一分组要合理。如果数据有分组结构比如同一个用户的多个样本要按用户分组不能把同一用户的样本分到不同折。否则模型在验证集上看到的是训练时见过的用户评估会偏乐观。sklearn的GroupKFold能处理这种情况。第二分层要保证。分类任务里每折的类别比例要和整体一致。特别是类别不平衡时随机划分可能导致某折里没有少数类样本。StratifiedKFold能保证分层。第三时间序列要用TimeSeriesSplit。它保证训练集的时间都在验证集之前不会泄漏未来信息。from sklearn.model_selection import TimeSeriesSplit tscv TimeSeriesSplit(n_splits5) for train_idx, val_idx in tscv.split(data): # train_idx的时间都早于val_idx pass交叉验证的结果要看均值和方差。均值代表整体性能方差代表稳定性。如果某折特别好或特别差要分析原因可能是数据分布不均匀。5.3 指标选择的场景化思考不同场景要选不同的指标。分类任务里准确率适合类别平衡的场景F1适合不平衡场景AUC适合排序场景。回归任务里MSE对大误差敏感MAE对异常值鲁棒MAPE适合看相对误差。但指标不是越多越好。我见过有人报告十几个指标但没一个能说明模型在业务上到底行不行。指标要服务于决策。比如你做风控模型关心的是在某个通过率下能拦截多少欺诈那就看KS和lift曲线。你做推荐模型关心的是用户会不会点那就看AUC和NDCG。还有一个容易被忽略的点是指标的置信区间。单次评估的结果有随机性特别是测试集小的时候。用bootstrap重采样能估计指标的波动范围from sklearn.utils import resample scores [] for _ in range(1000): sample resample(y_true, y_pred) scores.append(f1_score(sample[0], sample[1])) print(fF1: {np.mean(scores):.3f} ± {np.std(scores):.3f})这样报告出来的结果更可信也能避免因为测试集划分的偶然性做出错误判断。6. 部署推理从实验室到生产环境的最后一公里6.1 模型导出的精度陷阱训练用PyTorch部署用ONNX或TensorRT这是常见流程。但导出过程中有精度损失不注意的话线上效果会掉。最常见的问题是算子不支持。PyTorch的动态图里有些操作ONNX没有对应的算子导出时会报错或者静默替换成近似实现。我一般导出后用onnxruntime跑一遍验证集对比PyTorch的输出看差异有多大import onnxruntime as ort sess ort.InferenceSession(model.onnx) onnx_out sess.run(None, {input: x.numpy()}) torch_out model(x).detach().numpy() diff np.abs(onnx_out - torch_out).max() print(fMax diff: {diff})如果diff在1e-5以内基本可以接受。如果大了就要查是哪个算子的问题。另一个坑是动态维度。训练时batch size可能不固定导出时要指定动态轴torch.onnx.export(model, dummy_input, model.onnx, dynamic_axes{input: {0: batch}, output: {0: batch}})不指定的话导出的模型只能跑固定batch size线上请求一变就报错。6.2 推理性能的优化路径推理性能优化有几个方向模型压缩、算子融合、批处理、硬件加速。每个方向的收益和成本不一样要根据场景选。模型压缩里量化是最常用的。FP32转FP16能减半显存、提速30%左右精度损失通常很小。INT8量化能再提速一倍但精度损失明显需要校准。我一般先试FP16不够再考虑INT8。算子融合是推理引擎自动做的比如把卷积、BN、ReLU融合成一个算子减少内存访问。TensorRT在这方面做得最好但只支持NVIDIA显卡。ONNX Runtime的融合能力弱一些但跨平台。批处理能显著提升吞吐量。单条推理时GPU利用率可能只有10%凑成batch后能到80%以上。但批处理会增加延迟因为要等凑够一个batch。折中方案是设一个超时比如等10毫秒还没凑够就发出去。# 简单的动态批处理逻辑 batch [] last_time time.time() while True: item queue.get() batch.append(item) if len(batch) max_batch or time.time() - last_time timeout: process(batch) batch [] last_time time.time()这个逻辑看起来简单但实际部署时要考虑并发、超时、错误处理复杂度不低。Triton Inference Server内置了动态批处理省得自己写。6.3 线上服务的监控与回滚模型上线不是终点而是起点。线上环境的数据分布会变模型效果会衰减。没有监控你根本不知道模型什么时候变差了。我一般监控三类指标系统指标、业务指标、模型指标。系统指标包括延迟、吞吐、错误率、GPU利用率。业务指标包括点击率、转化率、拦截率。模型指标包括输入分布、输出分布、置信度分布。输入分布漂移是最早的预警信号。如果线上输入的均值、方差和训练时差异大说明数据分布变了模型可能不适用了。我一般用KL散度或PSI来量化分布差异from scipy.stats import entropy psi entropy(train_dist, online_dist) if psi 0.2: alert(Distribution drift detected)PSI超过0.1就要关注超过0.2就要考虑重新训练。回滚机制也要提前准备好。新模型上线时先小流量灰度观察一段时间再全量。如果指标变差能一键切回旧模型。这个流程要在上线前就搭好不要等出问题了再临时搞。7. 一些让我少走弯路的习惯7.1 实验记录要写到未来的自己能看懂实验记录不是记流水账。我见过有人记“跑了实验效果还行”过两周自己都看不懂。好的实验记录应该包含改了什么、为什么改、结果如何、下一步计划。最好附上git commit hash和配置文件。我用的是简单的Markdown文件每个实验一个条目## 2024-01-15 实验12 - 改动学习率从1e-3降到5e-4加了warmup - 原因之前loss震荡怀疑学习率太大 - 结果验证F1从0.82升到0.85loss曲线平滑了 - 下一步试试warmup步数从500加到1000这个习惯看起来简单但坚持下来能省大量重复劳动。你不需要记住所有细节翻记录就行。7.2 代码review在AI项目里同样重要AI项目也是软件项目代码质量同样重要。我见过太多notebook里跑通的代码一改成脚本就各种问题。变量命名混乱、函数职责不清、没有类型标注这些问题在实验阶段不明显一到协作和部署就暴露。我的做法是实验阶段可以用notebook快速迭代但一旦某个方案要进入生产必须重写成模块化的Python代码。函数要有docstring关键变量要有类型标注配置要抽出来。这样别人能看懂自己过两个月也能看懂。def preprocess(text: str, max_len: int 128) - dict: Tokenize and pad text to fixed length. Args: text: Raw input string. max_len: Maximum sequence length. Returns: Dict with input_ids and attention_mask. ...类型标注和docstring不是形式主义它们能帮你在写代码时就发现逻辑问题。7.3 性能优化要先测量再动手性能优化最大的坑是凭直觉猜瓶颈。你觉得是模型推理慢结果发现是数据预处理占了80%的时间。不测量就优化很可能优化了不重要的部分真正的问题还在。我的做法是用profiler先跑一遍看时间花在哪。Python自带的cProfile能看函数级耗时PyTorch的profiler能看算子级耗时with torch.profiler.profile(activities[torch.profiler.ProfilerActivity.CUDA]) as prof: model(inputs) print(prof.key_averages().table(sort_bycuda_time_total))输出会按CUDA耗时排序一眼就能看出哪个算子最耗时。优化的时候从最耗时的开始收益最大。还有一个原则是优化要有基线。优化前记录当前性能优化后再测对比看提升了多少。没有基线你无法判断优化是否有效。7.4 文档是写给三个月后的自己最后说一个容易被忽略的文档。不是那种形式化的API文档而是决策文档。记录你为什么选了这个方案、放弃了哪些备选、当时考虑了什么因素。三个月后你回头看可能已经忘了当时的上下文这些记录能帮你快速恢复记忆。我一般会在项目根目录放一个DECISIONS.md按时间记录关键决策## 2024-01-10 选择ONNX而非TensorRT - 原因部署环境不只有NVIDIA显卡需要跨平台 - 代价推理速度比TensorRT慢约20% - 备选TensorRT速度快但绑定硬件这种记录花不了几分钟但价值很高。特别是团队协作时新人能通过它快速理解项目的来龙去脉。我在实际项目里最大的体会是AI工程能力的提升不来自于学了某个新模型或新框架而来自于对每个环节的深入理解和反复实践。你踩过的坑、解决过的问题、做过的取舍才是真正属于你的能力。从零构建这套能力没有捷径但有方法——就是每次遇到问题都往下挖一层搞清楚根本原因然后把这个经验固化到流程里。下次再遇到类似问题你就有了一套可复用的排查路径。这个过程很慢但每一步都算数。