
简介这份面向JavaScript与前端AI开发者的TensorFlow.js实践包围绕6轴机械臂的强化学习控制展开适合希望把三维空间目标点求解、奖励函数设计与浏览器端模型训练串起来的学习者对强化学习入门者、机器人控制爱好者及前端AI实验人群较友好。包内共11个文件以html与js承载2D、3D测试页面和交互脚本md与json补充说明及依赖配置glb提供机械臂骨骼模型css负责页面样式压缩包约1.88MB。已有1262人学习。内容从10×10二维网格寻路起步扩展到10×10×10三维空间通过逐步奖励缩短当前位置与目标点距离让模型学习多轴旋转策略读者可据此理解状态表示、奖励设计、训练循环与可视化调试的组织方式并借助现成页面和模型资源复现实验、改造地图规模或替换机械臂模型。目录将二维与三维试验分开便于按阶段阅读和比较形成可继续迭代的强化学习小项目。1. 从浏览器里的机械臂说起tensorflow-robot-arm 到底测什么tensorflow-robot-arm 这个资源的核心是把 6 轴机器人手臂的强化学习测试放进 JavaScript 技术栈里跑关节角、末端位姿、目标点和奖励函数都在一个可读的 JS 环境里策略网络用 TensorFlow.js 训练或加载。它适合两类人一类是想验证奖励设计和动作空间但不想先搭一套重型仿真的人另一类是做机器人上位机、教学演示或算法回归测试的开发者。它不是工业级控制器别指望直接上产线它的价值在于把“状态怎么定义、动作怎么限幅、策略怎么收敛”这些前置问题快速跑通。你拿到后可以先在 Node 端训练小规模策略再把模型丢到浏览器里做回放和可视化整条链路不用换语言。2. 把 6 轴运动学拆成 JavaScript 能算的状态环境建模与动作空间2.1 为什么用 TensorFlow.js 而不是直接上 Python 训练栈这个项目关键词是 JavaScript不是随便选的。6 轴机械臂强化学习测试里环境步进、奖励计算、模型推理和可视化如果分属两种语言调试时最痛苦的是状态对不上Python 端算出的关节角顺序、弧度单位、归一化参数和前端拿到的数组经常差一位。用 TensorFlow.js 的好处是环境、策略、日志、回放都在同一套 JS 数据结构和调试链路里Node 端跑训练浏览器端用同一份环境代码做可视化排查问题时不用来回翻译。缺点也要说清楚。JS 没有 Python 生态里那么多现成 RL 库训练吞吐低复杂算法要自己写所以它适合中小规模状态空间、低维连续动作和验证型任务不适合大规模并行采样。常见做法是先在 Node 端用tensorflow/tfjs-node跑通再决定是否迁移到更重的训练栈。下面这段初始化命令没有版本号装的是当前环境可用的包mkdir robot-arm-rl cd robot-arm-rl npm init -y npm i tensorflow/tfjs-nodetensorflow/tfjs-node在 Node 环境提供原生后端适合训练循环如果只在浏览器里跑推理和回放换成tensorflow/tfjs即可。要注意 Windows 上原生依赖可能编译失败退回纯 JS 后端或放到 Linux 子系统里跑别一上来就怀疑模型有问题。提示先把环境代码写成不依赖 TensorFlow 的纯 JavaScript 类训练脚本再引用它。这样换后端时环境不用重写。2.2 六轴角度、末端位姿与奖励函数怎么落到代码里状态设计不要一上来塞 6 个绝对角度就完事。六轴机械臂的关节角有周期性和限位直接给网络绝对弧度靠近 ±π 时会出现跳变。我一般会把关节角除以该关节最大绝对值做归一化再拼接末端位置和目标位置。正运动学用简化 DH 参数算下面这组参数只是模拟项目X的示例不要直接映射到真实机械臂关节 ia(m)alpha(rad)d(m)offset(rad)10.12-1.57080.08020.1800.02030.161.57080.04040.04-1.57080.10050.031.57080.02060.0200.060代码里先实现 4×4 矩阵乘和 DH 变换再串起 6 个关节。这里不追求工业级精度只要求输入输出顺序稳定// 四阶矩阵乘用于串联 DH 变换 function matMul(A, B) { return A.map((row, i) B[0].map((_, j) row.reduce((sum, v, k) sum v * B[k][j], 0) )); } // 单个 DH 变换theta 为关节角加零位偏置 function dhTransform(a, alpha, d, theta) { const ct Math.cos(theta), st Math.sin(theta); const ca Math.cos(alpha), sa Math.sin(alpha); return [ [ct, -st * ca, st * sa, a * ct], [st, ct * ca, -ct * sa, a * st], [0, sa, ca, d], [0, 0, 0, 1] ]; } // 6 轴正运动学q 是 6 个弧度值dh 是上面的参数数组 function forwardKinematics(q, dh) { let T [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]; for (let i 0; i 6; i) { const { a, alpha, d, offset } dh[i]; T matMul(T, dhTransform(a, alpha, d, q[i] offset)); } return { x: T[0][3], y: T[1][3], z: T[2][3] }; } function distance(p, q) { return Math.hypot(p.x - q.x, p.y - q.y, p.z - q.z); }q用弧度dh的offset负责对齐零位alpha是相邻关节轴的扭角d是连杆偏距。调试时先让q全零看末端位置是否符合预期再逐个关节加 0.1 弧度确认运动方向没反。方向反了不一定是算法问题常见是 DH 表的alpha符号或零位偏置写错。奖励函数比网络结构更容易翻车。距离奖励不要只用最终距离否则稀疏得很难学用“靠近目标的进度”做塑形奖励再给动作代价和碰撞粗筛// prev/next 是末端位置target 是目标点action 是 6 维关节增量 function reward(prev, next, target, action) { const distPrev distance(prev, target); const distNext distance(next, target); const progress distPrev - distNext; // 靠近目标为正 const actionCost 0.01 * action.reduce((s, v) s v * v, 0); const energyCost 0.001 * Math.abs(action.reduce((s, v) s v, 0)); const collisionPenalty next.z 0.02 ? -1 : 0; // 粗筛末端低于台面 const success distNext 0.02 ? 5 : 0; return progress * 10 - actionCost - energyCost collisionPenalty success; }progress * 10把靠近目标的收益放大actionCost的 0.01 抑制抖动太大策略会变懒collisionPenalty只做粗筛不要当成真实碰撞检测success阈值 0.02 是模拟尺度换真实机械臂要按工作空间改。奖励尺度要统一否则 critic loss 会爆。2.3 用 JavaScript 写一个可复现的 Gym 风格环境环境接口保持reset、step、getState三件套方便后面换算法。动作用 6 维关节增量不用绝对角每步裁剪到 ±0.05 弧度太小训练慢太大直接飞车。下面这段可以直接放进项目里跑class RobotArmEnv { constructor(dh, jointLimits, target) { this.dh dh; this.jointLimits jointLimits; this.target target; this.q new Array(6).fill(0); } reset() { // 只随机初始 20% 范围避免一上来就撞限位 this.q this.q.map((_, i) { const [lo, hi] this.jointLimits[i]; return lo Math.random() * (hi - lo) * 0.2; }); return this.getState(); } getState() { const ee forwardKinematics(this.q, this.dh); // 关节角按最大绝对值归一化再拼末端位置和目标位置 return [ ...this.q.map((v, i) v / Math.max(Math.abs(this.jointLimits[i][0]), Math.abs(this.jointLimits[i][1])) ), ee.x, ee.y, ee.z, this.target.x, this.target.y, this.target.z ]; } step(action) { const prev forwardKinematics(this.q, this.dh); const clipped action.map((a, i) { const delta Math.max(-0.05, Math.min(0.05, a)); // 单步最大增量 const [lo, hi] this.jointLimits[i]; return Math.max(lo, Math.min(hi, this.q[i] delta)); }); this.q clipped; const next forwardKinematics(this.q, this.dh); const r reward(prev, next, this.target, action); const dist distance(next, this.target); const done dist 0.02; return { state: this.getState(), reward: r, done }; } }reset只随机 20% 范围是为了让随机策略有机会靠近目标getState里归一化能减少网络输入尺度差异step先裁剪动作再裁剪关节限位顺序不要反。参数0.05是每步最大关节增量换真实机械臂要把它降得更小再乘安全系数。3. 强化学习训练回路从随机策略到 PPO/DQN 的取舍3.1 离散动作还是连续控制6 轴机械臂的动作空间选型6 轴同时输出连续增量动作维度是 6。如果每个关节离散成 -1、0、1 三档动作组合是 3^6729 维DQN 输出层能接但样本效率低探索也难。离散化的好处是代码短、调试直观适合先验证奖励函数和状态设计连续控制更贴近机械臂实际使用PPO、DDPG 这类算法输出高斯策略的均值和方差动作范围更细。我一般会先用离散入口跑通 200 到 500 个回合确认随机策略有少量成功、奖励不是全负再换连续控制。下面这段把离散动作索引解码成 6 维关节增量const ACTION_LEVELS [-1, 0, 1]; // 输入 0~728 的整数输出 6 维弧度增量 function decodeDiscrete(actionIndex) { const action new Array(6); let idx actionIndex; for (let i 5; i 0; i--) { action[i] ACTION_LEVELS[idx % 3]; idx Math.floor(idx / 3); } return action.map(v v * 0.05); // 映射到最大关节增量 }actionIndex是 DQN 的输出动作编号循环从后往前拆保证第 1 关节在数组第 0 位乘0.05和环境的步长保持一致。离散化跑通后如果成功率和轨迹平滑度不够再换连续策略不要一开始就上复杂算法。3.2 训练循环、经验回放与模型保存的 JavaScript 实现经验回放用普通数组存状态、动作、奖励、下一状态和 done不要存 tensor。TensorFlow.js 的 tensor 需要手动释放回放池里存 tensor 很容易把内存吃满。下面是一个最小回放池class ReplayBuffer { constructor(capacity) { this.capacity capacity; this.data []; this.pos 0; } push(s, a, r, ns, done) { const item { s, a, r, ns, done }; if (this.data.length this.capacity) this.data.push(item); else this.data[this.pos] item; this.pos (this.pos 1) % this.capacity; } sample(batchSize) { const batch []; for (let i 0; i batchSize; i) { batch.push(this.data[Math.floor(Math.random() * this.data.length)]); } return batch; } }capacity按内存定JS 里 5 万条以上就要观察内存push用循环覆盖避免数组无限增长sample没有做优先级适合先跑通。下面是一个简化的 actor-critic 训练步骤重点看 tensor 创建和释放顺序async function trainActorCritic(actor, critic, actorOpt, criticOpt, batch, gamma 0.99) { const states tf.tensor2d(batch.map(x x.s)); const actions tf.tensor2d(batch.map(x x.a)); const rewards tf.tensor1d(batch.map(x x.r)); const nextStates tf.tensor2d(batch.map(x x.ns)); const dones tf.tensor1d(batch.map(x x.done ? 0 : 1)); const nextValues critic.predict(nextStates).squeeze(); const targets rewards.add(nextValues.mul(dones).mul(gamma)); const values critic.predict(states).squeeze(); const criticLoss tf.losses.meanSquaredError(targets, values); // 简化版优势target - valuestopGradient 防止 actor 更新 critic const advantages targets.sub(values).stopGradient(); const mu actor.predict(states); const actorLoss mu.sub(actions).square().mean().mul(advantages.mean().abs()); const criticGrads criticOpt.computeGradients(criticLoss, critic.trainableWeights.map(w w.val)); criticOpt.applyGradients(criticGrads); const actorGrads actorOpt.computeGradients(actorLoss, actor.trainableWeights.map(w w.val)); actorOpt.applyGradients(actorGrads); [states, actions, rewards, nextStates, dones, nextValues, targets, values, advantages, mu] .forEach(t t.dispose()); criticGrads.dispose(); actorGrads.dispose(); }gamma是折扣因子0.99 适合回合不长的情况dones把终止状态的价值截断advantages用 stopGradient 防止策略梯度污染价值网络。这段是教学级示意不是完整 PPO正式训练还要加 clip、熵奖励和旧策略概率。模型保存分环境// Node 端保存到文件系统 await actor.save(file://./models/actor); await critic.save(file://./models/critic); // 浏览器端保存到 IndexedDB // await actor.save(indexeddb://robot-arm-actor);Node 端用file://浏览器端用indexeddb://保存时把状态归一化参数、关节限位和 DH 参数一起写成metadata.json否则换机器加载后输出全错。这个坑在后面会展开。3.3 用 TensorBoard 或自定义日志看收敛指标怎么读JS 环境里 TensorBoard 不如 Python 顺手通常先用 CSV 日志。不要只看 episode rewardreward 震荡是正常的关键看成功率、平均距离和动作幅度。下面这些指标够判断训练有没有跑偏指标计算方式观察重点episode reward每回合奖励累加长期不涨先查奖励尺度和随机基线success rate成功回合/总回合比 reward 更直观固定种子下应稳定average distance回合结束末端到目标距离突然变大可能是策略绕路或动作限幅变了action norm每步动作平方和均值突然增大说明策略发散或奖励动作代价太小critic loss价值网络均方误差爆炸时优先检查奖励是否过大const fs require(fs); function logEpisode(ep, reward, success, dist, actionNorm) { const line ${ep},${reward.toFixed(4)},${success},${dist.toFixed(4)},${actionNorm.toFixed(4)}\n; fs.appendFileSync(training-log.csv, line); }ep是回合编号success用 0/1 记录dist用米或归一化单位都行但整条链路要统一actionNorm是动作平方和均值用来观察抖动。日志每回合写一次不要每步写否则文件会很大。4. 把训练结果跑回机械臂推理、可视化和安全边界4.1 从模型输出到关节指令限幅、平滑与碰撞粗筛模型输出不是关节角是增量。推理后先做一阶低通平滑再裁剪到安全范围最后交给环境步进。下面这段是推理包装async function act(model, state, prevAction, alpha 0.2) { const input tf.tensor2d([state]); const output model.predict(input); const raw Array.from(await output.data()); input.dispose(); output.dispose(); // 一阶低通alpha 越小越稳但响应越慢 const smoothed raw.map((v, i) alpha * v (1 - alpha) * (prevAction[i] || 0)); return smoothed.map(v Math.max(-0.05, Math.min(0.05, v))); }alpha0.2是常用起点实物第一次跑可以降到 0.10.05是单步最大增量必须和训练时一致。碰撞粗筛不要塞进模型放在环境step里末端 z 低于台面、关节角接近软限位、动作变化率超过阈值都直接给惩罚或终止。模型只负责输出安全边界由外层代码兜底。4.2 浏览器端 Three.js 可视化和 Node.js 端推理的差异浏览器端用requestAnimationFrame驱动推理频率受帧率影响Node 端用固定循环或setInterval适合批量评估。TensorFlow.js 后端不同同一个模型输出可能有微小数值差异通常不影响动作但评估时要固定后端。加载模型的方式也不一样// 浏览器端先设后端再从 IndexedDB 加载 tf.setBackend(webgl); const model await tf.loadLayersModel(indexeddb://robot-arm-actor); // Node 端从文件系统加载路径要包含 model.json // const model await tf.loadLayersModel(file://./models/actor/model.json);tf.setBackend(webgl)适合浏览器可视化Node 端tfjs-node后端名通常是tensorflow。路径不要写成目录loadLayersModel要的是model.json。浏览器不能直接读本地file://要么走 HTTP 静态服务要么用 IndexedDB。混用保存格式和加载格式是常见翻车点。4.3 用回放轨迹验证策略成功率、超时与抖动统计训练完不要只看一条轨迹。固定目标点、固定初始状态跑 50 到 100 回合统计成功率、平均步数和动作抖动。下面这段评估代码可以直接改async function evaluate(env, model, episodes 50) { let success 0, totalSteps 0, jerk 0; for (let e 0; e episodes; e) { let state env.reset(); let prevAction new Array(6).fill(0); for (let t 0; t 200; t) { const action await act(model, state, prevAction); const out env.step(action); jerk action.reduce((s, v, i) s (v - prevAction[i]) ** 2, 0); prevAction action; state out.state; if (out.done) { success; totalSteps t 1; break; } } } return { successRate: success / episodes, avgSteps: success ? totalSteps / success : 0, jerk: jerk / episodes }; }episodes是评估回合数50 起步200是最大步数按任务改jerk是相邻动作差的平方和越大越抖。评估时关掉探索噪声固定随机种子。成功率突然掉、平均步数突然涨、jerk 变大三者同时出现基本就是策略退化或环境参数被改过。5. 避坑排查六个轴的项目最容易翻车的五件事这几个坑我踩过不止一次有些是玄学有些是纯工程疏忽。下面每条按“现象 → 原因 → 解决”拆开拿到资源后可以先对照检查。5.1 现象训练奖励一直不涨现象跑了上千回合episode reward 在 -200 到 -180 之间抖success rate 始终为 0。原因奖励尺度不对距离奖励太小、碰撞惩罚太大或者状态没归一化网络输入里关节角是 ±3.14末端位置是 0.1 量级梯度被大数吃掉也可能是动作空间太大随机探索根本到不了目标。解决本文还有配套的精品资源点击获取