手机里那个 agent 想拿麦克风,要先过 7 道门
手机里那个 agent 想拿麦克风,要先过 7 道门
我在做一个跑在 Android 上的 agent,叫 Andee。他没有自己的服务器,也没有一台云端虚拟机——他就装在这台手机里,靠无障碍服务读屏幕、点按钮,用麦克风听你说话。你锁屏,他也跟着安静;你接电话,他就闭嘴。
11 月 5 日,Baseten 发布 Sandboxes,给每个 agent 一台独立的 microVM,断开就快照、25 毫秒唤醒,删掉 sandbox 就等于永久抹掉里面的数据。往前两周,10 月 22 日 Google 的 Agent Compute Engine 是同一件事的另一版画法,10 月 23 日 AWS 开源的 Infinity 把动机写得最直白:agent 花钱的地方不是思考,是等待;在他们的测试里,挂起的 agent 每个只占约 100KB,8GB 内存能塞下大约 7.5 万个。三家给出的是同一个答案:给 agent 造一台电脑,然后想办法在他不干活的时候把机器收回去。
这条路有一个前提,没人明说:那台电脑是空的,没有人在用它。
而他这台不是。他的电脑就是你的手机,而这台手机此刻正被你使用。这篇讲的就是这件事逼出来的唯一一条硬规矩:让路。
先看一个真实的下午
你戴着耳机在跟人微信语音。如果开过「锁屏时也听」,他在后台一直开着 AudioRecord,等着你叫他的名字。
Android 的麦克风不是共享的。一个 App 拿住不放,另一个就要等——不是等一会儿,是可能一直等不到。如果他这个时候不肯松手,电话那头的人听到的就是断断续续的你。
所以他必须让。不是"尽量让",是"一发现就立刻让"。这部分代码只有一个文件、五十九行,叫 CallState。
怎么知道"现在在通话"
第一反应都是问系统:AudioManager.getMode()。
这个方案在真机上碎得很难看,代码注释里老实记了三条:
MODE_IN_CALL在部分厂商的 ROM 上要电话权限,不给就永远读不到;MODE_IN_COMMUNICATION是在 App 已经把自己的麦克风打开之后才置上的——太晚了,等它变成真,唤醒词监听那边早就撞上去了;- 最关键的是,它完全不覆盖响铃那一段。电话正在响、还没接通,系统模式还是普通模式,而这时候对面的麦克风已经在被占用了。
所以判据换成了通知。通知是系统在事件发生的当下发出来的,比任何轮询都早。
判断分两层,因为没有任何单一信号能覆盖全部:
CATEGORY_CALL—— 平台自己的约定。AOSP 的电话、WhatsApp、Telegram 都会老实标上。这条路免费,一旦命中就不用再看别的。- 微信和 QQ 不标。 这是真机测出来的,不是猜的:2026 年 9 月 22 日抓到的那条语音通话通知,
cat=null。它们的通话通知里有一段独特的字——语音通话中、视频通话中、正在语音通话、正在视频通话、来电——而这两个 App 的普通聊天通知里永远不会出现这几个词(聊天通知长这样:“13个联系人发来66条消息”、“张三: 在吗”)。所以对这两个包名,退回文案匹配是安全的。
这五条中文串,代码里专门写了一段注释禁止它们走 AppLocale:
这些词是微信写进它自己通知里的字,跟我们 App 的语言设置没有关系。翻译它不会改变用户看到的任何一个字,只会让通话检测在"语言真的会变"的那些设备上静默失效。
原文更狠一点:翻译之后,一通漏掉的电话就永远不会变成一个事件——而且是静默的,没有任何报错。
为什么状态是个 Set,不是个 Boolean
这是整个模块里最有意思的一处。布尔值在两个边缘上会错:
并发通话。 WhatsApp 的未接来电横幅弹出来的时候,电话还在响。布尔值会在第一个"通知被移除"的瞬间清零,于是麦克风在一通还在进行的电话中间被打开。
通知更新。 Android 在同一个通知的文字变化时会复用同一个 key("正在呼叫…“变成"通话 00:12”),每一次都会再触发一次 posted。如果按次数计数,就永远清不干净。
两个问题被同一个选择解决:用 StatusBarNotification.key——平台自己给的唯一标识——放进一个 ConcurrentHashMap.newKeySet()。同一个 key post 两次看起来像一次,remove 只移除那一个。代码里就一句:private val activeKeys = ConcurrentHashMap.newKeySet<String>()。
还有一个细节差点漏掉:通知监听器被系统解绑(onListenerDisconnected)的时候,必须 reset() 清空。不清的后果是——那通电话的通知再也不会有人来报告"它没了",这个 key 就永久留在集合里,唤醒词从此永远不开麦克风。用户在设置里看着开关是开的,实际叫不醒,而且没有任何错误。
七道门
真正的门在 BodyCoreService 里,是一个七个条件的合取:
gate = {
!exitRequested && wakeListeningAllowed() && !asr.isActive() && !tts.isSpeaking() &&
!meeting.isActive() && !CallState.isActive && !Earcon.isSounding()
}
- 还能听吗:屏幕亮着,或者用户自己打开了「锁屏时也听」。
- ASR 空闲吗:对话自己的录音器不能输给一个等唤醒词的线程。
- TTS 静着吗:不然他会听见自己说出接近唤醒词的声音,然后被自己叫醒。
- 没在开会吗:会议录音(579 行)独占麦克风一小时。
- 没在通话吗:就是上面那套。
- 没有提示音在响吗:
Earcon那两声"叮"是他自己发的,但那也是扬声器在出声、也是同一套音频资源。
这七项里,前六项都好理解,第七项是那种只有做过才会想到的东西:他自己发出的声音,也算"别人在用麦克风"。
监管线程 100 毫秒问一次(POLL_MS = 100L),任何一项翻成 false,下一次问就会关麦。也就是说,一通电话打进来,最坏情况下他多占麦克风 100 毫秒。麦克风打不开的时候不是硬试,是退 2 秒再试(RETRY_MS = 2_000L);一次唤醒之后 2.5 秒内不再响应第二次(REFRACTORY_MS = 2_500L),免得一句拖长的话启动两个回合。
还有一处容易被当成多余:没有录过唤醒词的时候,他不持有麦克风。类注释写得很清楚——一个没配置过的安装不该占着麦克风,也不该让系统状态栏那个绿点为一个用户没开启的功能亮着。
让路之后,那句人话
如果电话正响着,你按了球想说话,startTurn() 里第一件事就是查通话状态,命中就只弹一句字幕然后返回:
通话中,等挂断了再跟我说吧
同一道墙还有两个入口:speakCardText()(会议或通话中不出声——在通话里说话会顶掉对方的音频焦点),followUpWindow()(说完话之后那个"等你说下一句"的窗口,在通话中不开)。
这里有一处不对称,我很喜欢。收听回合失去音频焦点的时候,只有来电会终止这一回合;别的东西想要焦点(视频自动播放、消息提示音、一个语音条),最多要回两次(MAX_REGRABS = 2),要不到就继续听。代码里的理由是:半句话比丢掉焦点更贵。
所以他对"谁更该拿麦克风"是有判断的,不是一律退让——电话是真的别人要用,弹个提示音不是。
这里现在不行
四条,都是真的:
- 只认通知。 纯 App 内的 VoIP——游戏里的语音、某些会议软件里的临时通话——既没有
CATEGORY_CALL,也不在那两个包名里。这种通话我们不知道,那段麦克风还是会抢。这不是"以后优化",是当前版本明确覆盖不到的地方。 - 那五个中文词是硬编码。 微信切成英文界面,这套兜底就失效。代码注释明说这是故意的:它只为微信和 QQ 存在,别的 App 要么走
CATEGORY_CALL,要么本来就无法和聊天消息区分开。 - 粒度是 100 毫秒。 一个刚响就挂的电话,可能只在他的监听里留下半声噪声,也可能刚好落在两次轮询之间。
- 更根本的一条:云上那台虚拟机至少有内核级隔离,他没有。 他和你共用同一个内核、同一份电量、同一个用户身份。这不是"更安全",是"更近"——近到必须学会在别人要用的时候消失。
最后
AWS 那句话说得很准:agent 贵在等待。于是大家把"等待"做成了一个可以暂停的状态,把机器收回,把成本压到零。
但这个结论只对一台空着的电脑成立。他这台不空。所以他要考虑的不是怎么被暂停,而是怎么在你接起电话的那一秒安静下来——省下的不是钱,是另一种代价:他得知道自己什么时候该消失。
那七道门里,没有一道是为省钱写的。
更多推荐

所有评论(0)