ENZH
和 AI 讨论这篇文章
ChatGPTClaude

agent 看不见没写下来的约定

上一篇 我说,仓库现在就是 agent 编程时要对着的接口,这个接口里最贵的是 tribal knowledge,或者说默契。agent 没有跨 session 的记忆,每开一个 session,这些默契它就忘光一次。这篇讲具体怎么办。办法说出来挺无聊,就是把每条边界都变成写下来的契约。但我只能说,整套打法里这一招杠杆最高。先看一个具体的事故,这篇要防的就是这类事。

这个 bug,agent 根本看不见

你代码库某处有这么一行:

queue.publish('user.updated', JSON.stringify(user));

别的地方有东西在消费 'user.updated',而且指望它是某个固定的形状。这个消费方可能在同一个服务里,也可能在另一个仓库里,那个仓库 agent 从来没打开过。这个字符串其实就是一条契约,两个系统说好了按它来。但说好的这件事哪儿都没写,只有当年把两头搭起来的那几个人记得。

现在你让 agent 去动这个事件,改个名,或者加个字段,或者把 user 从扁平对象改成带嵌套 profile 的。agent 看见一个字符串和一个序列化调用,就改了,然后把它能找到的测试跑了一遍。生产者这边的测试全绿,因为生产者本身确实没毛病。消费者在另一个服务里,它的测试压根没跑到。agent 回头读自己的 diff,代码干净,完全照要求做的,于是报告成功。

讲道理,它没做错什么,任务确实完成了。问题是它弄坏的那条契约谁都看不见,它能看到的所有文件里,没有一个字说「另一个系统依赖这个形状」。换个人来做,可能会想起来移动端也在消费这个事件,但 agent 没有记忆,没东西可以拿来「想起来」。所以对 agent 来说规则特别简单,一条契约只要没写下来,它就当没有,隐性的东西在它眼里通通不存在。

默契是看不见的线,契约是写下来的线默契是看不见的线,契约是写下来的线

所以「改 user.updated 的时候小心点」这种话没用,读它的人每个 session 都会忘一次,你说了等于没说。有用的做法是把这条边界做成 agent 绕不开、机器查得出来的东西,比如这样:

// contracts/events.ts
export const userEvents = {
  updated: 'user.updated.v1',
} as const;

export const UserUpdatedV1 = z.object({
  version: z.literal(1),
  userId: z.string().uuid(),
  profile: z.object({ /* ... */ }),
});

// 生产者
publishUserUpdated(UserUpdatedV1.parse(payload));

这么改完,事件名成了一个得 import 的常量,不再是一个会敲错的字符串。形状成了一个 schema,payload 一跑偏马上就报错。版本直接写在名字里,想做破坏性改动就得另开一条新契约,没法把旧的悄悄改掉。这条边界就从人的脑子里搬进了仓库,agent 看得见,检查也拦得住。

什么算一条边界

这个事故看明白了,剩下的就是划范围。规则可以压成一句话,而一句话的规则 agent 执行得特别好:

只要还有另一个模块、进程、用户、服务、文件、数据库、队列、CLI 或 agent 依赖它,它就是一条契约。

这个范围比大多数人直觉里的要宽,是故意定这么宽的。因为出事的很少是明面上那个公开 API,一般是散在代码里、没人拿它当接口的那些裸字符串:

  • route 路径和它的参数
  • API 的请求和响应形状
  • localStorage 的 key,以及存在它底下的 JSON
  • 事件名、消息名
  • 错误码
  • feature flag 的 key
  • 指标名、span 名
  • 配置和环境变量
  • 一个文件格式的列
  • 你端到端测试拿来选元素的 data-testid

每一项都是系统里两个部分说好了的地方。只要它还是直接内联在代码里的字面量,agent 就可能把它弄坏,而且弄坏了自己完全不知道。对付的办法很机械,别内联字面量,永远 import 一个有名字的常量,或者一个生成出来的类型。同一个契约字符串要是出现了第二次,别当成以后再清理的重复代码。这就是少了一条契约,第二次出现的那个地方就是将来的 bug。

顺带还有个好处,这一招能把一类幻觉直接堵死。agent 找不到真的 key,就会编一个看着很像的出来。如果真的 key 是唯一能 import 的符号,它就没得编,编了也编译不过。

parse,别 cast

有一种写法得拎出来说,因为它写起来太顺手,错得又很隐蔽:

const invoice = (await res.json()) as Invoice;

这个 cast 等于跟类型检查器打包票,说网络上下来的这堆字节就是 Invoice 的形状。但 TypeScript 的类型到运行时就没了,代码真跑起来的时候,这句保证早被抹掉了,所以这个 cast 什么都没验。要是 API 真返回了别的东西,坏数据就顶着一个大家都信的类型一路往下走,最后在离入口三层远的地方炸掉,而那个地方看起来完全正常。

