happyin.work / 博客

English · Русский · Español · Deutsch · Português (Brasil) · 简体中文 · 日本語

安装它,并在项目页面上试用接缝演示

每个手写的接缝都需要守卫: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。

更多文章

本站其他内容

Anastasiia Butova - ML engineer, Belgrade, Serbia. Email [email protected] · LinkedIn · GitHub · Telegram · Hugging Face