1. 项目概述与核心价值
最近在机器人模仿学习圈子里,LeRobot这个开源框架的热度持续攀升,尤其是它针对SO-101这类桌面机械臂的友好支持,让很多研究者和开发者找到了低成本、高效率的入门路径。今天我想和大家深入聊聊的,就是如何利用LeRobot,从零开始为SO-101机器人训练一个ACT策略。这不仅仅是跑通一个Demo,更是理解如何将前沿的模仿学习算法落地到真实物理硬件上的完整过程。ACT,即Action Chunking with Transformers,是一种非常巧妙的策略架构,它通过预测未来一小段时间内的动作序列(动作块),来让机器人的运动更加平滑和连贯,这对于SO-101完成插拔、抓取等精细操作至关重要。
如果你手头有一台SO-101机器人,或者正在仿真环境中进行算法验证,希望通过模仿学习来教会它一些新技能,那么这篇文章就是为你准备的。我们将绕过那些过于理论化的部分,直接切入实操,从环境搭建、数据准备、模型训练到策略部署,一步步拆解。你会发现,即使没有庞大的计算集群,利用LeRobot清晰的模块化设计和SO-101良好的仿真支持,你也能在个人工作站上完成一个可用策略的训练。整个过程不仅会涉及代码和配置,更会分享我在调试中遇到的那些“坑”和解决技巧,这些是官方文档里不会写的实战经验。
2. 核心思路与方案选型解析
2.1 为什么选择ACT策略与LeRobot框架?
在开始动手之前,我们得先搞清楚两个核心选择:为什么是ACT策略?又为什么是LeRobot框架?这决定了我们整个项目的技术基调和实现路径。
首先看ACT策略。传统的机器人策略通常是“单步”预测,即根据当前观测(比如图像、关节角度),输出下一个瞬间的动作指令。这种方式在高速、动态环境中容易产生抖动和不连贯的运动。ACT策略的核心创新在于“动作分块预测”。简单来说,模型不再只预测下一个动作,而是像Transformer处理句子一样,一次性预测未来一段时间(例如0.5秒)内的一系列动作,构成一个“动作块”。这个块会被缓存在一个队列里,机器人按顺序执行。这样做有几个显著优势:第一,运动轨迹自然平滑,因为动作序列是在一个一致的上下文下规划出来的;第二,降低了实时推理的频率,减轻了计算压力;第三,对于带有延迟的系统更加鲁棒。对于SO-101这类执行精细操作的机器人,动作的平滑性和预见性至关重要,ACT的策略范式非常匹配。
再看LeRobot框架。它是一个由Meta AI开源、社区驱动的机器人学习库,其设计哲学就是“让机器人学习像训练CV/NLP模型一样简单”。它针对像SO-101这样的桌面机械臂提供了开箱即用的支持,包括:
- 标准化的数据加载与处理:它定义了一套清晰的数据格式,无论是你自行采集的真实数据还是仿真数据,都能方便地接入。
- 丰富的模型实现:ACT、扩散策略(Diffusion Policy)等主流模仿学习算法都已集成,无需自己从头实现复杂的网络结构。
- 便捷的训练与评估流水线:提供了训练脚本、日志记录、模型检查点保存和可视化工具,大大降低了工程复杂度。
- 仿真与实机部署桥梁:LeRobot与PyBullet、MuJoCo等仿真环境以及ROS等机器人中间件有较好的集成设计,方便从仿真验证过渡到真机测试。
选择LeRobot,意味着我们可以将精力集中在任务定义、数据质量和调参上,而不是重复造轮子。特别是对于SO-101,社区已经提供了相关的模型配置和示例,起步速度会快很多。
2.2 项目整体流程设计
基于以上选型,我们整个训练流程可以划分为四个清晰的阶段,形成一个从虚拟到现实、从数据到智能的闭环:
- 环境搭建与仿真启动:这是所有工作的基础。我们需要搭建一个能够模拟SO-101机器人及其工作环境的仿真世界。通常使用PyBullet,因为它免费、轻量且与LeRobot集成良好。这一步要确保机器人的URDF模型正确加载,关节控制接口畅通,并且能渲染出用于策略观测的图像。
- 示范数据采集与处理:模仿学习,数据为王。我们需要为SO-101录制演示数据。可以在仿真环境中用键盘、鼠标或者脚本“手把手”控制机器人完成目标任务(比如将积木从一个位置移动到另一个位置)。LeRobot会负责将这些操作记录成标准格式的数据集。
- ACT模型训练与调优:利用上一步得到的数据集,配置并启动ACT模型的训练。这个阶段的核心是理解模型的关键超参数(如动作块长度、预测时域、网络结构等),并通过训练损失和仿真测试来评估策略的学习效果。
- 策略评估与部署应用:训练好的模型需要在实际场景中验证。先在仿真环境中进行大量测试,评估其成功率、鲁棒性。如果效果满意,则可以进一步考虑部署到真实的SO-101硬件上运行,这其中会涉及相机标定、坐标变换、实时推理优化等一系列工程问题。
这个流程看似线性,实则充满迭代。往往需要在数据采集和模型训练之间来回几次,根据模型的表现来补充或修正演示数据。
3. 环境搭建与核心依赖配置
3.1 基础软件环境准备
工欲善其事,必先利其器。一个稳定、兼容的Python环境是后续所有工作的基石。我强烈建议使用Conda或Mamba来管理环境,以避免不同项目间的依赖冲突。
# 创建一个新的Python环境,建议使用Python 3.9或3.10,兼容性最好 conda create -n lerobot_so101 python=3.9 -y conda activate lerobot_so101 # 安装PyTorch。请务必根据你的CUDA版本(如果有GPU)去PyTorch官网获取正确的安装命令。 # 例如,对于CUDA 11.8: pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 # 安装LeRobot核心库 pip install lerobot # 安装仿真环境支持。PyBullet是首选。 pip install pybullet # 安装额外的工具库,用于数据可视化和处理 pip install matplotlib opencv-python ipywidgets注意:PyTorch的版本与CUDA驱动版本的匹配至关重要。如果版本不匹配,可能会导致无法使用GPU甚至运行时崩溃。使用
nvidia-smi查看CUDA版本,并严格对照PyTorch官网的安装指令。
3.2 SO-101机器人仿真环境搭建
LeRobot本身并不包含具体的机器人模型,但它提供了加载标准URDF文件的接口。SO-101(有时也被称为SO-Ar101或SO-arm101)的模型文件通常需要从相关资源库获取。
- 获取机器人模型:你可以从SO-101机器人的官方网站、GitHub仓库(例如
so-robotics/so-arm101)或LeRobot的示例资产中寻找其URDF文件(通常是一个.urdf或.xacro文件)。确保下载的模型包含准确的几何、惯性参数和关节限位。 - 搭建简易仿真场景:在PyBullet中,我们不仅需要加载机器人,还需要创建任务场景。例如,一个简单的拾放任务可能需要一张桌子、一个目标方块和一个初始位置的方块。
import pybullet as p import pybullet_data import time # 连接物理引擎 physicsClient = p.connect(p.GUI) # 使用p.DIRECT则不显示图形界面,用于后台训练 p.setAdditionalSearchPath(pybullet_data.getDataPath()) p.setGravity(0, 0, -9.8) # 加载地面 planeId = p.loadURDF("plane.urdf") # 加载SO-101机器人,假设URDF文件路径为 `./assets/so_arm101/urdf/so_arm101.urdf` robotStartPos = [0, 0, 0.5] robotStartOrientation = p.getQuaternionFromEuler([0, 0, 0]) robotId = p.loadURDF("./assets/so_arm101/urdf/so_arm101.urdf", robotStartPos, robotStartOrientation, useFixedBase=True) # 加载任务物体 - 一个红色方块作为操作目标 cubeStartPos = [0.3, 0, 0.55] cubeId = p.loadURDF("cube.urdf", cubeStartPos, globalScaling=0.05) p.changeVisualShape(cubeId, -1, rgbaColor=[1, 0, 0, 1]) # 改为红色 # 进入仿真循环 for i in range(10000): p.stepSimulation() time.sleep(1./240.)这段代码创建了一个包含地面、SO-101机器人和一个红色方块的仿真世界。p.GUI模式会打开可视化窗口,方便我们观察。在实际训练的数据采集阶段,我们可能更倾向于使用p.DIRECT模式以提升速度。
3.3 LeRobot数据存储结构初始化
LeRobot要求数据按照特定的目录结构进行组织。我们需要提前创建好这个结构,用于存放后续采集的演示数据。
# 在你的项目根目录下,创建如下结构 mkdir -p ~/lerobot_data/so101_pick_and_place cd ~/lerobot_data/so101_pick_and_place mkdir -p train val # 在每个文件夹内,LeRobot期望找到 `observation`, `action`, `episode_data` 等子文件夹或文件。LeRobot使用一种基于HDF5或视频帧的数据格式。一种更简单的方式是直接使用LeRobot提供的DatasetAPI来自动化处理数据存储,我们会在数据采集部分详细说明。
4. 示范数据采集与预处理实战
4.1 设计演示任务与采集脚本
数据质量直接决定模型上限。对于SO-101的拾放任务,我们需要设计清晰的任务流程,并编写脚本记录机器人的状态和我们的控制指令。
任务定义:机器人末端执行器从初始位置移动至红色方块上方,下移并闭合夹爪抓起方块,将方块抬起并移动至目标位置(例如桌面另一个标记点),最后放下方块并打开夹爪。
采集脚本关键组件:
- 观测(Observation):通常包括机器人本体的状态(关节角度、末端位姿)和外部感知(如固定视角的RGB图像、深度图像)。对于初学者,可以先用关节角度和末端位姿作为观测。
- 动作(Action):发送给机器人的控制指令。对于SO-101,通常是末端执行器的相对位置增量(delta x, y, z)和夹爪的开合指令。也可以直接使用关节位置或速度控制。
- 数据记录:使用LeRobot的
Episode和Dataset类来封装和保存数据。
import numpy as np from lerobot.common.datasets.lerobot_dataset import LeRobotDataset from lerobot.common.datasets.utils import save_episode # 初始化一个空的数据集结构 # 这里我们定义观测空间和动作空间 info = { "fps": 10, # 控制频率,与仿真步频可能不同 "observation": { "state": {"shape": (7,), "dtype": "float32"} # 例如:6个关节角+1个夹爪开合度 }, "action": {"shape": (4,), "dtype": "float32"} # 例如:dx, dy, dz, gripper_action } dataset = LeRobotDataset(info, root="~/lerobot_data/so101_pick_and_place/train") episode = [] # 在仿真循环中,每一帧(或每N个仿真步)执行以下操作: while task_not_finished: # 1. 获取当前观测 joint_states = p.getJointStates(robotId, range(6)) # 假设前6个是旋转关节 joint_positions = [state[0] for state in joint_states] gripper_open = get_gripper_state() # 自定义函数获取夹爪状态 obs_state = np.array(joint_positions + [gripper_open], dtype=np.float32) # 2. 通过键盘、鼠标或算法生成当前动作 (action) # 例如,根据目标计算末端执行器位置增量 target_pos = calculate_target() current_pos = get_ee_pos() delta_pos = target_pos - current_pos gripper_cmd = 1.0 if should_grasp else -1.0 action = np.concatenate([delta_pos, [gripper_cmd]]).astype(np.float32) # 3. 将动作应用到仿真中 apply_action(robotId, action) # 4. 记录数据点 frame = { "observation": {"state": obs_state}, "action": action, # 可以记录其他信息,如reward, done等,但模仿学习通常不需要 } episode.append(frame) p.stepSimulation() # 一个episode(一次任务尝试)结束后,保存数据 save_episode(dataset, episode, episode_index=0)这个脚本勾勒出了数据采集的核心循环。在实际操作中,calculate_target和apply_action函数需要根据你选择的控制接口(位置控制、速度控制、力控)来具体实现。一个关键技巧是:采集数据时,控制频率(fps)不宜过高。10-20Hz通常足够,这既能保证动作序列的平滑,又能减少数据冗余,加快后续训练速度。
4.2 数据质量检查与增强
采集完一批数据后(例如20-50次成功的任务演示),不要急于开始训练,先做数据质量检查。
- 可视化回放:编写一个脚本,读取保存的数据,并驱动仿真环境重新播放一遍机器人的动作。观察运动轨迹是否自然,任务是否被正确完成。这是发现采集脚本bug(如坐标轴错误、单位不一致)的最直接方法。
- 数据统计:检查动作值的分布范围是否合理,有无异常值(NaN或极大/极小值)。检查观测值是否在传感器合理量程内。
- 数据增强(可选但有效):对于图像观测,可以在训练时在线进行简单的增强,如随机裁剪、颜色抖动,以提升模型的泛化能力。对于状态观测,可以添加轻微的高斯噪声。LeRobot的数据加载器通常支持这些增强操作。
实操心得:在早期,我常常因为坐标系混乱(世界坐标系、机器人基座标系、末端坐标系混用)而导致采集的数据无效。我的建议是,在仿真环境中明确画出各个坐标系的原点和轴向(PyBullet可以用
p.addUserDebugLine),并在数据采集脚本的注释中清晰定义每个数据的坐标系。统一使用机器人基座标系或世界坐标系作为基准,会减少很多麻烦。
5. ACT模型训练详解与参数调优
5.1 LeRobot中ACT模型配置解析
LeRobot将模型配置、训练参数等都放在了统一的配置文件中(通常是YAML格式)。我们需要根据SO-101的任务特性来调整这些配置。
一个简化版的核心配置项解读:
# config.yaml model: name: "act" # 指定使用ACT模型 # 网络结构参数 hidden_dim: 512 # Transformer隐藏层维度 num_encoder_layers: 6 # Encoder层数 num_decoder_layers: 6 # Decoder层数 nheads: 8 # 注意力头数 # ACT特有参数 chunk_size: 50 # 动作块长度(预测未来多少步动作) action_horizon: 10 # 动作时域(每次执行多少步后重新预测) observation_adapter: # 观测适配器,将不同模态的观测编码为向量 state: type: "mlp" # 对状态观测使用多层感知机编码 input_dim: 7 # 输入维度,对应我们的7维状态 output_dim: 128 # 编码后维度 action_adapter: type: "mlp" input_dim: 4 # 动作维度 output_dim: 128 dataset: name: "so101_pick_and_place" # 自定义数据集名 root: "~/lerobot_data/so101_pick_and_place" split: "train" train: batch_size: 16 # 根据GPU内存调整 num_epochs: 200 learning_rate: 1e-4 lr_scheduler: "cosine" # 余弦退火学习率调度 save_every_n_epochs: 20 # 每20个epoch保存一次检查点 eval_every_n_epochs: 5 # 每5个epoch在验证集上评估一次关键参数深度解析:
chunk_size(动作块长度):这是ACT的灵魂参数。它定义了模型一次预测的未来动作序列长度。chunk_size * (1/fps)就等于模型“展望”的未来时间。例如,chunk_size=50,fps=10,则模型预测未来5秒的动作。设置太长:预测难度剧增,训练不稳定,且推理时延迟大。设置太短:动作块带来的平滑和预见性优势减弱。对于SO-101的拾放任务(全程可能3-10秒),设置为30-100是合理的起点,需要根据任务时长调整。action_horizon(动作时域):定义了每次从预测的动作块中取出多少步来执行。执行完这action_horizon步后,再根据新的观测,预测下一个动作块。它必须小于等于chunk_size。较小的action_horizon(如5-10)意味着更频繁的重规划,对动态环境适应性强,但计算开销大。较大的action_horizon则更流畅,但响应变慢。通常设置为chunk_size的1/5到1/2。hidden_dim,num_layers,nheads:这些是Transformer架构的标准参数,决定了模型的容量和表达能力。对于SO-101的7维状态+4维动作,任务相对简单,不需要过大的模型。hidden_dim=256或512,num_layers=4或6通常足够。模型越大,拟合能力越强,但也越容易过拟合,训练更慢。
5.2 启动训练与监控
配置好YAML文件后,可以使用LeRobot提供的训练脚本启动训练。
# 假设配置文件为 config.yaml python -m lerobot.scripts.train --config-path ./config.yaml训练开始后,监控以下指标至关重要:
- 训练损失(Train Loss):通常是预测动作与真实动作之间的均方误差(MSE)或平滑L1损失。观察其是否平稳下降,并在后期趋于收敛。如果损失剧烈震荡或迟迟不降,可能是学习率太高、批次大小不合适或数据有问题。
- 验证损失(Validation Loss):在未参与训练的数据上计算的损失。用于检测过拟合。理想情况是训练损失和验证损失同步下降,且最终差距不大。如果验证损失很早就停止下降甚至上升,而训练损失持续下降,就是过拟合的典型信号。
- 仿真测试成功率:这是最直接的性能指标。定期(如每10个epoch)在仿真环境中运行当前训练好的策略若干次(如20次),计算任务成功的比例。这个指标比损失函数更能反映策略的实际可用性。
LeRobot通常集成了TensorBoard或W&B等可视化工具。务必利用它们来绘制损失曲线和关键指标。
避坑技巧:训练初期,损失可能非常高。一个常见原因是动作值的量纲不统一。例如,末端位置增量(单位:米)的值范围在
[-0.01, 0.01]左右,而夹爪指令可能是[-1, 1]。这会导致损失函数被大数值的维度主导。务必对动作数据进行标准化(例如,减去均值,除以标准差),让每个维度的数据分布接近标准正态分布。这能极大提升训练的稳定性和速度。可以在数据加载阶段通过LeRobot的Normalizer组件自动完成。
5.3 模型调优策略
如果第一次训练结果不理想,可以按以下顺序进行排查和调优:
- 数据问题优先:模型学不好,十有八九是数据问题。回顾你的演示数据:演示是否足够多样?是否覆盖了任务可能遇到的各种初始状态?是否有错误的演示(失败案例)?尝试增加演示数据量(50-100条优质演示通常是一个好的起点),并确保数据质量。
- 调整模型容量:如果数据确信没问题,但模型在训练集上损失都降不下来(欠拟合),可以尝试增大模型(增加
hidden_dim或num_layers)。反之,如果训练损失很低但验证损失高、成功率低(过拟合),则尝试减小模型,或增加Dropout等正则化手段。 - 优化超参数:学习率是最敏感的超参数之一。尝试使用学习率预热(warmup)和余弦退火(cosine decay)策略。批次大小(
batch_size)也会影响训练,GPU内存允许的情况下,适当增大批次大小通常能使训练更稳定。 - 修改ACT核心参数:重新审视
chunk_size和action_horizon。对于一个快速抓取任务,也许未来1秒(chunk_size=20 @ 20Hz)的预测就足够了。尝试不同的组合,观察对任务成功率的影响。
6. 策略评估、部署与问题排查
6.1 仿真环境中的策略评估
训练完成后,我们得到一个模型检查点(.ckpt或.pth文件)。下一步是在仿真环境中系统性地评估它。
编写一个评估脚本,其核心逻辑如下:
# 加载训练好的模型 policy = load_policy_from_checkpoint("path/to/checkpoint.ckpt", config) policy.eval() # 设置为评估模式 num_eval_episodes = 50 success_count = 0 for ep in range(num_eval_episodes): obs = env.reset() # 重置仿真环境,随机化物体初始位置(如果做了随机化) done = False episode_reward = 0 while not done: # 模型预测动作。ACT模型需要维护一个动作队列。 with torch.no_grad(): action = policy(obs) # 执行动作 obs, reward, done, info = env.step(action) episode_reward += reward if info.get('is_success', False): success_count += 1 success_rate = success_count / num_eval_episodes print(f"评估成功率: {success_rate:.2%}")评估时,关键是要在与训练数据分布不同但合理的条件下测试。例如:
- 位置泛化:随机化方块和目标点的位置,而不是固定在训练时的位置。
- 姿态扰动:给机器人的初始关节角度添加微小噪声。
- 视觉变化:如果用了图像,可以改变灯光、背景纹理。
一个只在训练初始位置上成功的策略是没用的。良好的成功率(例如>85%)是部署到真机的前提。
6.2 从仿真到真实SO-101的部署考量
将仿真中训练的策略部署到真实的SO-101机器人,是“临门一脚”,也是挑战最大的一步。这里存在著名的“仿真到现实”(Sim2Real)鸿沟。
- 感知对齐:如果你的策略使用了图像观测,那么仿真中的虚拟相机和真实相机的内参、畸变、颜色、光照完全不同。解决方案包括:
- 域随机化:在仿真训练时,就随机化纹理、光照、相机参数等,让模型学会忽略这些无关特征。
- 域适应:使用少量真实数据对模型进行微调。
- 使用更鲁棒的观测:对于拾放任务,可以考虑使用机械臂的关节编码器结合外部运动捕捉系统(如Vicon)或RGB-D相机(如Intel Realsense)提供的精准3D位置信息作为观测,这比纯RGB图像更容易跨域。
- 动力学差异:仿真中的物理参数(质量、摩擦、电机响应)与真实世界不符。这会导致相同的动作指令产生不同的运动效果。
- 系统辨识:尝试测量真实机器人的动力学参数并更新到仿真模型中。
- 动作空间设计:使用阻抗控制或力/力矩控制作为底层,让策略输出期望的力而非直接的位置,这对接触任务(如抓取)更鲁棒。
- 在线自适应:在真实机器人上运行时,用一个快速的在线模型来实时修正动作。
- 实时性保证:真实系统对延迟敏感。确保你的推理循环(从获取观测、模型预测到发送指令)能在控制周期(例如100Hz)内完成。可能需要对模型进行优化(如使用TensorRT、ONNX Runtime加速,或模型剪枝、量化)。
一个务实的部署流程建议:
- 第一步:在仿真中,使用状态观测(关节角、末端位姿)训练一个高性能策略。
- 第二步:在真实机器人上,通过手眼标定等方法,获取目标物体在机器人基坐标系下的精确3D位置(作为额外的观测输入)。
- 第三步:将仿真训练的策略部署到真机,但将其输出的动作(末端位姿增量)作为参考信号,输入到一个高增益的PID位置控制器中。这样,策略负责高层规划,PID负责底层稳定跟踪,可以一定程度上抵抗动力学误差。
- 第四步:收集真实机器人执行时的数据(观测和成功/失败标签),用这些数据对策略进行少量迭代的微调(Fine-tuning)。
6.3 常见问题与排查技巧实录
在实际操作中,你一定会遇到各种各样的问题。下面是我总结的一些典型问题及其排查思路:
| 问题现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
| 训练损失为NaN或突然爆炸 | 1. 学习率过高。 2. 数据中存在异常值(Inf/NaN)。 3. 网络梯度爆炸。 | 1. 立即降低学习率(如从1e-4降到1e-5)。 2. 仔细检查数据预处理步骤,确保输入数据是归一化且有限的。 3. 使用梯度裁剪( torch.nn.utils.clip_grad_norm_)。 |
| 验证损失远高于训练损失,策略表现差 | 1. 严重过拟合。 2. 训练集和验证集分布不一致。 3. 验证集数据量太少或质量差。 | 1. 增加训练数据量,或使用数据增强、Dropout、权重衰减等正则化方法。 2. 检查数据划分逻辑,确保随机、均匀划分。 3. 增加验证集大小,并手动检查验证集数据是否正常。 |
| 策略在仿真中抖动严重 | 1.action_horizon设置过小,重规划太频繁。2. 模型预测的动作噪声大。 3. 底层控制器增益太高。 | 1. 适当增大action_horizon,让机器人更连贯地执行一个动作块。2. 在模型输出层后加入低通滤波器,平滑动作序列。 3. (仿真中)降低PyBullet中位置控制器的增益。 |
| 策略总是错过目标或抓取失败 | 1. 演示数据不足或质量不高,未覆盖足够的情况。 2. 观测空间信息不足(例如,缺少物体相对位置)。 3. 动作空间设计不合理(如移动速度太快)。 | 1. 针对性补充失败场景的演示数据(数据增强)。 2. 在观测中显式加入目标物体相对于末端的位置信息。 3. 对动作输出进行限幅,或让策略预测速度而非位置增量。 |
| 仿真运行正常,真机完全失败 | 1. Sim2Real鸿沟(感知、动力学)。 2. 坐标系未对齐。 3. 通信延迟或丢包。 | 1. 参见上一节部署考量,采用域随机化或状态观测。 2. 仔细校准机器人基坐标系、世界坐标系和相机坐标系之间的变换关系。 3. 检查通信链路,确保控制指令以稳定频率送达。 |
最后再分享一个小技巧:在训练ACT模型时,除了监控损失,一定要定期可视化预测的动作块。将模型预测的未来几十步动作在图中画出来,并与专家演示的真实动作块进行对比。这能给你非常直观的反馈:模型是学会了连贯的动作规划,还是只是在杂乱无章地预测?这个习惯能帮你提前发现很多模型学习行为上的问题,而不是等到评估阶段才看到失败的结果。