walk_these_ways项目学习记录第四篇(通过行为多样性 (MoB) 实现地形泛化)--配置文件阅读
配置系统精读:LeggedRobotConfig 与 Go2Config 如何定义任务世界
在这个项目里,训练一个 Go2 策略,并不是简单地“把 PPO 跑起来”。更准确地说,是先用一套层级化配置系统,把“机器人是谁、看见什么、做什么、踩在什么地上、会遇到哪些扰动、什么行为算好”全部定义清楚,然后训练脚本再按具体任务目标,对这些默认值进行二次覆盖。
如果把整个系统看成一条配置继承链,那么它大致是这样的:
legged_robot_config.py提供通用四足任务的基础默认值go2_config.py在此基础上,把机器人具体化为 Go2scripts/train.py再进一步把它改造成当前这篇任务对应的 gait-conditioned agility (基于步态条件的敏捷性)训练配置
这篇文章就沿着这条链,去看 observation、reward、normalization(归一化)、PD、terrain、domain randomization 的默认值到底从哪里来,以及训练脚本又改了什么。
一、配置系统的总体结构:先有基类默认值,再做任务覆盖
这个项目的配置不是散落在 JSON 里,而是直接写在 Python 类中。核心入口是 legged_robot_config.py 里的 Cfg:
class Cfg(PrefixProto, cli=False): #定义一个继承自 PrefixProto 的配置类 Cfg,并关闭命令行参数自动注册/解析
class env(PrefixProto, cli=False):
num_envs = 1024 # 并行环境数量,值越大采样越快,但显存和算力压力越大
num_observations = 235 # 策略输入观测维度
num_privileged_obs = 18 # 特权观测维度,通常只在训练时给 teacher/critic 使用
num_actions = 12 # 动作维度,对应 Go2 的 12 个主要关节
num_observation_history = 15 # 历史观测帧数,用于处理延迟和隐式系统辨识
这类写法的好处是:配置天然分层。env 负责观测和 episode 级行为,terrain 负责地形,commands 负责指令空间,control 负责控制器,domain_rand 负责扰动,rewards 和 reward_scales 决定优化目标。
然后在 go2_config.py 里,通过 config_go2(Cfg) 直接修改这些默认值:
def config_go2(Cnfg): #Cnfg只是这里的形参名
_ = Cnfg.init_state
_.pos = [0.0, 0.0, 0.34] # 机器人初始机身高度,决定初始站姿离地多高
最后,训练脚本再次覆盖:
config_go2(Cfg) # 先加载 Go2 专用默认配置
Cfg.control.control_type = "actuator_net" # 再针对当前任务,把底层控制从 PD 切到 actuator net
Cfg.env.num_privileged_obs = 2 # 当前任务只保留 2 维特权信息
Cfg.env.num_observation_history = 30 # 将历史观测长度扩展到 30 帧
所以,这个项目的配置优先级是:Cfg 基类默认值 < config_go2() 机器人专属覆盖 < scripts/train.py 任务专属覆盖。
二、env:定义策略“看见什么”和“以什么粒度交互”
env 这一层最重要的问题其实只有两个:
- 策略每一步能看到什么
- 每个 episode 怎么结束、要不要记录额外信息
在基础配置中:
class env(PrefixProto, cli=False):
num_envs = 1024
num_observations = 235
num_scalar_observations = 42
num_privileged_obs = 18
num_actions = 12
num_observation_history = 15
episode_length_s = 20 # 单回合最长持续 20 秒,控制训练样本的时间范围
observe_vel = True # 是否把机身线速度/角速度放进观测
observe_yaw = False # 是否显式观测朝向角
observe_command = True # 是否把速度/步态等命令拼进观测
observe_clock_inputs = False # 是否给策略周期相位信号,帮助形成节律性步态
observe_two_prev_actions = False # 是否把前两步动作也喂给网络,用于稳定控制
这里有一个很重要的设计思想:配置项不只是“开关功能”,更是在定义任务的可观测性。
例如:
observe_vel=True表示策略直接知道当前速度状态observe_clock_inputs=True表示策略还会知道“当前步态周期走到哪里了”num_observation_history > 1表示策略不再只依赖单帧,而是依赖时间序列
到了 Go2 配置里,这些值会先被收缩到一个更简单的默认任务:
_ = Cnfg.env
_.num_observations = 42 # Go2 默认任务的观测维度更小,说明默认观测更精简
_.observe_vel = False # 不直接给速度,迫使策略更多依赖其他状态量
_.num_envs = 4096 # 提高并行环境数,强调大规模采样训练
而在当前 scripts/train.py 里,又被改造成另一种任务风格:
Cfg.env.num_privileged_obs = 2 # 只保留 friction 和 restitution 两个特权量
Cfg.env.num_observation_history = 30 # 扩大历史窗口,用于隐式估计环境因子和补偿控制延迟
Cfg.env.observe_two_prev_actions = True # 把过去动作也输入策略,帮助推断系统动力学
Cfg.env.num_observations = 70 # 当前任务的单帧观测维度被扩展
Cfg.env.observe_gait_commands = True # 显式加入步态相关命令,如频率、相位、步宽等
Cfg.env.observe_clock_inputs = True # 给周期信号,帮助策略学出节律性接触模式
这意味着当前任务不是一个“最简单的速度跟踪”任务,而是一个带有步态条件输入、历史建模、特权适应模块的复杂 locomotion 任务。
三、commands:定义机器人“被要求做什么”
如果说 observation 决定策略看见什么,那么 commands 决定的就是策略被要求完成什么。
基础配置中的 commands 更像一个通用命令空间模板:
class commands(PrefixProto, cli=False):
num_commands = 3 # 默认只控制少量命令,例如 vx、vy、yaw rate
resampling_time = 10.0 # 命令每 10 秒重采样一次,避免策略只记住固定命令
heading_command = True # 用 heading 误差推导角速度命令
lin_vel_x = [-1.0, 1.0] # 前向速度命令范围,单位 m/s
lin_vel_y = [-1.0, 1.0] # 侧向速度命令范围
ang_vel_yaw = [-1, 1] # 偏航角速度范围,单位 rad/s
这些参数的物理意义非常直接:
lin_vel_x决定机器人前后移动速度需求lin_vel_y决定横移能力要求ang_vel_yaw决定转向强度要求resampling_time决定任务目标变化频率
Go2 配置对命令空间做了第一次具体化:
_ = Cnfg.commands
_.heading_command = False # 不再用 heading 误差间接控制,而是直接给 yaw rate
_.command_curriculum = True # 启用命令课程学习,让任务难度随训练动态展开
_.num_lin_vel_bins = 30 # 将命令空间离散成 bins,便于做分布式课程采样
_.num_ang_vel_bins = 30
_.lin_vel_x = [-0.6, 0.6] # 缩小速度范围,限制在更稳定的工作区间
_.lin_vel_y = [-0.6, 0.6]
_.ang_vel_yaw = [-1, 1]
而在 scripts/train.py 中,命令空间被大幅扩展成“步态条件控制”:
Cfg.commands.num_commands = 15 # 从简单速度命令扩展到更丰富的步态条件空间
Cfg.commands.lin_vel_x = [-1.0, 1.0] # 更大的前向速度变化范围
Cfg.commands.body_height_cmd = [-0.25, 0.15] # 允许策略调节目标机身高度
Cfg.commands.gait_frequency_cmd_range = [2.0, 4.0] # 步频范围,单位 Hz 左右
Cfg.commands.gait_phase_cmd_range = [0.0, 1.0] # 步态相位
Cfg.commands.gait_offset_cmd_range = [0.0, 1.0] # 对角腿或前后腿的相位偏移
Cfg.commands.footswing_height_range = [0.03, 0.35] # 抬脚高度范围,影响跨越能力
Cfg.commands.stance_width_range = [0.10, 0.45] # 站立宽度范围,影响横向稳定性
Cfg.commands.stance_length_range = [0.35, 0.45] # 前后落脚距离范围,影响步幅
这一步非常关键。它意味着当前训练目标不只是“跟踪速度”,而是在学一个条件步态生成器:给定速度、步频、相位、步宽、步长等条件,策略需要输出相应的动作。
四、control:默认控制器是谁,动作到底对应什么物理量
对四足机器人来说,动作并不直接等于电机扭矩。它要先经过一层控制器解释。
在基础配置里:
class control(PrefixProto, cli=False):
control_type = 'actuator_net' # 默认控制类型,可选 P 或 actuator_net
stiffness = {'joint_a': 10.0} # PD 中的 Kp,决定位置误差被“拉回去”的强度
damping = {'joint_a': 1.0} # PD 中的 Kd,决定速度阻尼大小,抑制振荡
action_scale = 0.5 # 策略输出动作会被缩放,再加到默认关节角上
hip_scale_reduction = 1.0 # 髋关节动作可额外缩放,防止摆幅过大
decimation = 4 # 每 1 次策略动作,对应 4 次底层物理控制步
这里最值得解释的是 stiffness 和 damping:
stiffness越大,关节越像一根“硬弹簧”,会更强烈地往目标位置拉damping越大,运动越有阻尼,能抑制抖动,但太大又会显得笨重
在 Go2 配置中,控制器先被设成普通 PD:
_ = Cnfg.control
_.control_type = 'P' # Go2 默认先用 PD 位置控制
_.stiffness = {'joint': 25.} # 提高 Kp,让关节更有“跟随目标角度”的刚性
_.damping = {'joint': 0.6} # 设置阻尼,避免高 Kp 带来的震荡
_.action_scale = 0.25 # 限制动作幅度,避免目标角跃迁过大
_.hip_scale_reduction = 0.5 # 髋关节额外减半,防止横摆过猛
但在当前训练脚本中,这个默认值被覆盖掉了:
Cfg.control.control_type = "actuator_net" # 用学习到的执行器网络代替简单 PD
Cfg.domain_rand.lag_timesteps = 6 # 加入 6 步动作延迟,模拟真实执行器和通信链路延迟
Cfg.domain_rand.randomize_lag_timesteps = True #randomize_lag_timesteps = True:启用“延迟机制”
这说明当前任务不再满足于“理想 PD 控制器 + 无延迟”的简化设定,而是试图更真实地建模执行器动力学和控制时滞。这也是为什么后面要把 num_observation_history 提高到 30:单帧观测已经不足以推断真实控制效果。
五、terrain:机器人走在什么样的世界上
terrain 配置回答的是另一个根本问题:机器人脚下到底是什么。
在基础配置里:
class terrain(PrefixProto, cli=False):
mesh_type = 'trimesh' # 地形表示方式:plane / heightfield / trimesh
horizontal_scale = 0.1 # 水平方向采样分辨率,越小地形越细
vertical_scale = 0.005 # 垂直方向高度分辨率
curriculum = True # 是否启用地形课程学习,逐步增加难度
static_friction = 1.0 # 静摩擦系数,决定接触后“是否容易滑”
dynamic_friction = 1.0 # 动摩擦系数,决定运动中滑动阻力
restitution = 0.0 # 恢复系数,越大碰撞越“弹”
measure_heights = True # 是否采样机器人周围地形高度作为观测
这里最核心的物理量是:
friction:决定脚掌和地面的附着能力restitution:决定碰撞是否弹跳horizontal_scale/vertical_scale:决定地形几何离散化的精细程度
Go2 配置把默认地形改得更简单:
_ = Cnfg.terrain
_.mesh_type = 'trimesh'
_.measure_heights = False # 默认 Go2 配置不把周围高度显式喂给观测
_.terrain_noise_magnitude = 0.0 # 不加随机粗糙度
_.teleport_robots = True # 防止机器人跑到地形边界之外
_.border_size = 50 # 给地形加很大边界
_.terrain_proportions = [0, 0, 0, 0, 0, 0, 0, 0, 1.0] # 只保留一种地形类型
_.curriculum = False # 关闭地形课程
而在 train.py 中,地形又被重新配置为当前任务所需的版本:
Cfg.terrain.mesh_type = "trimesh" # 继续使用三角网格地形
Cfg.terrain.num_cols = 30 # 地形列数,决定地形类型横向展开数量
Cfg.terrain.num_rows = 30 # 地形行数,决定难度层级纵向展开数量
Cfg.terrain.terrain_width = 1.0 # 单个子地形宽度
Cfg.terrain.terrain_length = 1.0 # 单个子地形长度
Cfg.terrain.teleport_robots = False # 不再使用边界传送,更强调真实局部运动
Cfg.terrain.center_robots = True # 初始化时把机器人更集中地放到地形中心
Cfg.terrain.horizontal_scale = 0.10 # 地形网格分辨率
这表明当前任务对地形的关注点不在“超大连续世界”,而在“局部、密集、可控的步态训练场”。
六、domain_rand:真实世界不会老老实实按标称参数运行
domain_rand 是强化学习从仿真走向现实最重要的配置之一。它的核心思想是:仿真环境不应该只有一个固定版本,而要在每个 episode 或每隔一段时间随机变化一些物理参数,让策略学会适应参数不确定性。
基础配置里已经给出了一整套默认项:
class domain_rand(PrefixProto, cli=False):
randomize_friction = True
friction_range = [0.5, 1.25] # 地面/接触面摩擦变化范围,模拟不同材质地面
randomize_restitution = False
restitution_range = [0, 1.0] # 恢复系数变化范围,模拟碰撞弹性差异
randomize_base_mass = False
added_mass_range = [-1., 1.] # 机身附加质量范围,模拟载荷变化
randomize_com_displacement = False
com_displacement_range = [-0.15, 0.15] # 质心偏移范围,模拟装配误差
randomize_motor_strength = False
motor_strength_range = [0.9, 1.1] # 电机强度变化范围,模拟执行器个体差异
randomize_gravity = False
gravity_range = [-1.0, 1.0] # 重力扰动范围,常用于增强姿态鲁棒性
push_robots = True
max_push_vel_xy = 1.0 # 随机外推扰动强度,模拟被撞/外力干扰
randomize_lag_timesteps = True
lag_timesteps = 6 # 控制延迟步数,模拟通信和执行器时滞
Go2 配置对这套默认值进行了第一次强化:
_ = Cnfg.domain_rand
_.randomize_base_mass = True
_.added_mass_range = [-1, 3] # 明显增大机身质量变化范围,逼策略适应载荷变化
_.push_robots = False # 默认 Go2 任务不加外推干扰
_.randomize_friction = True
_.friction_range = [0.05, 4.5] # 将摩擦范围拉得很宽,覆盖从打滑到高附着的地面
_.randomize_restitution = True
_.restitution_range = [0.0, 1.0] # 从近乎不弹到非常弹
_.randomize_com_displacement = True
_.randomize_motor_strength = True
_.rand_interval_s = 6 # 每 6 秒随机化一次部分参数
到了当前训练脚本,这套随机化又被做了一次更有针对性的裁剪:
Cfg.domain_rand.randomize_friction = True
Cfg.domain_rand.friction_range = [0.1, 3.0] # 缩小到更可训练但仍较宽的摩擦区间
Cfg.domain_rand.randomize_restitution = True
Cfg.domain_rand.restitution_range = [0.0, 0.4] # 恢复系数范围收窄,减小过强弹跳带来的训练不稳定
Cfg.domain_rand.randomize_base_mass = True
Cfg.domain_rand.added_mass_range = [-1.0, 3.0] # 继续保留载荷扰动
Cfg.domain_rand.randomize_gravity = True
Cfg.domain_rand.gravity_range = [-1.0, 1.0] # 引入重力扰动,提高姿态适应性
Cfg.domain_rand.randomize_motor_strength = True
Cfg.domain_rand.motor_strength_range = [0.9, 1.1]
Cfg.domain_rand.randomize_motor_offset = True
Cfg.domain_rand.motor_offset_range = [-0.02, 0.02] # 电机零偏扰动,模拟零点不准
Cfg.domain_rand.push_robots = False
Cfg.domain_rand.rand_interval_s = 4 # 更频繁地刷新随机化参数
这里能看出当前任务的设计哲学很明确:
- 保留对步态影响大的接触与执行器相关扰动
- 删掉或关闭一部分会明显增加学习难度、但短期收益不高的项
- 通过更短的随机化周期,增加训练中物理条件变化频率
七、rewards 与 reward_scales:策略到底在优化什么
强化学习里的 reward,从来不是一个标量那么简单。它本质上是一组行为偏好的组合。
基础配置把 reward 分成两层:
rewards:定义奖励公式里的超参数reward_scales:决定每一项奖励的权重
例如:
class rewards(PrefixProto, cli=False):
tracking_sigma = 0.25 # 速度跟踪误差的高斯宽度,越小越强调精确跟踪
soft_dof_pos_limit = 1.0 # 超出关节安全范围后开始惩罚
base_height_target = 1.0 # 目标机身高度
max_contact_force = 100.0 # 超过该接触力后会被惩罚
use_terminal_body_height = False # 是否因为机身太低而直接终止 episode
class reward_scales(ParamsProto, cli=False):
tracking_lin_vel = 1.0 # 奖励前向/侧向速度跟踪
tracking_ang_vel = 0.5 # 奖励角速度跟踪
lin_vel_z = -2.0 # 惩罚机身上下乱跳
ang_vel_xy = -0.05 # 惩罚 roll/pitch 方向角速度过大
torques = -0.00001 # 惩罚电机输出过大,抑制高能耗动作
action_rate = -0.01 # 惩罚动作变化太快,抑制抖动
collision = -1.0 # 惩罚不该发生的碰撞
feet_air_time = 1.0 # 奖励合理的腾空时间,鼓励形成节律性步态
Go2 配置对 reward 做了第一次“机器人特化”:
_ = Cnfg.rewards
_.soft_dof_pos_limit = 0.9 # 更早惩罚接近关节极限的姿态,保护实体机器人安全
_.base_height_target = 0.34 # 目标机身高度设为 Go2 的正常站姿高度
_ = Cnfg.reward_scales
_.torques = -0.0001 # 加重扭矩惩罚,约束能耗和动作激进程度
_.action_rate = -0.01 # 继续惩罚动作突变
_.dof_pos_limits = -10.0 # 强力惩罚关节接近极限
_.orientation = -5. # 惩罚机身姿态偏离
_.base_height = -30. # 强力约束机身高度
到了 train.py,奖励又被完全重写成当前任务真正关心的行为组合:
Cfg.reward_scales.feet_slip = -0.04 # 惩罚脚底打滑,鼓励稳定接触
Cfg.reward_scales.action_smoothness_1 = -0.1 # 惩罚动作一阶不平滑
Cfg.reward_scales.action_smoothness_2 = -0.1 # 惩罚动作二阶不平滑,进一步抑制抖动
Cfg.reward_scales.jump = 10.0 # 强奖励跳跃或弹跳相关行为
Cfg.reward_scales.raibert_heuristic = -10.0 # 惩罚偏离启发式落脚规律的行为
Cfg.reward_scales.feet_clearance_cmd_linear = -30.0 # 对抬脚高度与命令不一致进行强惩罚
Cfg.reward_scales.orientation_control = -5.0 # 惩罚姿态控制失稳
Cfg.reward_scales.tracking_contacts_shaped_force = 4.0 # 奖励接触力模式符合目标步态
Cfg.reward_scales.tracking_contacts_shaped_vel = 4.0 # 奖励足端速度接触模式符合目标步态
Cfg.reward_scales.collision = -5.0 # 加重碰撞惩罚
同时还修改了奖励聚合方式:
Cfg.rewards.reward_container_name = "CoRLRewards" # 使用指定奖励容器实现
Cfg.rewards.only_positive_rewards = False # 不再简单把总负奖励裁成 0
Cfg.rewards.only_positive_rewards_ji22_style = True # 使用另一种正奖励裁剪逻辑
Cfg.rewards.sigma_rew_neg = 0.02 # 控制负奖励部分的缩放强度
这说明当前任务已经不是“标准速度跟踪”,而是一个更复杂的步态模式塑形任务:希望脚的接触时序、足端速度、动作平滑性、抬脚高度等都符合预期。
在当前的强化学习环境配置下,总奖励的计算方式并并不是将所有正负奖励项线性相加后再简单裁剪为正,而是启用了 only_positive_rewards_ji22_style=True 这一特性。这意味着环境会对奖励项进行拆分处理与指数重构。
- 奖励拆分环境会先将各个加权后的奖励项拆分为两部分:
rposr_{pos}rpos:所有正向奖励项之和。
rnegr_{neg}rneg:所有负向惩罚项之和。- 总奖励计算公式在完成拆分后,系统用如下指数形式得到最终的总奖励 rrr:r=rpos⋅exp(rnegσneg)r = r_{pos} \cdot \exp\left(\frac{r_{neg}}{\sigma_{neg}}\right)r=rpos⋅exp(σnegrneg)
- 参数说明:
rposr_{pos}rpos:正奖励总和。
rnegr_{neg}rneg:负奖励总和(必定 ≤0\le 0≤0)。
σneg\sigma_{neg}σneg:控制惩罚项敏感度的参数。- 机制本质:从“线性扣分”到“指数衰减”理解这个公式的关键在于指数项 exp(rnegσneg)\exp\left(\frac{r_{neg}}{\sigma_{neg}}\right)exp(σnegrneg)。由于 rneg≤0r_{neg} \le 0rneg≤0,且 σneg\sigma_{neg}σneg 为正,使得该指数项的值域始终被限制在 (0,1](0,1](0,1] 内。因此,负奖励的作用不再是粗暴的线性扣分,而是变成了对正奖励进行指数衰减。 无论惩罚有多大,总奖励始终被保证为非负数,从而有效避免了智能体为了躲避扣分而产生的消极行为。
八、normalization:为什么特权量和物理量不能直接裸喂网络
很多人第一次看这个配置时,会忽略 normalization。但它实际上非常关键,因为神经网络对输入尺度非常敏感。
基础配置里:
class normalization(PrefixProto, cli=False):
clip_observations = 100.
clip_actions = 100.
friction_range = [0.05, 4.5] # 用于把摩擦系数映射到统一尺度
restitution_range = [0, 1.0] # 用于归一化恢复系数
added_mass_range = [-1., 3.] # 用于归一化附加载荷
motor_strength_range = [0.9, 1.1] # 用于归一化电机强度
gravity_range = [-1.0, 1.0] # 用于归一化额外重力扰动
这类配置的本质是:把不同量纲、不同物理范围的参数都映射到更统一的数值区间,避免某一维因为数值过大而主导训练。
在 train.py 中,这一层也被针对性修改了:
Cfg.normalization.friction_range = [0, 1] # 将摩擦特权量重新映射到更紧的归一化范围
Cfg.normalization.ground_friction_range = [0, 1] # 地面摩擦也统一归一化
Cfg.normalization.clip_actions = 10.0 # 动作裁剪范围改小,防止异常大动作破坏训练稳定性
这和前面的 num_privileged_obs = 2 是一套设计:既然当前只让自适应模块去预测 friction 和 restitution 一类核心环境因子,那么这些量的数值尺度就必须控制得更稳定。
九、从 config_go2() 到 train.py:一次完整的配置覆盖过程
如果要把整个系统压缩成一句最重要的话,那就是:
legged_robot_config.py 定义“通用四足世界”,go2_config.py 把它实例化为 Go2,train.py 则把 Go2 进一步改造成当前具体任务。
这个覆盖过程可以用下面这段代码理解:
from go2_gym.envs.base.legged_robot_config import Cfg
from go2_gym.envs.go2.go2_config import config_go2
config_go2(Cfg) # 第一步:加载 Go2 的默认机器人参数、地形、奖励、控制器
Cfg.control.control_type = "actuator_net" # 第二步:切换到更真实的执行器模型
Cfg.env.num_privileged_obs = 2 # 第三步:收缩特权信息维度
Cfg.env.num_observation_history = 30 # 第四步:扩大历史窗口做隐式适应
Cfg.commands.num_commands = 15 # 第五步:扩展命令空间为步态条件控制
Cfg.reward_scales.jump = 10.0 # 第六步:把奖励目标改造成跳跃/敏捷步态任务
这也是为什么看 train.py 时,不能把每一行赋值都当成“随手调参”。它其实是在重写任务定义本身。
十、结语:配置文件不是附属品,而是任务定义本体
很多工程里,配置文件只是附属参数;但在这个项目里,配置系统本身就是任务世界的定义语言。
env决定了策略看见什么commands决定了策略被要求做什么control决定了动作如何变成物理执行terrain决定了机器人脚下世界的几何形态domain_rand决定了仿真和现实之间的差距如何被建模rewards和reward_scales决定了系统偏好什么样的行为normalization决定了这些物理量以什么数值尺度进入网络
因此,读懂 LeggedRobotConfig 和 Go2Config,本质上就是读懂“这个任务世界是如何被发明出来的”。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)