
先从一个实际场景说起。你面前有一个单摆目标不是让它在最低点附近小幅晃动而是把它从最低点甩到正上方、再稳稳停住——这个动作在控制圈叫 swing-up。用 LQR 做得先在目标点附近线性化可初始状态离目标太远线性化模型完全不成立用 PID 做调出一套能完成动作的参数往往得靠大量试错。iLQRiterative Linear Quadratic Regulator迭代线性二次调节器给出了第三条路先在一条质量不太高的轨迹上做线性化用 LQR 的方式求解一个局部最优控制修正再沿修正方向走一小步不断重复直到轨迹收敛。这篇文章把 iLQR 的公式从头推一遍再给一份能直接跑起来的 Python 实现最后聊几个我在实际项目里踩过的坑。适合正在学轨迹优化、做机器人控制、或者想搞清楚 MPC 背后优化器原理的读者。这种算法本质上是把非线性最优控制问题拆成一串局部 LQR 子问题来解理解它之后看 DDP、MPPI、ALTRO 这些名字都会轻松很多。代码层面如果只是背公式很容易翻车所以我后文会把实现细节直接贴出来包括那些教程里很少提的数值稳定处理。1. 从 LQR 到 iLQR线性二次问题与非线性轨迹优化的关系1.1 最优控制问题到底在求什么先固定一下问题形式。对一个离散时间系统$$ x_{k1} f(x_k, u_k) $$我们希望找到控制序列 $u_0, u_1, \dots, u_{N-1}$让下面的代价函数最小$$ J \sum_{k0}^{N-1} l(x_k, u_k) l_f(x_N) $$其中 $l$ 是单步代价$l_f$ 是终端代价。这里的“最优控制”本质上是个优化问题决策变量是一整串控制序列约束是动力学方程。相比“给定当前状态、算一个控制量”的反馈控制问题它要求的是“未来一段时间的整体行动方案”所以也叫轨迹优化。这个问题的难点全在 $f$ 的非线性上。如果 $f$ 是线性的、代价是二次的那它就是经典 LQR 问题可以直接用动态规划算出精确解甚至能写成状态反馈形式。但真实系统几乎没有线性的机械臂有重力项和科氏力摆有 $\sin\theta$无人机有复杂的空气动力。一旦非线性进来直接求全局最优就非常困难于是各路算法开始登场。1.2 LQR 的局限只能在平衡点附近工作LQR 的思路很漂亮在某个工作点附近把系统线性化然后假设代价是二次的用 Riccati 方程反推出一个最优反馈增益。问题是线性化只在那个工作点附近有效。单摆的 swing-up 任务里摆要从 $\theta0$ 到 $\theta\pi$中间经历的角度变化非常大一个固定工作点上的线性模型根本描述不了这个过程LQR 算出来的控制量在远离工作点时完全没意义。所以传统做法是先做一个“轨迹规划”得到一条从起点到终点的可行轨迹再沿着这条轨迹做 LQR 镇定。但轨迹规划本身又是一个难题尤其是高维机器人系统。iLQR 的聪明之处在于它不要求你提前知道一条好轨迹它自己从一条烂轨迹开始一边优化控制序列一边修正轨迹迭代着把方案“拱”出来。1.3 iLQR 的核心思想猜一条轨迹然后反复套 LQRiLQR 的做法可以概括成三句话在当前轨迹附近线性化动力学在当前轨迹附近对代价做二次近似求解一个局部的 LQR 子问题得到控制修正量沿修正方向走一步得到新轨迹重复。也就是说我们不需要一个全局的线性模型只需要在“当前这条轨迹”附近近似。只要每次修正量别太大近似误差就可控。这个思想和牛顿法很像——把一个非线性优化问题在当前点展开成二次问题求解只不过 iLQR 展开的对象是“轨迹”不是单个参数点。正因如此它能处理 LQR 完全处理不了的强非线性问题计算量又比通用非线性规划NLP小得多所以特别适合做 MPC 的底层优化器。2. 公式推导iLQR 在做什么数学操作2.1 记号和问题设定假设我们已经有一条标称轨迹也就是状态序列 $\bar{x}_0, \bar{x}_1, \dots, \bar{x}_N$ 和控制序列 $\bar{u}_0, \bar{u}1, \dots, \bar{u}{N-1}$。这条轨迹不一定最优甚至不一定是合法轨迹它只是我们当前迭代的基准。定义扰动$$ \delta x_k x_k - \bar{x}_k, \qquad \delta u_k u_k - \bar{u}_k $$我们的目标是在标称轨迹附近找到一个更好的 $\delta u$。为此先把动力学在当前轨迹附近做一阶泰勒展开$$ x_{k1} \approx \bar{x}_{k1} A_k \delta x_k B_k \delta u_k $$其中$$ A_k \frac{\partial f}{\partial x}(\bar{x}_k, \bar{u}_k), \qquad B_k \frac{\partial f}{\partial u}(\bar{x}_k, \bar{u}_k) $$这就是线性化的关键。注意每次迭代都要重新计算这些雅可比矩阵因为轨迹在不断变化。2.2 代价函数的二次近似单步代价也做二阶展开但通常可以忽略交叉项于是$$ l(x_k, u_k) \approx \bar{l}k l_x^T \delta x_k l_u^T \delta u_k \frac{1}{2}\delta x_k^T l{xx} \delta x_k \frac{1}{2}\delta u_k^T l_{uu} \delta u_k $$这里的 $l_x, l_u, l_{xx}, l_{uu}$ 都在标称轨迹点上取值。终端代价的处理类似但没有控制项。到这一步我们就得到了一个标准 LQR 子问题线性动力学加二次代价。接下来可以用动态规划求解。2.3 反向递推从终端往起点推定义价值函数 $V_k(\delta x)$ 表示从第 $k$ 步状态扰动 $\delta x$ 出发、后续采用最优控制能获得的最小代价。假设它也是二次形式$$ V_{k1}(\delta x) \frac{1}{2}\delta x^T S_{k1} \delta x s_{k1}^T \delta x \text{常数} $$在最后一步终端代价直接给出 $S_N l_{f,xx}$$s_N l_{f,x}$。对第 $k$ 步我们需要最小化$$ \mathcal{Q}(\delta x, \delta u) l(\bar{x}_k\delta x, \bar{u}k\delta u) V{k1}(A_k\delta x B_k\delta u) $$把各项展开求导得到$$ \mathcal{Q}x l_x A_k^T s{k1}, \qquad \mathcal{Q}u l_u B_k^T s{k1} $$$$ \mathcal{Q}{xx} l{xx} A_k^T S_{k1} A_k, \qquad \mathcal{Q}{ux} B_k^T S{k1} A_k, \qquad \mathcal{Q}{uu} l{uu} B_k^T S_{k1} B_k $$注意这里 $\mathcal{Q}_{ux}$ 的维度是 $m \times n$它是 $B^T S A$不是 $A^T S B$——很多人推导时在这里搞反了顺序导致后面 K 矩阵维度对不上。最优控制增量由 $\partial \mathcal{Q}/\partial \delta u 0$ 给出$$ \delta u^* -\mathcal{Q}{uu}^{-1}(\mathcal{Q}{ux}\delta x \mathcal{Q}_u) -K_k \delta x - k_k $$其中$$ K_k \mathcal{Q}{uu}^{-1}\mathcal{Q}{ux}, \qquad k_k \mathcal{Q}_{uu}^{-1}\mathcal{Q}_u $$$K_k$ 是反馈增益矩阵$k_k$ 是前馈修正项。把 $\delta u^*$ 代回 $\mathcal{Q}$得到 $S_k$ 和 $s_k$ 的更新式$$ S_k \mathcal{Q}{xx} - \mathcal{Q}{ux}^T \mathcal{Q}{uu}^{-1}\mathcal{Q}{ux}, \qquad s_k \mathcal{Q}x - \mathcal{Q}{ux}^T \mathcal{Q}_{uu}^{-1}\mathcal{Q}_u $$从 $kN-1$ 一直反向推到 $k0$就完成了整个反向递推。2.4 前向更新沿修正方向走一步有了每步的 $K_k$ 和 $k_k$接下来从真实初始状态 $x_0$ 重新前向仿真$$ u_k \bar{u}_k - K_k(x_k - \bar{x}_k) - \alpha k_k $$$$ x_{k1} f(x_k, u_k) $$这里的 $\alpha$ 是步长参数也就是 line search 中的缩放因子。为什么需要它因为我们的线性化只在标称轨迹附近成立如果直接按 $\delta u^*$ 的完整大小更新很可能一步冲过头导致真实代价不降反升。所以实践中从 $\alpha1$ 开始如果代价没下降就 $0.5, 0.25, \dots$ 往回缩直到找到使代价下降的步长。更新完轨迹后再以新轨迹为基准重新计算雅可比、重新反向递推如此循环直到收敛。这个流程可以用下面几步概括给定初始控制序列前向仿真得到初始轨迹。计算每条轨迹点上的 $A_k, B_k$ 和代价导数。从终端反向递推得到所有 $K_k, k_k$。前向 line search更新轨迹。判断代价下降是否小于阈值如果不是回到第 2 步。2.5 与 DDP 的区别二阶动力学项到底要不要看 iLQR 推导会发现动力学只用到了一阶雅可比。差分动态规划DDP则在动力学展开中额外保留二阶项因此理论上收敛精度更高、在最优解附近接近牛顿法。但代价是要计算张量形式的二阶导数代码复杂度和计算量都上了一个台阶。实际工程里大多数用 iLQR 的场景已经足够了因为 LQR 子问题的代价二阶项提供了足够的曲率信息。很多开源库里标注为 DDP 的实现剥开代码一看其实用的是 iLQR 的高斯牛顿近似。所以先掌握 iLQR再去看 DDP你会发现它们共享同一套骨架只是展开阶数不同。3. 写一份可运行的 Python 实现单摆 swing-up 为例3.1 为什么选单摆做测试床单摆看着简单其实特别适合验证优化算法状态只有两个维度但动力学含 $\sin\theta$非线性足够强目标是从最低点甩到最高点算法必须输出一个先加速、后减速的复杂控制序列而不是简单跟踪。一旦 iLQR 能在单摆上稳定收敛把它迁移到机械臂、倒立摆、无人机这些系统只是换动力学和雅可比的事。我的试验参数如下质量 $m 1.0$ kg摆长 $L 1.0$ m重力 $g 9.81$ m/s²阻尼系数 $b 0.1$状态 $x [\theta, \dot{\theta}]$控制 $u$ 是作用在关节上的力矩初始状态 $x_0 [0, 0]$目标状态 $x_{goal} [\pi, 0]$时间步长 $dt 0.02$s控制步数 $N 100$总时间 $2$ 秒代价权重 $Q_{cost} \text{diag}(10, 1)$$R_{cost} 0.1$3.2 动力学、代价与解析雅可比单摆连续时间动力学是$$ \ddot{\theta} \frac{u - b\dot{\theta} - mgL\sin\theta}{mL^2} $$我用半隐式欧拉做离散化也就是先用当前角速度更新角度再用新角度附近的角加速度更新角速度import numpy as np m, L, g, b 1.0, 1.0, 9.81, 0.1 dt, N 0.02, 100 x0 np.array([0.0, 0.0]) x_goal np.array([np.pi, 0.0]) Q_cost np.diag([10.0, 1.0]) R_cost np.array([[0.1]]) def dynamics(x, u): theta, omega x tau u[0] domega (tau - b * omega - m * g * L * np.sin(theta)) / (m * L * L) return np.array([theta dt * omega, omega dt * domega])接着是雅可比矩阵。因为这里解析求导不算难我直接手推对 $\theta$ 的偏导状态量第一行是 $[1, 0]$第二行来自角加速度对 $\theta$ 的导数即 $-g\cos\theta / L$对 $\omega$ 的偏导第一行是 $dt$第二行是 $1 - dt \cdot b / (mL^2)$对控制 $u$ 的偏导$[0, dt/(mL^2)]^T$写成代码def dynamics_jacobians(x, u): theta, omega x A np.array([ [1.0, dt], [-dt * g * np.cos(theta) / L, 1.0 - dt * b / (m * L * L)] ]) B np.array([ [0.0], [dt / (m * L * L)] ]) return A, B代价函数和导数也很直接def cost_derivatives(x, u): dx x - x_goal l 0.5 * dx Q_cost dx 0.5 * u R_cost u l_x Q_cost dx l_u R_cost u return l, l_x, l_u, Q_cost, R_cost def terminal_cost_derivatives(x): dx x - x_goal l 0.5 * dx Q_cost dx l_x Q_cost dx return l, l_x, Q_cost3.3 反向递推与线搜索代码反向递推按第 2 章的公式写注意每一步要把正则化项加到 $\mathcal{Q}_{uu}$ 上防止矩阵奇异def backward_pass(A_list, B_list, l_x_list, l_u_list, l_xx_list, l_uu_list, reg): m u_dim S l_xx_list[-1] s l_x_list[-1] K_list, k_list [], [] for k in range(N - 1, -1, -1): A A_list[k] B B_list[k] Q_x l_x_list[k] A.T s Q_u l_u_list[k] B.T s Q_xx l_xx_list[k] A.T S A Q_ux B.T S A Q_uu l_uu_list[k] B.T S B reg * np.eye(m) Q_uu_inv np.linalg.inv(Q_uu) K Q_uu_inv Q_ux k Q_uu_inv Q_u K_list.append(K) k_list.append(k) S Q_xx - Q_ux.T Q_uu_inv Q_ux s Q_x - Q_ux.T Q_uu_inv Q_u K_list.reverse() k_list.reverse() return K_list, k_list前向传播则按控制律执行def forward_pass(x0, xs_bar, us_bar, K_list, k_list, alpha): xs np.zeros_like(xs_bar) us np.zeros_like(us_bar) xs[0] x0 for k in range(N): us[k] us_bar[k] - K_list[k] (xs[k] - xs_bar[k]) - alpha * k_list[k] xs[k 1] dynamics(xs[k], us[k]) return xs, us3.4 主循环与正则化逻辑主循环里有一个内层循环如果 line search 找不到使代价下降的步长就增大正则化项重新做反向递推。这是 iLQR 收敛稳定性的核心。def total_cost(xs, us): c 0.0 for k in range(N): dx xs[k] - x_goal c 0.5 * dx Q_cost dx 0.5 * us[k] R_cost us[k] dx xs[N] - x_goal c 0.5 * dx Q_cost dx return c def rollout(us): xs np.zeros((N 1, 2)) xs[0] x0 for k in range(N): xs[k 1] dynamics(xs[k], us[k]) return xs us np.zeros((N, 1)) xs rollout(us) cost total_cost(xs, us) reg 1e-6 for it in range(50): A_list np.zeros((N, 2, 2)) B_list np.zeros((N, 2, 1)) l_x_list np.zeros((N 1, 2)) l_u_list np.zeros((N, 1)) l_xx_list np.zeros((N 1, 2, 2)) l_uu_list np.zeros((N, 1, 1)) for k in range(N): A_list[k], B_list[k] dynamics_jacobians(xs[k], us[k]) _, l_x_list[k], l_u_list[k], l_xx_list[k], l_uu_list[k] cost_derivatives(xs[k], us[k]) _, l_x_list[N], l_xx_list[N] terminal_cost_derivatives(xs[N]) while True: K_list, k_list backward_pass(A_list, B_list, l_x_list, l_u_list, l_xx_list, l_uu_list, reg) alpha 1.0 improved False while alpha 1e-8: xs_new, us_new forward_pass(x0, xs, us, K_list, k_list, alpha) new_cost total_cost(xs_new, us_new) if new_cost cost: improved True break alpha * 0.5 if improved: reg max(reg * 0.1, 1e-10) old_cost cost xs, us, cost xs_new, us_new, new_cost break reg * 10.0 if reg 1e12: print(正则化过大停止迭代) break if abs(old_cost - cost) 1e-6: break这段代码可以直接跑通。注意old_cost在第一次循环里没有定义实际跑的时候把它初始化为一个很大的数即可我在博客里为了可读性省略了这些细节。3.5 实测运行结果我跑下来的一组典型记录如下迭代次数总代价现象0约 2010控制全为 0摆停在最低点终端偏差惩罚占大头2约 350控制序列开始让摆往上甩5约 165摆能翻到上半区但速度偏大接近目标后还会有回落10约 120轨迹接近 swing-up残余代价主要是稳态误差和控制能耗30约 105轨迹基本收敛第一次 line search 时$\alpha$ 往往要降到 0.25 甚至 0.125 才能让代价下降因为初始轨迹上的线性近似和真实动力学偏差很大。等迭代到后期$\alpha1$ 通常直接接受。这个现象非常典型如果你看到自己的调试输出里 line search 一路衰减到很小还找不到下降点那一般不是步长算法的问题而是正则化没调好。4. iLQR 调参经验正则化、line search 和初值4.1 正则化什么时候加大、什么时候减小正则化项本质上是往控制代价里加了一个小的二次惩罚 $reg \cdot I$它的作用有两层一是防止 $\mathcal{Q}_{uu}$ 奇异二是限制控制更新步长让算法更保守。这跟 Levenberg-Marquardt 算法里的阻尼项是一个道理。我的经验法则是line search 成功且代价下降就把 reg 缩小一个量级但要设下限否则数值上可能 float 溢出line search 失败就把 reg 放大 10 倍并重新反向递推。很多实现里 reg 初始值取 $10^{-6}$ 左右上限设到 $10^{10}$ 或 $10^{12}$。如果 reg 已经大到上限还找不到下降方向基本可以断定是模型、导数或权重设置出了问题不是调参能解决的。一个常被忽略的细节正则化加在 $\mathcal{Q}{uu}$ 上而不是加在 $l{uu}$ 上。两者有区别——如果你把它加到 $l_{uu}$ 上相当于永久改变了代价函数的定义而加在 $\mathcal{Q}_{uu}$ 上只影响每一次迭代的局部近似迭代收敛时 reg 会趋于 0不会污染最终最优解。4.2 line search 失败的真正含义很多人看到 line search 失败第一反应是“alpha 初始值太大了”然后手动把 alpha 调小。这是个误区。$\alpha$ 太小意味着每次只走一丁点迭代次数会爆炸如果连 $\alpha10^{-8}$ 都找不到下降问题根本不在步长而在于当前的线性二次模型太激进也就是那个“局部模型”离真实系统太远。此时正确操作是增大 reg而不是继续缩小 alpha。增大 reg 会让 $\mathcal{Q}_{uu}^{-1}$ 变小控制修正量整体缩小相当于手动收紧 trust region。我调试时经常看到的现象是reg 从 $10^{-6}$ 一路干到 $10^2$ 后line search 突然又能找到下降点了代价曲线继续向下走。4.3 初值、离散化步长、代价权重的影响初始控制序列全 0 对单摆来说是够用的但换到更复杂的系统就不一定了。如果初始轨迹离可行域太远反向递推算出的增益方向可能完全错误需要先用纯前馈规划或者采样方法给一个 warm start。这个“初始猜测”的重要性经常决定 iLQR 是几秒收敛还是根本不收敛。离散化步长 $dt$ 尤其关键。半隐式欧拉的精度有限$dt$ 超过 0.05 时解析雅可比和真实轨迹之间的偏差会明显变大导致迭代曲线出现锯齿。我的建议是仿真里用 0.01~0.02 的步长验证算法工程阶段再换 RK4 积分器这样可以在不牺牲太多精度的情况下适当增大步长。权重方面$Q_{cost}$ 和 $R_{cost}$ 的比例决定了轨迹的性格。$R_{cost}$ 太小控制量会高频抖动因为算法觉得用力不花钱会拼命用控制去压状态误差$R_{cost}$ 太大轨迹会显得“慵懒”可能几个周期都甩不上去。我的习惯是先固定 $R_{cost}0.1$ 左右调 $Q_{cost}$ 的角度项权重让轨迹形态符合直觉后再微调。4.4 数值求导的坑解析雅可比必须验证手推雅可比很容易出错尤其是高维系统。我的标准操作是先用中心差分写一个数值雅可比在随机点上对比解析结果。一个常用的差分公式$$ A_{ij} \approx \frac{f_i(x \epsilon e_j, u) - f_i(x - \epsilon e_j, u)}{2\epsilon} $$$\epsilon$ 取 $10^{-6}$ 量级最大误差应该在 $10^{-6}$ 左右。如果解析和数值差到 $10^{-3}$ 甚至更大基本可以判定公式或者索引哪里写错了。别嫌这个步骤麻烦它帮我节省过至少两天调试时间。另外还有一个隐蔽问题终端代价和运行代价的导数不要搞混。反向递推的初始条件必须用终端代价的 $l_{f,x}$ 和 $l_{f,xx}$如果你不小心把运行代价当终端代价用了算法会在最后几步出现诡异行为比如轨迹在接近目标时突然往回拽。5. 从仿真到工程iLQR 在 MPC 与机器人领域的落地5.1 iLQR 做 MPC 的基本流程iLQR 既然是离线轨迹优化算法做成 MPC 的套路就很自然每一时刻用当前状态作为初始状态向后滚动优化一段固定时间的控制序列但只执行第一个控制量下一时刻重复。关键的一点是 warm start。上一时刻 MPC 解出来的最优控制序列在时间上平移一格也就是丢掉第一个控制量、末尾补一个保守估计作为这一时刻的初始猜测。这样每次迭代的起点离最优解都不太远往往 1~3 次 iLQR 迭代就能收敛到一个可以执行的控制量实时性压力小很多。反过来如果每次都从零初始化迭代次数会明显增加控制周期可能就撑不住了。MPC 框架下还有一个容易被忽视的好处反向递推得到的反馈增益 $K_0$ 可以拿来直接用。$u \bar{u}_0 - K_0(x - \bar{x}_0)$ 这一项是在优化过程中免费获得的局部反馈策略能补偿模型误差和外部扰动比纯前馈控制稳得多。5.2 模型失配与闭环鲁棒性iLQR 本质上是个基于模型的方法模型越准控制越漂亮。实际系统中摩擦、延迟、参数漂移都会让开环轨迹烂掉因此工程上几乎不会做纯开环执行一定会套闭环。MPC 每步重新求解相当于自带了隐式的反馈校正能容忍一定程度的模型失配。如果你的系统不确定性很大可以考虑在代价里加终端惩罚或者轨迹跟踪项让算法偏向保守而不是追求一条贴着约束边界的极限轨迹。同理对控制量加二阶差分惩罚也就是控制变化率的惩罚能显著减少抖动让执行机构寿命长很多。这个技巧在机械臂和无人机上尤其好用。5.3 约束iLQR 原生不足与常见补救标准的 iLQR 是不带硬约束的状态约束和控制约束都得自己想办法。最土的办法是把约束写进代价里比如加一个 barrier 型惩罚项离约束边界越近代价越高。这样做实现简单缺点是如果约束是严格的比如不允许超过关节限位barrier 参数需要调得很尖锐容易出现数值问题。控制限幅的处理稍微简单一点每次前向传播后直接对 $u$ 做 clipping 也能跑但严格来说会引入次优性。如果项目对约束质量要求高建议去看 ALTRO 这类带 augmented Lagrangian 的 iLQR 变体或者把 iLQR 作为初始猜测给 SQP 求解器。我的经验是控制限幅用 clipping 足够状态约束尽量在规划层面避开别指望优化器在硬约束下还能稳定收敛。5.4 我的调试习惯我个人的习惯是先在仿真里把每次迭代的 rollout 轨迹画成动画观察摆或者机械臂的运动姿态这比盯着一串数字有效得多。看到代价下降但运动轨迹异常时优先检查动力学雅可比看到代价不降时优先增大正则化而不是改 alpha。还有一个小技巧在跑真实系统之前故意给模型加一点参数偏差在仿真里做一次“蒙特卡洛测试”比如质量偏大 20%、阻尼偏大 50%看看 MPC 闭环还能不能稳住。如果在这种测试下依然稳定上真机的把握就很大了。iLQR 的代码实现其实只有几十行最难调的不是公式而是数值细节。这套“局部线性化LQR 动态规划”的框架几乎可以无缝迁移到各种机器人控制任务里。希望这篇文章能帮你少踩几个我踩过的坑。