本文摘要
本文基于 nanoMuse v1.0.9 源码(main @ 067384b)拆解开源个人 Agent 的主动智能:一个 30 秒一醒的调度循环负责全部到期检查,到期事项经 SQLite 幂等账本投递给模型,输出由 [quiet] 提示词协议决定是否打扰用户,危险操作由 Sentinel 门控拦截。最反直觉的发现:模型跑失败也算“这一次发生过”,只有进程崩溃才重放。结论:主动智能的可靠性来自确定性的工程外壳,而非模型本身。

调研基线:nanoMuse v1.0.9 “Tiller”(main @ 067384b)。本文所有行号均指该 commit。

过去,是你去找 Agent 聊天——打开 App,敲下问题,它回答。而现在,Agent 会在合适的时间,根据你的需求主动来找你:提醒你、跟进你的目标、告诉你“那件事有进展了”。

这不是魔法,也不是什么新型模型,而是一套环环相扣的工程系统。今天我们以开源项目 nanoMuse 为例,从源码把“主动智能”拆开看。

先给一张全景图——一次主动行动的完整路径,从“到点了”到“找到你”:

一次主动行动的完整执行路径

下面每一节,都是在放大这张图的某一段。

一、一个循环,每 30 秒醒一次

整个“主动”的地基,是 MuseService 里一个普通的 Python 协程(service.py:2150-2187):

async def _goal_scheduler(self) -> None:
self.schedule_next_pass()
while True:
try:
self._run_due_reminders()
self._run_due_check_ins()
await self._refresh_calendar()
self._run_event_triggers()
await self._poll_mail()
self._prune_fired()
self._announce_wake()
await self._run_feed_routine()
due = self.next_goal_pass_at or datetime.now(UTC)
remaining = (due - datetime.now(UTC)).total_seconds()
if remaining > 0:
# short naps so a changed setting takes effect without a restart
await self._nap(min(remaining, 30))
continue
...
goal = self._pick_goal_for_pass()
if goal is not None:
self.advance_goal(
goal.id, occurrence=due.isoformat(timespec="seconds")
) # one goal per tick keeps the chat readable

没有 cron,没有消息队列,没有调度框架——就是一个 while True,每隔最多 30 秒醒一轮,依次问几个问题:有到点的提醒吗?有该跟进的目标吗?日历上快有什么事了?邮箱里有新邮件吗?

上面代码里的 ... 省略了几道闸门:proactivity 关了不推进、免打扰时段不推进、主聊天正忙时让路——结尾的总结图里你会再遇到它们。

值得注意的是:这一层的所有检查——_run_due_reminders、_run_event_triggers、_poll_mail——全是 SQL 查询和数据比对,不花一个 token。邮件轮询用 UID 高水位标记增量(service.py:2027-2054),日历触发是“事件标题匹配 + 距开始不足 N 分钟”(service.py:2070),都是确定性的代码。

确定性的活交给确定性代码。这一层不需要模型,这正是它可靠的原因。

小睡的上限是 30 秒,注释解释了为什么:改设置不用重启就能生效。而“每 tick 只推进一个目标”也是刻意的——注释原话:“one goal per tick keeps the chat readable”(service.py:2182)。

二、先记账,再做事:Outbox 与 Inbox

循环发现了“该做的事”之后,不是直接扔给模型,而是先经过一座桥——DeliveryOutbox。

它本质是一张 SQLite 表(outbox.py:38-54),每一行是一次“待投递的工作”,靠 source_key TEXT UNIQUE 这个唯一键做幂等:

CREATE TABLE IF NOT EXISTS scheduled_deliveries (
id TEXT PRIMARY KEY,
source_key TEXT NOT NULL UNIQUE, -- 幂等键,如 "goal:<id>:<时间戳>"
kind TEXT NOT NULL, -- reminder | goal | goal_check | trigger
...
status TEXT NOT NULL DEFAULT 'pending', -- pending → done
finalized INTEGER NOT NULL DEFAULT 0 -- done → finalized
)

调度器发现一件到期的事,先 claim()(service.py:1654-1676):尝试插入这行,唯一键冲突说明“这件事已经记过账了”,直接放弃。claim 成功才把任务排进对应会话的 inbox——一个内存里的 asyncio.Queue(service.py:331)。

