【PPO系列3】PPO的真相:你以为的算法革命,其实是代码优化的胜利?
前言
还记得我们上次聊的PPO吗?那个被誉为"强化学习工业界标准"的算法,以其简单、稳定、高效的特点,几乎统治了从游戏AI到机器人控制的所有强化学习应用场景。
所有人都告诉你,PPO的成功归功于它那天才般的裁剪代理目标——用一个简单的min和clip函数,就完美解决了TRPO复杂的信任域约束问题。但今天,我要给你讲一个颠覆认知的故事:
PPO比TRPO好,根本不是因为裁剪机制!
2020年,MIT和Two Sigma的研究人员发表了一篇石破天惊的论文,他们通过严谨的实验发现:PPO相对于TRPO的性能优势,90%以上都来自于那些论文里一笔带过、甚至根本没提的"代码级优化"。而那个被吹上天的裁剪机制,其实可有可无。
这篇论文不仅揭开了PPO成功的真正秘密,更揭示了深度强化学习领域一个令人不安的真相:我们以为自己在做算法创新,其实很多时候只是在调代码的小细节。
论文信息
- 标题:Implementation Matters in Deep Policy Gradients: A Case Study on PPO and TRPO
- 会议:ICLR 2020
- 单位:麻省理工学院、Two Sigma
- 代码:github.com/MadryLab/implementation-matters
- 论文:https://arxiv.org/pdf/2005.12729.pdf
1 问题背景:强化学习的"复现危机"
在深度强化学习领域,有一个公开的秘密:论文里的结果很难复现。
你可能有过这样的经历:照着论文里的算法描述,一字不差地写了代码,调了无数次超参数,但性能就是达不到论文里的水平。甚至有时候,同一个算法,用不同的代码库实现,性能会差好几倍。
为什么会这样?
以前大家都觉得,是自己超参数调得不好,或者是框架的问题。但这篇论文的作者们提出了一个大胆的假设:真正决定性能的,不是论文里描述的核心算法,而是那些藏在代码里、被认为是"无关紧要"的小优化。
为了验证这个假设,他们选择了两个最具代表性的策略梯度算法:PPO和TRPO,进行了一场深入骨髓的解剖。
通俗解释:这就像两家餐厅做同一道菜——红烧肉。A餐厅的厨师说,他的秘诀是用了一种特殊的酱油;B餐厅的厨师不信,照着做了,结果味道差远了。后来才发现,A餐厅的厨师在炖肉的时候,偷偷加了冰糖、八角、桂皮,还控制了火候和时间,而这些他都没告诉别人。
2 什么是"代码级优化"?
作者们仔细对比了OpenAI baselines中PPO的实现和原始PPO论文的描述,发现了9个在论文中几乎没有提到,但对性能至关重要的代码级优化。
这些优化看起来都是些微不足道的小细节,但组合起来,却能让PPO的性能提升一倍以上。
2.1 价值函数裁剪
原始PPO论文中,价值网络的损失函数就是简单的均方误差:
LV=(Vθt−Vtarg)2L^V = (V_{\theta_t} - V_{targ})^2LV=(Vθt−Vtarg)2
公式符号全解释:
- LVL^VLV:价值网络的损失函数
- VθtV_{\theta_t}Vθt:当前价值网络对状态sts_tst的估值
- VtargV_{targ}Vtarg:状态sts_tst的目标回报值
但在实际实现中,PPO对价值网络也采用了类似策略网络的裁剪机制:
LV=max[(Vθt−Vtarg)2,(clip(Vθt,Vθt−1−ϵ,Vθt−1+ϵ)−Vtarg)2]L^V = max\left[ (V_{\theta_t} - V_{targ})^2, \left( clip(V_{\theta_t}, V_{\theta_{t-1}} - \epsilon, V_{\theta_{t-1}} + \epsilon) - V_{targ} \right)^2 \right]LV=max[(Vθt−Vtarg)2,(clip(Vθt,Vθt−1−ϵ,Vθt−1+ϵ)−Vtarg)2]
公式符号全解释:
- Vθt−1V_{\theta_{t-1}}Vθt−1:上一次迭代时价值网络对状态sts_tst的估值
- ϵ\epsilonϵ:裁剪参数,通常和策略裁剪的ϵ\epsilonϵ相同,取0.2
- maxmaxmax:取两个项的最大值
通俗解释:这就像给价值网络也加了一个"安全带",防止它在一次更新中变化太大。如果新的估值和旧的估值相差超过20%,我们就用裁剪后的值来计算损失。这样可以避免价值网络的剧烈波动,从而稳定整个训练过程。
2.2 奖励缩放
原始论文中,奖励是直接从环境中获取然后使用的。但在实际实现中,PPO会对奖励进行一种特殊的缩放:
- 维护一个滚动的折扣回报统计量
- 每次收到新的奖励rtr_trt,更新滚动统计量:Rt=γRt−1+rtR_t = \gamma R_{t-1} + r_tRt=γRt−1+rt
- 将奖励除以滚动统计量的标准差:rt′=rt/std(R)r_t' = r_t / std(R)rt′=rt/std(R)
通俗解释:不同的环境,奖励的尺度可能相差很大。比如在CartPole中,每步奖励是1;而在HalfCheetah中,每步奖励可能是几十甚至上百。奖励缩放可以自动将奖励调整到一个合适的范围,让学习率更容易选择。
2.3 正交初始化与层缩放
原始论文中,网络权重使用默认的Xavier初始化。但在实际实现中,PPO使用正交初始化,并且对最后一层的权重进行缩放。
正交初始化的好处是:
- 可以保持梯度的范数在反向传播过程中不变
- 避免梯度消失或爆炸
- 提高训练的稳定性
2.4 Adam学习率退火
原始论文中,Adam的学习率是固定的。但在实际实现中,PPO会随着训练的进行,线性地降低学习率。
通俗解释:这就像开车,刚开始的时候油门踩大一点,开得快;快到目的地的时候,油门踩小一点,慢慢停。学习率退火可以让算法在训练后期更精细地调整参数,避免在最优点附近震荡。
2.5 其他重要优化
除了上面四个,还有五个同样重要的优化:
- 奖励裁剪:将奖励限制在一个固定范围内,通常是[-10, 10],防止异常奖励值破坏训练
- 观测归一化:将观测值归一化为均值为0、方差为1的向量
- 观测裁剪:将归一化后的观测值限制在[-10, 10]范围内
- tanh激活函数:在所有隐藏层使用tanh激活函数,而不是ReLU
- 全局梯度裁剪:将所有参数的梯度的L2范数限制在0.5以内,防止梯度爆炸
有趣的案例:OpenAI在训练Dota2 AI OpenAI Five的时候,使用了超过100个这样的代码级优化。这些优化加起来,让OpenAI Five的性能提升了一个数量级。而这些优化,几乎没有一个在论文里被详细描述过。
3 消融实验:哪些优化最重要?
为了搞清楚这些优化各自的贡献,作者们做了一个全面的消融实验。他们测试了所有24=162^4=1624=16种前四个优化的组合,在Humanoid-v2和Walker2d-v2两个任务上进行了训练。
【图片1 代码级优化的消融实验结果,出处:论文原文图1】
结果分析:
- 奖励归一化是最重要的优化,没有它,算法几乎无法学习
- 正交初始化和学习率退火也有非常显著的影响
- 价值函数裁剪的影响相对较小,但仍然是正的
- 所有优化都打开的组合,取得了最好的性能
- 任何一个优化的缺失,都会导致性能的显著下降
这个实验清楚地表明:这些看似微不足道的代码级优化,才是PPO性能的真正来源。
4 惊天发现:代码优化 > 算法本身!
接下来,作者们做了一个最具颠覆性的实验:他们把PPO的所有代码级优化,原封不动地搬到了TRPO上,得到了一个新的算法——TRPO+。
然后,他们对比了四个算法的性能:
- PPO:标准的PPO实现,包含所有代码级优化
- PPO-M:最小化的PPO实现,只保留核心的裁剪机制,去掉所有代码级优化
- TRPO:标准的TRPO实现,没有任何代码级优化
- TRPO+:TRPO的核心算法,加上PPO的所有代码级优化
【表格1 算法对比概览,出处:论文原文表1】
| 算法 | 核心步骤 | 使用PPO裁剪? | 使用PPO优化? |
|---|---|---|---|
| PPO | PPO | 是 | 是 |
| PPO-M | PPO | 是 | 否 |
| TRPO | TRPO | 否 | 否 |
| TRPO+ | TRPO | 否 | 是 |
【表格2 四个算法在三个任务上的性能对比,出处:论文原文表2】
| 算法 | Walker2d-v2 | Hopper-v2 | Humanoid-v2 |
|---|---|---|---|
| PPO | 3292 [3157, 3426] | 2513 [2391, 2632] | 806 [785, 827] |
| PPO-M | 2735 [2602, 2866] | 2142 [2008, 2279] | 674 [656, 695] |
| TRPO | 2791 [2709, 2873] | 2043 [1948, 2136] | 586 [576, 596] |
| TRPO+ | 3050 [2976, 3126] | 2466 [2381, 2549] | 1030 [979, 1083] |
为了量化对比,作者们定义了两个指标:
- 平均算法改进(AAI):在固定优化的情况下,切换算法带来的最大性能提升
- 平均代码级改进(ACLI):在固定算法的情况下,加入代码优化带来的最大性能提升
AAI=max{∣PPO−TRPO+∣,∣PPO−M−TRPO∣}AAI = max\{ |PPO - TRPO+|, |PPO-M - TRPO| \}AAI=max{∣PPO−TRPO+∣,∣PPO−M−TRPO∣}
ACLI=max{∣PPO−PPO−M∣,∣TRPO+−TRPO∣}ACLI = max\{ |PPO - PPO-M|, |TRPO+ - TRPO| \}ACLI=max{∣PPO−PPO−M∣,∣TRPO+−TRPO∣}
结果分析:
- 在Walker2d-v2上,AAI=242,ACLI=557
- 在Hopper-v2上,AAI=421,ACLI=99
- 在Humanoid-v2上,AAI=224,ACLI=444
结论:在所有三个任务上,代码级改进都大于算法改进!
这意味着:你选择用PPO还是TRPO,对你的最终性能影响很小;而你是否使用了这些代码级优化,才是决定性能的关键。
更令人震惊的是,TRPO+的性能超过了标准的PPO!在Humanoid-v2任务上,TRPO+的得分是1030,而PPO只有806。这说明,如果给TRPO同样的优化,它的性能其实比PPO更好。
5 裁剪机制真的有用吗?
既然代码优化这么重要,那PPO最引以为傲的裁剪机制,到底还有没有用?
为了回答这个问题,作者们又做了一个实验:他们创建了一个PPO-NOCLIP算法,去掉了PPO的裁剪机制,但保留了所有其他代码级优化。
【表格3 PPO与PPO-NOCLIP的性能对比,出处:论文原文表3】
| 算法 | Walker2d-v2 | Hopper-v2 | Humanoid-v2 |
|---|---|---|---|
| PPO | 3292 [3157, 3426] | 2513 [2391, 2632] | 806 [785, 827] |
| PPO-NOCLIP | 2867 [2701, 3024] | 2371 [2316, 2424] | 831 [798, 869] |
结果分析:
- 在Walker2d-v2和Hopper-v2上,PPO-NOCLIP的性能略低于标准PPO
- 在Humanoid-v2上,PPO-NOCLIP的性能超过了标准PPO
- 总体来说,两者的性能差距非常小,远小于代码优化带来的差距
这说明:裁剪机制确实有一定的作用,但它的重要性远不如那些代码级优化。甚至在某些任务上,没有裁剪机制的PPO,性能反而更好。
通俗解释:这就像你买了一辆跑车,商家告诉你,这辆车跑得快是因为它有一个特别的尾翼。但后来你发现,把尾翼拆了,车还是跑得很快;而如果你把轮胎换成普通的轮胎,车就跑不动了。原来,真正决定速度的是轮胎,而不是尾翼。
6 代码优化如何改变算法行为?
这些代码级优化不仅影响了最终的性能,还从根本上改变了算法的行为。
PPO的裁剪机制本来是为了限制新旧策略的概率比在[1−ϵ,1+ϵ][1-\epsilon, 1+\epsilon][1−ϵ,1+ϵ]之间,从而保证策略更新不会太大。但作者们发现,在实际训练中,最大概率比经常远远超过这个范围。
【图片2 不同算法的信任域行为对比,出处:论文原文图2】
结果分析:
- TRPO精确地控制了KL散度在设定的阈值附近
- PPO和PPO-M的最大概率比都经常超过1+ϵ1+\epsilon1+ϵ,违反了裁剪的初衷
- 但令人惊讶的是,PPO的平均KL散度反而被控制得很好,甚至比TRPO更稳定
这说明:PPO的裁剪机制并没有真正起到限制概率比的作用。实际上,是学习率退火、梯度裁剪等其他优化,共同起到了控制策略更新幅度的作用。
7 核心代码:包含所有优化的PPO实现
下面是一个完整的、包含了论文中所有9个代码级优化的PPO实现。这个实现可以达到和OpenAI baselines相当的性能。
import gym
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
import numpy as np
from torch.distributions import Normal
# 超参数(完全按照论文中的设置)
GAMMA = 0.99
GAE_LAMBDA = 0.95
CLIP_EPS = 0.2
BATCH_SIZE = 64
UPDATE_EPOCHS = 10
HORIZON = 2048
LEARNING_RATE = 3e-4
ENTROPY_COEF = 0.0
VALUE_COEF = 0.5
MAX_GRAD_NORM = 0.5 # 全局梯度裁剪
REWARD_CLIP = (-10.0, 10.0) # 奖励裁剪
OBS_CLIP = (-10.0, 10.0) # 观测裁剪
# 设备配置
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# 正交初始化(代码级优化3)
def orthogonal_init(layer, gain=np.sqrt(2)):
nn.init.orthogonal_(layer.weight, gain=gain)
nn.init.constant_(layer.bias, 0)
return layer
# 策略-价值共享网络
class ActorCritic(nn.Module):
def __init__(self, state_dim, action_dim, action_std=0.5):
super(ActorCritic, self).__init__()
# 共享特征层(使用tanh激活函数,代码级优化8)
self.shared = nn.Sequential(
orthogonal_init(nn.Linear(state_dim, 64)),
nn.Tanh(),
orthogonal_init(nn.Linear(64, 64)),
nn.Tanh()
)
# 策略头(最后一层增益为0.01,层缩放)
self.actor_mean = orthogonal_init(nn.Linear(64, action_dim), gain=0.01)
self.actor_log_std = nn.Parameter(torch.zeros(1, action_dim))
# 价值头(最后一层增益为1.0)
self.critic = orthogonal_init(nn.Linear(64, 1), gain=1.0)
def forward(self, x):
x = self.shared(x)
mean = self.actor_mean(x)
log_std = self.actor_log_std.expand_as(mean)
std = torch.exp(log_std)
value = self.critic(x)
return mean, std, value
def get_action_and_value(self, x, action=None):
mean, std, value = self(x)
dist = Normal(mean, std)
if action is None:
action = dist.sample()
log_prob = dist.log_prob(action).sum(-1)
entropy = dist.entropy().sum(-1)
return action, log_prob, entropy, value
# 运行统计量,用于观测和奖励归一化
class RunningMeanStd:
def __init__(self, shape=()):
self.mean = np.zeros(shape, np.float32)
self.var = np.ones(shape, np.float32)
self.count = 1e-4
def update(self, x):
batch_mean = np.mean(x, axis=0)
batch_var = np.var(x, axis=0)
batch_count = x.shape[0]
delta = batch_mean - self.mean
total_count = self.count + batch_count
self.mean = self.mean + delta * batch_count / total_count
m_a = self.var * self.count
m_b = batch_var * batch_count
M2 = m_a + m_b + np.square(delta) * self.count * batch_count / total_count
self.var = M2 / total_count
self.count = total_count
# 计算广义优势估计(GAE)
def compute_gae_and_returns(rewards, values, dones, next_value, next_done):
advantages = np.zeros_like(rewards)
last_advantage = 0
for t in reversed(range(len(rewards))):
if t == len(rewards) - 1:
next_non_terminal = 1.0 - next_done
next_val = next_value
else:
next_non_terminal = 1.0 - dones[t+1]
next_val = values[t+1]
delta = rewards[t] + GAMMA * next_val * next_non_terminal - values[t]
last_advantage = delta + GAMMA * GAE_LAMBDA * next_non_terminal * last_advantage
advantages[t] = last_advantage
returns = advantages + values
return advantages, returns
# 主函数
def main():
env = gym.make("HalfCheetah-v4")
state_dim = env.observation_space.shape[0]
action_dim = env.action_space.shape[0]
model = ActorCritic(state_dim, action_dim).to(device)
optimizer = optim.Adam(model.parameters(), lr=LEARNING_RATE, eps=1e-5)
# 初始化运行统计量(代码级优化2和6)
obs_rms = RunningMeanStd(shape=state_dim)
ret_rms = RunningMeanStd(shape=())
# 初始化存储
states = []
actions = []
log_probs = []
rewards = []
dones = []
values = []
state, _ = env.reset()
done = False
episode_reward = 0
episode_count = 0
total_steps = 0
max_steps = 1000000
# 学习率退火(代码级优化4)
def update_learning_rate(step):
frac = 1.0 - step / max_steps
lr = frac * LEARNING_RATE
for param_group in optimizer.param_groups:
param_group['lr'] = lr
while total_steps < max_steps:
# 收集数据
for _ in range(HORIZON):
# 观测归一化和裁剪(代码级优化6和7)
state_norm = np.clip((state - obs_rms.mean) / np.sqrt(obs_rms.var + 1e-8), *OBS_CLIP)
state_tensor = torch.tensor(state_norm, dtype=torch.float32).unsqueeze(0).to(device)
with torch.no_grad():
action, log_prob, _, value = model.get_action_and_value(state_tensor)
next_state, reward, done, truncated, _ = env.step(action.cpu().numpy()[0])
done = done or truncated
# 奖励裁剪(代码级优化5)
reward = np.clip(reward, *REWARD_CLIP)
# 存储数据
states.append(state)
actions.append(action.cpu().numpy()[0])
log_probs.append(log_prob.cpu().numpy()[0])
rewards.append(reward)
dones.append(done)
values.append(value.cpu().numpy()[0])
episode_reward += reward
state = next_state
total_steps += 1
if done:
episode_count += 1
if episode_count % 10 == 0:
print(f"Episode {episode_count} | 总步数: {total_steps} | 平均奖励: {episode_reward:.1f}")
state, _ = env.reset()
episode_reward = 0
# 更新运行统计量
obs_rms.update(np.array(states))
# 奖励缩放(代码级优化2)
rewards_np = np.array(rewards)
for t in range(len(rewards_np)):
ret_rms.update(np.array([rewards_np[t] + GAMMA * (0 if dones[t] else ret_rms.mean)]))
rewards_scaled = rewards_np / np.sqrt(ret_rms.var + 1e-8)
# 计算最后一个状态的价值
next_state_norm = np.clip((next_state - obs_rms.mean) / np.sqrt(obs_rms.var + 1e-8), *OBS_CLIP)
next_state_tensor = torch.tensor(next_state_norm, dtype=torch.float32).unsqueeze(0).to(device)
with torch.no_grad():
_, _, _, next_value = model.get_action_and_value(next_state_tensor)
next_value = next_value.cpu().numpy()[0]
# 转换为numpy数组
states_np = np.array(states)
actions_np = np.array(actions)
log_probs_np = np.array(log_probs)
dones_np = np.array(dones)
values_np = np.array(values)
# 归一化观测
states_norm_np = np.clip((states_np - obs_rms.mean) / np.sqrt(obs_rms.var + 1e-8), *OBS_CLIP)
# 计算优势和回报
advantages_np, returns_np = compute_gae_and_returns(
rewards_scaled, values_np, dones_np, next_value, done
)
# 转换为张量
states_tensor = torch.tensor(states_norm_np, dtype=torch.float32).to(device)
actions_tensor = torch.tensor(actions_np, dtype=torch.float32).to(device)
old_log_probs_tensor = torch.tensor(log_probs_np, dtype=torch.float32).to(device)
advantages_tensor = torch.tensor(advantages_np, dtype=torch.float32).to(device)
returns_tensor = torch.tensor(returns_np, dtype=torch.float32).to(device)
old_values_tensor = torch.tensor(values_np, dtype=torch.float32).to(device)
# 标准化优势
advantages_tensor = (advantages_tensor - advantages_tensor.mean()) / (advantages_tensor.std() + 1e-8)
# 更新学习率
update_learning_rate(total_steps)
# 优化K个epoch
for epoch in range(UPDATE_EPOCHS):
# 随机打乱数据
indices = torch.randperm(HORIZON)
for start in range(0, HORIZON, BATCH_SIZE):
end = start + BATCH_SIZE
batch_indices = indices[start:end]
# 获取小批量数据
batch_states = states_tensor[batch_indices]
batch_actions = actions_tensor[batch_indices]
batch_old_log_probs = old_log_probs_tensor[batch_indices]
batch_advantages = advantages_tensor[batch_indices]
batch_returns = returns_tensor[batch_indices]
batch_old_values = old_values_tensor[batch_indices]
# 前向传播
_, new_log_probs, entropy, new_values = model.get_action_and_value(
batch_states, batch_actions
)
# 计算概率比
ratio = torch.exp(new_log_probs - batch_old_log_probs)
# 计算策略裁剪损失
surr1 = ratio * batch_advantages
surr2 = torch.clamp(ratio, 1 - CLIP_EPS, 1 + CLIP_EPS) * batch_advantages
policy_loss = -torch.min(surr1, surr2).mean()
# 计算价值裁剪损失(代码级优化1)
value_pred_clipped = batch_old_values + torch.clamp(
new_values.squeeze() - batch_old_values, -CLIP_EPS, CLIP_EPS
)
value_loss_unclipped = (new_values.squeeze() - batch_returns) ** 2
value_loss_clipped = (value_pred_clipped - batch_returns) ** 2
value_loss = 0.5 * torch.max(value_loss_unclipped, value_loss_clipped).mean()
# 计算熵损失
entropy_loss = entropy.mean()
# 总损失
loss = policy_loss + VALUE_COEF * value_loss - ENTROPY_COEF * entropy_loss
# 反向传播和全局梯度裁剪(代码级优化9)
optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), MAX_GRAD_NORM)
optimizer.step()
# 清空存储
states.clear()
actions.clear()
log_probs.clear()
rewards.clear()
dones.clear()
values.clear()
if __name__ == "__main__":
main()
代码亮点:
- 完整实现了论文中提到的所有9个代码级优化
- 使用了正交初始化和层缩放,提高了训练稳定性
- 实现了奖励和观测的归一化与裁剪
- 加入了学习率退火和全局梯度裁剪
- 实现了价值函数裁剪,这是很多PPO实现中漏掉的关键优化
8 总结与启示
这篇论文是近年来强化学习领域最重要的论文之一,它给我们带来了三个深刻的启示:
8.1 不要迷信"核心算法"
很多时候,我们以为算法的性能提升来自于某个天才的核心创新,但实际上,真正起决定作用的是那些看似微不足道的工程细节。在强化学习中,工程实现的重要性不亚于算法本身。
8.2 复现论文结果的正确姿势
如果你复现论文结果不好,不要先怀疑自己的算法理解。先检查一下:
- 你是否使用了和论文相同的网络结构和激活函数?
- 你是否使用了相同的权重初始化方法?
- 你是否对观测和奖励进行了归一化?
- 你是否使用了梯度裁剪?
- 你是否使用了学习率调度?
90%的复现问题,都出在这些细节上。
8.3 强化学习研究需要更严谨的实验
这篇论文也暴露了当前强化学习研究的一个严重问题:很多论文只强调核心算法的创新,而忽略了工程细节的影响。这导致很多研究结果无法复现,也阻碍了领域的进步。
未来的强化学习研究,应该更加注重实验的严谨性和可复现性。研究者应该公开完整的代码,详细描述所有的实现细节,并进行充分的消融实验,以证明核心算法的有效性。
9 写在最后
PPO的故事告诉我们:真正的大师,都注重细节。
在机器学习领域,我们总是热衷于追逐新的算法、新的模型,以为掌握了这些就能解决所有问题。但实际上,那些看似平凡的工程细节,才是区分高手和新手的真正标准。
下次当你看到一篇论文宣称自己的算法比SOTA提升了多少的时候,不妨多问一句:如果给SOTA同样的代码优化,它还能赢吗?
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)