计划没有错,错的就是计划:我给 Agent 装心跳的五次排雷

1 阅读13分钟

计划没有错,错的就是计划:我给 Agent 装心跳的五次排雷

上一篇聊 Claude Duet 的文章结尾,我留过一句伏笔:“还有两个我还没修完的坑”。

写下那句话之后,在修坑的过程中,坑一路裂变成了五个,外加一条凭据覆盖链的运维发现。到今天,五个代码缺陷全部修复、带回归用例、活体验收通过、已推送合并。

这篇把整条排雷记录完整复盘。

先说结论:五个坑里最深的一个,根因不在代码,而在计划文档。 计划没有错,错的就是计划——实现忠实执行了计划,而计划里埋着一个谁都没写出来的致命假设。

1. 背景:心跳是什么,为什么值得排雷

我的 Agent 工作台是纯消息驱动形态:平时靠几个 IM 渠道接活,用户不说话,它就处于绝对静默。

上一篇提过,要想往“主动式 Agent”演进,第一步是给它装上心跳——一条完全不依赖用户输入的主动唤醒路径。第一阶段落地的基本盘如下:

  • 待办意图(open_loop)解耦:挂载到期时间(due_at)与唤醒契约,到期由调度器自动物化成执行任务;
  • 心跳巡逻:定时自省,默认关闭,间隔下限设为 30 分钟,并严格划定静默窗口;
  • 宿主代写回:scheduled 轮次对记忆强制只读,产物必须交由宿主校验后代写——“醒来”绝不逾越治理红线。

第一阶段累计新增代码 3300 行,存储迁移至 Schema v75,全量测试套件 3688 通过、45 跳过(跳过项均为 Docker 未拉起的批量标定,非回归破损)。

为了稳妥,验收直接在隔离实例上进行——在真实数据旁边起了一套平行的影子环境,生产存储一行未碰。冷启动迁移、准点唤醒、执行边界约束、开关持久化、owner-only 治理红线,五项全数通过。

然而,就在影子环境的验收现场,深坑开始接二连三地冒了出来。

2. 缺陷 A:两条写路径的 CAS 撞车

  • 现象:某个明明已到期的意图,该醒的时候毫无动静。翻遍日志无报错,状态机无异常,整条意图凭空蒸发——典型的“静默吞”。
  • 定位:心跳系统中有两条异步写路径会命中同一行状态:一条是同步任务,负责将意图定义周期性同步入库;另一条是物化调度,负责把到期的意图翻转为待执行任务。

物化为了防止重复执行,走的是严格的 CAS 条件更新:WHERE next_run = ?,拿版本当排他锁。撞车点正落在此处:

同步路径先行改写了到期游标;等物化路径的 CAS 赶到现场时,版本号已对不上,受影响行数返回 0。在并发乐观锁设计中,“0 行影响”本是标准的“已有并发先登、本操作让位”信号。但此时跑在前面的根本不是其他物化者,而是一个做元数据同步的旁路工具。物化操作悄无声息地撤退,意图永久停留在原地,无人认领。

同步路径:  改写 next_run ─────────┐
                                  ├──► 同一行,两次写,语义互不知情
物化路径:  WHERE next_run = ? ───┘     └─► 0 行让位,意图被静默吞掉
  • 裁定(A1) :严格做职责切分。 同步路径剥离到期游标的修改权,仅负责无运行时间的新意图落库与静态定义漂移修复;推进到期游标的独家写入权,全量收归物化路径收口。
  • 教训:当两条写路径共享同一行记录时,字段的“写所有权”必须物理级划界。 “0 行让位”这一信号,只有当竞争双方意图完全同构时,才代表“别人替你做完了”;一旦一方改元数据、一方改业务游标,0 行的真实含义就是“任务被静默吞掉了”。 显式报错好抓,静默丢失才最要命。

3. 缺陷 B:计划没有错,错的就是计划

  • 现象:隐蔽性更强。某次心跳按时唤醒,但下游执行因环境波动失败了——结果该跟进任务自此消失,重试从未触发。
  • 定位:排查调用链后发现,只要物化成功的那一秒,到期游标就被当场消费、翻转推进——完全不顾下游模型 run 的成败。失败的那一轮,游标早已被前推,意图在账本上被标记完成,重试机制根本没有介入点。

翻开当时的设计文档对照,出现了整场排雷最令人背脊发凉的一幕:

设计文档 二 白纸黑字写着: “物化成功即写游标” 。

实现与计划一字不差。代码甚至没有 Bug,Bug 扎扎实实写在计划里。