这里有一个我认为整个投递机制里最精彩的决策。后台任务跑失败了怎么办?看 _worker 的注释(service.py:1388-1393):

# The attempt happened and the failure is on screen: the reminder, the
# routine or the trigger moves on to its next time rather than standing
# due until a restart. Only a run the process never finished (a crash, a
# shutdown) is replayed at the next start.

模型跑失败了,也算“这一次发生过”——提醒照常跳到下一次,而不是一直挂着,每次重启都重发一遍。只有进程真的崩溃了(任务从没跑完),下次启动才会重放。这一条规则,避免了“每天重启一次服务,用户被同一封邮件轰炸一次”的经典事故。

那失败的任务,就永远不做了吗?源码的答案分两种。目标推进失败:目标本身还在数据库里,下一轮 pass(默认间隔 60 分钟)会按“逾期优先、最久未动”再次选中它(_pick_goal_for_pass,service.py:1795-1799)——失败被转化为“下次再来”,而不是重试风暴。提醒则更干脆:mark_fired 的 docstring 写着“a routine moves to its next occurrence, a one-off is done”(reminders/store.py:188-190)——例行提醒跳到下一次,一次性提醒直接结束,失败通知留在屏幕上。宁可少发一次,绝不重复轰炸,这是刻意的取舍。

进程重启时,_recover_deliveries(service.py:1631-1636)先补账(把 done 但没前移计数器的 finalize 掉),再重放 pending 的任务。一张 SQLite 表,三个状态,换来的是效果上的恰好一次:投递层是 at-least-once(崩溃后重放),靠唯一键去重收敛。

三、Agent 终于开始思考——以及“计划绝不在背后被改写”

过了桥,才到模型。目标的数据结构很朴素(goals/store.py):Goal 有 active/paused/done/cancelled 四个状态,Step 有 pending/in_progress/done/blocked/skipped,全部存 SQLite,进程重启即恢复——“app 关了还在干活”的前提,是目标本身活在数据库里,而不是对话历史里。

每轮目标推进由一段提示词驱动(ADVANCE_GOAL_PROMPT,prompts.py:259-269),其中有一条边界让我印象深刻:模型发现计划不合身时,不允许自己改计划,只能提交提案(proposal)——用户在 App 里接受或拒绝。store 的 docstring 把这条原则写得很直白(store.py:10-11):

“The plan itself is never rewritten behind the user’s back.”

(计划绝不在用户背后被改写。)

而“这轮干完活,要不要告诉你”,同样不是代码判断的,是提示词协议:ADVANCE_GOAL_PROMPT 末尾按用户的 proactivity 档位注入一段措辞(prompts.py:315-326):

SURFACING = {
"low": "Reach out only if a step got finished or you need the user … "
"Otherwise begin the summary with [quiet].",
"default": "Reach out if there is real progress … If nothing changed … "
"begin the summary with [quiet] so the user is not interrupted.",
"high": "Always report, briefly, including 'still on track' updates.",
}

这个档位管的不止措辞——它是三根联动旋钮(service.py:197-198,237-239):

档位目标推进间隔*“要不要告诉你”(SURFACING 概述)连带影响
off完全不推进——没有自主行动,自然无话可说记忆整理也停摆(它占用同一个 tick)
low×2.0,默认 120 分钟只在完成一步或需要你出手时才开口,其余 [quiet]
default×1.0,默认 60 分钟有真实进展才开口;没变化就 [quiet]
high×0.5,默认 30 分钟总是简报,包括“仍在正轨”这类更新

* 间隔 = goal_interval_minutes(默认 60,可调范围 5 分钟至 24 小时)× 档位系数,下限 60 秒;改档位无需重启,30 秒内生效。

不受档位影响的,恰恰是你点名的那些:提醒、目标跟进消息、触发器照常必达;免打扰时段(quiet_hours)是另一根独立的旋钮,只管“几点不做”,不管“做多少”。

如果模型认为不值得打扰你,就在回复开头打 [quiet](QUIET_MARKER,prompts.py:311);_worker 用 split_quiet() 拆掉这个前缀(service.py:1367-1369),结果只进 Feed,不推送、不打断聊天。

对照之下,用户点名的提醒是另一套待遇——那些提示词里硬写着 “Never begin with {quiet}”(prompts.py:280,287,306)。**你点名的必达;Agent 自主的,要自证值得。**这个不对称,我认为是整个主动智能设计里最有人味的一条。

