1. 从一个空标题说起为什么“tensorflow”这四个字值得单独写一篇拿到这个标题的时候正文是空的关键词是空的摘要也是空的。只有一个词tensorflow。这种情况其实挺常见的——很多人在整理自己的技术笔记或者项目归档时就扔一个框架名字过来剩下的全靠自己去回忆和补全。我第一反应不是去查官方文档而是回想自己这些年用这个框架到底踩过哪些坑、哪些东西是真正值得写下来给后来人看的。TensorFlow 这个名字本身就是一个很好的切入点。它不是一个简单的深度学习库而是一整套围绕张量计算构建的生态系统。从最早的静态图模式到后来的 Eager Execution再到 tf.function 和 SavedModel 的部署链路这个框架的演进路线其实反映了整个行业对“研究友好”和“生产可用”这两个目标的反复权衡。很多人学 TensorFlow 学到一半就放弃了转去用别的框架根本原因往往不是它难而是没有搞清楚它的设计哲学——它到底想让你怎么组织代码以及为什么非要这么组织。这篇文章适合谁看如果你已经写过一些 Python对神经网络有基本概念但每次打开 TensorFlow 的官方教程都觉得“能跑通但不知道为什么要这样写”那这篇内容就是为你准备的。我不会从头讲什么是梯度下降也不会把官方 API 文档复述一遍。我要做的是把 TensorFlow 这套东西拆开告诉你每个模块存在的理由以及在实际项目里怎么组合它们才不会把自己绕进去。下面我会从计算图这个核心概念开始一路讲到数据管道、模型训练、调试技巧和部署落地。每一部分都会给出可以直接运行的代码片段同时解释为什么这样写而不是那样写。中间会穿插一些我自己的踩坑记录比如某个回调函数写错位置导致训练了三个小时才发现模型根本没保存或者数据管道里的 shuffle 顺序搞反导致验证集泄漏。这些东西官方文档不会告诉你但实际做项目的时候它们才是真正消耗你时间的地方。2. 计算图与 Eager ExecutionTensorFlow 的两副面孔2.1 静态图到底解决了什么问题TensorFlow 最早出名靠的就是静态计算图。你先把整个计算流程定义好包括所有的矩阵乘法、激活函数、损失计算然后把这个图交给一个 Session 去执行。这种模式在 2015 年左右是非常先进的想法因为它把“定义”和“执行”分开了。分开的好处是什么你可以把定义好的图序列化放到另一台机器上跑或者放到移动端跑甚至可以用不同的后端来执行同一个图。但静态图的问题也很明显。你没办法在定义的时候打印一个张量的值因为那时候还没有值只有一个占位符。调试的时候只能靠 tf.Print 这种操作把中间结果塞到图里然后跑一遍再看输出。写惯了普通 Python 的人会觉得非常别扭因为 Python 是解释执行的你写一行 print 就能看到结果而在静态图里这行 print 只会打印出一个 Tensor 对象的描述信息。我刚开始用 TensorFlow 的时候花了整整一个下午才搞明白为什么我的 loss 打印出来是 Tensor(mean_squared_error:0, shape(), dtypefloat32) 而不是一个具体的数字。后来才知道需要用 session.run() 去真正执行那个节点。这个经历让我意识到理解 TensorFlow 的第一步不是学 API而是理解它的执行模型。2.2 Eager Execution 带来的改变和新的代价TensorFlow 2.x 默认开启了 Eager Execution也就是说你写一行代码它就立刻执行跟 NumPy 一样直观。这对调试来说是天大的好事你可以随时 print 中间结果可以用 Python 的调试器单步跟踪可以用 if 语句根据张量的值来决定下一步做什么。但 Eager 模式也有代价。每次操作都要从 Python 层调到 C 层再调回来开销不小。如果你写一个循环里面做几千次小矩阵乘法Eager 模式会比静态图慢很多。所以 TensorFlow 提供了 tf.function 这个装饰器它可以把一个 Python 函数追踪成一个计算图然后以图模式执行。这样你既保留了写代码时的直观性又能在执行时获得图优化的性能。这里有一个很容易踩的坑tf.function 并不是简单地把你的 Python 代码“编译”一遍。它做的事情是追踪tracing也就是说它会用一些符号张量去执行你的函数记录下所有被调用的 TensorFlow 操作然后把这些操作拼成一个图。如果你的函数里有依赖 Python 值的控制流比如 if x 0 这种其中 x 是一个 Python 变量而不是张量那么追踪只会发生一次图里只会保留当时走的那条分支。要避免这个问题需要用 tf.cond 或者确保条件本身是张量。import tensorflow as tf tf.function def compute(x): if x 0: return x * 2 else: return x * -1 # 第一次调用会追踪x 被当作张量处理 print(compute(tf.constant(3.0))) # 输出 6.0 print(compute(tf.constant(-2.0))) # 输出 2.0上面这段代码之所以能正确处理两种情况是因为 x 是张量tf.function 会把 if 转换成 tf.cond 操作。但如果 x 是一个 Python 的 float那么追踪时只会保留一条分支后续传入不同符号的值也不会改变图的结构。这个区别在实际项目中非常关键尤其是当你写复杂的训练循环时。2.3 什么时候该用 tf.function什么时候不该用我的经验是数据预处理和简单的推理逻辑用 Eager 模式就够了写起来快调试也方便。训练循环、自定义层的前向传播、损失函数这些会被反复调用的部分用 tf.function 包起来能明显提升速度。但不要一上来就给所有函数都加装饰器因为追踪本身也有开销而且一旦追踪出问题报错信息会比 Eager 模式下难懂得多。还有一个细节tf.function 对 Python 的副作用比如往列表里 append 元素是不友好的。因为在图模式下函数体只会被执行一次来生成图后续调用不会重新执行 Python 代码。所以如果你在 tf.function 里面修改了一个 Python 列表第二次调用时那个列表不会再次被修改。这种问题在调试时非常隐蔽因为第一次调用看起来一切正常。3. 数据管道tf.data 的正确打开方式3.1 为什么不能直接用 NumPy 数组喂数据很多人刚开始做项目的时候数据量不大直接把整个数据集加载成 NumPy 数组然后切片成 batch 喂给模型。这种做法在数据量小于内存的时候没问题但一旦数据量上来了比如几十 GB 的图片或者文本就完全行不通了。而且即使数据能放进内存这种方式的效率也不高因为数据加载和模型计算是串行的——GPU 在算的时候 CPU 在闲着CPU 在准备数据的时候 GPU 在闲着。tf.data 就是为了解决这个问题而设计的。它把数据加载、预处理、批处理、预取这些步骤组织成一个管道可以并行执行。你可以把它想象成一条流水线一边从磁盘读数据一边做增强一边往模型里送各个环节同时进行。3.2 构建高效管道的几个关键操作构建 tf.data 管道的基本流程是从数据源创建 Dataset 对象然后链式调用各种变换方法最后得到一个可以迭代的 Dataset。常用的变换包括 map、batch、shuffle、prefetch、cache。map 用来做逐样本的预处理比如图片解码、归一化、数据增强。这里有一个重要的参数 num_parallel_calls设置成 tf.data.AUTOTUNE 可以让 TensorFlow 自动决定用多少个线程来并行处理。如果不设置这个参数map 就是串行执行的速度会慢很多。batch 用来把多个样本合并成一个批次。注意 batch 的顺序会影响后续 shuffle 的效果。通常的做法是先 shuffle 再 batch这样每个 epoch 的批次组成都会不同。如果先 batch 再 shuffle那么批次内部的样本顺序是固定的只是批次之间的顺序被打乱了这通常不是我们想要的效果。shuffle 用来打乱数据顺序。它有一个 buffer_size 参数表示从数据集中随机抽取样本的范围。这个值越大打乱得越彻底但消耗的内存也越多。一个常见的经验法则是把 buffer_size 设置成数据集大小或者一个足够大的值比如 10000。如果 buffer_size 太小打乱效果会很差模型可能会因为看到的数据顺序有规律而学偏。prefetch 用来实现流水线并行。它会在模型计算当前批次的时候提前准备好下一个批次的数据。通常设置成 tf.data.AUTOTUNE 让 TensorFlow 自动调整。cache 用来把数据缓存到内存或者文件里。如果数据集能放进内存在 map 之后调用 cache 可以避免每个 epoch 都重新做预处理。但要注意如果 cache 放在 map 之前缓存的是原始数据放在 map 之后缓存的是预处理后的数据。后者通常更省时间但更占内存。import tensorflow as tf def preprocess(image, label): image tf.image.resize(image, [224, 224]) image image / 255.0 return image, label dataset tf.data.Dataset.from_tensor_slices((images, labels)) dataset dataset.map(preprocess, num_parallel_callstf.data.AUTOTUNE) dataset dataset.cache() dataset dataset.shuffle(buffer_size10000) dataset dataset.batch(32) dataset dataset.prefetch(tf.data.AUTOTUNE)上面这段代码的顺序是有讲究的。map 放在最前面因为预处理后的数据更小cache 起来更省内存。cache 放在 shuffle 之前这样每个 epoch 都是从缓存里拿数据再打乱速度很快。prefetch 放在最后确保模型在计算的时候下一个批次已经在准备了。3.3 数据管道里那些让人抓狂的坑第一个坑是 shuffle 和 repeat 的顺序。如果你先 repeat 再 shuffle那么每个 epoch 的数据顺序其实是固定的因为 repeat 会把数据集复制多份然后串起来shuffle 只是在每个 epoch 内部打乱。正确的做法是先 shuffle 再 repeat这样每个 epoch 都会重新打乱。第二个坑是验证集也用了 shuffle。训练集打乱是必要的但验证集和测试集通常不需要打乱因为你需要保证每次评估的结果是可复现的。如果验证集也 shuffle 了那么每次评估时模型看到的样本顺序都不同虽然最终指标应该差不多但调试时会让你怀疑人生。第三个坑是 map 函数里的 Python 副作用。比如你在 map 函数里用了一个全局计数器来记录处理了多少张图片在 Eager 模式下这个计数器会正常工作但一旦用 tf.function 包装或者开启图模式计数器就不会按预期更新了。正确的做法是用 tf.py_function 或者把计数逻辑放到管道外面。还有一个很隐蔽的问题当你的数据集很大时cache 到内存可能会导致 OOM。这时候可以考虑 cache 到文件用 cache 的 filename 参数指定一个路径。但要注意如果预处理逻辑变了旧的缓存文件不会自动失效需要手动删除或者换一个文件名。4. 模型构建从 Sequential 到自定义 Layer4.1 三种建模方式的适用场景TensorFlow 提供了三种主要的建模方式Sequential、Functional API 和 Subclassing。Sequential 最简单适合层与层之间是线性堆叠关系的模型。比如一个简单的全连接网络或者卷积网络用 Sequential 几行代码就能写完。Functional API 适合有分支、多输入多输出的模型。比如你要做一个模型同时接收图片和文本两个输入或者一个模型有两个输出头分别做分类和回归这时候 Sequential 就不够用了需要用 Functional API 来显式地定义每一层的输入输出关系。Subclassing 最灵活适合需要自定义前向传播逻辑的模型。比如你要实现一个带有动态路由的胶囊网络或者一个在 forward 里面根据输入形状动态调整结构的模型那就只能用 Subclassing。但 Subclassing 的代价是模型的结构不像前两种方式那样可以被自动推断保存和加载的时候需要额外注意。我的建议是能用 Sequential 就用 Sequential不够用了再上 Functional API只有在确实需要写复杂控制流的时候才用 Subclassing。因为前两种方式构建的模型有明确的计算图结构TensorFlow 可以自动做很多优化比如层融合、内存复用。Subclassing 的模型是一个黑盒很多优化做不了。4.2 自定义 Layer 时最容易忽略的细节写自定义 Layer 的时候build 方法和 call 方法的职责要分清楚。build 方法在第一次调用时执行用来创建权重变量。call 方法在每次前向传播时执行用来定义计算逻辑。很多人会把权重的创建放在 call 方法里这样每次调用都会创建新的变量导致内存泄漏和训练不稳定。class MyDense(tf.keras.layers.Layer): def __init__(self, units): super().__init__() self.units units def build(self, input_shape): self.w self.add_weight( shape(input_shape[-1], self.units), initializerglorot_uniform, trainableTrue, namekernel ) self.b self.add_weight( shape(self.units,), initializerzeros, trainableTrue, namebias ) def call(self, inputs): return tf.matmul(inputs, self.w) self.b上面这个例子展示了正确的写法。build 方法接收 input_shape 参数根据输入维度来创建权重。call 方法只做计算不创建变量。add_weight 方法会自动把变量注册到 Layer 的 trainable_variables 里这样优化器就能找到它们。还有一个细节是 name 参数。给权重起一个有意义的名字在调试和保存模型的时候会方便很多。如果不指定名字TensorFlow 会自动生成类似 variable、variable_1 这样的名字层数多了之后根本分不清哪个是哪个。4.3 模型保存与加载的版本兼容问题TensorFlow 的模型保存格式经历过几次变化。最早是 checkpoint 文件只保存权重加载的时候需要重新定义模型结构。后来有了 SavedModel 格式把模型结构和权重一起保存可以直接加载用于推理。Keras 还有自己的 H5 格式但在 TensorFlow 2.x 里已经不太推荐了。SavedModel 是目前最推荐的格式因为它跨平台、跨语言可以在 Python 里训练然后放到其他环境里做推理。保存的时候用 model.save(path)加载的时候用 tf.keras.models.load_model(path)。如果模型里有自定义的 Layer 或者 Loss加载的时候需要提供 custom_objects 参数否则会报错说找不到对应的类。我遇到过的一个坑是在 A 机器上保存的模型拿到 B 机器上加载时报错原因是两台机器的 TensorFlow 版本不一致。SavedModel 格式虽然设计上是向后兼容的但不同大版本之间还是可能有差异。所以部署的时候一定要确保训练和推理环境的 TensorFlow 版本一致或者至少是同一个大版本。5. 训练循环从 model.fit 到自定义 GradientTape5.1 model.fit 到底帮你做了哪些事model.fit 是 Keras 提供的高级训练接口你只需要指定训练数据、验证数据、epoch 数、batch size它就会自动完成前向传播、损失计算、反向传播、参数更新这一整套流程。对于标准的有监督学习任务model.fit 完全够用而且它内置了很多实用的功能比如进度条显示、自动保存最佳模型、学习率衰减、早停等。但 model.fit 也不是万能的。如果你的损失函数需要额外的输入比如对比学习里的正负样本对或者你的训练过程需要交替训练多个模型比如 GAN或者你需要在每个 batch 之后做一些自定义的操作比如梯度裁剪、梯度惩罚那么 model.fit 就不够灵活了需要自己写训练循环。5.2 自定义训练循环的骨架和关键细节自定义训练循环的核心是 tf.GradientTape。它负责记录前向传播过程中的所有操作然后通过 tape.gradient 计算梯度。基本流程是在 with tf.GradientTape() as tape 块里做前向传播和损失计算然后在块外面用 tape.gradient 求梯度最后用优化器的 apply_gradients 更新参数。optimizer tf.keras.optimizers.Adam(learning_rate1e-3) loss_fn tf.keras.losses.SparseCategoricalCrossentropy(from_logitsTrue) tf.function def train_step(x, y): with tf.GradientTape() as tape: logits model(x, trainingTrue) loss loss_fn(y, logits) gradients tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(gradients, model.trainable_variables)) return loss for epoch in range(epochs): for x_batch, y_batch in train_dataset: loss train_step(x_batch, y_batch) print(fEpoch {epoch}, Loss: {loss.numpy()})这段代码看起来简单但有几个地方容易出错。第一model(x, trainingTrue) 里的 training 参数很重要因为 Dropout 和 BatchNormalization 在训练和推理时的行为不同。如果不传这个参数模型可能不会正确地进行 Dropout。第二tape.gradient 返回的梯度列表可能包含 None如果某个变量没有参与损失计算的话。这时候 apply_gradients 会报错需要手动过滤掉 None。第三如果用了混合精度训练还需要对损失做缩放否则梯度可能会下溢。5.3 梯度裁剪和权重衰减的实操经验梯度裁剪是防止梯度爆炸的常用手段。在 TensorFlow 里可以用 optimizer 的 clipnorm 或 clipvalue 参数也可以在 apply_gradients 之前手动裁剪。clipnorm 是按梯度的全局范数裁剪clipvalue 是按每个元素的值裁剪。通常 clipnorm 更常用因为它保持了梯度的方向不变只是限制了大小。权重衰减和 L2 正则化在数学上不完全等价但在实践中效果差不多。TensorFlow 的优化器有 weight_decay 参数可以直接在优化器层面做权重衰减。但要注意weight_decay 通常不作用于偏置项和 BatchNormalization 的缩放参数所以如果你手动实现权重衰减需要把这些变量排除在外。我自己的习惯是在自定义训练循环里显式地做梯度裁剪因为这样更可控。比如gradients tape.gradient(loss, model.trainable_variables) gradients [tf.clip_by_norm(g, 1.0) if g is not None else g for g in gradients] optimizer.apply_gradients(zip(gradients, model.trainable_variables))这段代码把每个梯度张量的范数限制在 1.0 以内。如果某个梯度是 None就保持 None但 apply_gradients 遇到 None 会报错所以更稳妥的做法是过滤掉 None 对应的变量对。6. 调试与性能优化那些官方文档不会告诉你的技巧6.1 用 tf.debugging 定位 NaN 和 Inf训练过程中突然出现 loss 变成 NaN 是最让人头疼的问题之一。可能的原因有很多学习率太大、数据里有异常值、某个除法操作分母为零、梯度爆炸等。TensorFlow 提供了一组 tf.debugging 工具来帮助定位这类问题。tf.debugging.check_numerics 可以在张量出现 NaN 或 Inf 时抛出异常并告诉你是在哪个操作之后出现的。tf.debugging.assert_all_finite 可以检查张量是否全部有限。你可以在模型的关键位置插入这些检查比如在每个层的输出后面。但要注意这些检查会拖慢训练速度所以通常只在调试阶段开启。一个更轻量的做法是用 tf.summary 记录每个层的激活值分布然后在 TensorBoard 里观察。如果某一层的激活值突然变得很大或者很小那可能就是问题的源头。6.2 性能瓶颈的排查思路训练速度慢的时候首先要判断瓶颈在数据侧还是模型侧。一个简单的方法是看 GPU 利用率。如果 GPU 利用率很低比如低于 50%那很可能是数据管道跟不上GPU 在等数据。这时候可以优化 tf.data 管道增加 num_parallel_calls、prefetch 的 buffer size或者把预处理逻辑简化。如果 GPU 利用率很高但训练速度还是慢那可能是模型本身的计算量太大或者 batch size 太小导致 GPU 没有跑满。可以尝试增大 batch size或者用混合精度训练来加速。混合精度训练用 float16 做前向传播和反向传播用 float32 保存权重可以在几乎不损失精度的情况下把速度提升一倍左右。还有一个容易被忽略的点是 CPU 和 GPU 之间的数据传输。如果数据在 CPU 上预处理然后传到 GPU 上训练传输本身也会消耗时间。把预处理逻辑放到 GPU 上做比如用 tf.image 的操作可以减少传输开销。但要注意不是所有的操作都有 GPU 实现有些操作会自动回退到 CPU。6.3 显存不够用的几种解法显存不够是另一个常见问题。最直接的解法是减小 batch size但这可能会影响训练效果。更好的做法是用梯度累积用小的 batch size 做多次前向传播把梯度累加起来然后一次性更新参数。这样等效于用了更大的 batch size但显存占用不变。accum_steps 4 for i, (x_batch, y_batch) in enumerate(train_dataset): with tf.GradientTape() as tape: logits model(x_batch, trainingTrue) loss loss_fn(y_batch, logits) / accum_steps gradients tape.gradient(loss, model.trainable_variables) if accumulated_gradients is None: accumulated_gradients gradients else: accumulated_gradients [ag g for ag, g in zip(accumulated_gradients, gradients)] if (i 1) % accum_steps 0: optimizer.apply_gradients(zip(accumulated_gradients, model.trainable_variables)) accumulated_gradients None这段代码把损失除以累积步数这样累加后的梯度等效于大 batch 的梯度。注意在 apply_gradients 之后要把 accumulated_gradients 重置为 None否则下一轮会继续累加。另一个解法是用 tf.keras.mixed_precision 做混合精度训练显存占用可以减少将近一半。还可以用 gradient checkpointing用计算时间换显存空间但 TensorFlow 里没有官方实现需要自己写。7. 部署落地从 SavedModel 到实际服务7.1 SavedModel 的目录结构和加载方式SavedModel 是一个目录里面包含 saved_model.pb 文件和 variables 子目录。saved_model.pb 里保存了模型的计算图结构variables 里保存了权重的值。加载的时候用 tf.saved_model.load 或者 tf.keras.models.load_model前者返回的是一个可以调用的对象后者返回的是 Keras 模型。如果只是做推理用 tf.saved_model.load 更轻量因为它不需要重建 Keras 的层结构。但如果你需要继续训练或者访问模型的中间层那就需要用 tf.keras.models.load_model。一个常见的坑是签名signature的问题。SavedModel 可以包含多个签名每个签名定义了输入输出的名称和类型。用 tf.saved_model.load 加载后需要通过 signatures 属性来访问具体的签名。如果不指定签名默认会用 serving_default。7.2 推理性能的优化手段推理阶段的优化目标和训练阶段不同。训练阶段关注的是吞吐量推理阶段可能更关注延迟。如果是在服务器上做批量推理吞吐量更重要如果是在移动端或者浏览器里做实时推理延迟更关键。对于服务器端推理可以用 TensorFlow Serving 来部署 SavedModel。它支持模型版本管理、动态加载、批量推理等功能。对于移动端可以用 TensorFlow Lite 把模型转换成轻量级的格式体积更小推理更快。对于浏览器可以用 TensorFlow.js。模型量化是另一个常用的优化手段。把 float32 的权重转换成 int8模型体积可以减少四分之三推理速度也能提升。但量化会带来精度损失需要评估对业务指标的影响。TensorFlow 提供了训练后量化和量化感知训练两种方式后者在训练时就模拟量化的效果精度损失更小。7.3 版本管理和回滚策略在实际生产环境里模型不是部署一次就完事了。你需要考虑版本管理新模型上线后效果不好怎么办怎么快速回滚到旧版本TensorFlow Serving 支持同时加载多个版本的模型并通过 API 指定用哪个版本。这样你可以先让一小部分流量走新模型观察一段时间后再全量切换。保存模型的时候建议在路径里包含版本号或者时间戳比如 model/1/、model/2/。这样每个版本都是独立的目录回滚的时候只需要把流量切回旧版本的路径就行。同时建议保存一份模型的元数据记录训练数据的时间范围、超参数、评估指标等信息方便后续追溯。8. 我踩过的那些坑和最后的几句实话第一个坑是关于 tf.function 的。我曾经写了一个训练循环用 tf.function 包装了 train_step然后在循环里根据 loss 的值来决定要不要调整学习率。结果发现学习率从来没有调整过因为 loss 在 tf.function 里是一个张量Python 的 if 语句在追踪时只走了一条分支。后来改成用 tf.cond 或者把学习率调整逻辑放到 tf.function 外面才解决。第二个坑是关于 BatchNormalization 的。我在自定义训练循环里忘了传 trainingTrue导致 BatchNormalization 在训练时用的是推理模式均值和方差没有更新。模型训练了几十个 epoch 后 loss 一直不下降排查了很久才发现是这个原因。这个坑的隐蔽之处在于模型不会报错只是效果不好。第三个坑是关于数据管道的。我在 map 函数里用了一个 Python 的随机数生成器来做数据增强结果发现每个 epoch 增强出来的图片都是一样的。原因是 tf.data 在第一次遍历数据集时就把 map 函数追踪成了图后续的遍历不会重新执行 Python 代码。正确的做法是用 tf.random 的操作或者用 tf.py_function 包装。第四个坑是关于模型保存的。我用 model.save 保存了一个 Subclassing 的模型加载的时候报错说找不到自定义的 Layer。原因是 Subclassing 的模型结构不是显式定义的SavedModel 里只保存了计算图没有保存类的定义。加载时需要提供 custom_objects 参数把自定义的类传进去。这些坑的共同点是它们都不会导致程序崩溃只会让结果不符合预期。而排查这类问题往往比修复一个报错更耗时因为你不知道问题出在哪里只能一点点试。我的经验是遇到结果不符合预期时先检查数据管道再检查模型的 training 参数最后检查 tf.function 的控制流。这三个地方覆盖了大部分隐蔽问题。TensorFlow 这个框架确实有它的学习曲线但一旦你理解了它的设计逻辑——计算图、Eager Execution、tf.function 的追踪机制、数据管道的流水线模型——后面的事情就会顺很多。它不像 PyTorch 那样“所见即所得”但它在大规模部署和生产环境里的成熟度是实实在在的优势。选哪个框架取决于你的场景但如果选了 TensorFlow就花点时间把它的脾气摸清楚后面会省下很多时间。
阅读完成 · 觉得有帮助?