市面上的独立 AI 录音硬件(Plaud 199)定位是 always-on 全天记录。我想验证一个更便宜的替代
MVP 要回答四个问题:手腕麦克风音质是否可用、每小时真实掉电多少、后台录音存活率、以及我自己到底会不会用。本文记录一天之内把三端(watchOS app、ingest 服务、Mac Mini 双感知管线)全部建完的过程,重点不是”建得快”,而是两个方法论层面的收获:研究阶段杀掉了四个想当然,以及对抗审查抓出了三个测试全绿但上线必挂的 bug。
整个流程分四个阶段,全部由并行 agent 完成:8 个研究 agent → 5 个实现 agent → 6 个审查 agent → 3 个修复 agent,共 22 个,外加一个被掐掉的死循环 agent(后述)。
阶段一:研究先行,四个想当然全部被证伪
动手前放出 8 个并行研究 agent:3 个翻自有平台的代码(对象存储、通知通道、已有 ASR 服务),5 个做 WebSearch(watchOS 后台录音、HealthKit 回查、DeepSeek 音频 API、Gemini 音频理解、叹气检测选型)。每个 agent 强制结构化输出:结论、细节(可直接抄的代码)、坑、来源。这一轮的价值在于,原计划里有四个”看起来理所当然”的技术选型,全部被研究结果推翻:
想当然一chat/completions 的 content 类型只有文本,没有任何 input_audio 字段,官方 voice agent 指南明说”API 不直接接收音频,走 STT → 文本 → TTS 三段式”。感知层只能用 Gemini,DeepSeek(deepseek-v4-flash,0.28/M out)退守纯文本摘要层。
想当然二audio_timestamp 配置就有音频时间戳。 实况:这个字段在公共 Gemini Developer API 上直接返回 400 “Unknown name ‘audioTimestamp’“,多个 GitHub issue 挂着没修,只有 Vertex AI 企业端点可能支持。可行替代是 prompt 工程:在 response_schema 里强制 start_time/end_time 字段(MMgemini-2.5-flash 处理 1 小时录音一次约 $0.15。
想当然三:音频事件检测用 YAMNet(教程最多、AudioSet 自带 Sigh 类)。 实况panns-inference,纯 PyTorch,MPS 直接可用);转写用 mlx-whisper 跑同一个 large-v3-turbo 模型,benchmark 比 whisper.cpp 快约 2 倍。研究还给了清醒剂
想当然四:上传复用平台现成的对象存储 presign 流程。 实况isDiscretionary=true 的 background URLSession——系统会刻意把传输推迟到充电 + Wi-Fi,可能是几个小时后。两者一拼,URL 必过期。结论:给手表专门建一个单请求直传的 ingest 端点,挂 write-only 静态 token(设备上永远不放完整身份凭证,这条教训上一个服务已经交过学费,见位置追踪服务上线记)。
watchOS 本身的研究结论也值得单独记:
- 后台录音的声明是
UIBackgroundModes=[audio]写在 Watch App target——不是WKBackgroundModes,那个 key 只有workout-processing一个合法值。 - 录音引擎选
AVAudioRecorder而非 AVAudioEngine tap上前者更稳,SoC 有硬件 AAC 编码器,5 分钟分块就是 stop → 新文件 → record,接受 100–300ms 间隙,靠每块的绝对时间戳对齐。 - 后台中断后不可能自动恢复。session 被 Siri/来电打断后,后台
setActive(true)直接报错,这是 Apple 工程师在论坛明确确认的平台硬限制。能做的只有:发通知请用户点开 app,回到前台再恢复。 - 静音检测不要事后解码音频算能量(费电),录音时开
isMeteringEnabled,每 5 秒updateMeters一次,块轮换时就有现成的 avg/peak dBFS。
阶段二:契约先行,五个 agent 并行实现
三端由不同 agent 并行写,接缝必然是重灾区,所以动手前先写死两份文档:architecture.md(含一张”原计划 → 修订 → 依据”的决策表)和 contracts.md(文件命名、JSONL 逐字段格式、ingest API、感知层统一 JSON、时间轴映射规则),所有 agent 的 prompt 都以契约为唯一事实源,文件所有权严格不相交。
时间轴映射是契约里最容易错的一条,值得展开:管线输出的时间戳是拼接音频时间轴上的秒数,但块与块之间有间隙、静音块整块被丢弃,拼接时间轴和墙钟之间布满空洞,绝不能线性换算。正确做法是用 manifest 里每块的 started_at 做分段映射:
拼接音频 [offset_k, offset_k + duration_k) → 墙钟 [chunk_k.started_at, +duration_k)每块的实际时长以 ffprobe 实测为准(AAC 编码器有 priming sample,5.000s 的块实测 5.056s),manifest 声称值只用来对账。
实现轮的结果xcodebuild 模拟器构建通过。如果故事到这里结束,那就是一篇平平无奇的”agent 写代码真快”。
阶段三:对抗审查,测试全绿不等于能上线
6 个审查 agent 并行开工:跨组件契约一致性、Swift 深审、ingest 安全审、backend 逻辑审、部署就绪校验,外加一个写 E2E 冒烟脚本并跑通的 agent(本地起真服务、ffmpeg 合成会话、乱序上传、worker 全流程)。抓出来的东西里有三个是”单测全绿但上线必挂”级别的:
一、共享 SQLite 连接的脏事务跨请求污染(已实测复现)。 ingest 服务整个进程共用一个 SQLite 连接,手动 conn.commit()。写路径是 async 的,async for chunk in request.stream() 处会让出事件循环。审查 agent 构造了一个结构合法但 chunk index 重复的 manifest:executemany 插到第二条触发 UNIQUE 约束抛异常,500 返回,但事务还开着。下一个完全无关的会话来 PUT 一个文件,它的 conn.commit() 把上一个失败请求的半截写入一起提交了——跨会话脏数据。为什么 77 个测试抓不到?因为每个测试用例都是独立连接、串行执行,根本不存在”失败事务残留给下一个请求”的场景。修法是根因级的:所有写路径改 with conn: 显式事务,端点最外层异常统一 rollback,并且 manifest 在碰数据库之前先完整 sanitize(去重、范围校验、标量检查、条数上限)。
二、合并窗 3 秒 < 滑窗 hop 5 秒,重复事件永远合并不上。 管线 B 用 10s 窗、5s hop 滑窗推理,同一声叹气必然在相邻两个窗口都触发;后处理”相邻 3 秒内同类事件合并”——但相邻窗口的起点间隔恰好是 5 秒,3 秒的合并窗一次都不会生效。这个参数是研究材料里原样抄来的,研究代码本身就带着这个 bug。修正:合并窗必须 ≥ hop(改 6s),事件时间戳同时从窗口起点改锚到窗口中心,消除相对 Gemini 精确时间戳的系统性偏早。
三、中断恢复链整条挂在一个经常不投递的通知上。 实现版的恢复逻辑:.ended 通知到达时若在后台,设 pendingResume 标志 + 发提醒;回前台时检查标志恢复。问题是 watchOS 后台场景 .ended 经常根本不来——一旦 .began 之后没有下文,标志永远是 false,提醒永远不发,UI 上 paused 状态只有一个”停止”按钮,录音就静默地永久暂停下去。对一个要验证”后台录音存活率”的 MVP,这是致命伤。修复后的恢复链不依赖任何单一信号:.began 时(非前台)立即发 time-sensitive 通知;回前台时只要状态是 paused 就尝试恢复,不看标志;UI 给 paused 态加”继续录音/结束”双按钮兜底。
除此之外还有一批典型问题.bad 隔离,下次视为 miss 重调);launchd 启动的 worker 拿到的 PATH 不含 /opt/homebrew/bin,裸调 ffmpeg 必然 FileNotFoundError(plist 里显式写 PATH)。
修复轮 3 个 agent 又干掉全部 high/medium 项,最终三端 72 + 125 + 122 = 319 个测试,E2E 冒烟全链路 PASS:
PASS: PUT manifest 先到 → 状态保持 uploading(乱序状态机正确)PASS: chunk 到齐 → uploaded;worker 跳过 silent 块,concat 实测 5.056sPASS: 双管线事件 ts_wall 均落在 manifest 时间窗内PASS: comparison events_matched_within_5s=1;telemetry 裸数组 3 条PASS: stats_line '叹气 1 · 笑声 0 · HR 均值 79' 符合契约格式E2E PASS — 全链路通过翻车记录:一个 agent 的死循环
研究阶段有个插曲:8 个 agent 里 7 个正常返回,第 8 个(平台侦察)卡了 25 分钟不动。翻它的 transcript,最后几十个 tool call 全是同一个动作——反复提交空的结构化输出,schema 校验失败,重试,再提交空的,再失败。378 行 transcript 里它就这么原地转圈。处置:直接掐掉整个 workflow,从执行日志(journal)里收割那 7 个已完成 agent 的结果,缺的那份侦察自己花五分钟读源码补上。教训有二:并行 agent 的产出要落在可恢复的地方(journal 里每条 result 都是完整的),以及给 agent 的结构化输出加一句”绝不能提交空对象”——听起来很蠢,但后续三轮 14 个 agent 再没复发。
另一个值得记的细节
收尾
现在的状态:三端代码全部完成并通过测试,ingest 服务在 feature 分支上等部署(平台是 push main 即上线,合并前要先在服务器上初始化服务身份和密钥),手表端等真机装上后跑四个实测场景——息屏、water lock、Siri、来电,每一个都要核对遥测里的 interruption 事件链。然后是一周真实使用:两条管线的叹气召回/误报对比、电量时间序列、触发次数统计。如果一周触发不到 3 次,结论就是需求伪,这个项目最大的产出反而是这篇方法论记录。
回头看,这一天里最值钱的不是 22 个 agent 的并行产能,而是两条流程纪律:研究先行(四个想当然如果带进实现,每一个都是返工半天起步)和对抗审查(三个必挂 bug 没有一个是单元测试能抓到的——它们全部藏在”环境的形状”里:并发的事件循环、滑窗的几何关系、通知系统的不可靠性)。测试全绿只说明代码和你想的一致,审查才能发现你想的本身就错了。