项目档案 · 实时语音反馈
跑步到第 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%。
规格要求「端到端 P95 小于两秒」。这句话不能直接量 —— 因为系统里有 一段是故意等的:等跑者跑到自然间隙再开口。 把等待算进延迟,会得出「超预算三倍」的假结论。我早期就是这么错的。
| 口径 | 含义 | P50 | P95 | 算不算两秒预算 |
|---|---|---|---|---|
| A 决策计算 | 状态 → 触发 → 意图 → 选话术 | 0 ms | 0 ms | 算 |
| B 话术生成 | 真大模型写一句话 | 1406 ms | 1676 ms | 算(预算 900ms,超了) |
| C 语音合成 | 在线,冷启动 | 4.3~7.9 s | 16.7 s | 算(数量级超标) |
| └ 缓存命中 | 同一句话已经合成过 | 57 ms | 59 ms | 算 |
| C' 离线合成 | 本机 SAPI,不需要网络 | 0.78~1.46 s | 1.41 s | 算 |
| D 排队等待 | 故意等到自然间隙 | — | — | 不算,这是设计 |
| 状态 | 触发次数 | 产出合规话术 | 可用率 |
|---|---|---|---|
| 正常(云端可用) | 154 | 154 | 100.00% |
| 断网(云端全停) | 154 | 154 | 100.00% |
定义说明:这个数度量的是「断网时还能不能产出一句合规的话」,不含出声环节。 出声能力由离线本机 TTS 单独验证(上表 C')。
规格 Phase 4 要四种对照条件。跑在 22 场真实跑步上,最直接的证据出现在 「重复率」这一列 —— 连续两次说同一类话的比例。
| 条件 | 开口次数/场 | 静默比 | 重复率 | 话术合规率 |
|---|---|---|---|---|
| A 无 Agent | 0 | 100.00% | 0.000 | — |
| B 固定播报 | 8.09 | 9.66% | 0.843 | 1.000 |
| C 状态触发 + 模板 | 14.32 | 55.76% | 0.413 | 1.000 |
| D 状态触发 + 个性化 | 14.32 | 54.01% | 0.413 | 1.000 |
我把两类指标分开了,因为它们混在一起会骗人 —— 系统指标好看(话说得又少又合规)不代表运动指标好看(人跑得更好了)。
| 类别 | 四种条件的结果 | 为什么 |
|---|---|---|
| 运动指标 配速偏差 / 区间保持 / 步频稳定性 / 完成率 |
四组完全相同 | 跑的是同一批历史数据 |
| 系统指标 静默比 / 重复率 / 合规率 / 延迟 |
差异明显 | 条件改变的是系统行为 |
运动指标四组完全相同,是因为被动数据缺反事实 —— 不管教练说没说话,那场跑已经跑完了。要证明「教练让人跑得更好」,只能做真人实验。
规格要求「第二次训练能读取第一次的记忆,并且策略有变化」。 这条我跑出了实证,闭环一共五步。
跑出来的报告头上都带一行指纹,说明这份数字是哪套代码、哪套阈值、 哪个模型产出的。用内容哈希而不是 git 提交号 —— 因为改了阈值但没提交,也应该能看出来。
实测:心率上限从 170 改成 180,config 指纹立刻变。
回归里有一条专门守这个(C27)。
这是唯一一项我做不完的 —— 需要真人跑者。 代码骨架和统计脚本都已可运行,跑完数据直接出报告。
| 项目 | 内容 |
|---|---|
| 设计 | 被试内:同一个人跑四场,四种条件用拉丁方平衡顺序, 抵消练习效应和疲劳效应 |
| 样本量 | 建议 8 人(最少 6),共 32 场 |
| 问卷 | 四维度 × 3 题,1~7 分:时机(该说时说了没有)、 内容(说的话有没有用)、感受(听舒不舒服)、 信任(敢不敢信它) |
| 统计 | Friedman 检验(整体差异)+ Wilcoxon 事后比较(含 Bonferroni 校正) + 效应量 |
| 为什么不 ANOVA | 主观评分通常不正态,样本又小,非参数方法更稳 |
python tools/survey.py --verify)
对照规格
设计规格是一份 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 —— 他始终达不到目标,于是教练反复提示而效果接近零。 这恰好说明目标配速必须来自训练计划,不能随便设 —— 目标定得不合理,再好的话术也是噪音。