【Scala PyTorch深度学习】PyTorch On Scala 系列课程 第三章 07 :张量梯度【AI Infra 3.0】[PyTorch Scala硕士研一课程]

PyTorch Scala 高校计算机硕士研一课程
访问梯度(.grad)
在计算标量张量(通常是损失)相对于计算图中其他张量的梯度时,PyTorch 使用 backward() 方法。此方法触发梯度计算,但它不会直接返回梯度。相反,PyTorch 会将计算出的梯度存储在张量自身的一个特殊属性中:即 .grad 属性。
这个属性主要为计算图中的叶张量填充,即那些你通过设置 requires_grad=True 明确要求进行梯度跟踪的张量。请记住,叶张量通常是你直接创建的,例如模型参数或输入,而不是通过运算生成的中间张量。
.grad 属性包含一个与它所属的原始张量形状相同的张量。.grad 张量中的每个元素表示标量(调用 backward() 的对象)相对于原始张量中对应元素的偏导数。如果 LL 是标量损失,ww 是一个张量参数,那么在调用 L.backward() 之后,属性 w.grad 将包含表示 ∂L∂w∂w∂L 的张量。
我们通过一个简单例子来说明这一点:
import torch.*
// 创建需要梯度的输入张量
val x = torch.tensor(2.0, requires_grad=true)
val w = torch.tensor(3.0, requires_grad=true)
val b = torch.tensor(1.0, requires_grad=true)
// 定义一个简单的计算
val y = w * x + b // y = 3.0 * 2.0 + 1.0 = 7.0
// 计算梯度
y.backward()
// 访问存储在 .grad 属性中的梯度
println(f"y 对 x 的梯度 (dy/dx): {x.grad}")
println(f"y 对 w 的梯度 (dy/dw): {w.grad}")
println(f"y 对 b 的梯度 (dy/db): {b.grad}")
// 创建一个不需要梯度的张量
val z = torch.tensor(4.0, requires_grad=false)
println(f"张量 z 的梯度 (requires_grad=False): {z.grad}")
预期输出:
y 对 x 的梯度 (dy/dx): 3.0
y 对 w 的梯度 (dy/dw): 2.0
y 对 b 的梯度 (dy/db): 1.0
张量 z 的梯度 (requires_grad=False): None
在这个例子中:
- 我们定义了
x、w和b,并设置requires_grad=True,将它们标记为我们想要梯度的叶节点。 - 我们执行了操作 y=w∗x+by=w∗x+b。PyTorch 在后台构建了一个计算图。
- 我们调用了
y.backward()。Autograd 从y开始向后遍历图以计算梯度。- ∂y∂x=w=3.0∂x∂y=w=3.0
- ∂y∂w=x=2.0∂w∂y=x=2.0
- ∂y∂b=1=1.0∂b∂y=1=1.0
- 计算出的梯度存储在
x.grad、w.grad和b.grad中。访问这些属性会显示计算出的张量值。 - 张量
z是在requires_grad=False的情况下创建的,因此它未参与 Autograd 跟踪的梯度计算,其.grad属性保持为None。
需要记住的是,梯度默认是累积的。如果你在不清除梯度的情况下,对图中可能不同的部分(或相同的部分)多次调用 backward(),新计算的梯度将被添加到 .grad 属性中已经存在的值上。这种行为是故意的,对于像跨小批量进行梯度累积这样的情况很有用,但在典型的训练循环中,你需要在每个反向传播步骤之前明确地将梯度清零。这通常通过 optimizer.zero_grad() 完成,我们将在构建训练循环时更详细地讨论它。
目前,要点是,在 loss.backward() 之后,更新模型参数所需的梯度可以直接在这些参数张量的 .grad 属性中可用。
禁用梯度追踪
尽管Autograd自动追踪操作并计算梯度的能力对模型训练不可或缺,但在某些情况下,这种追踪是不必要甚至不希望的。具体来说,在模型评估(推理)期间,或者当你执行不应影响梯度计算的操作时,追踪历史会消耗内存和计算资源,而没有任何益处。PyTorch提供了选择性禁用梯度追踪的方法。
为什么要禁用梯度追踪?
- 模型评估(推理): 当你使用训练好的模型对新数据进行预测时,你不会更新其权重。在此阶段计算梯度没有意义。禁用梯度追踪可以显著减少内存使用(因为无需存储计算图),并加快前向传播的速度。
- 冻结模型参数: 在微调期间,你可能希望冻结预训练模型的一部分(例如,早期的卷积层),而只训练后面的层。你需要告知PyTorch不要为这些冻结的参数计算或存储梯度。
- 内存效率: Autograd存储的计算图会消耗大量内存,特别是对于复杂的模型或长序列。在不需要时关闭追踪可以避免这种额外开销。
使用 torch.no_grad() 上下文管理器
禁用代码块梯度追踪最常用且推荐的方式是使用 torch.no_grad() 上下文管理器。在此 with 块内执行的任何PyTorch操作都会表现得如同所有输入张量都不需要梯度,即使它们最初设置了 requires_grad=True。
import torch.*
// 示例张量
val x = torch.randn(2, 2, requires_grad=true)
val w = torch.randn(2, 2, requires_grad=true)
val b = torch.randn(2, 2, requires_grad=true)
// 在no_grad上下文之外的操作
val y = x * w + b
println(f"y.requires_grad: {y.requires_grad}") // 输出:y.requires_grad: True
println(f"y.grad_fn: {y.grad_fn}") // 输出:y.grad_fn: <AddBackward0 object at ...>
// 在no_grad上下文内执行操作
println("\n进入torch.no_grad()上下文:")
with torch.no_grad():
val z = x * w + b
println(f" z.requires_grad: {z.requires_grad}") // 输出:z.requires_grad: False
println(f" z.grad_fn: {z.grad_fn}") // 输出:z.grad_fn: None
// 即使输入需要梯度,输出也不会
val k = x * 5
println(f" k.requires_grad: {k.requires_grad}") // 输出:k.requires_grad: False
// 在上下文之外,如果输入需要梯度,追踪会恢复
println("\n退出torch.no_grad()上下文:")
val p = x * w
println(f"p.requires_grad: {p.requires_grad}") // 输出:p.requires_grad: True
println(f"p.grad_fn: {p.grad_fn}") // 输出:p.grad_fn: <MulBackward0 object at ...>
如示例所示,with torch.no_grad(): 块内的操作会产生 requires_grad=False 且没有关联 grad_fn 的输出(z、k),这表明它们已脱离计算图历史。这正是你在评估循环中想要的结果:
// 评估循环片段
model.eval() // 将模型设置为评估模式(对dropout、batchnorm等层很重要)
var total_loss = 0.0
var correct_predictions = 0
with torch.no_grad(): // 禁用评估期间的梯度计算
for inputs, labels in validation_dataloader:
val inputs = inputs.to(device) // 将数据移动到相应的设备
val labels = labels.to(device) // 将数据移动到相应的设备
val outputs = model(inputs) // 前向传播
val loss = criterion(outputs, labels) // 计算损失
total_loss += loss.item()
val predicted = torch.max(outputs.data, 1).values
correct_predictions += (predicted == labels).sum().item()
// 计算平均损失和准确率...
使用 .detach() 方法
另一种阻止特定张量进行梯度追踪的方法是使用 .detach() 方法。此方法会创建一个新张量,它与原始张量共享底层数据存储,但明确地脱离了当前计算图。它将拥有 requires_grad=False。
import torch.*
// 需要梯度的原始张量
val a = torch.randn(3, 3, requires_grad=true)
val b = a * 2
println(f"b.requires_grad: {b.requires_grad}") // 输出:b.requires_grad: True
println(f"b.grad_fn: {b.grad_fn}") // 输出:b.grad_fn: <MulBackward0 object at ...>
// 分离张量 'b'
val c = b.detach()
println(f"\n分离b以创建c后:")
println(f"c.requires_grad: {c.requires_grad}") // 输出:c.requires_grad: False
println(f"c.grad_fn: {c.grad_fn}") // 输出:c.grad_fn: None
// 重要的是,原始张量 'b' 未改变
println(f"\n原始张量 b 仍保持连接:")
println(f"b.requires_grad: {b.requires_grad}") // 输出:b.requires_grad: True
println(f"b.grad_fn: {b.grad_fn}") // 输出:b.grad_fn: <MulBackward0 object at ...>
// 使用分离张量 'c' 的操作将不会被追踪
val d = c + 1
println(f"\n在分离张量 c 上的操作:")
println(f"d.requires_grad: {d.requires_grad}") // 输出:d.requires_grad: False
何时使用 .detach() 与 torch.no_grad()?
- 当你希望执行一段操作而不追踪梯度时,使用
torch.no_grad(),这通常用于推理或评估代码段。为此目的,它通常更高效。 - 当你需要将特定张量从计算图中移除时,使用
.detach(),例如为了记录其值、在不应影响梯度的操作中使用它(如更新指标),或将其传递给期望非梯度追踪张量的函数,同时可能仍需要在其他地方使用原始张量的梯度历史。由于.detach()共享数据,原地修改分离的张量会影响原始张量,如果处理不当,这可能会对梯度计算产生影响。
原地修改 requires_grad
你也可以直接原地修改张量的 requires_grad 属性,但与上下文管理器或 .detach() 相比,这种临时禁用方法通常不太常见。它通常用于定义你明确不希望训练的参数。
// 初始张量
val my_tensor = torch.randn(5, requires_grad=true)
println(f"初始requires_grad: {my_tensor.requires_grad}") // 输出:初始requires_grad: True
// 原地禁用梯度追踪
my_tensor.requires_grad_(false) // 注意下划线表示原地操作
println(f"requires_grad_(false)后: {my_tensor.requires_grad}") // 输出:requires_grad_(false)后: False
使用 torch.no_grad() 是进行高效推理和评估的标准做法,而 .detach() 在你需要将特定张量从梯度历史中隔离时,提供更细致的控制。了解何时以及如何禁用梯度追踪对于编写高效且正确的PyTorch代码非常重要,特别是在你进一步学习基础训练循环之后。
梯度累积
对标量张量(如损失值)调用 .backward() 会触发计算图中所有 requires_grad=True 的张量的梯度。需要了解的一个主要特性是,PyTorch 默认会累积梯度。
默认梯度累积行为
如果您在多次调用 .backward() 之间不清除梯度,PyTorch 会将新计算的梯度添加到叶张量(参数)的.grad属性中已有的值上。
我们用一个简单例子来说明这一点:
import torch.*
// 创建一个需要梯度的张量
val x = torch.tensor([2.0], requires_grad=true)
// 执行一些操作
val y = x * x
val z = y * 3 // z = 3 * x^2
// 第一次反向传播
// dz/dx = 6*x = 6*2 = 12
z.backward(retain_graph=true) // retain_graph=true 允许后续的反向传播调用
println(f"After first backward pass, x.grad: {x.grad}")
# 执行另一个操作(可以相同也可以不同)
// 为简单起见,我们再次使用相同的 z 进行演示
// 注意:在实际应用中,您可能会基于新的输入或模型的不同部分计算新的损失。
z.backward() // 第二次反向传播
// 我们预期新梯度 (12) 会被加到已有的梯度 (12) 上
println(f"After second backward pass, x.grad: {x.grad}")
// 手动清零梯度
x.grad.zero_()
println(f"After zeroing, x.grad: {x.grad}")
运行此代码会产生类似于以下的输出:
After first backward pass, x.grad: tensor([12.])
After second backward pass, x.grad: tensor([24.])
After zeroing, x.grad: tensor([0.])
请注意,第二次调用 z.backward() 如何将新计算的梯度(12)加到之前存储的梯度(12)上,结果为 24。这种累积是刻意为之的,并且有重要的用途。
为何累积梯度?模拟更大批量
这种默认行为的主要原因是为了方便梯度累积。当训练大型模型需要大批量数据以实现稳定收敛,但现有 GPU 内存无法一次性容纳如此大的批量时,此方法就很有用。
与其处理一个大批量,不如这样操作:
- 将大批量分成若干个更小的迷你批量。
- 每次处理一个迷你批量:执行前向传播并计算损失。
- 对当前迷你批量的损失调用
.backward()。为此迷你批量计算的梯度将添加到模型参数的.grad属性中。 - 对大批量内的所有迷你批量重复步骤 2-3。
- 在处理完所有迷你批量并累积其梯度后,使用
optimizer.step()执行一次优化器更新步。此步骤使用所有迷你批量梯度的总和来更新模型权重,从而有效地模拟了更大批量的一次更新步。 - 非常重要的一点是,在开始处理下一个大批量(如果不是累积,则为下一个迷你批量)之前,使用
optimizer.zero_grad()清除梯度。
这使您可以使用模型所需的有效批量大小进行训练,即使它不能一次性完全载入内存,从而牺牲计算时间来提高内存效率。
标准训练中 optimizer.zero_grad() 的必要性
在标准的训练循环中,您在每次迭代中处理一个批量、计算损失、计算梯度并更新权重,您通常不希望来自前一个批量的梯度影响当前的更新步。每个批量的梯度计算应该是独立的。
由于 PyTorch 默认累积梯度,如果在计算新批量的梯度之前未能清除它们,将导致不正确的更新。优化器将使用新旧梯度的混合,从而损害训练过程。
这就是为什么在标准的 PyTorch 训练循环中,您几乎总能看到 optimizer.zero_grad() 被调用的原因。它将优化器管理的所有参数的 .grad 属性重置,以确保随后的 .backward() 调用完全基于当前批量的损失来计算梯度。
典型的训练迭代结构如下所示:
// 假设 model, dataloader, loss_fn, 和 optimizer 已定义
// 遍历 epoch...
// 遍历批量...
// 1. 获取数据批量
val data_batch = dataloader.next()
val inputs = data_batch._1
val labels = data_batch._2
inputs, labels = inputs.to(device), labels.to(device) // 将数据移动到适当的设备
// 2. 清零梯度
// 重要:在处理新批量之前清除之前的梯度
optimizer.zero_grad()
// 3. 前向传播:计算模型预测
val outputs = model(inputs)
// 4. 计算损失
val loss = loss_fn(outputs, labels)
// 5. 反向传播:计算梯度
loss.backward()
// 6. 优化器更新步:更新模型权重
optimizer.step()
// ... (记录日志、评估等)
optimizer.zero_grad() 的放置位置很重要。它应该在您计算当前迭代的损失并执行反向传播之前发生,确保当前批量的梯度计算有一个干净的开始。虽然它通常放在循环的开头,但技术上它只需要在 loss.backward() 之前发生。然而,将其放在开头是常见做法,并且能清楚地划分新批量处理的开始。
总而言之,梯度累积是 PyTorch 的一个内置功能,对于模拟更大的批量数据很有用。然而,在标准训练循环中,您必须通过在每次迭代开始时调用 optimizer.zero_grad() 来明确阻止这种累积,以确保模型更新仅基于当前批量的数据是正确的。
动手实践:Autograd 运用
实际例子演示 PyTorch Autograd 系统。这些练习会引导您设置梯度要求、执行反向传播、查看梯度、观察累积以及禁用梯度跟踪。请确保您已安装 PyTorch 并能导入 torch 库。
设置
首先,导入 PyTorch:
import torch
例子 1:基本梯度计算
我们从一个非常简单的计算开始,并跟踪梯度。我们将定义两个张量 x 和 w,其中 w 表示我们想要优化的权重。我们将计算一个简单的输出 y,然后计算一个标量损失 L。
-
创建张量:将
x定义为一个包含一些数据的张量,将w定义为一个需要计算其梯度的张量(使用requires_grad=True)。// 输入数据 val x = torch.tensor([2.0, 4.0, 6.0]) // 权重张量 - 需要计算梯度 val w = torch.tensor([0.5], requires_grad=true) println(f"x: {x}") println(f"w: {w}") println(f"x.requires_grad: {x.requires_grad}") println(f"w.requires_grad: {w.requires_grad}")请注意,
x默认情况下不需要梯度,而我们为w显式设置了它。 -
定义计算:执行一个简单的运算。任何通过涉及
requires_grad=True的张量运算而得到的张量,其requires_grad也会是True。// 前向传播:y = w * x val y = w * x // 定义一个简单的标量损失 L(例如,y 的均值) val L = y.mean() println(f"y: {y}") println(f"L: {L}") println(f"y.requires_grad: {y.requires_grad}") println(f"L.requires_grad: {L.requires_grad}")您会看到
y和L现在都需要梯度,因为它们依赖于w。 -
计算梯度:在最终的标量输出 (
L) 上使用.backward()方法来计算整个图中的梯度。// 执行反向传播 L.backward() -
查看梯度:查看张量
w的.grad属性。// 梯度存储在 w.grad 中 println(f"Gradient dL/dw: {w.grad}") // x 不需要梯度,因此它的梯度为 None println(f"Gradient dL/dx: {x.grad}")我们来分析
w.grad的结果。计算过程为: yi=w∗xiy**i=w∗x**i L=13∑yi=13(wx1+wx2+wx3)L=31∑y**i=31(w**x1+w**x2+w**x3) 梯度 ∂L∂w∂w∂L 为:∂L∂w=13(x1+x2+x3)∂w∂L=31(x1+x2+x3)
当 x=[2.0,4.0,6.0]x=[2.0,4.0,6.0] 时,梯度为 13(2.0+4.0+6.0)=12.03=4.031(2.0+4.0+6.0)=312.0=4.0。这与输出
tensor([4.])相符。因为x在创建时没有设置requires_grad=True,所以它的梯度未被计算,仍为None。
例子 2:梯度与计算图
Autograd 动态构建图。我们来看一个稍微复杂一点的例子。
-
创建张量:
// 输入数据 val a = torch.tensor(2.0, requires_grad=true) val b = torch.tensor(3.0, requires_grad=true) val c = torch.tensor(4.0, requires_grad=false) // 不需要梯度 println(f"a: {a}, requires_grad={a.requires_grad}") println(f"b: {b}, requires_grad={b.requires_grad}") println(f"c: {c}, requires_grad={c.requires_grad}") -
定义计算:
// 前向传播 val d = a * b val e = d + c val f = e * 2 println(f"d: {d}, requires_grad={d.requires_grad}") // True(依赖于 a, b) println(f"e: {e}, requires_grad={e.requires_grad}") // True(依赖于 d) println(f"f: {f}, requires_grad={f.requires_grad}") // True(依赖于 e) -
计算并查看梯度:
// 从最终的标量输出 f 进行反向传播 f.backward() // 检查梯度 println(f"Gradient df/da: {a.grad}") println(f"Gradient df/db: {b.grad}") println(f"Gradient df/dc: {c.grad}") // 预期结果:None我们手动计算一下: d=a×bd=a×b e=d+c=a×b+ce=d+c=a×b+c f=2×e=2(a×b+c)f=2×e=2(a×b+c)
∂f∂a=2×b=2×3.0=6.0∂a∂f=2×b=2×3.0=6.0 ∂f∂b=2×a=2×2.0=4.0∂b∂f=2×a=2×2.0=4.0 ∂f∂c=2∂c∂f=2
a和b的计算梯度是匹配的。由于c定义时requires_grad=False,Autograd 没有跟踪涉及c的操作来计算关于c本身的梯度,因此c.grad为None。
例子 3:梯度累积
默认情况下,每次调用 .backward() 时,梯度都会累积到 .grad 属性中。这对于计算多个损失的梯度或模拟更大的批次大小等情况很有用,但在标准训练循环中,需要显式地将梯度清零。
-
设置:我们再次使用一个简单的设置。
// 输入数据 val x = torch.tensor(5.0, requires_grad=true) val y = x * x println(f"Initial x.grad: {x.grad}") // 最初应为 None -
第一次反向传播:
// 对 y 执行反向传播。注意:backward() 通常需要一个标量。 // 如果在非标量张量上调用,需要提供梯度参数。 // 为了演示,我们计算 y 对 x 的梯度(即 2x)。 // 我们将使用 y.backward(gradient=torch.tensor(1.0)) 来实现。 // 通常,您会有一个从 y 导出的标量损失 L。 // 如果 y 是多元素,则 L = y.mean();如果 y 是标量,则 L = y。 y.backward(retain_graph=True) // 对于多次反向传播需要 retain_graph=True println(f"x.grad after 1st backward: {x.grad}") // 预期结果:2*x = 10.0 -
第二次反向传播(累积):再次调用
backward,不清零梯度。// 再次调用 backward y.backward(retain_graph=True) // 再次调用 backward println(f"x.grad after 2nd backward: {x.grad}") // 预期结果:10.0 + 10.0 = 20.0梯度被累积(相加)到之前的值上。
-
清零梯度:手动清零梯度。在典型的训练循环中,这通常通过
optimizer.zero_grad()完成。// 手动清零梯度 if x.grad is not None: x.grad.zero_() // 原位清零 println(f"x.grad after zeroing: {x.grad}") // 预期结果:0.0 -
第三次反向传播(清零后):
// 最后一次反向传播不需要 retain_graph y.backward() // 最后一次反向传播不需要 retain_graph println(f"x.grad after 3rd backward: {x.grad}") // 预期结果:10.0梯度清零后会重新计算。在训练循环中忘记清零梯度是常见的错误原因。
例子 4:禁用梯度跟踪
有时,您需要执行操作而不跟踪其梯度计算,最常见的情况是在模型评估(推理)期间或在优化步骤之外调整参数时。
-
使用
torch.no_grad():这个上下文管理器是禁用代码块梯度跟踪的标准方法。// 上下文管理器 torch.no_grad() val a = torch.tensor(2.0, requires_grad=true) println(f"Outside context: a.requires_grad = {a.requires_grad}") with torch.no_grad(): print(f"Inside context: a.requires_grad = {a.requires_grad}") # 仍然是 True b = a * 2 print(f"Inside context: b = {b}, b.requires_grad = {b.requires_grad}") # False! // 在上下文之外,如果输入需要梯度,计算会恢复跟踪 val c = a * 3 println(f"Outside context: c = {c}, c.requires_grad = {c.requires_grad}") // True在
torch.no_grad()块内部,尽管a需要梯度,但生成的张量b却不需要。这使得块内的操作更节省内存且更快,因为反向传播的历史不会被保存。 -
使用
.detach():这个方法会创建一个新张量,它共享相同的数据,但与计算历史分离。它不需要梯度。// 分离 a,创建一个不需要梯度的新张量 c val c = a.detach() println(f"a.requires_grad: {a.requires_grad}") // True println(f"c.requires_grad: {c.requires_grad}") // False // 分离 a,创建一个不需要梯度的新张量 c val d = a.detach() println(f"a.requires_grad: {a.requires_grad}") // True println(f"d.requires_grad: {d.requires_grad}") // False // 涉及 c 的操作不会跟踪回 a val e = c * 3 // d 不需要梯度 println(f"e.requires_grad: {e.requires_grad}") // False // 如果您对涉及 'b' 的计算执行反向传播, // 它会流回 'a'。如果您使用 'd',则不会。 val L1 = b.mean() // 依赖于 'a' L1.backward() println(f"Gradient dL1/da: {a.grad}") // 预期结果:2*a = 10.0 // 在下一次反向调用前清零梯度 if a.grad is not None: a.grad.zero_() # 尝试通过 'd' 进行反向传播 - 它不会影响 'a' 的梯度 try: # L2 = d.mean() # 最终需要一个需要梯度的计算 // 示例:再次使用 'a' 与分离后的结果 val L2 = (a + d).mean() // L2 = (a + a.detach()*3).mean() L2.backward() println(f"Gradient dL2/da: {a.grad}") // 只计算来自 'a' 路径的梯度 (1.0) catch RuntimeError as e: println(f"Error demonstrating backward with detached: {e}") // 如果最终的标量不依赖于 // 分离后任何需要梯度的输入,您可能会得到一个错误。 // 这里,L2 依赖于 'a',所以梯度是 1.0。 // 经由 'd' 的路径对 a.grad 没有贡献。 // 修改 c(分离的张量) - 它会影响 a,因为它们共享数据! with torch.no_grad(): c[0] = 100.0 // 原位修改 c(对标量使用索引) println(f"After modifying c, a = {a}") // 'a' 也改变了! println(f"After modifying c, c = {c}")detach()在您想在计算中使用张量的值但阻止梯度通过该特定路径回流时很有用,或者当您需要一个没有梯度历史的张量时(例如,用于绘图或日志记录)。请注意,它共享数据存储,因此原位修改会影响原始张量,除非您先.clone()它(c = a.detach().clone())。
这些练习展示了 Autograd 的核心机制。您已经练习了启用梯度跟踪、执行反向传播、查看计算出的梯度、理解累积以及在需要时禁用跟踪。掌握这些操作对在 PyTorch 中构建和训练神经网络来说非常重要。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)