国庆假期我干了件事:让 Agent 平时睡大觉,数据一变就醒来干活

这个国庆我没出门,在家写了七天代码。

成果主要是两个东西:一个是给 OpenClaw.NET 提的 PR #274,给 MetaSkill 加上了幂等直调 API;另一个是新开的项目 DrasiWake,就干一件事——把 Drasi 感知到的数据变化,翻译成 Agent 会话的唤醒。

单看哪个都不算大动作。但把两个拼在一起,我心里惦记了很久的一条链路终于闭环了:数据变了 → Agent 被叫醒 → 恰好执行一次 → 出了故障也能收场。

这套玩法有个挺性感的名字:Ambient Agent,环境式 Agent。

今天这篇文章,跟大家聊聊我为什么做它,以及做的时候都踩了哪些坑、做了哪些取舍。

Ambient-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"的完整生命周期是这样的:

  1. Drasi 的持续查询发现结果集变了,发个通知。通知本身不重要,重要的是桥被提醒"该去看看了"。
  2. DrasiWake 读取当前结果集,收敛到最新快照。如果上一个唤醒还没派发就被新快照取代,旧的直接作废。
  3. 唤醒连同幂等键先落库,再向网关发起 POST /api/integration/meta-invocations。
  4. 网关查账:键见过?重放结果。没见过?记账,接管租约,跑 MetaSkill DAG。审计和 checkpoint 先于账本完成落库。
  5. 桥这边靠回执推进状态:受理了不等于完成,执行中不等于完成,直到收到完成回执。
  6. 任何一环出现"不确定"——网关如实说,桥如实记,进死信,等人来。

四条不变量,概括这条链路的气质:

  • 权威在状态,不在事件——收敛而非逐条消费;
  • 幂等键贯穿崩溃边界——重启重发不会重复执行;
  • "不确定"是一等公民——两端都不许默认当成功;
  • 跨系统配置不变量前置校验——雷在启动期引爆,不在凌晨三点引爆。

写在最后

这个假期写完代码,我自己复盘了一下:这两个东西里没有炫技,没有新概念,全是不性感但救命的工程细节——幂等键、账本、租约、对账、死信、启动校验。

但我越做越觉得,Ambient Agent 真正难的问题从来不是"让 Agent 做什么事",而是另一组:怎么在对的时机叫醒它?怎么保证恰好执行一次?怎么在进程崩溃、网络抖动、结果不明的各种烂摊子面前,体体面面地收场?

PR #274 在执行层补上了幂等和"不确定"建模,DrasiWake 在桥接层用 outbox、对账和启动期校验,把这份可靠性一路传导到感知层。

如果把这两个项目的设计哲学浓缩成一个词,我会选:诚实。不确定就说不确定,没准备好就说没准备好,配置不对就拒绝启动。

系统如此,写系统的人也该如此。

这大概才是能托付生产的软件该有的样子。


相关链接:

(DrasiWake 当前为 V1 阶段,单 Host、非 HA,语义以后续版本为准。欢迎来仓库围观、提 issue。)

posted @ 2026-10-07 06:58  张善友  阅读(53)  评论(0)    收藏  举报