当然,这条协议也有它的失效模式,值得诚实说出来:它完全依赖模型的遵从性,没有代码兜底——翻遍 _finish_run(service.py:1439-1440),推送与否只取决于回复开头有没有那个前缀。漏打 [quiet],你会被多打扰一次;打得太滥,该说的没说,只能等你打开 Feed 才看到。两个方向的失效代价都不高,这大概是它敢用“一个字符串前缀”做控制流的原因。但如果要做成产品,我会给 quiet 率建一套评测集,持续监控模型对这条协议的遵从度——提示词协议的可靠性,本身就是一个需要被评测的对象。

四、安全门:自主性的笼子

模型决定做事之后,每一个工具调用都要过同一道门——Sentinel。策略是一条六步流水线(sentinel/policy.py:1-13):硬拒名单 → 显式规则 → 单工具覆盖 → 风险分级 → taint 追踪 → 危险特征。

先看第五步 taint 追踪:会话一旦读过私有数据(比如你的邮件、你的文件),后续再向不在白名单里的主机外发,一律升级为询问。gate.py:306-310 的注释写得很清楚——auto 模式跳过询问,唯独两个例外:带 warning 的调用,和会把本会话私有数据发出去的调用。

而第六步是最后防线,注释直接说明了它存在的理由(policy.py:144-152):auto 模式(自动放行)下,带危险特征的调用——比如 rm -rf、curl | sh——依然会被拦下,模式本身压不住它。这就是阻止无人值守的后台 pass 干出不可逆事情的机制。自主性每扩大一步,笼子就先加固一格。

五、最后一公里:找到你,以及手机闹钟的接力

第三节那个 [quiet] 前缀的最终去向,就在这最后一公里。跑完任务、过了安检、值得说的结果,通过两条路同时出发:系统级 Web Push(push.py,标准 VAPID,无第三方账号),加上用户标记为 “deliver here” 的 IM 通道(企业微信、飞书等长连接,channels/manager.py:519-527)。而打了 [quiet] 的结果只留在 Feed 里,安静等待你下次主动打开——“该不该打扰你”的判断,到这里才真正落地。

还有一个容易被忽略的工程细节,值得单独一提:手机休眠时,进程是冻结的,一个“睡到 7:30”的 Python 循环并不知道自己在 7:30 没醒过来。nanoMuse 的解法是一场接力(service.py:1712-1742、api.py:1939-1946):服务端算出“下一个到期时刻”,通过 WebSocket 广播出去;Android 宿主收到后设一个 setExactAndAllowWhileIdle 精确闹钟;闹钟响起,宿主拿着 45 秒的 wakelock 调 POST /api/tick,唤醒调度循环。没有到期事项,就不设闹钟——“Nothing is due → no alarm”。

结语:确定性的外壳,自主性的内核

把前面五节压成一张图,就是这样——循环在规则里打转,只在确实有事时,才把控制权交给模型:

循环到模型 loop 的分支总览

拆完这条流水线,再回头看开头那张全景图,你会发现一个模式:越靠前的环节越“笨”——时钟、SQL、唯一键、状态机,全是百年尺度的老技术;越靠后的环节越“聪明”——判断计划要不要改、判断值不值得打扰你,才是模型的工作。而最聪明的那一层,被外面每一层的“笨”包裹着:账本保证不重复,队列保证不抢话,门控保证不越界,提示词协议保证不啰嗦。

主动智能的可靠性,从来不是模型的问题,是系统工程的问题。外层的确定性,是内层自主性的信用背书——每一层的“笨”,都是在为里面那层的“聪明”买保险。

如果你也想做主动型 Agent,我从这份源码里带走三条:

  1. 能用 SQL 判断的事,不要调模型。
  2. 让模型自主行动之前,先建好账本(幂等)和门(审批)。
  3. “何时开口”和“如何行动”同样重要——给它一个像 [quiet] 这样便宜的沉默选项。

本文没覆盖的部分:goals 的证据式评估(goals/evaluation.py,一套独立的目标完成度评估,值得单独写一篇)、iOS 侧的后台实现、多 relay 场景。nanoMuse 的论文(arXiv:2610.08699)对这些机制的描述比源码粗得多——proposal 状态机、outbox 投递、[quiet] 协议,论文里都没有展开。