项目档案 · 实时语音反馈
跑步到第 40 分钟,你不会有空低头看表。所以语音不是"另一种显示方式", 而是唯一可用的通道。 这个项目研究的就一件事:什么时候开口、说什么、怎么说。
数据源:Garmin 运动手表导出 · 18 个月 · 单跑者(N=1 案例研究)
控制台是真的连着一个 agent。
你在里面拖滑块、切情景,每一句话都是本地 agent 当场算出来的
—— 状态感知 → 决策 → 意图 → 话术 → 语音合成,全部实跑,不是播放录像。
它需要在你电脑上跑一个本地服务(python -m coach.serve),
页面里有一键说明。
核心矛盾
技术栈不是难点。难点是这三个互相拉扯的权衡 —— 而且它们会互相否决。
打扰度和及时性是一对反比。 说得越多越及时,也越烦人;说得越少越安静,也越没用。 这个项目把"默认沉默"写成了第一原则:每一帧都要先证明"这句话此刻值得打断"。
实测:一次训练里教练开口 12 次,静默比 99.33%。
不能只按"触发条件"分组,得按意图分组 —— 否则"纠正配速"和"提醒心率"永远算不出"纠正类一共说了几次"。 所以决策层输出的是 7 种意图,而不是内部触发名。
CORRECT · WARN · ENCOURAGE · GUIDE · CONFIRM · RECOVER · SUMMARY
耳机里只有几秒。所以人格被写成可执行约束: 单句 ≤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% |
| 参数 | 初始设定 | 标定后 | 依据 |
|---|---|---|---|
| 心率上限 | 165 bpm | 173 bpm | 85% × 实测最高心率 204 |
| 掉速阈值 | 偏离 15% | 偏离 10% | 触发率扫描,19 组参数 |
| 掉速持续 | 60 s | 60 s | 保持 |
标定过程本身暴露了一个设计错误: 我原来把「本次跑步的中位配速」当作偏离参照。这个跑者配速变异系数只有 8.5% —— 一个跑得很稳的人,永远不会偏离自己的中位数。 正确做法是拿目标配速当参照(训练计划给),这也是规格从一开始就写对的。
关键发现
用 45 次真实跑步回放,测「纯信息播报之后 15 秒,配速比之前乱了多少」。 只统计不要求改变行为的播报,所以扰动只可能来自打断本身。
| 场景 | A 固定门槛·不等间隙 | B 固定门槛·等间隙 | C 自适应门槛 |
|---|---|---|---|
| 最稳的 1/3 | 1.099 | 1.099 | 1.099 |
| 最不稳的 1/3 | 1.498 | 1.310 | 1.310 |
| 相对 A 的改善 | — | 12.6% | 与 B 相同 |
诚实结论:自适应门槛完全无效(C = B)。
先去排查「是不是代码没生效」—— 不是,档位每场切换 4~53 次,逻辑是对的。 真正的原因藏在拦截原因统计里:最短间隔和 5 分钟名额一次都没被触发过。 实测每场只有约 6 次发言(约 1.8 次 / 5 分钟),而设计上限是 3 次 / 5 分钟 —— 两个约束都是松的。
"每 45 秒最多说 3 次"这个核心机制从未生效。 它写在文档里、被反复讨论、还专门为它做了自适应 —— 但在真实跑步里从没拦下过任何一句话。真正决定发言次数的是触发条件本身和间隙门。纸面推演永远发现不了这一点。
被动数据无法证明间隙检测的价值。 无论代理指标怎么设计,都缺一样东西 —— 反事实。我们只知道"说了话之后变乱了",不知道"如果没说会怎样"。所以最终结论必须来自用户实验,再有创意的代理指标也补不上这个洞。
"真实步态/呼吸相位"在这个硬件条件下做不到。 数据集 100/100 个跑步文件只提供约 1 Hz 的信号:心率、步频、速度、海拔、GPS。没有 RR 间期、呼吸率、跑步动态、原始加速度。所以问题被重新定义成:在 1 Hz 商用信号下能多好地近似"可打扰度" —— 这也更贴近耳机产品的真实约束。
听得见的部分
同一句话、同一个音色,一次性合成听起来就是读稿。 按标点切段、每段给不同语速音高、段间插真实毫秒停顿之后,立刻像在跟你说话。
「慢一点,心率超了。」
「第一公里,用时五分二十秒。」
「还剩四百米,坚持住。」
同一句,说两遍
顺带测到的一条硬约束
云 TTS 冷启动合成一句要 1.7~4.0 秒(网络抖动时最坏 12.6 秒), 而端侧预算是 <300 毫秒 —— 差十倍以上。 所以安全类提示必须走预置音频片段(命中缓存后 75 毫秒), 不能实时合成。这从"逻辑上的端云分工"变成了"有数字支撑的分工"。
决策日志
下面是从真实跑步回放里导出的两条事件(未经修改)。
behavior_change 和 effectiveness
必须事后补齐 —— 出声的那一刻根本不知道后面会怎样。
{
"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
}
}
注意最后一条的 effectiveness 是 null。
播报类不要求跑者改变任何行为,给它算"有效性"是伪命题 —— 强行打分只会污染统计。
这次回放共 71 条决策事件、42 次出声,
静默比 40.85%。
对照规格
设计规格是一份 190 行的执行文档。我拿到后做了一次逐条核对 —— 数条目而不凭印象,连自己的估计值都推翻过一次。
| 缺口 | 为什么它卡住后面 | 状态 |
|---|---|---|
| 状态层 | 决策逻辑直接读传感器字段;没有疲劳度、置信度、心率分区 | 已补 |
| 意图层 | 没有它,规格的四种对照实验(比较单位是意图)根本做不了 | 已补 |
| 事件日志 | 闭环的最后一环断了 —— 反馈效果没有写回记忆 | 已补 |
| 项 | 规格 | 我的实现 | 判定 |
|---|---|---|---|
| 配速偏离参照源 | 目标配速 | 本次跑步中位配速 | 我错了 |
| 配速偏离幅度 / 持续 | 5% / 30 s | 10% / 60 s | 数值冲突 |
| 心率超区间持续 | 20 s | 30 s | 数值冲突 |
| 距上次反馈间隔 | 45 s | 45 s | 一致 |
| 批次 | 内容 | 状态 |
|---|---|---|
| 第二批 | 话术 JSON 契约 · 时长 3~8 秒约束 · 三种预置人格 · 两类记忆 · 本地缓存兜底 · 按规格改阈值 | 进行中 |
| 第三批 | 全链路延迟 P95 · 断网可用率 · 重复率统计 · 版本追溯 | 待做 |
| 第四批 | Phase 4 四种对照条件 · 指标脚本 · 问卷与分析 | 待做 |
局限
| 局限 | 说明 |
|---|---|
| 单跑者(N=1) | 全部数据来自一位跑者,不能推及人群。这是案例研究,不是用户实验。 |
| 无相位级信号 | 只有约 1 Hz 商用信号,所以方法只能叫可打扰度近似,不能声称检测到了自然间隙。 |
| 缺反事实 | 被动数据无法回答「如果不说会怎样」。间隙检测的最终价值判断必须靠用户实验。 |
| 端侧是模拟的 | 端侧低延迟路径由常数模拟,未在真实耳机硬件上验证。 |
| 主观指标全缺 | 打扰度、及时性、有用性、可信度、认知负担、继续使用意愿 —— 六项主观量表都还没测。 |
一个值得说明的负结果: 在上面那次回放里,纠错类反馈的平均有效性是 −0.06(略负)。 部分原因是这次刻意把目标配速设成了 5:00/km,而这个跑者惯常配速是 5:22/km —— 他始终达不到目标,于是教练反复提示而效果接近零。 这恰好说明目标配速必须来自训练计划,不能随便设 —— 目标定得不合理,再好的话术也是噪音。