每个手写的接缝都需要守卫:Crossweft,一个给 AI 代理用的跨语言契约检查
作者: Anastasiia Butova, ComfyUI 与扩散模型工程师,贝尔格莱德 · 发布于 2026-10-08
译自英文版: English
我们代价最高的 bug 从来不在某个文件内部,而是在文件与文件之间。一个值被写在两三个地方,用两种语言分别书写;有人改了其中一处,一切仍然能构建,测试也全是绿的,结果安装程序在客户的机器上坏掉了。这篇文章讲的是我们如何把这些地方显式化、可检查化,并把这项检查内置到如今编写我们很大一部分代码的 AI 代理中。这个工具是开源的:github.com/AnastasiyaW/crossweft,项目页面:happyin.work/crossweft。
编译器看不见的十个边界
我们为 Photoshop 开发一个修图插件,它在用户自己的机器上运行神经网络。摄影师与模型之间有十个信任边界:插件与一个系统服务之间的受保护通道、设备注册、带有可信时间戳的签名产物、构建证明、更新通道、模型加密和水印。这些部分用 C++ 和 Go 编写,运行在不同的进程中,并通过不同的安装程序交付。
这些边界中没有一个是一个模块。每一个都是两方之间的约定:相同的服务名、相同的协议版本、相同的路由集合、相同的确认字符串。今天,这些边界上的 54 条链接承载着 188 处值比较(代码中的 468 个位置)以及 105 处集合比较。编译器看不见这些,因为两边使用的是不同的语言。
我们开始把这些地方称为接缝(seam):两个组件必须完全一致的位置。
- 客户端调用的路由与服务端注册的路由;
- 协议版本头;
- Go 与 TypeScript 中 DTO 的字段;
- 服务读取的环境变量,以及
.env.example中列出的环境变量; - 用两种语言各写一遍的价格取整规则。
类型、测试和 linter 在一种语言内部工作得很好。它们很少检查这样的约定,因此这些约定只存在于人们的记忆中。
这个名字来自纬线(weft),即横穿经线、把织物连在一起的那根线。
三个故事
一个标识符,三份副本。 服务名在三个几乎相同的函数中被解析。在一台干净的机器上,服务尚未安装,查找失败,一台空机器被当成了旧的、不兼容的安装,安装程序以退出码 76 停止。我们修好了一份副本。另外两份仍然是坏的。
同一依赖的两半。 构建从一个安装目录取库,却从另一个安装目录取工具。配置只打印了一条警告,于是问题直到很久之后才暴露出来,而真正的原因很难找到。
一个什么都没守住的守卫。 我们一条安全规则的验证器有一个错误的路径。几周来,它检查的文件数量是零,却每次都报告 OK。它之所以通过,恰恰是因为它根本没有运行。
这三个故事的原因是相同的:一个约定有两三方,而它们是否一致,取决于是否有人留意。
为什么 AI 代理让这件事更糟,以及它们如何帮忙
我们的大部分代码由代理编写,主要是 Claude Code 和 Codex。它们处理跨组件约定的能力比人差。典型的情况是:代理修改了一个 Go 结构体,运行测试,看到绿色,然后说"完成",却从未打开另一侧的 TypeScript 接口。
- 局部编辑。 接缝的另一端在另一个文件夹、另一种语言、另一个进程里。
- 上下文有限。 代理并不把整个产品装在脑子里,而编译器也不会告诉它,某处有一个客户端依赖那个结构体。
- 狭隘的"完成"概念。 对代理来说,完成意味着"我能看到的每项检查都通过了"。如果没有任何检查看向接缝,漂移就会一直看不见,直到某个地方出问题。
代理能很好地遵循精确的局部指令。在编辑之后立即告诉代理,另一侧该打开哪个文件,以及那里必须保持什么一致,它就会照做。不要让它在两边仍不一致时结束工作,这样错误就永远到不了提交。
规则:每条链接都要说明它如何保持一致
每条组件之间的链接都要声明它的两边如何保持一致。如果两边都是手写的,这条链接就需要一个守卫:一项同时读取两边的检查。
链接存放在代码旁边的 JSON 文件中。映射包含块(程序、服务、存储、配置文件)以及块之间的链接(HTTPS、命名管道、文件、代码生成、环境变量)。映射中的每项声明都锚定在源文件中的一个字面量上,因此代码移动时,映射不可能悄无声息地继续描述过去。检查随即失败。
每条链接都有一个 contract.enforcement 字段:
| Enforcement | 谁保证两边相等 | 检查要求 |
|---|---|---|
shared-code |
两边都导入的一个符号 | defined_in 指明该符号 |
generated |
来自同一源头的生成器 | defined_in 指明该源头 |
schema-tests |
共享的测试向量 | defined_in 指明这些向量 |
duplicated |
没有人,它是手工复制的 | 一个分别读取两边文件的守卫 |
convention |
没有人,它是约定好的 | 一个分别读取两边文件的守卫 |
none |
没有人 | 一个分别读取两边文件的守卫 |
仅仅是这种分类就很有用。"我们在哪里仍然依赖某人的记忆?"就变成了一份可以计数的清单。只读一边的守卫不算数:工具会检查,守卫读取的是链接两端不同位置的文件。
三种守卫:join、set 与 pair
这些守卫有意保持简单:正则表达式,比较值或值的集合。一次检查只需要几毫秒,无需构建,并且适用于任何语言。
join:值在所有地方都相同。 TypeScript 客户端发送的 API 版本,与 Go 服务端期望的版本:
{"id": "api-version", "link": "web-orders", "points": [
{"path": "web/src/api.ts", "side": "web", "regex": "API_VERSION = \"([^\"]+)\""},
{"path": "server/main.go", "side": "api", "regex": "APIVersion = \"([^\"]+)\""}]}
每一次出现都会被比较,而不只是第一次。我们的第一版只检查第一处匹配,因此文件后面的第二份副本可能会漏过去。
set:成员一致。 TypeScript 接口字段与 Go 的 JSON 标签;Python worker 写入的状态与 Go 常量;代码读取的环境变量与 .env.example。有意的差异放入 allow 并附上理由;一个已经不再需要的 allow 条目本身也会让检查失败。
pair:重复的逻辑,没有可以直接比较的单一值。 例如 Go 与 TypeScript 中的价格取整。两个区域分别用 crossweft:begin price-rounding 和 crossweft:end price-rounding 标记,Crossweft 为它们计算指纹。如果任何一方发生变化,检查都会失败,直到有人重新阅读另一侧,并运行 crossweft attest price-rounding --reason "..."。理由会写入锁文件,并进入代码审查。
对于代理来说,摆脱一个红色 pair 最短的途径,就是为它自己修改过的那一侧做 attest,而从不去碰另一侧。因此,如果自上次 attest 以来只有一侧发生了变化,attest 会拒绝执行。先修好对侧;或者,如果它确实已经等价,就显式地用 --other-side-unchanged 说明。
还有更大的检查。服务器注册的每条路由都必须在映射中;Go chi 可以原生读取(也支持 Express、Fastify、FastAPI、Flask、gin 和 echo),其他框架则通过正则表达式读取。客户端代码中的每一次 /v1/... 调用都必须能被某条链接解释。一个块只能发送它自己创建或接收到的数据。每个源文件夹都必须出现在映射中。
当内置的类型不够用时,仓库可以通过插件添加自己的类型。我们有 13 个这样的插件。其中一个将 916 处 Go 服务端与 SQL 交互的位置,与架构中的 161 张表和 276 个触发器进行比较;另一个检查 185 条服务端路由;第三个检查 27 对超时设置的"等待 ≥ 工作 + 余量"。插件导入失败、崩溃或者什么都没比较,都会被视为错误,而绝不会得到绿色结果。
代理看到的内容
检查只是一半。代理需要在编辑之后立即得知某个接缝的存在,而不是等到代码准备提交时才知道。有三个 hook 负责这件事:
- 会话开始时: 一段简短的提示,说明仓库中有一张接缝映射、有多少个接缝,以及如何与它们协作。
- 每次编辑之后: 如果该文件位于某个接缝上,代理会被告知另一侧是什么,以及对应的守卫。
- 结束之前: 运行
crossweft check。如果某个接缝不一致,代理就不能结束,并会被告知该怎么做。
在演示中,对 Go 服务端做了一次编辑之后,代理会看到:
crossweft: server/main.go is part of 3 cross-component seam(s). Keep both sides in agreement:
- link web-orders (web -> api, https; Orders API [duplicated]): you changed its to side.
Re-read: web/src/api.ts. Guarded by: join:api-version, join:version-header,
set:order-fields, set:web-statuses, pair:price-rounding.
...
Run `crossweft check` before you finish.
如果它在 Go 中提升了 API 版本,并试图停止,hook 会把它送回去:
crossweft check fails:
- join:api-version disagrees: web@web/src/api.ts:3='2026-09-01'; api@server/main.go:14='2026-10-01'
[server/main.go:14] (key: join:api-version:b5dfb7e5)
A seam is out of agreement: bring the other side in line with the one you changed
(`crossweft impact <file>` names it).
建议取决于问题的种类。如果映射指向的某个字面量已经消失,代理会被告知去更新锚点或恢复代码,而不是去"修好另一侧"。这个键末尾附有一段摘要,记录哪个文件持有哪个值,因此一项已记录的例外只会豁免那一种确切的不一致。
一个 stop hook 有两种经典的失效模式:无休止的循环,以及悄无声息的放行。我们的 hook 只在代理取得进展时(失败键的集合在变化)才把它送回去,并且连续最多四次。若没有进展,代理可以停止,而人会收到一条明确的消息,说明检查仍然是红色的。
对于 Claude Code,我们提供了一个带有 hook 和技能的插件。对于 Codex,提供了一个技能插件。任何支持 MCP 的代理都可以连接 crossweft mcp,并获得检查、变更影响,以及"该重新阅读哪个文件"的只读工具。Gemini CLI、Copilot 和 Cursor 的 hook 适配器仍是实验性的:它们的输出格式经过测试,但完整的循环尚未经过测试。
不能悄无声息地通过的检查
第三个故事是一个几周来什么都没扫描的验证器。并非所有规则都是"A 等于 B";有时你需要证明某个常量只有一个来源。这类规则住在小脚本里,而这些脚本恰恰是最容易悄无声息地坏掉的。
crossweft validators 运行器会让以下情况失败:验证器以非零退出;没有打印 SCANNED: files=<n> items=<n>,或者打印的是零;没有自测,或者自测失败(SELF-TEST: checks=<n>,针对一个植入的错误运行);或者超时。一个确实不适用的验证器会打印 APPLICABILITY: <reason>,并得到 SKIP,而不是 PASS。
Crossweft 本身也是同样的做法。结果有三种,而不是两种:0 表示一切一致,1 表示发生了漂移,2 表示检查没有真正进行(模型无法读取,或者什么都没有被扫描)。退出码 2 绝不会看起来像成功。
不会腐烂的已知漂移登记
并非每一处不一致都能在同一个 pull request 中修好。那时你可以记录一个 finding(发现):一个问题键,一位负责人,以及下一步。只要问题仍然存在,检查就会通过。一旦修好,这个 finding 就会过期,并使检查失败,直到有人把它关闭。损坏的锚点不能以这种方式豁免:映射不允许错误地描述代码。
对于已经存在漂移的仓库,crossweft baseline 会把所有漂移记录为带有负责人的未关闭 finding。检查变为绿色,新的漂移仍然会使它失败,而已修复的内容则会要求被关闭。
它带来了什么改变
先说明背景。从七月中旬起,我们的安装程序从未能在一台干净的虚拟机上把系统服务启动起来。我们在 9 月 26 日到 28 日加入了经过检查的接缝,然后在 9 月 29 日,服务第一次启动成功。这只是时间上的巧合,并不能证明因果:同一时间还有其他修复落地。
确实改变的是失败的种类。9 月 28 日最初的三次失败都是接缝:退出码 76(服务名有三份副本);服务器拒绝一个以产物版本注册、却按协议版本请求的加载器;以及因为两条发布路径删除了旧包而第三条没有删除,导致 HTTP 500。现在每一个都有自己的守卫。接下来的两个问题是普通的 bug,而不是"同一个值出现在好几处"。
从仓库历史中重新统计,映射显示:
| 9 月 27 日 | 9 月 29 日 | 10 月 4 日 | |
|---|---|---|---|
| 块 / 链接 | 130 / 160 | 134 / 166 | 138 / 192 |
| join 守卫 | 10 | 220(529 个位置) | 261(644 个位置) |
| set 守卫 | 4 | 125 | 138 |
| 发现(findings) | 58,全部未关闭 | 106:93 已关闭,10 未关闭,3 已接受 | 146:117 已关闭,25 未关闭,4 已接受 |
"每条链接都声明其保证方式"这一轮的第一遍工作是由代理自己完成的。一天之内,它们新增了 185 个 join 守卫和 107 个 set 守卫,并在代码中发现了 15 处真实的不一致。在 93 个已关闭的 finding 中,有 91 个有对应的修复提交,其中 58 条提交信息中写明了映射里的 finding id。
没有检查的映射,一周之内就会过时。 第一次测量一周之后,有 96 个问题,其中 94 个是映射过时,而不是代码中的 bug。这就是为什么每次代理停止时都会检查映射。从六月的 4 个验证器,我们增加到 50 个,而且每一个都必须证明它确实扫描了某些东西。
开源,以及第一次 CI 运行发现了什么
这个工具是在一个商业产品内部成长起来的,因此我们小心地将其开放。仓库有两个分支:公开的 oss,只包含工具本身;以及私有的 internal,即 oss 加上一个包含我们自己规则的文件夹。变更只从 oss 流向 internal。公开的内容是树的快照,每个版本一个提交,带有中性的签名,而不是我们的历史。发布前,一个扫描器会依据一份私有词汇表,检查快照及其提交元数据;同时,一位全新的独立审查者会阅读自上一次发布以来的差异,因为扫描器只认识给它的那些词。第一次审查在文档中发现了三处暗示我们产品架构的措辞,我们在发布前替换了它们。
只有在 Linux、Windows 和 macOS 上 CI 全部为绿色之后,才会打上发布标签,因为标签是不可变的。第一次运行是红色的。它发现了两个真实的 bug,而本地的 157 个测试都没有发现:
- 代码使用了一个只从 Python 3.10 起才存在的参数,而我们承诺支持 3.9;
- 一个插件在一秒之内被编辑了两次,且长度相同,于是它从过时的字节码运行。Python 的缓存只以秒和大小来区分文件。我们在本地看不到这个问题,因为在我们的开发机上,字节码写入是全局关闭的。
我们修好了这两个问题,第二次运行变成绿色,v0.1.0 发布了。两天后,v0.2.0 增加了 MCP 服务器、check --changed、从 OpenAPI 和 proto 模式构建的守卫,以及另外五个框架的路由扫描器。它体现了本文的论点:一个绿色的检查,只能告诉你人们已经想到过的那类错误。
这不是已经存在了吗?
每一个组件在某处都已存在。我们没有找到的是这种组合,以及这条规则:每条链接都声明它如何保持一致,每个手写的接缝都有守卫。
| 工具 | 它做什么 | 它的不同之处 |
|---|---|---|
Google LINT.IfChange、ifttt-lint、if-changed |
"如果这个块改变了,也要改那个文件" | 检查的是差异是否触及了该文件;join 则每次运行都比较值 |
| clevis | 让一个值在多个文件中保持相等 | 没有集合,没有区域,没有映射 |
| Pact、buf、oasdiff | 契约测试与模式兼容性 | 存在模式时的正确答案;Crossweft 则从 OpenAPI 和 proto 为手写的一侧构建守卫 |
| ArchUnit、dependency-cruiser、import-linter | 架构规则 | 限于一种语言之内 |
| archagent | 面向代理的架构不变量与技能 | 精神上最接近,但它关注结构,而不是跨语言的值 |
| GitNexus、codegraph | 面向代理的代码图与变更影响,通过 MCP | 一个你去查询的图,而不是会失败的检查;二者可以互补 |
| fiberplane/drift、Swimm | 锚定到代码的文档 | 哈希在任何编辑时都会失败;锚点只在事实本身消失时才失败 |
这种思路也不是我们首创。Birgitta Böckeler 在 harness engineering 中论述了它:在代理写代码之前就起作用的引导(guides),以及事后进行检查的传感器(sensors),还有确定性检查与由语言模型完成的检查之间的区别。OpenAI 在 Harness engineering: leveraging Codex in an agent-first world 中谈的是同一件事。Crossweft 是一个针对跨组件接缝的确定性传感器,它的输出会精确地告诉代理该修什么。
局限
- 正则表达式,而不是解析。 重新格式化一条声明,检查会大声失败;你需要更新模式。但过于宽泛的模式仍可能漏掉真实的不一致,因此应当查看提取出的值,并用普通测试验证运行时行为。
- 仍需要人来检查,确认保证方式是诚实的。 代理构建映射,Crossweft 不允许它们捏造链接或跳过文件夹,但一条链接究竟是
shared-code还是两份副本,有时只有开发者才知道。 - 采用需要付出努力。 在小项目上收益很小。它在多种语言、多个进程的地方才划算。
- 到目前为止,我们是唯一的用户。 Claude Code 的循环已经端到端地验证过;其他代理通过 MCP 和技能连接,而它们的 hook 适配器仍是实验性的。
check --changed加快了提交前检查,但当编辑涉及路由器或客户端文件时,还没有提速。
试一试
理解这一机制最快的方式,就是去破坏演示项目(一个 Go API、一个 TypeScript 客户端、一个 Python worker):
pip install "git+https://github.com/AnastasiyaW/[email protected]"
git clone --branch v0.2.8 --depth 1 https://github.com/AnastasiyaW/crossweft
cd crossweft/examples/polyglot-shop && crossweft check # RESULT: PASS
# now change API_VERSION in web/src/api.ts only:
crossweft check # join:api-version disagrees
下表中的每一行都在演示的副本上验证过:检查以退出码 1 失败,并指出守卫的名字。
| 修改 | 由以下守卫捕获 |
|---|---|
只修改 web/src/api.ts 中的 API_VERSION |
join:api-version |
只在 Go 的 Order 结构体中添加一个 JSON 字段 |
set:order-fields |
| 只在 Python worker 中添加一个状态 | set:worker-statuses |
在 worker 中读取一个 .env.example 中没有的新环境变量 |
set:env-worker-vars |
从客户端调用 /v1/refunds |
route:client-unexplained |
修改 server/pricing.go 中的取整 |
pair:price-rounding |
在你自己的仓库中:运行 crossweft init(init --example 会先展示一个可用的接缝),然后让你的代理为仓库绘制映射;技能会引导它完成,而 crossweft check 会保持它的诚实。在 Claude Code 中:/plugin marketplace add AnastasiyaW/crossweft,然后 /plugin install crossweft@crossweft。在 CI 中:uses: AnastasiyaW/[email protected],或者使用 crossweft check --format sarif 接入 GitHub 代码扫描。
Crossweft 仓库本身也用 Crossweft 守卫自己的接缝:三个文件中的版本号、hook 事件、文档中的命令。在编写 README 时,"文档中的命令存在"这一守卫有三次被触发,原因是它把散文中的几行当成了命令。我们也修好了这些。
Crossweft 采用 Apache-2.0 许可证。欢迎在 github.com/AnastasiyaW/crossweft 提交 issue 和 pull request。