语音运动教练 Agent

项目档案 · 实时语音反馈

运动中的人,
看不见屏幕。

跑步到第 40 分钟,你不会有空低头看表。所以语音不是"另一种显示方式", 而是唯一可用的通道。 这个项目研究的就一件事:什么时候开口、说什么、怎么说。

112真实跑步,用于标定
292.8km26.2 小时实测数据
99.33%静默比(绝大部分时间闭嘴)
22/22回归测试通过

数据源:Garmin 运动手表导出 · 18 个月 · 单跑者(N=1 案例研究)

核心矛盾

语音教练只有三个真正的难题

技术栈不是难点。难点是这三个互相拉扯的权衡 —— 而且它们会互相否决。

01

什么时候说

打扰度和及时性是一对反比。 说得越多越及时,也越烦人;说得越少越安静,也越没用。 这个项目把"默认沉默"写成了第一原则:每一帧都要先证明"这句话此刻值得打断"。

实测:一次训练里教练开口 12 次,静默比 99.33%

02

说什么

不能只按"触发条件"分组,得按意图分组 —— 否则"纠正配速"和"提醒心率"永远算不出"纠正类一共说了几次"。 所以决策层输出的是 7 种意图,而不是内部触发名。

CORRECT · WARN · ENCOURAGE · GUIDE · CONFIRM · RECOVER · SUMMARY

03

怎么说

耳机里只有几秒。所以人格被写成可执行约束: 单句 ≤20 字、不许出现阿拉伯数字(TTS 会念错)、 禁用 markdown、指令在前理由在后。

这些不是文档里的建议,是每句话出口前都要过的检查项。

系统架构

五层,每层只干一件事

参考 Hermes 五层模型设计。分层的意义是可替换: 换传感器不用改决策,换大模型不用改人格。

传感器状态感知反馈决策 意图人格约束生成/播放 写回记忆
职责对应模块状态
State把心率/配速/步频/GPS 变成结构化状态;缺传感器时如实降低置信度coach/state.py已实现
Model判断何时说话(严重度 / 及时性 / 打扰成本 / 冷却)coach/policy.py已实现
Intent触发 → 7 种意图 + 紧急度 + 理由码coach/intent.py已实现
Personality固定人格配置,LLM 只能措辞、不能改人格SOUL.md · coach/persona.py已实现
Memory长期档案 / 训练计划 / 当前训练 / 反馈效果coach/profile.py · events.py部分
Skill生成话术、守门、TTS、落日志language · llm · voice · events已实现
Channel端侧(低延迟安全类)/云侧(复杂话术)分工policy 路由 · fit_io已实现

一条不变量贯穿全系统: 安全类(用户主诉不适、心率爆表)不参与"闲聊预算"记账, 不占发言名额、不重置最小间隔、不受自适应门槛影响。 否则一次心率漂移就会把后面五分钟的正常提示全部静音。

真实数据

阈值不是拍脑袋定的

规格给了阈值数字,但没说数字从哪来。所以我把 1914 个 Garmin 文件扫了一遍, 用其中 112 次跑步做了参数标定 —— 然后把标定结果写回个人档案。

数据集构成

类别数量
运动活动162
 其中跑步112
日常监测文件607
空文件1145
合计1914

跑者画像(标定输入)

指标
总时长 / 总距离26.2 h / 292.8 km
观测最高心率204 bpm
心率 10/50/90 分位136 / 163 / 182
配速 10/50/90 分位4:21 / 5:22 / 6:12
配速变异系数8.5%

标定结果:我猜的 vs 数据说的

参数初始设定标定后依据
心率上限165 bpm173 bpm85% × 实测最高心率 204
掉速阈值偏离 15%偏离 10%触发率扫描,19 组参数
掉速持续60 s60 s保持

标定过程本身暴露了一个设计错误: 我原来把「本次跑步的中位配速」当作偏离参照。这个跑者配速变异系数只有 8.5% —— 一个跑得很稳的人,永远不会偏离自己的中位数。 正确做法是拿目标配速当参照(训练计划给),这也是规格从一开始就写对的。

关键发现

打断是有成本的 —— 而且成本不是常数

用 45 次真实跑步回放,测「纯信息播报之后 15 秒,配速比之前乱了多少」。 只统计不要求改变行为的播报,所以扰动只可能来自打断本身。

最稳的 1/3不打折
+10%
最不稳的 1/3代价高 5 倍
+50%

纵轴:播报后 15 秒的配速变异系数 ÷ 播报前 15 秒的变异系数。横线 = 1.0(没变化)。

于是问:既然稳的时候便宜,能不能分情况调整开口门槛?

场景A 固定门槛·不等间隙B 固定门槛·等间隙C 自适应门槛
最稳的 1/31.0991.0991.099
最不稳的 1/31.4981.3101.310
相对 A 的改善12.6%与 B 相同

诚实结论:自适应门槛完全无效(C = B)。

先去排查「是不是代码没生效」—— 不是,档位每场切换 4~53 次,逻辑是对的。 真正的原因藏在拦截原因统计里:最短间隔和 5 分钟名额一次都没被触发过。 实测每场只有约 6 次发言(约 1.8 次 / 5 分钟),而设计上限是 3 次 / 5 分钟 —— 两个约束都是松的。

三个否定结论(比"我做完了"更有说服力)

否定 1

"每 45 秒最多说 3 次"这个核心机制从未生效。 它写在文档里、被反复讨论、还专门为它做了自适应 —— 但在真实跑步里从没拦下过任何一句话。真正决定发言次数的是触发条件本身和间隙门。纸面推演永远发现不了这一点。

否定 2

被动数据无法证明间隙检测的价值。 无论代理指标怎么设计,都缺一样东西 —— 反事实。我们只知道"说了话之后变乱了",不知道"如果没说会怎样"。所以最终结论必须来自用户实验,再有创意的代理指标也补不上这个洞。

