语音运动教练 Agent

项目档案 · 实时语音反馈

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

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

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

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

控制台是真的连着一个 agent。 你在里面拖滑块、切情景,每一句话都是本地 agent 当场算出来的 —— 状态感知 → 决策 → 意图 → 话术 → 语音合成,全部实跑,不是播放录像。 它需要在你电脑上跑一个本地服务(python -m coach.serve), 页面里有一键说明。

核心矛盾

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

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

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%

第三批 · 实测

延迟必须拆成四个口径

规格要求「端到端 P95 小于两秒」。这句话不能直接量 —— 因为系统里有 一段是故意等的:等跑者跑到自然间隙再开口。 把等待算进延迟,会得出「超预算三倍」的假结论。我早期就是这么错的。

口径含义P50P95算不算两秒预算
A 决策计算状态 → 触发 → 意图 → 选话术 0 ms0 ms
B 话术生成真大模型写一句话 1406 ms1676 ms 算(预算 900ms,超了)
C 语音合成在线,冷启动 4.3~7.9 s16.7 s 算(数量级超标)
 └ 缓存命中同一句话已经合成过 57 ms59 ms
C' 离线合成本机 SAPI,不需要网络 0.78~1.46 s1.41 s
D 排队等待故意等到自然间隙 不算,这是设计
意外发现:离线合成的冷启动比在线快五倍。 在线 edge 要 4.3 到 7.9 秒,本机 SAPI 只要 1 秒左右,而且不依赖网络。 这直接改了端云分工的建议 —— 安全提示和纠错应该走离线, 因为一次网络抖动就能把 5 秒变成 16 秒。

断网可用率

状态触发次数产出合规话术可用率
正常(云端可用)154154 100.00%
断网(云端全停)154154 100.00%

定义说明:这个数度量的是「断网时还能不能产出一句合规的话」,不含出声环节。 出声能力由离线本机 TTS 单独验证(上表 C')。

第三批 · 对照实验

固定播报就是台复读机

规格 Phase 4 要四种对照条件。跑在 22 场真实跑步上,最直接的证据出现在 「重复率」这一列 —— 连续两次说同一类话的比例。

条件开口次数/场静默比重复率话术合规率
A 无 Agent0100.00% 0.000
B 固定播报8.099.66% 0.8431.000
C 状态触发 + 模板14.3255.76% 0.4131.000
D 状态触发 + 个性化14.3254.01% 0.4131.000
固定播报每公里说同一句话,重复率 0.843; 加上状态触发后降到 0.413,只有原来的一半。 这是「什么时候说」这套设计最直接的一个证据。

运动指标 vs 系统指标:必须分开报

我把两类指标分开了,因为它们混在一起会骗人 —— 系统指标好看(话说得又少又合规)不代表运动指标好看(人跑得更好了)

类别四种条件的结果为什么
运动指标
配速偏差 / 区间保持 / 步频稳定性 / 完成率
四组完全相同 跑的是同一批历史数据
系统指标
静默比 / 重复率 / 合规率 / 延迟
差异明显 条件改变的是系统行为

运动指标四组完全相同,是因为被动数据缺反事实 —— 不管教练说没说话,那场跑已经跑完了。要证明「教练让人跑得更好」,只能做真人实验。

规格 Phase 3 验收

第二场训练读到了第一场的记忆

规格要求「第二次训练能读取第一次的记忆,并且策略有变化」。 这条我跑出了实证,闭环一共五步。

  1. 1 训练计划给目标 —— 今天是节奏跑,目标配速 5:06、心率上限 175。 阈值终于有出处了,不再是硬编码。
  2. 2 第一场跑起来 —— 心率爬到 190、步频从 186 掉到 160。 t=271s 触发心率警告,t=309s 触发掉速纠正。
  3. 3 跑后复盘 —— 12 条事件、开口 2 次、静默比 83.33%。 疲劳度 0.793(高),由三个成分合成: 心率负荷 0.931 / 心率漂移 0.125 / 步频下滑 0.072。
  4. 4 写回记忆 —— 「上次训练疲劳度 0.79,建议下次降级为轻松跑」。
  5. 5 第二场读到它 —— 课型从节奏跑降级为轻松跑, 心率上限从 175 变成 145
第三批 · 可追溯

每个数字都能回答「谁算的」

跑出来的报告头上都带一行指纹,说明这份数字是哪套代码、哪套阈值、 哪个模型产出的。用内容哈希而不是 git 提交号 —— 因为改了阈值但没提交,也应该能看出来。

code=32093468d6 schema=4a26da5744 config=bf21a9e8fb prompt=d51760a98b llm=deepseek-v4-flash

实测:心率上限从 170 改成 180,config 指纹立刻变。 回归里有一条专门守这个(C27)。

真人实验:已设计好,等招募

这是唯一一项我做不完的 —— 需要真人跑者。 代码骨架和统计脚本都已可运行,跑完数据直接出报告。

项目内容
设计被试内:同一个人跑四场,四种条件用拉丁方平衡顺序, 抵消练习效应和疲劳效应
样本量建议 8 人(最少 6),共 32 场
问卷四维度 × 3 题,1~7 分:时机(该说时说了没有)、 内容(说的话有没有用)、感受(听舒不舒服)、 信任(敢不敢信它)
统计Friedman 检验(整体差异)+ Wilcoxon 事后比较(含 Bonferroni 校正) + 效应量
为什么不 ANOVA主观评分通常不正态,样本又小,非参数方法更稳
统计脚本我自己验证过了。 卡方生存函数对着教科书的 8 个临界值全部吻合, Friedman 对着手算的 20.0000 逐位一致。 (一键复现:python tools/survey.py --verify

对照规格

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

设计规格是一份 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 —— 他始终达不到目标,于是教练反复提示而效果接近零。 这恰好说明目标配速必须来自训练计划,不能随便设 —— 目标定得不合理,再好的话术也是噪音。