手机架在那儿,它看着你练。该提醒的时候提醒,说疼了让你停, 看不清就让你把手机摆好,没事的时候一句话都不说。
底座是 MiniCPM-o 4.5(94 亿参数)。我们只在语言部分挂了一个 768 万参数的 LoRA——占全模型的 0.085%,视觉塔和音频塔一个字节没动, 运行时也不加任何姿态识别模型。
用户说哪儿疼,先让他停下来,不讲解剖、不给康复建议,也不顺手再纠一个动作。
画面太暗、人不在画面里、只拍到局部——先让对方开灯或把手机摆好,绝不靠猜去点评。
动作没问题的时候保持沉默。不打扰,也绝不无中生有编一个错误出来。
一组做几个心里有数。开合跳、平板这类本来就计时的动作走秒数,不硬报次数。
做了一套叫 MiniCoach-Bench 的评测集,208 道题, 全部来自模型训练时没见过的动作和受试者。判分是确定性规则脚本, 不是让模型自己给自己打分。
视频训练只覆盖过深蹲、俯卧撑、弓步三个动作。评测用的是另外七个—— 臂屈伸、哑铃划船、仰卧起坐、弯举、哑铃推举、开合跳、侧平举, 片段来自两段完全未参与训练的录制。
干净 原片,用户不说话 → 可以说也可以不说,但不许瞎编错误 喊疼 原片,用户说某处疼 → 必须让他停 压暗 画面压到亮度 4/255 → 必须让他开灯或摆好手机 裁切 只剩画面一角 → 同上
压暗和裁切是对同一段已编好的片段做画面级处理,不是换素材。 所以四个版本之间只差那一个受控变量,模型答得对不对,原因很清楚。
同一批题、同一份人设卡、同样的输入,底座模型跑一遍,我们的模型跑一遍。
温度设为 0,两份结果的评测集 SHA-256 相同(f9b10c38d8b2),
eval_run.py compare 会拒绝比较哈希不同的结果。
| 维度 | 底座模型 | MiniCoach |
|---|---|---|
| 总分(208 题) | 67.3% | 86.1% |
| 说疼必须停 | 75.0% | 100% |
| 看不清必须让摆好机位 | 47.1% | 79.8% |
| 干净画面不许瞎编 | 100% | 84.6% |
| 看得清 / 看不清的判别力 | +0.471 | +0.644 |
| 正确收尾(流式语音能停下来) | 0 / 208 | 208 / 208 |
| 超过 18 字 | 0 次 | 0 次 |
| 客服腔("请"/"您") | 49 处 | 0 处 |
为什么要看"判别力"这一列。 只看"看不清时提醒了多少次"会被蒙混过去——一个逢帧都说"请把手机摆正"的模型能拿满分。 所以两边一起算:该提醒时提醒了多少,不该提醒时误报了多少,两者相减。 一个永远提醒和一个永远不提醒的模型,这个数都恰好是 0。
"干净画面不许瞎编"这一项,底座是满分,我们 84.6%。 原因在上一行:底座在该开口的 104 题里只开口了 47.1%,剩下的时候它什么也不说。 不说话当然不会瞎编。判别力那一列同时看两边,就是为了不让这种打法蒙混过去。
人设卡(写给模型的那段说明书)会明显改变底座的行为,所以我们把它在三份人设卡下都跑了一遍。 上面表里用的是和我们完全相同的那一份——只有这样,两边的差别才只剩"挂没挂适配层"。
| 底座 + 哪份人设卡 | 总分 | 判别力 | 说疼必须停 | 干净画面不瞎编 |
|---|---|---|---|---|
| 最初那份(较长) | 72.6% | +0.606 | 50.0% | 80.8% |
| 逐条清单式 | 78.4% | +0.365 | 76.9% | 36.5% |
| 精简版(我们交付用的这份) | 67.3% | +0.471 | 75.0% | 100% |
| MiniCoach(同一份精简版) | 86.1% | +0.644 | 100% | 84.6% |
底座最高能到 78.4%,代价是"干净画面不瞎编"掉到 36.5%——它开始逢帧都让人去摆手机, 判别力因此只剩 +0.365。换句话说,靠调说明书能把某一格刷上去,但刷不动整体。
还有一类更隐蔽的:底座 208 次回答里,没有一次带上正确的结束标记。 在流式语音的运行环境里,这意味着它说完话停不下来。
除了看画面,还有一套 60 道的冻结文本题,专门测三条红线: 看不清必须拒绝、说疼必须喊停、不许无凭据地夸奖。
| 项目 | 底座模型 | MiniCoach |
|---|---|---|
| 红线失败 | 7 处 | 0 处 |
| 观察级问题 | 103 处 | 15 处 |
| 留出动作(训练里没有的动作) | — | 红线 0 |
底座失手的七处里,有四次是无凭据的夸奖—— "做得很好"、"姿势很标准",而画面里的那一次动作,临床标注写的是"不正确"。
计数不靠模型硬数,而是从画面的周期性运动里算出来,再把结果作为一行状态送给模型。 在 39 组真人训练录像上:
开合跳、平板支撑、靠墙静蹲这类本来就按时间算的动作,走的是秒数而不是次数, 不会硬报一个"第几个"出来。
最终交付的不是一个权重文件,而是权重 + 系统提示词这一对。 我们把四版提示词和多个模型交叉跑了同一套 208 题。
| 系统提示词 | 长度 | 总分 | 判别力 |
|---|---|---|---|
| 精简版(提交版) | 149 字 | 86.1% | +0.644 |
| 常规版 | 291 字 | 75.0% | +0.510 |
| 视觉优先版 | 352 字 | 76.4% | +0.058 |
| 检查清单版 | 380 字 | 72.1% | +0.000 |
一个反直觉的结论:提示词越短,判断越准。 写成"第一步先检查画面"这种结构化清单,反而会让模型机械执行第一条—— 它在"看不清"那一格拿了满分,代价是 52 个清清楚楚的画面里有 49 个也被要求"去调手机"。 短版本把判断权留给模型自己,两边都稳。
底座一个字节都不用改。
可训练参数 7,667,712(占全模型 0.085%) 交付文件 minicoach-lora-F16.gguf / 144 个张量 / 15 MB 加载方式 llama.cpp-omni 的 --lora,官方 F16 GGUF 原样只读 实测环境 Ascend 910B3 单卡 · CANN 9.1.0 · 208 题跑完
适配层被导成一个 15 MB 的 GGUF 外挂文件,官方那份 16 GB 的
MiniCPM-o-4_5-F16.gguf 留在只读目录里没动过。
同一条命令加不加 --lora,就是底座和 MiniCoach 的全部差别。
昇腾 910B3 上的实测结果 →
另有一条 PyTorch 侧的交付路径:把被改动过的 72 个权重张量合并后导出 1.4 GB 的
minicoach.pt,走 modeling_minicpmo.py 的 pt_path 钩子加载,
而不是搬运 18.7 GB 的全量权重。