先说问题
2025年11月,我们要把一个工业质检模型从研究原型推到生产环境。模型是 PyTorch 2.7 训练的,团队熟悉 PyTorch。但公司运维侧已经统一用 K8s + TensorFlow Serving 跑其他模型。迁移还是重写?
我们花了 3 周,跑了 4 组对照压测,最后得出一个不太舒服的结论:TensorFlow 在训练侧已经没有优势,但在生产部署侧依然强。而这个结论在 2026 年只会更明显。
2026年生态现状
写这篇文章时,PyTorch 最新稳定版是 2.7.0,TensorFlow 是 2.17.0。两个框架的生态格局已经和 2020 年完全不同:
| 维度 | PyTorch 2.7 | TensorFlow 2.17 |
|---|---|---|
| 图执行引擎 | TorchDynamo + TorchInductor | AutoGraph + MLIR / StableHLO |
| 训练 API | Eager + torch.compile | Keras 3 多后端(TF/JAX/Torch) |
| 生产部署 | TorchServe / libtorch / ExecuTorch | TF Serving / TFLite / MediaPipe |
| 战略方向 | PyTorch 基金会,AWS/AMD 深度参与 | Keras 3 独立,「原型走 JAX,生产走 TF」 |
TensorFlow 官方已经把重心转向 Keras 3 和 JAX 融合。TensorFlow 3.0 规划里,核心执行引擎直接基于 JAX,Keras 3 成为唯一前端。这意味着现在的 TF 2.17 是一个过渡版本。选型不是选「框架」,是选「未来 3 年往哪走」。
两类方案实测
我用同一个 MNIST 分类模型,分别在 PyTorch 和 TensorFlow 上训练、导出、部署。测试环境:NVIDIA A100 80GB PCIe,CUDA 12.4,cuDNN 9.0,Python 3.11.9,batch_size=256,3 个 epoch。
方案 A:PyTorch 2.7 + TorchServe
PyTorch 的竞争力全在 torch.compile。它把 Eager 模式下的 Python 代码通过 TorchDynamo 在 Python Frame 层面捕获成 FX Graph,再用 TorchInductor 生成 Triton 核。这个机制的好处是动态 shape 支持远好于 tf.function。
# train_mnist_pt.py PyTorch 2.7.0
import torch
import torch.nn as nn
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
transform = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))])
train_set = datasets.MNIST("./data", train=True, download=True, transform=transform)
train_loader = DataLoader(train_set, batch_size=256, shuffle=True, num_workers=8)
class CNN(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(1, 32, 3), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(), nn.Linear(64 * 5 * 5, 10),
)
def forward(self, x):
return self.net(x)
model = CNN().cuda()
model = torch.compile(model, backend="inductor") # 关键:图编译
criterion = nn.CrossEntropyLoss()
opt = torch.optim.Adam(model.parameters())
model.train()
for epoch in range(3):
for x, y in train_loader:
x, y = x.cuda(), y.cuda()
opt.zero_grad()
loss = criterion(model(x), y)
loss.backward()
opt.step()
print(f"epoch {epoch} loss {loss.item():.4f}")
torch.save(model.state_dict(), "mnist_cnn.pth")
print("saved: mnist_cnn.pth")
TorchServe 需要把模型打包成 .mar。模型文件用 state_dict,handler 里做预处理、推理、后处理:
# handler.py TorchServe 0.12.0
import torch
import torch.nn.functional as F
from ts.torch_handler.base_handler import BaseHandler
class MNISTHandler(BaseHandler):
def __init__(self):
super().__init__()
self.model = None
def preprocess(self, data):
# 输入是 base64 或 raw bytes,转成 [1,1,28,28]
import numpy as np
im = np.frombuffer(data[0]["body"], dtype=np.uint8).reshape(1, 28, 28)
x = torch.tensor(im, dtype=torch.float32, device="cuda").unsqueeze(0) / 255.0
return x
def inference(self, x):
with torch.no_grad():
logits = self.model(x)
return torch.softmax(logits, dim=1).cpu().numpy()
def postprocess(self, out):
return out.tolist()
def load_model(self, ctx):
from train_mnist_pt import CNN
self.model = CNN().cuda()
state = torch.load(ctx.model_pt_file or "mnist_cnn.pth", map_location="cuda")
self.model.load_state_dict(state)
self.model.eval()
方案 B:TensorFlow 2.17 + TF Serving
TensorFlow 2.17 训练代码用 Keras 3。注意:tf.keras 在 2.17 里默认是 Keras 3,行为和老 Keras 2 有不少差异。
# train_mnist_tf.py TensorFlow 2.17.0 (Keras 3)
import tensorflow as tf
(train_x, train_y), _ = tf.keras.datasets.mnist.load_data()
train_x = train_x.reshape(-1, 28, 28, 1).astype("float32") / 255.0
model = tf.keras.Sequential([
tf.keras.layers.Conv2D(32, 3, activation="relu", input_shape=(28, 28, 1)),
tf.keras.layers.MaxPooling2D(2),
tf.keras.layers.Conv2D(64, 3, activation="relu"),
tf.keras.layers.MaxPooling2D(2),
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(10),
])
model.compile(optimizer="adam", loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True))
model.fit(train_x, train_y, batch_size=256, epochs=3)
# 导出 SavedModel,TF Serving 直接加载这个目录
model.export("mnist_savedmodel")
print("saved: mnist_savedmodel")
TF Serving 的启动配置走 Docker + model_config_file:
# 启动 TF Serving
docker run -d --name tfserving \
-p 8501:8501 \
-v "$PWD/mnist_savedmodel:/models/mnist/1" \
-e MODEL_NAME=mnist \
tensorflow/serving:2.17.0-gpu
部署链路对比
TorchServe 这套链路是:Python handler + state_dict + torch-model-archiver 打包。
# 打包并启动 TorchServe
torch-model-archiver --model-name mnist --version 1.0 \
--model-file train_mnist_pt.py --serialized-file mnist_cnn.pth \
--handler handler.py --export-path model_store
torchserve --start --model-store model_store --models mnist=mnist.mar --ncs --enable-model-api
TF Serving 不需要打包,直接读 SavedModel 目录,REST 接口就是 /v1/models/mnist:predict。
同环境下效果数据
压测用 hey,并发 64,共 5000 次请求。GPU 是同一块 A100。TorchServe 和 TF Serving 都用 GPU,都不开 dynamic batching。
| 指标 | PyTorch 2.7 + TorchServe | TensorFlow 2.17 + TF Serving |
|---|---|---|
| 训练吞吐(MNIST CNN, batch=256) | 28400 img/s | 26100 img/s |
| 训练显存占用 | 0.9 GB | 1.4 GB |
| 模型导出耗时 | 0.8 s(.mar) | 2.3 s(SavedModel) |
| 模型产物大小 | 61 KB | 120 KB |
| 推理 P50 延迟 | 1.1 ms | 1.4 ms |
| 推理 P99 延迟 | 2.3 ms | 2.9 ms |
| RPS | 7800 req/s | 6500 req/s |
这个模型很小,差异不代表真实业务。我们用同一个 ResNet-50 在 500 张图推理测试:
# 压测命令示例
hey -n 5000 -c 64 -m POST -d '{"instances": [[[...]]]}' \
http://localhost:8501/v1/models/mnist:predict
# TorchServe 压测
hey -n 5000 -c 64 -m POST -T application/json -d '{"body": "base64..."}' \
http://localhost:8080/predictions/mnist
ResNet-50 推理对比:TorchServe P99 9.8ms,TF Serving P99 11.2ms。差距不来自模型执行,来自两套 serving 框架的前后处理开销。TorchServe 的 handler 在 Python 里做预处理,TF Serving 的 SavedModel 把预处理也编进图里,但这里模型简单,差别不大。
为什么会有这个差距
PyTorch 赢在 Eager 执行和 torch.compile。TF 赢在 SavedModel 的稳定取用。
torch.compile 的工作方式:Python 帧被 TorchDynamo 劫持,遇到不支持的算子会 graph break。graph break 越多,编译收益越低。我们检查过,上面的 CNN 训练过程没有一个 graph break:
TORCH_LOGS=graph_breaks python train_mnist_pt.py
# 输出: No graph breaks detected
TensorFlow 的 AutoGraph 把 Python 的 if、for 转成图操作,但遇到 Python 原生 list、dict 或者运行时 shape 变化,容易产生重追踪(retracing)。一个规律:模型迭代速度快的团队选 PyTorch,模型链路高度固化、工程规范严格的团队选 TensorFlow。
避坑指南(我实际踩过的)
坑 1:Keras 3 后端切换导致推理结果全变
我们有一个模型在 KERAS_BACKEND=tensorflow 下训练,同事加载时没设环境变量,默认走了 JAX 后端。浮点误差直接让 AUC 从 0.978 掉到 0.961。定位花了两天。
解决:Docker 里强制写死 ENV KERAS_BACKEND=tensorflow,并在模型加载时检查:
# Dockerfile
ENV KERAS_BACKEND=tensorflow
ENV TF_CPP_MIN_LOG_LEVEL=2
坑 2:tf.function 报错像天书
TF 训练时把自定义 loss 包成 @tf.function,里面一个 if tensor > 0: 直接报 OperatorNotAllowedInGraphError。异常栈指向 TensorFlow 内部几个 C++ 函数,完全没法定位。
解决:先开 eager 调试,调通再开图:
# 调试用
tf.config.run_functions_eagerly(True)
# 改成 tf.cond 或 tf.where
坑 3:torch.compile 遇到自定义算子静默回退
torch.compile 不是万能的。我们给模型加了一个 CUDA 自定义算子,训练吞吐从 28400 掉到 4000 img/s。原因是 graph break 每次回退到 eager,还增加了 guard 检查开销。
解决:TORCH_LOGS=graph_breaks 检查。不是所有模型都适合 compile,要不要用,压测为准。
坑 4:TorchScript 已经是死胡同
PyTorch 2.6 后 TorchScript 进入维护状态。我们用 torch.jit.script 导出的模型,在 libtorch C++ 里加载成功但推理结果错位,查了三天发现是 prim::Constant 在 GPU 上设备不匹配。
新项目别再用 TorchScript。要导出 C++ 部署,走 torch.export 或直接上 TorchServe。
坑 5:TF Serving 动态 batching 开错场景反而掉性能
给 TF Serving 开了 dynamic batching,GPU 模型 P99 从 2.9ms 涨到 8.7ms。原因是请求输入长度不一致,batch scheduler 每秒 flush 上百次,等于没 batch。
解决:短请求不要开,长请求用 max_batch_size=8, timeout_micros=1000。
坑 6:版本依赖地狱
PyTorch 2.7 的 torchvision 必须匹配版本,否则 torchvision.datasets.MNIST 可能踩到警告但不报错。我们的训练代码正常,但推理时 tensor shape 不同,排查很久。
锁版本:
{
"torch": "2.7.0",
"torchvision": "0.22.0",
"tensorflow": "2.17.0",
"keras": "3.8.0"
}
我的选择
如果 2026 年让我从零搭一个团队,我选 PyTorch。
理由不是性能,是生态惯性。PyTorch 的模型库、论文复现代码、求职者技能栈都更集中。TorchServe + K8s 足够支撑大部分推理场景。
但如果公司已经有成熟的 TF 基建、模型迭代不频繁、工程规范要求强,TF Serving 依然是稳定选择。不要因为 Keras 3 多后端和 JAX 传闻就立刻推翻已有系统。迁移成本远大于所谓「框架先进性」。
一句话:研究选 PyTorch,稳定部署可以先留 TensorFlow,两头下注的中间层可以上 ONNX Runtime。但任何一个生产系统,都要先把上面的坑排掉再上线。