很多人把深度学习项目想成“把模型训练完就大功告成”,但真正上手做过一轮才知道,训练只是其中一环。我最早接触 TensorFlow 2.x 实战时,最大的感受就是:从环境搭建、数据管道、模型训练到最终把模型交出去给别人调用,每一步都有大量文档里不会明说的细节。标题里写着“从环境搭建到深度学习模型落地”,这句话其实概括了完整链路,而链路上任何一环没接上,模型都只能躺在 .ipynb 里,不能算真正落地。
这篇文章我打算按自己多次从零搭建到交付的流程来写,重点不是把某个 Demo 跑通,而是把那些“为什么这样选、这里会踩什么坑、配置到底怎么配对”的经验讲透。适合刚学完 TensorFlow 基础、准备做第一个完整项目的读者,也适合已经在训练模型但卡在导出和部署环节的同学。内容不区分 GPU 和 CPU,两条路线都会说清楚。
1. 版本选型:为什么我把精力投在 TensorFlow 2.x 而不是 1.x
1.1 2.x 相比 1.x 的三个本质变化
如果你之前看过一些老教程,可能见过tf.Session()、tf.placeholder、tf.global_variables_initializer()这类写法。TensorFlow 1.x 是基于静态图设计的,先要定义好整张计算图,再放到 Session 里执行,调试时想看中间值特别费劲。TensorFlow 2.x 把默认执行模式改成了动态图(也就是 eager execution),写起来和普通 Python 逻辑几乎一样,变量、张量随手打印就能看到值,这对入门和调试来说体验是质变。
第二个变化是 Keras 被吸收为核心高层 API。2.x 里做模型不再需要一层层手写底层算子,tf.keras.Sequential和tf.keras.Model基本可以覆盖 90% 的建模需求。你编译、训练、评估、预测,都是一行行顺序写下来,心智负担小很多。
第三个变化是模型导出格式被统一为 SavedModel。1.x 时代要部署模型,走的是 freeze graph 加 checkpoint 的老路,画计算图、抠变量、转 pb 文件,一套操作下来非常劝退。到了 2.x,model.save('saved_model目录')一下就能导出标准格式,后续转 TFLite、TF Serving、TF.js 都有现成工具链对接。这几个本质变化,决定了同样的需求在 2.x 下实现周期可以大幅缩短。
1.2 老代码怎么处理
如果你手头有 1.x 的老代码,我的建议是不要试图“兼容式”地修修补补,因为两代 API 的设计思路差异太大。判断标准很简单:代码里如果出现tf.Session()、tf.placeholder、tf.contrib,说明它还停留在 1.x 写法,稳妥的做法是直接重写。重写时先把模型结构用tf.keras重新搭一遍,再把原来 session.run 里的训练逻辑换成model.fit,最后把 checkpoint 转成 SavedModel 导出。
对于那些还在依赖tf.compat.v1的项目,如果只是做简单迁移,可以把tf.compat.v1.disable_eager_execution()这种兼容模式临时跑起来,但我不建议在新项目里继续这么干。你一旦走了兼容模式,等于又回到静态图思维,2.x 的收益享受不到,后面加功能、调 bug 都会很难受。如果模型代码本身不算太老,比如只是用了tf.estimator,那迁移成本会低一些,可以逐步把自定义model_fn改成tf.keras模型,再用tf.keras的compile和fit替代训练流程。优先级上建议先迁移核心推理路径,再迁移训练和数据处理部分。
2. 环境搭建:版本匹配是最大的隐性成本
2.1 一套验证过多次的稳定组合
环境搭建看起来简单,实际是第一个大坑。很多人上来就pip install tensorflow,装完一跑发现 GPU 用不了,或者 import 直接报错。问题大多出在 Python、TensorFlow、CUDA、cuDNN 四者版本不匹配。TensorFlow 官方文档里有兼容版本表,但实际经历下来,有几个组合非常稳,我个人的推荐是:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| Python | 3.10 | 兼容面广,第三方库踩坑最少 |
| TensorFlow | 2.13 | 功能稳定,Keras API 兼容性好 |
| CUDA | 11.8 | 多数显卡驱动能直接兼容,不要求太新 |
| cuDNN | 8.6 | 配套 CUDA 11.8 非常成熟 |
这里有两个关键认知值得强调。第一个是 TensorFlow 2.10 是最后一个在 Windows 原生环境下支持 GPU 的版本,从 2.11 开始,Windows 上跑 GPU 基本要走 WSL2 或 Linux。如果你必须用 Windows 原生环境做 GPU 训练,建议装 2.10,后面版本不要硬追。第二个是 2.16 之后 Keras 逐渐走向独立的 3.x 体系,一些旧代码的部分行为会变,如果你的目标是稳定做项目,踩在 2.13、2.15 这一代即可,没必要一上来就追最新。
2.2 安装流程与 GPU 验证
我的安装流程一般是这样:先用 Anaconda 建一个独立环境,避免把系统 Python 搅乱。装 TensorFlow 时直接pip install tensorflow==2.13一个包就行,从 2.x 起 GPU 支持已经合并进主包,不需要分开装tensorflow-gpu。注意别同时装 CPU 版和 GPU 版,否则会互相覆盖依赖。国内网络环境下,大包下载容易超时,可以临时指定国内镜像源,比如在 pip 命令后面加-i参数指定镜像地址,速度会直观提升。
装完后的第一件事,不是急着跑模型,而是确认 GPU 是否真正被识别到。写一段临时脚本验证:
import tensorflow as tf print("TensorFlow version:", tf.__version__) print("GPU devices:", tf.config.list_physical_devices('GPU'))如果输出里能看到类似PhysicalDevice(name='/physical_device:GPU:0', device_type='GPU')的内容,说明 GPU 可用。如果列表为空,先别怀疑是 TensorFlow 的问题,按三个顺序排查:显卡驱动是否是较新版本、CUDA 和 cuDNN 是否确实安装在系统路径里、环境变量是否指向了正确的 CUDA 目录。我见过不少案例,驱动没更新到支持 CUDA 11.8 就成了“玄学识别不到”。
2.3 显存管理与 CPU 环境的定位
GPU 识别成功后,还有一个容易被忽略的显存配置。TensorFlow 默认启动时可能会一次性申请几乎全部显存,这样如果你同时开多个程序,或者想在同一个显卡上跑多次实验,显存不够用的概率就很高。我习惯在代码开头固定加两行配置:
gpus = tf.config.list_physical_devices('GPU') if gpus: try: for gpu in gpus: tf.config.experimental.set_memory_growth(gpu, True) except RuntimeError as e: print(e)这样设置的memory_growth模式,只有真正用多少显存才申请多少,后续其他进程也能共用显卡。训练大模型时还能再配合set_visible_devices限制只使用某几块卡。
至于没有 GPU 的场景,也不用慌。数据量不大、模型规模小、做的是周期性离线训练,CPU 完全够用。装 CPU 版本用pip install tensorflow-cpu即可,包体积更小、依赖更干净。很多务实的小项目,比如表格数据预测、短文本分类,CPU 跑起来一点问题没有,没必要为了“上 GPU”而强行去配 CUDA。
3. 核心 API 实战:把 Keras、tf.data、@tf.function 用对
3.1 Sequential 和 Functional 的选择标准
Keras 建模有两种常用方式:Sequential和Functional。很多教程只教你 Sequential,因为它最简单。但真实项目里,输入往往不是一根线,比如一个模型同时接收数值特征和文本特征,或者需要共享某个层,这时候 Sequential 就束手无策了。
我的选择标准很简单:如果模型结构是一层层顺序堆叠,输入输出只有一个,就用Sequential,写着痛快;只要有多个输入、多个输出,或者层之间有跳连、复用,就改用Functional。Functional并不是更复杂,它只是把层之间的连接关系显式表达出来。比如一个多输入模型,大致可以这样写:
import tensorflow as tf # 定义输入 numeric_input = tf.keras.Input(shape=(10,), name="numeric_input") text_input = tf.keras.Input(shape=(None,), name="text_input") # 数值特征直接进入全连接层 x1 = tf.keras.layers.Dense(64, activation="relu")(numeric_input) # 文本特征先经过嵌入和池化 embedding = tf.keras.layers.Embedding(5000, 32)(text_input) x2 = tf.keras.layers.GlobalAveragePooling1D()(embedding) # 合并两个分支 concat = tf.keras.layers.Concatenate()([x1, x2]) output = tf.keras.layers.Dense(1, activation="sigmoid")(concat) model = tf.keras.Model(inputs=[numeric_input, text_input], outputs=output)这样写出来的模型结构一目了然,调用model.fit时传入[数值数组, 文本数组]即可。而且Functional模型保存、导出都非常顺利,所以我建议新项目里优先考虑它,哪怕只是单输入,也能为后续扩展留空间。少部分需要完全自定义训练逻辑的场景才推荐Model子类化,但子类化模型在做序列化和部署时坑比较多,没摸清楚之前尽量先用Functional。
3.2 tf.data 是训练性能的分水岭
很多初学者的做法是把 NumPy 数组直接丢给model.fit(x_train, y_train),数据量小的时候确实能跑通。但只要数据量上来,训练就会发现瓶颈往往不在 GPU,而在 CPU 端的数据加载和预处理。tf.data.Dataset就是为了解决这个问题设计的:它能构建一条数据管道,把磁盘读取、图片解码、数据增强、打包批次、送入模型这些步骤都编排起来。
一个比较合理的管道思路是这样:
dataset = tf.data.Dataset.from_tensor_slices((x_train, y_train)) dataset = dataset.map(preprocess_fn, num_parallel_calls=tf.data.AUTOTUNE) dataset = dataset.shuffle(buffer_size=1000) dataset = dataset.batch(batch_size=32) dataset = dataset.prefetch(buffer_size=tf.data.AUTOTUNE)map里的num_parallel_calls会并行处理样本,prefetch会提前准备下一批数据,两者配合可以让 GPU 一直在干活,而不是等数据。预处理函数中常见操作包括归一化、裁剪、转换为tf.float32等。注意shuffle要在batch之前做,且buffer_size越大洗牌越充分。对于能重复利用的数据集,还可以在map后接dataset.cache(),这样第一次 epoch 预处理完的结果可以被缓存,后续 epoch 速度快很多。
我自己踩过的一个坑是from_tensor_slices里放了一个极大数组,结果内存被直接吃满。如果数据集太大,应该用tf.data.Dataset.from_generator或者读取 TFRecord 文件来构建管道,避免一次性把所有样本加载到内存。生产环境里我基本都用 TFRecord,虽然在写脚本时多几步,但跨机器处理和分布式训练都顺滑得多。
3.3 @tf.function 什么时候值得用
TensorFlow 2.x 默认是 eager 模式,调试很舒服。但它有一个特点:每次执行算子都要走 Python 层,开销相对高。@tf.function的作用,就是把一段 Python 函数“编译”成一张计算图,后续调用时直接跑图,省去 Python 层的解释开销。模型推理阶段,比如多人同时请求一个服务,图的执行速度快且更稳定,收益很明显。
我在实际项目里有两个使用习惯。第一,如果自定义了训练循环,比如用tf.GradientTape做个性化训练,会把整个训练步封装成一个@tf.function函数,这样能明显提升迭代速度。例子如下:
@tf.function def train_step(x, y): with tf.GradientTape() as tape: predictions = model(x, training=True) loss = loss_fn(y, predictions) gradients = tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(gradients, model.trainable_variables)) return loss第二,给模型做推理部署时,会把预处理和模型调用包成一个@tf.function函数,这样导出 Serving 或 Lite 时,图的边界更清晰。有一点要注意:@tf.function第一次被调用时会花时间做图编译(也就是 trace),之后再调用就快了。千万别在循环里反复定义带@tf.function的新函数,那样等于反复编译,反而更慢。它最适合的是“同一套逻辑被调用很多次”的场景。
4. 训练过程中决定模型能不能用的细节
4.1 损失函数、优化器与学习率
训练模型时,不少人一上来就照搬某个默认配置,结果模型要么不收敛,要么收敛极慢。其实损失函数和优化器的选择要跟着任务走。分类任务用交叉熵,回归任务用均方误差或平均绝对误差,多标签分类用二分类交叉熵。很多人觉得这种常识不用强调,但我在帮别人排查问题时,见过不止一次把回归当分类做的例子,损失函数乱选,能训练才怪。
优化器我常用的还是 Adam,它是自适应学习率算法,大多数任务表现都不错。但 Adam 默认学习率 0.001 在不少任务里会偏大,尤其是自定义数据比较小时,初期 loss 可能不降反升,或者震荡很明显。我的习惯是把初始学习率在 1e-3 和 1e-4 之间做一次试验:先拿 1e-3 跑几个 epoch,如果 loss 波动大,就降到 1e-4。实际项目里,两类任务各自能稳。
一个典型分类模型的编译过程是这样的:
model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss=tf.keras.losses.BinaryCrossentropy(), metrics=[tf.keras.metrics.AUC(name="auc")] )metrics里不要只放 accuracy,像点击率、异常检测这类正负样本不均衡的任务,accuracy 会非常具有欺骗性,AUC 才能反映排序能力。这一点业务场景里尤其重要。
4.2 回调组合是省心训练的关键
训练时如果一直盯着终端,人工看 loss 曲线,再手动决定什么时候早停、什么时候降学习率、什么时候保存模型,你会非常累而且容易出错。Keras 的回调机制就是把这些“训练期管理动作”自动化。我的标配组合基本固定:
callbacks = [ tf.keras.callbacks.ModelCheckpoint( filepath="best_model.keras", monitor="val_loss", save_best_only=True, mode="min" ), tf.keras.callbacks.EarlyStopping( monitor="val_loss", patience=10, restore_best_weights=True ), tf.keras.callbacks.ReduceLROnPlateau( monitor="val_loss", factor=0.5, patience=3, min_lr=1e-6 ), tf.keras.callbacks.TensorBoard(log_dir="logs") ]ModelCheckpoint只保存验证集 loss 最小的那个模型,训练几十轮之后,即使因为局部波动把最佳点跳过了,最后拿出来用的仍然是最佳权重。EarlyStopping的patience设成 10 或 15,意思是验证集连续多轮不再改善才停止,既能避免过拟合,又不会因为单轮上下波动就被打断。ReduceLROnPlateau则是在验证集指标进入平台期后自动把学习率减半,给模型一个继续“精调”的机会,这个组合实际用下来,绝大多数场景都能稳定收到一个可靠模型。
TensorBoard 的话别只当装饰用,训练完tensorboard --logdir logs打开界面,看 loss 曲线和梯度分布,能快速发现振荡和梯度消失问题。调参效率比盯着命令行高很多。
4.3 复现性与显存管理的工程习惯
做实验时会频繁调整参数,如果每次运行结果都不一样,就很难判断改动到底是好是坏。为了让实验结果可以复现,我在脚本入口处统一设置随机种子:
import random import numpy as np import tensorflow as tf tf.random.set_seed(42) np.random.seed(42) random.seed(42)更进一步,可以设置环境变量TF_DETERMINISTIC_OPS=1,这是官方为确定性计算提供的开关,代价是某些算子可能变慢,但只要不是大规模分布式训练,完全能接受。需要注意的是,哪怕设置了种子,如果硬件本身支持不确定性算子(比如某些 GPU 上的原子操作),复现仍然不完全,所以“尽量复现”就好,别较真到逐位一致。
显存这块,除了前面说的set_memory_growth,训练时也要留意 GPU 内存不够的报错。如果模型本来就能跑,但 batch 太大撑爆显存,优先减小 batch size;如果已经很小还报错,就要检查是不是有别的进程在占用显存。用nvidia-smi命令看一眼实时显存占用,能快速定位是谁在抢卡。Batch size 减小的同时,可以把学习率也适当调低一点,保证收敛稳定性。
5. 导出模型:SavedModel 是通往所有端点的统一中转站
5.1 三种保存方式怎么选
训练得到模型后,保存格式不是随心所欲的。model.save支持三种主要方式,我按场景拆分:
| 保存方式 | 适用场景 | 说明 |
|---|---|---|
model.save("model.keras") | Keras 内部继续训练、继续微调 | 完整保存权重、结构、优化器状态 |
model.save("model.h5") | 老工具链兼容 | 格式较旧,新项目不推荐优先选 |
tf.saved_model.save(model, "saved_model_path") | 部署、转换 TFLite / TF Serving / TF.js | 标准化目录格式,跨平台兼容最好 |
一句话总结:训练完想继续做实验,用.keras保存;训练完想交给别人上线,直接保存成SavedModel。.h5是历史产物,如果合作方没有特殊的旧系统约束,没必要主动选它。我现在的默认习惯是同时各存一份,一份方便自己断点续训,一份对外交付。SavedModel 是一个目录,里面包含模型结构、权重和签名信息,打开目录会看到saved_model.pb和variables文件夹,把整个目录打包发给对方即可,不需要对方安装 TensorFlow 就能配合部署工具使用。
5.2 自定义层和自定义损失函数的序列化问题
如果你在模型里用了自定义层或自定义损失函数,导出的坑马上就来了。最常见的问题是:用model.save保存后再加载,报错提示“无法反序列化”或找不到某个自定义类。原因在于 Keras 序列化时,除了权重,还要能重建模型结构,而自定义类没有记录构造参数。
解决办法有两个。第一,自定义层里实现get_config()方法,把构造函数参数完整写进去;第二,给自定义类加上@tf.keras.savers.register_keras_serializable()或@tf.keras.utils.register_keras_serializable()装饰器并给它一个全局唯一名字。以自定义层为例:
import tensorflow as tf @tf.keras.savers.register_keras_serializable(package="myproject") class MyLayer(tf.keras.layers.Layer): def __init__(self, units=32, **kwargs): super().__init__(**kwargs) self.units = units def call(self, inputs): return inputs * 2 def get_config(self): config = super().get_config() config.update({"units": self.units}) return config没有get_config的自定义层,即使训练时没有任何报错,一保存加载就会各种炸。这个规则不仅适应自定义层,自定义损失函数、自定义指标也都一样。做项目前提前知道这个机制,能在设计模型结构时就把序列化问题规避掉。
5.3 签名:部署后调用方需要知道的接口
SavedModel 目录里有一个重要概念叫做签名(signature)。点开saved_model_cli show --dir saved_model_path --all可以看到模型默认导出的serving_default签名,它描述了模型接收的输入 tensor 名称、类型、shape,以及输出 tensor 名称。部署系统(比如 TensorFlow Serving)就是靠这个签名来识别接口的。
这里有个容易被忽略的小细节:定义模型的Input时,最好显式指定name="input_tensor"这类清晰的名称。否则导出的签名里输入名可能变成input_1、input_2,部署时调试接口就非常痛苦。实际做服务端部署时,REST 请求里的 JSON 字段名需要和签名中的 tensor 名对齐,命名清晰能省很多沟通成本。另外,如果模型的输入不是单一数组,而是字典,签名会额外复杂,这时候建议在导出前就用@tf.function包一个干净的单入单出接口,只暴露业务真正需要的部分。
6. 部署落地:按使用场景选择 Serving、Lite 和 js
6.1 TensorFlow Serving 服务端部署
如果模型要作为一个正式的在线服务,供多个系统调用,支持版本管理、高并发,那首选就是 TensorFlow Serving。它的思路很简单:把 SavedModel 目录挂载给服务进程,服务进程对外提供 gRPC 和 REST 两种接口。部署多版本模型时,只需要在模型目录下按版本号建子目录,服务会自动做版本管理,上线回滚都很方便。
实战里我一般用 Docker 拉起 Serving,避免在本机手动配依赖:
docker pull tensorflow/serving docker run -p 8501:8501 \ -v /path/to/saved_model:/models/my_model \ -e MODEL_NAME=my_model \ tensorflow/serving启动后,接口默认在 8501 端口,用 REST 调用方式发一条带签名的 JSON 请求即可。比如模型接收一个长度为 10 的数值向量,请求体形如:
{ "instances": [[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]] }返回里带着predictions字段,就是模型输出。这种方式对客户端没有 TensorFlow 环境要求,写任何语言都能通过 HTTP 调用。如果是高吞吐场景,可以改用 gRPC 接口,以原始二进制传输数据,性能和稳定性都更好。
6.2 TensorFlow Lite 端侧模型转换与量化
模型要跑到手机、嵌入式设备或资源受限的边缘设备上,一般转成 TensorFlow Lite 模型。转换过程不是重新训练,而是把 SavedModel 变成更轻量的.tflite文件。核心命令如下:
converter = tf.lite.TFLiteConverter.from_saved_model("saved_model_path") converter.optimizations = [tf.lite.Optimize.DEFAULT] tflite_model = converter.convert() with open("model.tflite", "wb") as f: f.write(tflite_model)只加Optimize.DEFAULT,默认情况下会做动态范围量化,模型体积明显减小。如果想进一步压缩体积,可以尝试全整数量化,但这一步需要准备一个“代表性数据集”,用来统计每一层激活值的范围:
def representative_dataset(): for sample in sample_data[:100]: yield [sample.astype("float32")] converter.representative_dataset = representative_dataset converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]加了代表数据集之后,模型里的浮点算子有机会被整体替换成 int8 运算,速度和体积都会有明显改善,但精度会有一点点损失,需要在真实验证集上评估确认在可接受范围。这里要提醒一下:如果模型里包含自定义 op,转换工具多半会报错或不支持。我遇到这种情况的惯用解法是把预处理、后处理这些逻辑从 SavedModel 里拆出去,在应用侧用原生代码完成,模型只保留核心计算部分。这样既瘦身,又避免工具链不支持的风险。
6.3 TensorFlow.js 浏览器场景
如果目标环境是不装任何深度学习依赖的浏览器,或者想快速做前端交互 Demo,可以走 TensorFlow.js 路线。把 SavedModel 转成 TF.js 所需的格式,用官方提供的转换命令行工具即可:
tensorflowjs_converter --input_format=tf_saved_model \ saved_model_path \ tfjs_model_dir转换完会在tfjs_model_dir里生成model.json和若干分片权重文件。浏览器里加载后调用:
const model = await tf.loadGraphModel("tfjs_model_dir/model.json"); const result = model.predict(tf.tensor2d([1.0, 2.0, 3.0], [1, 3]));浏览器端推理的优势是零安装、跨平台,尤其方便做产品原型和演示工具。当然它的性能天花板和端侧大模型相比还是有限,生产级高并发场景通常还是要交给 Serving。我日常的做法是:做内部验收 Demo 用 TF.js,做正式系统服务用 Serving,做移动端或边缘盒子用 Lite。三条路线的工具链其实都围绕同一份 SavedModel,这也是为什么我坚持“导出统一走 SavedModel”这一步。
在完整链路跑过几次之后,我现在的习惯是在动手写模型前就先把“交付给谁”想清楚。对方需要的是服务端接口,还是端侧 SDK,还是浏览器运行,这会直接影响模型 API 设计、输入出命名、预处理位置以及导出格式。环境搭建和训练只是这条链路的起点,真正让模型产生价值的,永远是最后那个“能被别人顺畅使用”的落地环节。