计划在拟定阶段,理所当然地默认了一条隐蔽假设: “物化成功 ≈ 后续执行必然成功” 。但在大模型工程与真实分布式链路中,执行失败从来不是罕见异常,而是系统的绝对常态——推理端超时、网络抖动、限流熔断,皆为家常便饭。

  • 裁定(B1) :落地下游容错三件套:

    1. 失败重试强制携带 :attempt 结构化后缀,保障链路重试全流程可溯源;
    2. 针对同一意图的同一到期时间点,死磕重试上限收归 3 次;
    3. 状态推进解耦:仅当拿到成功终态时才真正消费游标;失败绝不前推,重试明确记账。若 3 次预算耗尽,发出结构化报警暴露可见状态——意图可以执行失败,但绝不允许死无对证。

这与 Anthropic 在 2026 年 3 月发布的长时程 Agent Harness 实践( 《Harness design for long-running application development》 ,Prithvi Rajasekaran)中的核心观点深度共鸣。文中两句话点透了关键:

  1. 不要寄希望于让模型靠上下文凭空记住状态,而应强制其每轮从结构化工件中冷读取——这正是我坚持用持久化游标与待办账本管理状态、坚决不信赖 Session 的原因;
  2. Harness 里的每个组件,本质上都在对“模型当下做不到某事”进行防御性假设编码,这些假设必然会随着环境演进而失真失效。

反推到架构设计亦是如此:计划文档中的每一句话都是一种业务假设,而未经落笔成文的“默认前提”,杀伤力最大。“物化即写游标”短短几个字背后,暗度陈仓地塞进去了“执行必然成功”的臆想,从未被拿出来置于聚光灯下推演。

4. 其余三坑:快节奏带过

在排查 A 和 B 的间隙,影子环境现场又接连撞出另外三个缺陷,记录在案:

  • 缺陷 C:静默窗口时区符号反转 单元测试套件全部跑在标准的 UTC 环境下,时区 Offset 恒为 0,导致符号写反在 CI 里直接陷入“结构性失明”。一放到东八区的影子环境里,静默窗口直接硬偏移 16 小时——白天巡逻全部静默,半夜深睡疯狂起振。教训:单测全绿往往只证明代码和测试共享了同一个错觉,只有活体环境才能证明这个错觉是否符合客观世界。 这个教训在后来的污点追踪模块中再次复现——写入逻辑与用例写了相同的折叠 Bug,单测绿得发亮,影子实例一跑原型毕露。
  • 缺陷 D:退避阶梯的基准锚点错位 重试原本挂载的退避阶梯把基准锚定在任务原始的 due_at 上。导致在节点挂起后的断点恢复场景下,due_at 早已是过去的时间戳;步长阶梯瞬间被减法吃平,3 次重试预算在几秒内被毫无节制地打光,硬生生全量撞击在尚未恢复的同一故障窗口内。 修复后基准严格锚定在上一次实际失败的时间戳:第 1 次退避 5 分钟,第 2 次退避 20 分钟。退避的本质是“留出时间等待外部世界恢复自愈”,绝非“加速把重试预算就地焚毁”。
  • 缺陷 E:幂等键长度超限爆裂 命名空间经过参数拼装后直接冲到 142 字符,硬撞底层 128 字符截断上限。在修复前的真实日志中,赫然躺着 idempotencyKey is too long 的报错(此前甚至出现过一次 114 字符的危险擦边)。 随后全部重构为基于持久化任务的 run_id 派生,稳定收敛至 95~123 字符区间,并在该次提交中将“下游拒绝时必须显式回滚/释放游标”的补丁一并打齐。

补充记录:验收中途端到端框架曾报出一次与库表及日志完全对不上的工具端失败——按既有排查规范,凡现场取证无法三方印证的一律判定为不可采信伪失败,坚决复核后才定性。这套纪律是在先前重构 deer-flow 时刻进肌肉记忆里的。

5. 一条“运维发现”:密钥覆盖链

除上述代码缺陷外,还有一起虽不属于业务逻辑、但极具实战代表性的事故。

心跳初次对接到实体上下文时,鉴权大面积挂死,报错均为 401 或超温超时。追查发现 10 月 4 日配置的旧密钥已过期。密钥轮转本无波澜,但离奇的是轮转推入后请求仍然报 401。

最终排查出的根因令人啼笑皆非:.claude/settings.json 本地配置内的 env 代码块,在容器启动的环境变量注入链路上,优先级高于全局 Provider Pool——外部池子虽然换了新钥匙,但宿主配置文件私下把过期的旧钥强行重新压进了进程中。