否定 3

"真实步态/呼吸相位"在这个硬件条件下做不到。 数据集 100/100 个跑步文件只提供约 1 Hz 的信号:心率、步频、速度、海拔、GPS。没有 RR 间期、呼吸率、跑步动态、原始加速度。所以问题被重新定义成:在 1 Hz 商用信号下能多好地近似"可打扰度" —— 这也更贴近耳机产品的真实约束。

听得见的部分

机械感来自韵律,不是音色

同一句话、同一个音色,一次性合成听起来就是读稿。 按标点切段、每段给不同语速音高、段间插真实毫秒停顿之后,立刻像在跟你说话。

纠错指令段稳住,理由段加快

「慢一点,心率超了。」

播报平稳,像报数

「第一公里,用时五分二十秒。」

激励末尾放慢上扬

「还剩四百米,坚持住。」

对比平铺基准 → 韵律处理

同一句,说两遍

顺带测到的一条硬约束

云 TTS 冷启动合成一句要 1.7~4.0 秒(网络抖动时最坏 12.6 秒), 而端侧预算是 <300 毫秒 —— 差十倍以上。 所以安全类提示必须走预置音频片段(命中缓存后 75 毫秒), 不能实时合成。这从"逻辑上的端云分工"变成了"有数字支撑的分工"。

决策日志

每次指导都有触发原因

下面是从真实跑步回放里导出的两条事件(未经修改)。 behavior_changeeffectiveness 必须事后补齐 —— 出声的那一刻根本不知道后面会怎样。

纠错类(有明确优化目标)

{
  "timestamp_ms": 926000,
  "intent": "WARN",
  "utterance": "慢一点,心率超了。",
  "latency_ms": 620.0,
  "behavior_change": {
    "pace_delta": 8.6,
    "heart_rate_delta": 1.2
  },
  "effectiveness": -1.0,
  "reason_codes": [
    "HR_ZONE_4",
    "HR_168"
  ],
  "state_snapshot": {
    "phase": "uphill",
    "pace_sec_per_km": 306.2,
    "target_pace_sec_per_km": 300.0,
    "heart_rate": 168.0,
    "heart_rate_zone": 4,
    "deviation": "too_slow",
    "fatigue_level": 0.662,
    "confidence": 1.0
  }
}

播报类(不要求改变行为)

{
  "timestamp_ms": 340000,
  "intent": "CONFIRM",
  "utterance": "一公里,五分四十秒。",
  "latency_ms": 620.0,
  "behavior_change": {
    "pace_delta": -0.9,
    "heart_rate_delta": 0.0
  },
  "effectiveness": null,
  "reason_codes": [
    "KM_MILESTONE"
  ],
  "state_snapshot": {
    "phase": "steady_running",
    "pace_sec_per_km": 344.6,
    "target_pace_sec_per_km": 300.0,
    "heart_rate": 160.0,
    "heart_rate_zone": 3,
    "deviation": "too_slow",
    "fatigue_level": 0.643,
    "confidence": 1.0
  }
}

注意最后一条的 effectivenessnull 播报类不要求跑者改变任何行为,给它算"有效性"是伪命题 —— 强行打分只会污染统计。 这次回放共 71 条决策事件、42 次出声, 静默比 40.85%

对照规格

把缺口摊开,比藏起来有用

设计规格是一份 190 行的执行文档。我拿到后做了一次逐条核对 —— 数条目而不凭印象,连自己的估计值都推翻过一次。

109规格里的可核对条目
~35%严格覆盖(只算完全符合)
0/5规格的验收关卡通过数
3已补上的结构性缺口

核对发现的三个结构性缺口 —— 现已补齐

缺口为什么它卡住后面状态
状态层决策逻辑直接读传感器字段;没有疲劳度、置信度、心率分区已补
意图层没有它,规格的四种对照实验(比较单位是意图)根本做不了已补
事件日志闭环的最后一环断了 —— 反馈效果没有写回记忆已补

规格纠正了我的一个错误

规格我的实现判定
配速偏离参照源目标配速本次跑步中位配速我错了
配速偏离幅度 / 持续5% / 30 s10% / 60 s数值冲突
心率超区间持续20 s30 s数值冲突
距上次反馈间隔45 s45 s一致

还有 11 项待补

批次内容状态
第二批话术 JSON 契约 · 时长 3~8 秒约束 · 三种预置人格 · 两类记忆 · 本地缓存兜底 · 按规格改阈值进行中
第三批全链路延迟 P95 · 断网可用率 · 重复率统计 · 版本追溯待做
第四批Phase 4 四种对照条件 · 指标脚本 · 问卷与分析待做

局限

这个项目做不到什么

局限说明
单跑者(N=1)全部数据来自一位跑者,不能推及人群。这是案例研究,不是用户实验。
无相位级信号只有约 1 Hz 商用信号,所以方法只能叫可打扰度近似,不能声称检测到了自然间隙
缺反事实被动数据无法回答「如果不说会怎样」。间隙检测的最终价值判断必须靠用户实验。
端侧是模拟的端侧低延迟路径由常数模拟,未在真实耳机硬件上验证。
主观指标全缺打扰度、及时性、有用性、可信度、认知负担、继续使用意愿 —— 六项主观量表都还没测。

一个值得说明的负结果: 在上面那次回放里,纠错类反馈的平均有效性是 −0.06(略负)。 部分原因是这次刻意把目标配速设成了 5:00/km,而这个跑者惯常配速是 5:22/km —— 他始终达不到目标,于是教练反复提示而效果接近零。 这恰好说明目标配速必须来自训练计划,不能随便设 —— 目标定得不合理,再好的话术也是噪音。