国庆假期我干了件事:让 Agent 平时睡大觉,数据一变就醒来干活
国庆假期我干了件事:让 Agent 平时睡大觉,数据一变就醒来干活
这个国庆我没出门,在家写了七天代码。
成果主要是两个东西:一个是给 OpenClaw.NET 提的 PR #274,给 MetaSkill 加上了幂等直调 API;另一个是新开的项目 DrasiWake,就干一件事——把 Drasi 感知到的数据变化,翻译成 Agent 会话的唤醒。
单看哪个都不算大动作。但把两个拼在一起,我心里惦记了很久的一条链路终于闭环了:数据变了 → Agent 被叫醒 → 恰好执行一次 → 出了故障也能收场。
这套玩法有个挺性感的名字:Ambient Agent,环境式 Agent。
今天这篇文章,跟大家聊聊我为什么做它,以及做的时候都踩了哪些坑、做了哪些取舍。
#
先聊聊:Agent 为什么非要"被唤醒"?#
我们现在用 Agent,基本都是"请求—响应"模式:你喊它,它干活,干完散伙。
但很多场景里,根本没有人来喊它。
库存跌破安全线了,谁去喊?竞争对手的财报半夜发出来了,谁去喊?产线传感器数据出现异常漂移,谁去喊?
让 Agent 7×24 小时轮询?烧钱不说,还笨。
所以我一直想搭一套 Ambient Agent:Agent 平时静默,环境里发生了值得关注的变化,才把它叫醒。 干完活,接着睡。
这个架构天然分三层:
- 感知层:定义"什么变化值得关注"。这块交给 Drasi——微软开源的持续查询引擎,写好查询,它盯着数据,结果集一变就吱声。
- 执行层:定义"醒来之后干什么"。这块用 OpenClaw.NET 的 MetaSkill DAG,可复用、可审计的工作流,支持依赖调度、并行步骤、失败兜底,甚至能中途停下来等人审批。
- 桥接层:把"查询结果集变了"翻译成"去叫醒 Agent"。
感知和执行都有现成的轮子,差的就是中间这座桥。而搭桥之前,我发现下游执行层还缺一块地基——这就是 PR #274 的由来。
第一块:给 MetaSkill 装上"幂等"这块压舱石#
动手写桥之前,我先问自己一个问题:桥把唤醒发给网关,发到一半桥崩了,重启之后怎么办?
重发?网关可能已经执行过了,有副作用的工作���跑两遍,事故。不重发?万一网关根本没收到,这次变化就永远丢了,也是事故。
经典的两难。解法也是经典的:幂等键 + 服务端账本。所以我国庆第一件事,就是给 OpenClaw.NET 的网关加了一个新的认证端点:
POST /api/integration/meta-invocations
外部系统可以通过它,直接按名字调用一个 MetaSkill DAG。重点在配套的几件事:
第一,支持 Idempotency-Key。
同一个键重发请求,拿到的是上一次的结果重放,而不是再跑一遍。做过支付对接的朋友都懂这意味着什么——重试从此不再是赌博。
第二,落了本"账"。
网关在持久化存储里维护一份调用账本,记录请求哈希和调用状态。进程重启之后,没干完的活能被正确接管,不会变成没人认领的孤儿。账本保留多久由 Gateway.MetaInvocations.RetentionDays 控制,带校验和默认值——这个配置后面还会出场,是个伏笔。
第三,也是最想跟大家聊的一点:我把"不确定"建模成了一等公民。
一次调用发出去,如果网关在"可能已经执行了、但没来得及确认"的窗口期崩了,调用方看到的是什么?
不是成功,也不是失败。是不知道。
我见过太多系统在这个角落里装死——超时了就当失败,让上游自己看着办。但"不知道"和"失败"是完全不同的两件事:失败了可以放心重试,不知道就贸然重试,可能就是把同一个工作流执行两遍。所以这个 PR 显式定义了 uncertain 状态,配合冲突响应,把"结果不明"如实告诉上游。
还有两个细节,是 code review 之后才补进去的,都是分布式系统里最疼的那类问题:
一个是顺序。MetaSkill 执行产生的会话审计和 checkpoint,会在恢复用户身份之后、账本标记完成之前持久化。不然可能出现"账本上写着完成,审计日志里却查无此事"的灵异事件。
另一个是诚实。如果完成状态写库失败了,返回的冲突响应里会显式带上持久化警告——等于明说:"这次结果可能没记下来,你别当幂等成功处理。"
运行时层面,IAgentRuntime 上新增了 InvokeMetaSkillAsync,Native 运行时和微软 MAF 运行时都实现了,序列化走源生成 JSON 上下文,NativeAOT 场景也不会炸。
地基打完,桥正式开工。
第二块:DrasiWake,一座"克制"的桥#
DrasiWake 是个独立的 .NET 10 Host。写它的时候,我给自己定的定位克制得近乎固执:
感知是 Drasi 的事,执行是 Gateway 的事,我只负责翻译。
但就是这份克制,让我能把翻译这件事做扎实。挑几个我觉得最见功力、也最纠结过的地方说。
通知只是提示,结果集才是真相#
这是我在运维手册开篇第一句就定下的话:Drasi 的 attach 通知只是提示,不是持久化事件日志。
为什么把这句话放这么重的位置?因为进程重启后,通知流里发生过的事情不会重放。你要是把通知当事件日志消费,漏了就是漏了,这个坑我见人踩过。
所以 DrasiWake 的姿势是:启动恢复要重新读结果集,重连之后要读,定期对账要读,队列溢出之后还是要读。一切向 Drasi 持续查询的当前 results 看齐。
它追求的是收敛到最新真相,而不是不重不漏地消费每条中间变化。一个还没派发的唤醒,如果被更新的快照取代了,直接标记为 Superseded——旧变化就不用叫 Agent 了,反正它醒来看的是最新状态。
这是"状态同步"的思路,不是"事件溯源"的思路。选型上没有优劣,但对"叫醒 Agent"这个场景,我认为是对的。
先记账,再喊人#
每个待处理的唤醒,派发之前先持久化,存在 SonnetDB 里。Gateway 的受理结果和快照 checkpoint,在同一个数据库事务里提交。
最关键的一笔是:如果进程在调用 Gateway 的半路上崩了,重启后会复用原来那个幂等键重新发。
看到这儿你应该反应过来了——PR #274 里那个 Idempotency-Key,就是在这里咬上的。桥这边崩了重发,网关那边查账发现"这单我见过",直接重放结果,MetaSkill 不会被傻乎乎地执行两遍。
先写 PR #274 再写 DrasiWake,顺序就是这么定的。一把钥匙一把锁。
"不确定"的死信,而不是"不确定"的成功#
DrasiWake 的 outbox 状态机里,有两个状态我想单独说说。
一个是 Accepted。网关受理了,checkpoint 也提交了,但执行完没完成?不知道。 我在文档里特意强调了一句:Accepted 不等于 Completed。
另一个是 DeadLetter。什么情况下进死信?契约失败、重试耗尽,还有一种——调用结果不确定。
也就是说,网关那边回了"我不确定这次算不算数",桥这边的处理不是睁一只眼闭一只眼当成功,而是老老实实进死信,挂上固定的错误码,等人来看。
宁可误杀,不可放过。在涉及副作用的系统里,我认为这是唯一正确的姿势。
一条写在启动校验里的跨系统不变量#
这个设计,是整条链路里我自己最得意的一笔。
前面埋的伏笔该回收了:网关那边有个配置 Gateway.MetaInvocations.RetentionDays——幂等账本保留多少天;桥这边每个绑定有个 retry.maxAgeSeconds——一个唤醒最长重试多久。
设想一下:如果网关的账本只留 1 天,而桥这边某个唤醒重试了 3 天还没成功,第 4 天再发——网关账本早清了,把重放误判成新调用,工作流又跑一遍。
跨系统的配置漂移,分布式事故的经典剧本。运行时根本防不住,因为它发生在两个系统的配置缝隙里。
我的做法是把这颗雷挪到启动期引爆:DrasiWake 启动时逐目标硬校验——网关的幂等保留时长,必须覆盖引用它的所有绑定的最大重试窗口。不满足?拒绝启动。
宁可起不来,不带病上岗。同样的执拗还有几处:SonnetDB 目录有所有权锁,一个目录只许一个活动 Host 持有;活动记录引用了已删除的绑定?拒绝启动。
遥测里的隐私洁癖#
顺手提一句遥测,因为这块我也花了心思。
指标和链路该有的都有,但所有标识符都是 SHA-256 派生的短 ID。事实数据、Bearer 凭据、幂等键、原始会话标识,一律不进 tag。日志只记固定错误类别和哈希过的查询标识,连异常消息和载荷内容都不记。
默认配置甚至连 exporter 都不接——你不主动配,一个字节都不会发出去。遥测不该成为数据泄露的旁路,这条线我守得很死。
验证:过了 109 个测试,我却不肯说它"准备好了"#
最后这部分,可能比技术本身更想跟大家分享。
DrasiWake 的测试我分了两层。一层是确定性集成测试,用本地 HTTP 契约处理器模拟服务端,验证收敛、幂等重放、崩溃窗口。另一层是三个真实服务检查,默认跳过,显式打开才跑——其中的 Gateway contract 会真的建立会话,并发发送相同的 MetaInvocation 请求,验证幂等键只产生一次调用。
10 月 3 号那天,完整解决方案 109/109 全部通过,包括三个真实检查。
然后呢?
然后我在文档里白纸黑字写下:这只是在本地 Aspire fixture 栈上验证的,不等同于对目标部署环境的契约认证。在你自己的环境里把三个真实检查跑通并记录结果之前,V1-ready 门禁保持关闭。
有朋友问我,测试全绿了为什么还不宣布 ready?我的答案是:本地 fixture 全绿,证明的是"我的实现和我的测试约定一致",而部署环境用的是真实版本、真实配置、真实网络,那是另一份契约。没验证过就说 ready,是对用的人不诚实。
过了全部测试,却不宣称就绪。我知道这种诚实有点反直觉,但它是我做这个项目最想守住的东西。
串起来:一次唤醒的完整旅程#
把整条链路捋一遍,一个"数据叫醒 Agent"的完整生命周期是这样的:
- Drasi 的持续查询发现结果集变了,发个通知。通知本身不重要,重要的是桥被提醒"该去看看了"。
- DrasiWake 读取当前结果集,收敛到最新快照。如果上一个唤醒还没派发就被新快照取代,旧的直接作废。
- 唤醒连同幂��键先落库,再向网关发起
POST /api/integration/meta-invocations。 - 网关查账:键见过?重放结果。没见过?记账,接管租约,跑 MetaSkill DAG。审计和 checkpoint 先于账本完成落库。
- 桥这边靠回执推进状态:受理了不等于完成,执行中不等于完成,直到收到完成回执。
- 任何一环出现"不确定"——网关如实说,桥如实记,进死信,等人来。
四条不变量,概括这条链路的气质:
- 权威在状态,不在事件——收敛而非逐条消费;
- 幂等键贯穿崩溃边界——重启重发不会重复执行;
- "不确定"是一等公民——两端都不许默认当成功;
- 跨系统配置不变量前置校验——雷在启动期引爆,不在凌晨三点引爆。
写在最后#
这个假期写完代码,我自己复盘了一下:这两个东西里没有炫技,没有新概念,全是不性感但救命的工程细节——幂等键、账本、租约、对账、死信、启动校验。
但我越做越觉得,Ambient Agent 真正难的问题从来不是"让 Agent 做什么事",而是另一组:怎么在对的时机叫醒它?怎么保证恰好执行一次?怎么在进程崩溃、网络抖动、结果不明的各种烂摊子面前,体体面面地收场?
PR #274 在执行层补上了幂等和"不确定"建模,DrasiWake 在桥接层用 outbox、对账和启动期校验,把这份可靠性一路传导到感知层。
如果把这两个项目的设计哲学浓缩成一个词,我会选:诚实。不确定就说不确定,没准备好就说没准备好,配置不对就拒绝启动。
系统如此,写系统的人也该如此。
这大概才是能托付生产的软件该有的样子。
相关链接:
- OpenClaw.NET PR #274:https://github.com/clawdotnet/openclaw.net/pull/274
- DrasiWake:https://github.com/Ai4c-AI/DrasiWake
- Bridge Core V1 运维手册:https://github.com/Ai4c-AI/DrasiWake/blob/main/docs/bridge-core-v1-operations.md
(DrasiWake 当前为 V1 阶段,单 Host、非 HA,语义以后续版本为准。欢迎来仓库围观、提 issue。)
评论