返回履历
Intuit · GTM Technology

重放 Agent

一个拥有生产环境写入权限的 AI agent

一个执行高危生产数据操作的 AI agent——把数百万条历史客户记录重新跑一遍 Intuit 的营销流水线——这项操作过去需要专职人员断续盯上一到三天。它可以无人值守连续运行数天,在自己的服务器于运行途中被销毁并替换后依然能接着跑,通过 Slack 和 JIRA 协调三个团队,经由一套经过审计的审批流程写入生产环境,并独立核验数据在每一个目的地都正确到达。

1.33 : 1
测试代码与生产代码之比
22
持久化工作流状态数
6 周
从零到上线
真正的难点

核心的工程难题不是让它跑起来,而是让它安全地失败。

01

一忘就出事的操作

客户数据从源系统出发,经过一条流水线,流向三个目的地:数据仓库、Adobe Experience Platform 和 Marketo。一旦下游哪个环节数据落错了,修复办法就是做一次 replay(重放)——把历史记录重新发布一遍,让它们再走一遍全流程。

replay 不是只读操作,这正是问题的全部所在。要跑一次 replay,你得先在生产数据库里临时调低这批数据的优先级,等上几个小时甚至几天,再把优先级调回来。漏掉最后这一步,或者跑到一半就挂掉,生产环境就会无限期地停留在错误配置上,而线上的客户数据还在以错误的优先级持续流入。没有人会收到告警,因为什么都没有崩溃。

手工做的话,这是断续盯上一到三天:在聊天里协调三个团队、手工编辑生产数据库、盯着仪表盘、抽查两个目的地,还要记得把改动撤销。这一整套操作是否正确,全靠有没有人忘记。

02

扛过自己被销毁

这个 agent 跑在 Kubernetes 上,而 Kubernetes 会经常性地销毁并重建它的服务器——一次发布、一次机器故障、一次触及内存上限,都会触发。一次 replay 要跑上好几天。所以这个 agent 会在运行途中被反复杀掉,这是家常便饭。

它是一台 22 个状态的状态机,每走一步都会把完整状态写入持久化存储。这里的正确性论证比“我们保存了进度”要强得多:决定下一步做什么的 17 个函数,全部是基于已保存状态的纯函数。没有一个会去读时钟、读网络、读随机源,或者读某个模块级全局变量。所以恢复运行不是一次可能和系统原本行为对不上的重建,而是拿同样的输入,把同一套计算原样重跑一遍。

负责推动工作流往前走的那个组件,刻意不持有任何状态,每个周期都会把一切从持久化存储里重新读一遍。Kubernetes 滚动发布时,新旧两台服务器会有几秒钟同时在跑,两边都在驱动同一个 replay。放在大多数设计里,这是一个严重的重复处理 bug;而在这里,两个驱动者产生的结果和一个驱动者完全一样——这是架构上刻意设计出来的。

有一类失败,光靠持久化状态解决不了:这个 agent 用来做身份认证的凭证,10分钟就会过期,而一次 replay 要跑上好几天。持久化状态能回答“接下来该做什么”,却回答不了“该用什么凭证去做”。

03

死不松手的锁

如果一次 replay 调低了生产优先级,还没来得及恢复就挂了,生产环境就处于错误配置状态。这时如果第二次 replay 又在有重叠的数据上启动,它会把当前那个——已经被调低过的——值当成“原始值”读进来,跑完之后再恢复到这个错误的基线上。真正的原始值,从此在任何地方都不存在了。没有报错,没有告警。

所以,一次结束时还欠着一次恢复没做的 replay,不会释放全局锁,后续每一次 replay 都会被挡住,直到有人来处理。真正见分晓的细节在于:系统判断不出来的时候会怎么办——一份读不出来的记录表,意味着“判断不出来”,而“判断不出来”会被当成“还欠着恢复”来处理,锁就这样被继续持有。

这是刻意的取舍:宁要一个吵闹、显眼、烦人的失败,也不要一个安静、永久、看不见的失败。同样的思路,出现在每一处“卡住的锁本可以自动清除”的地方:悄悄把它放开,等于是拿一把安全的卡住的锁,去换一个安静但不安全的“生产环境一直没恢复,谁都不知道”。

04

LLM 该出现在哪里,不该出现在哪里

这个 agent 在好几个地方用到了 LLM。但它刻意不用 LLM 来判断一份数据核验报告到底是不是通过——这个结论本来就已经是结构化的数字了,让模型去重新读一段写着“通过”的文字,等于让一次生产 replay 只隔一次幻觉的距离。真正的判断,就是四个普通的布尔比较。

用到模型的地方,它负责写审批闸门通知里那些给人看的文字——它从来不决定通知发给谁,也从来不决定流程往哪个分支走。如果它彻底失效,系统会回退到一套确定性模板,所以一道闸门永远不会被模型卡住。

即便有这样的限制,还是差点酿成一次事故。一次 LLM 改写审批闸门通知的时候,悄无声息地删掉了其中两条事实:一条范围不匹配的警告,还有一个 pull request 的链接——而这恰恰是那条本该拦下一次错误合并的消息里最关键的部分。这段改写读起来流畅、可信,也不是空的,所以每一项朴素的质量检查都放行了它。它只是把这条消息存在的唯一意义——它要传达的那个事实——给删掉了。