人写这个 cast,一般至少瞄过一眼 API 文档,算是心里有数地赌一把。agent 不一样。agent 想的是把类型签名对付过去、接着往下走,cast 是最省劲的那条路,类型检查器不报错了,看起来就算做完了。所以这个 cast 对 agent 来说是个天然的坑,它就是会往这儿走。

修法是在边界上做校验,往里传 parse 过的值:

const invoice = InvoiceSchema.parse(await res.json());

写起来一样省事,跑起来就完全不一样了。外面来的数据一进你的系统就被查了一遍,出错也出在边界上,好找。下游拿到的值,形状真的跟它的类型对得上。这条规则小到可以直接强制执行。所有从外部进来的东西,HTTP body、route 参数、storage、postMessage、webhook、配置、持久化的 JSON,过边界的时候都套一层运行时 schema。你自己代码在内存里造出来的值就不用了,有类型就够。类型管得住内部,管不住边界,schema 补的就是这个口子。

最硬的契约在数据库里

这个思路推到最狠的版本,是后端教会我的。不变量要往下放,放到 agent 就算犯了错也绕不过去的那一层。

拿「一个客户只能有一个 active 订阅」这条规则来说。你可以在应用层代码里查,人类团队大体没问题,因为大家都记得去查。但 agent 不一定记得,并发重试也不管你查没查,下个季度新加进来的第二个写入方,可能根本不知道有这条规则。规则放在代码里,可代码偏偏就是天天在改的东西。

推进数据库就不一样了:

CREATE UNIQUE INDEX one_active_subscription_per_customer
ON subscriptions (customer_id)
WHERE status = 'active';

这样一来,执行这条规则的是每个写入方都得经过的那一层,以后那些从没读过你应用逻辑的 agent 也得过这一层。not-null、外键、check 约束、unique partial index,这些东西扛得住并发和重试。有的 agent 嫌一个测试挡路,会很自信地把你的校验删掉,这些约束照样扛得住,而这种事是真会发生的。

这个思路还能接着往外推。API spec 在 CI 里跑 lint,破坏性改动直接挡下来。migration 一旦 apply 过就不许再改。错误统一走一个带类型的信封,别每个 handler 自己手搓一个 { error: string }。反正就是把不变量往绕不过去的那一层挪。规则放在那儿,是真有东西在执行,不用指望每个改代码的人都记得。

真出事的时候差别在哪

契约值钱,值在 blast radius(爆炸半径)上,倒不是因为代码变整洁了。agent 会犯什么错,是被它能看见的东西框住的。边界上有契约管着,改错了当场就炸(schema 拒收、常量不存在、数据库拒绝写入、契约测试变红),错误还在 diff 里就被逮住了,根本到不了线上。契约管不到的地方就不一样了,改错了一点动静都没有,类型过了,能跑到的测试也过了,看起来就是做完了。得等上一个礼拜,坏数据冒出来了,或者哪个客户端崩了,才知道出了事。而到了那个时候,没人会想到回头去查一次看着很无辜的改名。

爆炸半径:契约内当场炸,契约外一周后炸爆炸半径:契约内当场炸,契约外一周后炸

契约干的事,就是把炸的时间往前挪,挪到 diff 里,挪到上线之前。它不会让 agent 变聪明,只是让 agent 的盲区一出事当场就被发现。一个干活很快、很自信、又没有记忆的工人,你想让它大批量干活又不悄悄弄坏东西,我想不出第二种办法。所以光靠写文档管不住这件事。在 CLAUDE.md 里写一句「小心处理事件」,那还是 tribal knowledge,只是外面多包了一层文件。契约必须是机器能强制执行的东西。文档到底能管什么、管不住什么,留到下一篇再说。

反正 tribal knowledge 一直都是个隐患,以前没爆,只是因为总有人记得。现在读代码的换成了没有记忆的 agent,没人兜底了。能做的就是把它一条条写下来,给名字,给类型,给 schema,给约束,给测试。这个活不难,跟上一篇说的一样,就是得做。


这是 agent 可维护仓库系列四篇里的第二篇。上一篇:repo 得当成 agent 的 API 来设计。下一篇:一份 CLAUDE.md 管不住 agent。相关:我造了个知识编译器,讲的是把知识编译下来、而不是留成默契。

和 AI 讨论这篇文章
ChatGPTClaude
AI 随想第 25 篇 · 共 28 篇

订阅更新

新文章发布时发到你的邮箱,不发别的。


© Xingfan Xia 2024 - 2026 · CC BY-NC 4.0