处理手段很干脆:池化中心与宿主配置协同覆写、备份归档留痕。此处需要划清口径界限:前面五个代码缺陷均有严谨的单元回归用例在仓库可查可验;而密钥覆盖链是一起典型的外部环境注入层运维发现,不属于带单测的代码缺陷范畴。两者必须分账论述,绝不模糊边界。

6. 活体验收:让数字自己说话

系统稳定性不是靠辩解得来的,数据才能一锤定音。在影子实例中,全链路活体验收沉淀了以下这组实测指标:

验收项实测表现 / 指标工程语义与现场实证
重试退避语义上限 3 次;阶梯设为 +5′ / +20′严格锚定上一次失败时间戳,阻断雪崩
退避实测精度:1 = +300.003s :2 = +1200.001s提取自两条完全独立的 Trace,阶梯吻合度达到毫秒级
唤醒认领延迟16ms / 8ms退避窗口到期即刻拉起调度,确定性抢占,非区间轮询
耗尽报警可见化exhausted 告警严格触发 1 条3 次配额耗尽后必现结构化报警,无遗漏、无轰炸
真实链路自愈遭遇 2 次超时后第 3 次成功拉起历经 15.9s / 16.7s 两次 Request timed out 强断后,跟进最终完整抵达真实用户端
契约写回落地postpone 1 例 / close 1 例宿主代写回机制的两条分支路径在活体中均打通闭环
幂等键收敛95~123 字符(上限 128)根治此前 142 字符超限溢出的问题,留出充足安全裕度

其中最具备说服力的是真实链路自愈这一项:遭遇两次真实发生的 Provider 超时——如果是缺陷 B 尚未解决前的架构,这条跟进在此刻早已无影无踪;而在退避重试与状态解耦合力作用下,系统硬是在第三次尝试中扛过波动,成功将任务交付给了终态用户。

在实验室里写一千行 Mock 测试,不如在真实风浪中让系统亲自接住一次真正的故障。

7. 沉淀:心跳可以空转,不可以吞意图

五次排雷落地,沉淀下三条不可动摇的系统共识:

  1. 计划文档里的每一句话都是一个假设,没写下来的假设最危险。 “物化成功即写游标”之所以致命,在于它擅自绑定了“执行必然成功”这一前置幻想。设计评审时,花精力把每个动词背后默认的隐含前提全部晒到台面上,其价值远大于单纯审核代码实现。
  2. 状态机推进必须与执行结果解耦。 “到时间该去做了”和“事情已经做成了”是两个互为因果但物理独立的事件,账本必须拆开记。把二者混淆在单个状态更新中,下游失败就直接等价于全流程人间蒸发。
  3. 失败要留痕,静默要报警。 意图可以死于执行失败、可以耗尽预算停机、可以被人为干预丢弃,但每一条终态的归宿都必须在可观测面板上清晰可见。正如 Anthropic 那篇文章所强调的,结构化工件的核心职责是让下一个接手的执行体能够干净、无歧义地接着跑——哪怕那个接手者,是三天后在半夜爬起来排障的你自己。

顺带提一句,贯穿这一系列排雷的核心原则——“失败时究竟该怎么办”,我已整理为另一篇独立的工程思考:《fail-open 还是 fail-closed:看职责,不凭偏好》。核心判据其实极简:守卫位失败即拦截,增强位失败即放行,永远衡量“误放”与“误拦”哪一个带来的系统代价不可逆。

至此,上一篇文章欠下的技术账正式结清:当时我谈及自研架构与托管式 Agent 的核心落差在于“心跳自治”,如今这一块拼图已被彻底补齐。连同此前落地的污点追踪系统,环境隔离、凭据替身、独立授权方、污点追踪——安全纵深四件套至此已全数在本地工程化落地。与 Grok、Dots 相比,剩下的唯一差异只关乎主权:我的数据一行也不出这台机器。

至于心跳模块的生产环境全局开关——目前依然关着。

按照系统自身的上线纪律:这套新机制必须在隔离环境下顶着真实负载再压一阵,外部密钥链路再巡检一轮,才真正轮到它在生产中苏醒。

上一篇说还有两个坑没平;现在,五个暗坑全部踏平排尽,而系统依然选择克制。在软件工程里,我认为这是一个比盲目宣告“大获全胜”要成熟得多的结尾。

参考资料

  • Anthropic, 《Harness design for long-running application development》 , 2026-03-24 (Prithvi Rajasekaran). 出处:anthropic.com/research/ha…