这条教训能推广到这套系统之外:当措辞本身就是通向人类决策的接口时,把模型限制在“只管措辞”上,并不是足够的防护。一段丢了警告的改写,本身并不构成一个错误的决策——它删掉的是人做出正确决策所需要的证据。修复方案是把那些承重的事实钉住,只要缺了一项就回退到模板,而且这些“钉子”是从消息内容本身提取出来的,不是写死的,这样即便后来措辞改了,守卫也不会还钉在一个已经不存在的字符串上。一道悄悄停止把关的防线,比压根没有防线更糟。

05

把审批自动化,但不取消审批

三道审批闸门被实现了自动化,但没有一道因此不再是“人来审批”的闸门。三道闸门依然都可以由人来批准;拒绝按钮在每一道闸门上都照样能用。

最直接的实现方式——把这个阶段直接改成自动阶段——会连带取消掉在这道闸门上拒绝的能力,因为拒绝路径本身要检查这个阶段是不是“可由人审批”的。这样一来,agent 获得了批准的权力,同时也就剥夺了人拒绝的权力。这里的不对称很关键:真正扛分量的决策是“不行”,因为一旦拒绝,就会走一条会把生产环境恢复原状的清理路径,把这次运行终止掉。

取而代之的做法是,agent 通过和人一样的通道来提交它的决定。要不要回滚,变成了一个配置开关,而不是一次代码改动;自动路径和人工路径共用同一套实现;审计轨迹、超时语义,还有操作员原本的心智模型,全都完整保留了下来。三项自动化里有两项默认是关闭发布的,而且是刻意关闭的——两个看起来对称的开关,影响半径可以天差地别。放开其中一道闸门,后面直接通向一次生产 replay;而另一道闸门就算出错,顶多是优先级没恢复,这是可以补救、也看得见的。

06

没做检查,绝不能显示成检查通过

核验环节会把源头和每个目的地逐条记录做比对。各个目的地本身就有20到30分钟的延迟,所以一条记录缺失,可能是真丢了,也可能只是还没到;各处的格式又不一样,逐字节比对反而会产生大量误报。

真正微妙的问题在于范围。如果一次 replay 从一开始配置的就只发往一个目的地,那么把两个目的地都查一遍、在第二个目的地什么都没查到,报告出来的结论会是“两个目的地都查过了,一切正常”——这句话和“真的全部成功”、以及“全部投递失败”,三种情况读起来一模一样。人类审阅者通常能从上下文里推断出到底是哪一种。自动审批者做不到这一点。要让自动审批安全,必须先有范围感知,而且一个不确定的范围,永远不能被放宽成“全部目的地”。

同一条原则,以不同的名字,在至少六个独立的地方反复出现。一份不包含任何被检查字段的负载,算的是提取失败,不是匹配。一个不在任何目的地字段映射里的属性,算的是一个错误。一条没有覆盖到这个属性的规则,会被彻底排除在统计之外,而不是算作通过。就连测试替身也被改掉了,因为一个对超出范围的目的地伪造出“匹配”结果的假对象,会让测试断言出恰恰是这项工作要消灭的那种悄悄的部分通过——一项安全属性,是可能从测试替身这个缺口漏出去的,而这一点很少有工程师会想到。

四条随处适用的经验

  1. 要自动化,就往决策通道里加一个决策者,而不是把通道本身删掉。拿掉一个审批,连带拿掉的还有拒绝的能力、审计轨迹、超时语义,以及操作员的心智模型。
  2. 把模型放在输入本质上就是非结构化、没法再压缩的地方——绝不要放在结构化答案已经存在的地方。而且不管模型放在这个循环的哪个位置,都要让它失效时的方向是“去问人”。
  3. “只管措辞”不是一种安全的限制,只要措辞本身就是人的决策接口。把承重的事实钉住,让这些“钉子”从内容本身推导出来,这样才不会过期失效,而且连那套确定性的回退方案也要一并守好。
  4. 影响半径决定默认值,而不是结构上的相似。三个几乎一模一样的开关,正确的做法是给出不同的默认值;其中一个开关出问题,恰恰是因为它被硬凑成了和另一个“看齐”。

它没做到的事

这个页面所依据的那份记录,自己就有一节专门讲局限性,而这一节恰恰是最值得了解的部分。一个表示“无法判定”的结论状态,已经被完整地打通、纳入统计、也会被拦截,但目前没有任何一条代码路径真的会产生它。一个超时缺口,已经被记录在案、有人认领负责,而不是被悄悄依赖——但它是“发布了但没修”,不是“修好了”。一把全局锁,让整个组织同一时间只能跑一个 replay,而且会在长达24–48小时的人工审批闸门里一直被持有:这是一个清醒的取舍,失败方向是安全的,但也是一个真实存在的吞吐量天花板。还有,两项自动化的分阶段灰度发布,有遥测数据,却没有退出标准——没有阈值,没有负责人,没有日期。

关于哪些地方并不特别,他讲得同样直接:带检查点的持久化工作流,加上人在回路里的中断机制,这些都是底层框架本身就提供的能力。这是把一套持久化执行引擎用好,而不是发明了一套。