云计算百科
云计算领域专业知识百科平台

软约束与硬约束:AGENTS.md 写了“禁止跨层调用”,够吗?

一、一条写在文件第一行的规则,三个月后被违反了

还是 shop 订单服务。项目开工那天,你们在 AGENTS.md 的“什么不能做”一节写下第一条规则:

– 不允许 domain 层和 services 层直接依赖数据库驱动或 repositories 的实现细节

这条规则一直有效。它写在项目根目录,任何一次任务开始前,Agent 都会读到它。三个月里,大大小小的改动都遵守了这条约定,订单、支付、退款相关的用例都规规矩矩地通过仓库接口访问数据。

然后有一天,你要上线一个新的审计需求。Agent 的任务是:让取消订单时写入的审计事件和状态变更在同一个事务里完成。它读了代码,发现事务边界在仓库实现里,而用例层拿不到事务对象。于是它在 src/orders/services/cancel_order.py 顶部加了一行:

from orders.repositories.postgres import session_factory

然后打开会话、开启事务、写审计事件、提交。功能是对的,评审的时候这段代码看起来也挺自然:不就是多拿了一个会话吗。

麻烦出现在两天后。团队把订单仓库的实现从 PostgreSQL 换成了内存版,用于跑一批快速集成测试。所有用例瞬间失败,因为那个从 postgres 模块导入的会话对象在内存版里不存在。这时候大家才想起那条规则,并且发现一件让人不太舒服的事:规则一直都在,谁都知道,可它从来没有拦下过任何一次提交。

修复花的不是很多时间,一共改了三处。真正花时间的是讨论:这条规则到底要不要保留?如果保留,怎么保证它下次不再被无声地违反?讨论的最后结论也很简单——把它写成一条检查。这篇剩下的部分就是在讲这条检查长什么样。

这件事的关键不在于“模型不听话”。恰恰相反,它大概率读到过那条规则,也理解它。它违反规则的原因非常务实:眼下的任务需要事务,而那条路径最顺手。规则和方便之间,规则输了。

这里还有一个容易被忽视的细节:那次违规的代码本身质量不差。命名清楚、异常路径完整、事务边界也考虑到了。它唯一的错误是走了一条被约定排除的路。正因为如此,评审的人也会犹豫——代码看起来没问题,为什么要打回去?当规则只是文字时,这种犹豫几乎注定会以“先合进去再说”结束。

这就是这一篇要讲的东西:文字约定和可执行检查,是两种不同性质的约束。前者提高正确行为的概率,后者决定错误行为有没有后果。少了后者,规则会在最需要它的时候恰好失守。

二、先把词讲明白

软约束(soft constraint):写在文档、任务单、注释或者口头约定里的规则,比如“不要跨层调用”“保持函数短小”。它通过影响人的判断和模型的行为来起作用,违规时不会有任何自动反应。它的好处是便宜、灵活,代价是不确定。

硬约束(hard constraint):由机器判定的检查,违规时流程直接失败。比如一条测试、一个静态检查、一个 CI 必须通过的步骤。它不需要任何人记得,也不接受解释。它的好处是确定,代价是需要有人先把它写出来。

跨层调用:上层代码跳过约定的中间层,直接使用更底层的实现细节。日常类比:你去餐厅吃饭,正常流程是跟服务员点单;跨层调用相当于你直接走进厨房,从冰箱里拿食材。多数时候也能吃上饭,只是后厨的规矩、库存记录、卫生流程全部被跳过了。

依赖方向:谁可以引用谁。一个常见的约定是“外层可以依赖内层,内层不能依赖外层”:接口层可以调用用例层,用例层可以调用领域对象,但领域对象不应该反过来知道数据库、HTTP 或者消息队列的存在。这条约定可以用一句话写出来,也可以用一条检查来验证。

静态检查:不运行程序,只读代码结构就能做的检查。最典型的是看导入语句:哪个文件导入了哪个模块。因为它不需要数据库、不需要网络,所以又快又稳,适合当门禁。

架构测试:用测试的形式来检查结构约定,比如“src/orders/domain 下的文件不允许导入数据库驱动”。它和功能测试用同一套运行方式,只是断言的对象不是业务行为,而是代码的形态。

例外机制:允许出现的破例,但必须写清位置和理由,比如“只有 src/orders/repositories/postgres.py 允许导入驱动”。例外机制的价值是让规则保持可执行,同时不逼着人绕开检查。

规则腐化:规则还在文档里,但已经和代码现实不一致。常见的发生方式是有人为了通过检查修改了白名单或注释,却没有更新规则本身。腐化之后,规则不再是约束,只是历史遗迹。

三、为什么“写下来”只能提高概率

先把两个概念分开:知道和发生。软约束影响的是“知道”这一侧,它让正确的做法更容易被想到。可规则能不能被遵守,还取决于另外两个变量:眼下有没有更省事的做法,以及违规的后果是什么。

在开头那个例子里,两个变量都对规则不利。更省事的做法就在手边:导入会话工厂,三行代码解决问题。违规的后果是零:没有人拦,没有检查红,评审时这段代码看起来还挺合理。

再往深一层看,还有一个结构性问题:当唯一“执行规则”的人是模型自己时,规则的解释者和执行者是同一方。它需要一边完成任务,一边记得不去做那件方便的事,还要在两者冲突时选择麻烦的那条路。这不是模型的道德问题,而是任何执行者都会面临的取舍。人类工程师在没有检查的项目里,行为模式完全一样。

模型在这个取舍上还有两个特点值得记住。第一,它读到规则的效果受上下文影响,前面讲过的位置效应在这里同样成立:信息在长上下文中的位置会影响被利用的程度(Liu 等,2024,Lost in the Middle,TACL 12:157–173)。第二,没有外部反馈时,它很难通过自我检查发现自己的越界,这一点在 Huang 等(2023,arXiv:2310.01798)的结论里说得很直接:缺少外部反馈,自我修正很难把推理改对。

那么是不是所有规则都能变成硬约束?不是。判断标准是:这条规则有没有一个可判定的信号。

有些规则天然可判定:导入语句、文件路径、函数命名前缀、接口参数个数、某个目录下是否存在某个文件。这些都能被一条命令或一段脚本读出来。

有些规则很难判定:命名是否贴切、抽象是否合理、这段逻辑放在这一层是否合适。这些判断需要理解业务背景,用规则硬写出来会产生大量误报,最后被绕过。

所以现实的做法是分层:可判定的规则尽量硬起来,不可判定的规则留在软约束里,并且承认它只能提高概率。把不可判定的规则硬写成检查,是另一种常见浪费,下一篇会讲到这两种控制方向的区别。

还有一个常被忽略的收益:硬约束会改变软约束的写法。当“禁止跨层调用”有一条检查在跑,AGENTS.md 里那条规则就可以写得更短,因为它不需要再靠篇幅去强调。规则文件越短,剩下的规则越有可能被真正用上。

两种约束的差别可以列成一张表:

软约束硬约束
载体 AGENTS.md、任务单、注释、口头约定 静态检查、架构测试、CI 步骤、命令规则
生效方式 影响人和模型的判断 违规时流程直接失败
判定者 执行者自己 机器
失败模式 时好时坏,取决于上下文与当下的取舍 确定、可重复
修改成本 改一行字 改检查或加例外,并跑一遍
适合承载 需要判断的约定 有可判定信号的约定

这张表里最容易被忽略的一行是“判定者”。软约束的判定者是执行者自己,这意味着在执行者认为“这次情况特殊”的时候,规则就会自动失效。硬约束没有这个入口,它只认代码的现实。

哪些规则值得优先硬起来

不是每条规则都值得写检查,可以用三个筛子来排序。

第一个筛子是违规的代价。跨层依赖的代价很高,因为它会随着时间累积:一个文件越界之后,后面写代码的人会跟着越界,等到想换实现时已经拆不动了。相比之下,“函数最好不要超过五十行”被违反的代价很小,它更像是风格偏好。

第二个筛子是可判定性。导入语句、文件路径、函数签名、依赖清单,这些都能被直接读出来,属于天然可判定。命名是否达意、抽象是否合理、这段逻辑该放哪一层,这些需要业务判断,硬写成检查会产生大量误报。

第三个筛子是发生频率。一个每年被违反一次的规则,用文档提醒就够了;一个每周都被违反一次的规则,值得一条检查。频率高通常意味着存在结构性诱因,比如缺少一个方便的能力入口,这时候更好的做法是先把入口补上,再加检查。

三个筛子过完,通常会剩下两三条规则。这两三条就足够开始了。

软约束什么时候够用,以及怎么写才不浪费

有三种情况适合留软约束,不必急着写检查。

第一种是违规代价很小、发生频率也低的规则,比如日志文案的写法、错误信息里要不要带编号。这类规则用一句约定就够了,写检查的时间比它省下来的时间还多。

第二种是需要业务判断的规则,比如“这段逻辑放在用例层还是领域层”。它可以被写成检查,但那会是一堆脆弱的模式匹配。更实用的做法是把它写成一条带触发条件的约定:当你发现同一个规则判断出现在两个以上的用例里,就把它挪进领域对象。

第三种是还在探索期的规则。新约定刚提出时,先写成软约束观察两三周,看看它是不是真的被违反、违反了是不是真的导致问题,然后再决定要不要升级成检查。这个顺序比先写检查再发现规则不对要便宜。

软约束本身也可以写得更有用。有效的写法包含三个部分:触发条件、具体动作、验证方式。比如“当你需要在两个表之间做联合查询时,先检查 repositories 层是否已有对应的查询方法;没有就新增一个,不要在用例层直接写 SQL”。这三句话里没有形容词,读的人知道什么时候适用、该做什么、做完怎么确认。

对照一下常见的写法:“注意保持数据访问的一致性”。它听起来也是善意提醒,但它没有触发条件,也没有动作,读到的人只能把它当成背景噪音。

四、把一条文字规则变成门禁

下面这个 shop 项目是虚构示例,代码可以直接照着搭一遍。目标很清楚:把“不允许跨层调用”从一句话变成一条会失败的检查。

4.1 违规现场

先看那次违规的改动长什么样:

— a/src/orders/services/cancel_order.py
+++ b/src/orders/services/cancel_order.py
@@
+from orders.repositories.postgres import session_factory
+
+
async def cancel_order(*, order_id: str, repo, audit) -> None:
– await repo.save(order)
– await audit.record("order.cancelled", order_id=order.id)
+ async with session_factory() as session:
+ async with session.begin():
+ await repo.save(order, session=session)
+ await audit.record("order.cancelled", order_id=order.id, session=session)

改动只有几行,意图也很正当:让状态变更和审计事件落在同一个事务里。问题是它换来了两个后果。第一,用例层现在知道了数据库实现的存在,将来换存储就得改用例层。第二,测试环境里用的假实现没有 session 参数,所以这条路径在单元测试里走不到。

第二个后果解释了为什么单元测试全绿。单元测试注入的是内存版的仓库和审计记录器,它们不需要事务,也不会因为多了一个参数而失败。真正的问题只在集成环境里暴露:那里用的是真实实现,而真实实现的会话工厂来自一个被替换掉的模块。

4.2 找到可判定的信号

要把这条规则硬起来,第一步是找到一个机器能读的信号。这里最合适的信号就是导入语句:哪个文件导入了哪个模块。

规则的表述也可以同步精确一点,从“不要跨层调用”变成:

规则:src/orders/domain 与 src/orders/services 下的文件
不得导入 orders.repositories 下的任何模块

这句话有两个组成部分:作用范围(哪两个目录)和禁止对象(哪一类模块)。两部分都写具体,检查才可能准确。像“不要跨层调用”这样的表述,范围到底是哪些目录、什么算跨层,都需要读者自己补全。

4.3 第一版:一条正则命令

最快的硬约束是把它写成一条命令:

if rg -n "from orders\\.repositories" src/orders/services src/orders/domain; then
echo "services/domain 禁止直接依赖 repositories 实现;请通过传入的 repo 接口访问"
exit 1
fi

这条命令跑起来很快,也能拦住最常见的一种写法。但它的覆盖面有限,下面这些写法它抓不到:

import orders.repositories.postgres # 直接 import 模块
from ..repositories import postgres # 相对导入
from orders.repositories import postgres as pg # 带别名
mod = importlib.import_module("orders.repositories.postgres") # 字符串导入

规则文字里写的是“不得导入”,而命令只认一种字符串形态,这个缺口迟早会被填上。更稳的做法是直接分析语法结构,而不是匹配文本。

4.4 第二版:一条架构测试

Python 自带把源码解析成语法树的工具,不需要运行代码就能读出所有导入。下面这条测试放在 tests/architecture/ 下,和功能测试一起运行:

# tests/architecture/test_layer_dependencies.py
import ast
from pathlib import Path

SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
FORBIDDEN_PREFIX = "orders.repositories"
CHECKED_LAYERS = ("domain", "services")

def imported_modules(path: Path) –> set[str]:
"""读出这个文件里出现的所有导入模块名(只看语法,不执行代码)。"""
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
modules: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
modules.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom):
if node.level: # 相对导入,先还原成绝对名字
package = list(path.relative_to(SRC_ROOT).with_suffix("").parts[:–1])
keep = len(package) – (node.level – 1)
prefix = ".".join(package[:keep])
modules.add(f"{prefix}.{node.module}" if node.module else prefix)
elif node.module:
modules.add(node.module)
return modules

def test_domain_and_services_do_not_import_repositories():
offenders: dict[str, list[str]] = {}
for layer in CHECKED_LAYERS:
for path in (SRC_ROOT / "orders" / layer).rglob("*.py"):
bad = sorted(
m for m in imported_modules(path)
if m == FORBIDDEN_PREFIX or m.startswith(FORBIDDEN_PREFIX + ".")
)
if bad:
offenders[str(path.relative_to(SRC_ROOT))] = bad

assert not offenders, (
"domain 层和 services 层不允许直接依赖 repositories 实现,"
"请通过注入的仓库接口访问数据:" + repr(offenders)
)

这段代码里有三处值得解释。第一,ast.parse 只读语法结构,不执行文件,所以它不需要数据库、不需要网络,也不会被运行时问题干扰。第二,相对导入需要还原:from ..repositories import postgres 光看字面是看不出层级的,代码里用当前文件所在包的位置还原出绝对名字。第三,断言失败时输出了违规文件路径和具体的模块名,这就是下一轮修复需要的输入。

先在有违规的代码上跑一次,确认它会红:

pytest -q tests/architecture/test_layer_dependencies.py

FAILED tests/architecture/test_layer_dependencies.py::test_domain_and_services_do_not_import_repositories
AssertionError: domain 层和 services 层不允许直接依赖 repositories 实现,请通过注入的仓库接口访问数据:{'orders/services/cancel_order.py': ['orders.repositories.postgres']}
1 failed in 0.21s

这条失败信息给了三样东西:哪条规则被违反、哪个文件违反、违反了哪一行导入。修的人不需要猜。

4.5 修法:让事务从接口进来

违规的动机是想要一个事务,那么正确的解法就是把事务放进接口里,而不是让用例层自己去找会话:

# src/orders/services/cancel_order.py(修好的一版)
from orders.domain.errors import CancelNotAllowed, OrderNotFound
from orders.domain.order import OrderStatus

async def cancel_order(*, order_id: str, repo, audit) –> None:
order = await repo.get(order_id)
if order is None:
raise OrderNotFound(order_id)
if order.status is not OrderStatus.PENDING:
raise CancelNotAllowed(order_id=order.id, status=order.status)

async with repo.transaction():
order.status = OrderStatus.CANCELLED
await repo.save(order)
await audit.record("order.cancelled", order_id=order.id)

变化的只有一行:事务从 repo.transaction() 来,具体是哪一种数据库、用哪个会话工厂,由实现层决定。用例层仍然只认识仓库接口,内存实现也可以提供一个空的事务上下文,于是单元测试和集成测试走的是同一条路。

再跑一次那条架构测试:

pytest -q tests/architecture/test_layer_dependencies.py

1 passed in 0.19s

到这里,规则从“写在文档里的一句话”变成了“一条会失败的检查”。它的强度变了:以前它靠每个人记得,现在它靠代码现实说话。

顺便说明为什么这里选静态检查,而不是在运行时加一段断言。运行时检查只能覆盖被执行到的代码路径,一条很少被调用的分支里的违规可以潜伏很久;静态检查读的是全部源码,不受调用路径影响。代价是静态检查可能误报——它看到一个导入就认为违规,哪怕那个导入只在类型标注里用到。误报可以用明确的例外条目处理,这比漏报容易接受得多。

4.6 另一个选择:现成的依赖检查工具

手写 AST 测试的优点是零依赖、完全可控、报错信息由你自己写。缺点是当层次约定变复杂时,测试本身会开始膨胀。这时候可以考虑现成的依赖检查工具,例如 import-linter 这类专门用来表达“哪些包不能依赖哪些包”的工具。

两条实践建议。第一,先在小范围试跑,确认它对你们项目的目录结构理解正确,再把它接进 CI;直接上全量配置容易出现大量误报,而误报会让人开始绕过检查。第二,工具的配置文件也是一种规则载体,改配置的时候要同步改 AGENTS.md 里的说法,否则两边会出现两套事实。

选哪一种不重要,重要的是有并且被执行。

4.7 接进门禁才算完成

一条只在你手动运行时才生效的检查,强度介于软约束和硬约束之间。要让它变成硬约束,把它放进已有的验证入口:

#!/usr/bin/env bash
# scripts/verify.sh
set -euo pipefail

pytest -q tests/architecture
pytest -q tests/orders
ruff check .

然后在 CI 里调用同一个脚本。这样做的额外好处是,本地和 CI 跑的是同一条命令,出现分歧时容易排查。

这里顺便区分两类不同的门禁。架构测试管的是代码结构,比如“谁导入了谁”;命令规则管的是命令本身能不能跑,比如禁止强制推送、要求数据库迁移先审批。后者的写法是放在 rules/ 目录下的 .rules 文件里,用 prefix_rule(pattern=[…], decision="allow|prompt|forbidden", justification="…") 描述;多条规则同时匹配时取最严格的一条:forbidden 比 prompt 严格,prompt 比 allow 严格。想确认某条命令会得到什么结果,可以用 codex execpolicy check –pretty –rules <文件> — <命令> 先看一眼。这两类门禁互补,谁也替代不了谁:命令规则拦不住一次错误的导入,架构测试也拦不住一条危险命令。

4.8 报错信息要给出替代做法

同一条检查,报错信息写得不同,修复效率差别很大。

差的写法:

AssertionError

好一点的写法:

orders/services/cancel_order.py 导入了 orders.repositories.postgres

更好的写法:

orders/services/cancel_order.py 导入了 orders.repositories.postgres。
services 层不允许直接依赖 repositories 实现。需要事务时,请使用 repo.transaction()。

三种写法的差别在最后一句。前两种只告诉执行者“你被拦住了”,第三种告诉它往哪走。对 Agent 来说,这一句尤其重要:它看不到你们的口头约定,只能看到这条错误信息。错误信息实际上就是硬约束的说明书。

4.9 例外机制:给破例留一个入口

任何规则都会遇到需要破例的时刻。没有正式入口,破例就会以两种方式发生:要么有人偷偷绕过检查,要么检查被整体关掉。两种都比破例本身更糟。正式入口可以是一条白名单,每个条目都写清位置和理由:

EXCEPTIONS = {
# 位置:理由(谁批的、什么时候撤)
"orders/services/reporting.py": "报表聚合需要跨表查询,计划迁到 orders/reports/ 后删除",
}

关键在括号里的两件事:谁批的、什么时候撤。只写理由的例外会永久留在代码里,三年后没人知道它是否还需要。带着撤回时间的例外,会自己提醒你回头处理。

4.10 一次完整的记录

把这次改造串起来,命令行的时间线是这样的:

$ pytest -q tests/architecture/test_layer_dependencies.py
1 failed in 0.21s # 违规存在,检查会红
$ codex exec –sandbox workspace-write "按失败信息修复 cancel_order,不要修改架构测试"
$ pytest -q tests/architecture/test_layer_dependencies.py
1 passed in 0.19s # 结构恢复,规则被满足
$ bash scripts/verify.sh
8 passed, 2 passed in 1.04s # 全部门禁通过,退出码 0

这段记录里有两次通过。第一次通过说明结构对了,第二次通过说明整体没被破坏。两条都要,理由很实际:修结构的时候很容易顺手改坏别的东西。

4.11 同一套办法能覆盖的其它规则

跨层依赖只是最常见的一条。下面三条规则也能用同样的方式硬起来,写法略有不同,但骨架一样:范围、禁止对象、判定信号、失败信息。

第一条:生成代码不能被手工修改。判定信号是文件头注释里的“本文件由生成器产出”标记,检查方式是比对生成器的输出与仓库里的文件是否一致。失败信息里要写清重新生成的命令。

第二条:接口层不允许写业务规则。判定信号可以是“src/orders/api 下的函数体里出现状态判断”,实现方式通常需要一点语法层面的分析,比如统计函数里的条件分支和状态枚举引用。这条检查比导入检查复杂,所以更适合先写成提醒,等稳定之后再升级成门禁。

第三条:新增接口必须带测试。判定信号是新增的路由函数与测试文件之间的对应关系,实现方式是读取改动清单,检查新增的路由路径是否出现在测试里。它依赖版本控制的信息,适合放在 CI 里跑,因为那里能拿到两个版本之间的差异。

这三条的共同点是:它们都可以被一条命令回答。如果一条规则连“怎么算违规”都说不清,那它更适合留在文档里,靠人判断。

4.12 检查的运行成本

结构检查和功能测试有一个很大的差别:它不需要启动数据库、不需要网络、不依赖外部服务,所以它通常在一秒以内跑完。这个特点让它很适合放在最前面:开发者本地随时跑,提交前跑,CI 里第一个跑。前面失败了,后面的慢检查就不用启动。

基于这个差别,一个实用的顺序是:结构检查、快速单元测试、慢的集成测试。顺序本身就是一种反馈优化,因为越靠前的检查越便宜,越早停下来越省时间。注意这不意味着后面的检查可以不跑,只意味着失败时能更快地知道原因。

如果你们的架构检查跑一次超过几秒,通常说明它在做别的事:启动应用、连数据库、扫描整个依赖树。这类检查应该被拆成两部分,把能静态判定的部分单独拿出来,让它回到“秒级”的水平。

4.13 让 Agent 知道这条检查存在

检查写好之后还有一步经常被漏掉:把它的存在告诉执行者。这不是为了礼貌,而是为了省一次返工。

任务单里加一行就够:

验证:bash scripts/verify.sh(包含架构检查与订单模块用例,必须全部通过)
约束:services 层不得导入 orders.repositories 下的实现,需要事务时使用 repo.transaction()

这两行带来的差别很直接。没有它们,Agent 会先写一版自己认为合理的实现,然后被检查拦下,再花一轮改;有了它们,它一开始就会去找仓库接口里有没有可用的入口。前一种路径也能到达终点,只是多一次往返。

还有一个细节:任务单里的约束最好和检查的报错信息用同一套说法。如果文档说“不要跨层调用”,检查说“不得导入 orders.repositories”,改的人需要在两种表述之间做一次翻译。措辞统一之后,从读文档到读报错是连续的。

五、反例与代价

反例一:规则写成口号。 “保持分层清晰”“不要写重复代码”“注意命名”,这类规则读起来很像那么回事,但它们没有可判定的信号,也没有范围。执行者只能各自理解一遍,然后按自己的理解去遵守。半年后你会发现,十个人有十种“清晰”的定义。代价是规则看起来很多,实际约束力接近零。

反例二:检查做得太宽。 有的团队一上来就写“services 层不允许导入任何非标准库模块”,结果每次正常引用领域对象都要改成白名单条目。检查频繁误报之后,大家的应对方式是往白名单里加东西,两个星期后白名单比规则本身还长。检查太宽的代价不是误报本身,而是它训练了团队绕过检查的习惯。

反例三:检查存在,但不在门禁里。 架构测试写好了,放在仓库里,但没进 CI,也没进提交前脚本。它会一直在,也会一直能被运行,但没有任何一次违规会被它拦住。这种状态最迷惑人,因为所有人都知道“我们有架构测试”,却没有人见过它失败。

反例四:只改文档,不改检查。 规则调整了,比如允许 services 层直接调用某个新的查询服务,但检查没更新。于是检查开始报错,而人们知道“这个是老的规则”,选择忽略它。忽略一次之后,这条检查就再也不会被当真了。所以规则变更时,文档和检查要么一起改,要么一起删。

反例五:靠人工评审守住结构。 评审当然要看结构,但把结构约定完全交给评审,等于把检查放在流程末尾,而且放在一个更容易疲劳的位置上。跨层调用这类问题在 diff 里往往只有一行,评审人很容易跳过;而它的后果要到换实现、换存储、换测试环境时才会出现。

反例六:检查写了,但没人知道。 架构测试放在仓库里,可任务单不提、AGENTS.md 不提、CI 日志里也没有名字。结果每个执行者都要先违规一次才能发现它的存在。这种情况不严重,但很浪费:一条本来可以提前避免的返工,被重复支付了很多次。补法很便宜,在任务单的验证那一行里加上它的命令即可。

这些反例的共同代价是同一件事:团队成员对规则的信任被消耗掉。规则的意义不在于写下来,而在于它被违反时会发生什么。如果违反没有任何后果,那么这条规则就会在下一次“这次情况特殊”的时候自然失效——开头那个事务需求就是这样一个“特殊情况”。

反过来,硬约束也有它的代价,这一点不必回避。它会占用时间:写检查、调检查、维护例外。它还会带来一种风险,就是让人误以为“检查绿了就万事大吉”,从而忽略检查覆盖不到的地方。控制面的作用是让可判定的部分自动通过,把人的注意力留给需要判断的部分,而不是取代判断。

六、落地步骤

第一步,挑一条最值得硬起来的规则。

做什么:从现有规则里选一条违规代价最高的,比如跨层依赖、绕过鉴权、直接改生成代码。

为什么:一次做十条规则会让人失去耐心,而先做一条能立刻看到效果的规则,能带动后面几条。

怎么检查:问一个问题——如果这条规则被违反并且三个月后才被发现,代价是什么。答不出高代价的,先放一放。

第二步,把规则改写成“范围 + 禁止对象”。

做什么:把“不要跨层调用”改写成“src/orders/domain 与 src/orders/services 下的文件不得导入 orders.repositories 下的模块”。

为什么:检查只认具体的范围和对象,含糊的表述无法翻译成代码。

怎么检查:把改写后的规则读给一个不熟悉项目的人听,看他能不能说出“哪些文件被管、什么行为被禁”。

第三步,找到可判定的信号。

做什么:确定检查读什么——导入语句、文件路径、函数签名、配置项。

为什么:信号决定了检查是稳定还是会经常误报。

怎么检查:先手工在代码里搜一遍这个信号,确认它存在并且形态可控。如果同一个含义有七八种写法,先把写法统一,或者升级到语法层面的检查。

第四步,先写会失败的检查。

做什么:在违规存在的代码上把检查跑一遍,确认它会红,并且报错信息里包含文件路径和违规内容。

为什么:一条从没红过的检查,你不知道它在检查什么。

怎么检查:看报错信息。如果只有“断言失败”四个字,补上位置和替代做法。

第五步,修好违规再跑一次。

做什么:用符合规则的方式解决问题,而不是绕过检查。

为什么:这一步决定了规则是否可信。如果第一次违规都是靠加例外解决的,规则就白写了。

怎么检查:确认修复方式是在正确的层里提供能力,比如把事务做进仓库接口,而不是把违规文件加进白名单。

第六步,把检查接进已有的门禁。

做什么:把这条检查加进 scripts/verify.sh 和 CI,并确认它不会被条件跳过。

为什么:只有接进流程,检查才有后果。

怎么检查:故意制造一次违规,推到一个草稿分支上,看 CI 是否真的变红。

第七步,写清例外入口。

做什么:给出白名单的位置和格式,每个例外必须写理由和撤回条件。

为什么:没有正式入口,破例就会变成绕过的借口。

怎么检查:翻一遍白名单,凡是只有理由没有撤回条件的条目,补上或者删掉。

第八步,同步更新规则文件。

做什么:在 AGENTS.md 里把这条规则改成更短的表述,并注明“已由 tests/architecture/test_layer_dependencies.py 检查”。

为什么:这样 Agent 在生成阶段就知道边界在哪,也知道边界会被验证,而不是等到被拦下才知道。

怎么检查:读一遍文档里的规则,确认每一条都能对应到一条命令或者明确标注为“需要人工判断”。对不上的,要么补检查,要么标注清楚。

把八步合成一份可以直接复制的模板:

规则原文:____________________________________
作用范围:____________________________________
禁止对象:____________________________________
判定信号:____________________________________
检查命令:____________________________________
失败时的替代做法:____________________________
门禁位置:本地脚本 / pre-commit / CI
例外条目格式:位置 + 理由 + 谁批的 + 何时撤
规则文件里的表述:____________________________

这份模板填完之后,你会得到一条从文字到命令的完整链路。它比想象中短,往往一屏就能放下;但它把一个凭运气的约定,换成了一个确定的边界。

如果一次上线八步太多,可以只做其中最短的闭环:挑一条规则、写一条检查、跑一次失败、接进 CI。四件事做完,第一条硬约束就存在了。第二条、第三条可以等到下一次迭代,不必一次性把规则库全部改造完。

还有一个顺序问题值得说清:硬约束和权限不是同一件事。权限限制的是“能不能碰某个文件”,比如不允许写 pyproject.toml;硬约束检查的是“代码呈现什么形态”,比如不允许从某个模块导入。两者互补。权限挡不住一次合法的文件编辑,检查也挡不住一次被允许却方向错误的改动。设计控制面的时候,先问“这个动作是不是根本不该发生”,如果是,用权限;如果这个动作本身合理、只是形态不对,用检查。

七、FAQ

问:我把规则加粗、写得更严厉,会不会更有用?

措辞的强度确实会影响模型的行为,所以把关键规则写在显眼位置是值得的。但措辞改变的是概率,不是结果。今天它读到规则并遵守,明天它遇到一个新情况,可能就选了更顺手的那条路,而你无法从结果上分辨这两种状态。想让违反规则变成不可能,只有检查能做到。所以合理的做法是:文档里写清楚(帮助它做对),检查里写清楚(保证违规可见),两件事都要做。

问:架构测试和普通测试有什么区别?

断言的对象不同。普通测试断言业务行为,比如“已支付订单不能取消”;架构测试断言代码形态,比如“这个目录下的文件不能导入那个模块”。它们的运行方式可以完全一样,都放在同一个测试目录、用同一条命令跑。维护成本也不高,因为它检查的是结构,业务变化时基本不用改;真正需要改的是结构调整的时候,而那时候本来就该有人重新看一遍规则。

问:这条检查会不会太严,正常改动也被拦?

判断标准是误报率。如果一条检查经常让正常改动停下来,说明它约束的对象太宽或者信号选得不好,需要收窄,而不是放任它一直红着。一个可操作的检验方法:在最近二十次改动上跑一遍这条检查,看有多少次会让合法改动失败。如果有,先修检查;如果没有,就把它接进门禁。

问:手写检查还是用现成工具?

先用最简单的方式落地。如果只有一两条依赖规则,手写一段语法检查或者一条搜索命令就够了,零依赖、报错信息自己写。当规则变多、层次关系变复杂时,再换成专门表达依赖关系的工具,例如 import-linter 这类。不要一上来就把配置写成一大坨,误报会消耗团队对检查的信任。

问:检查应该放在哪个目录下?

放在测试目录里最省事,因为它和功能测试共用同一套运行方式和门禁。比如 tests/architecture/。这样做还有一个好处:任何人打开测试目录就能看到项目有哪些结构约定,不需要额外维护一份“架构文档”。如果团队更习惯用独立脚本,放在 scripts/ 下也可以,前提是它同样被门禁调用。

问:我用的是别的语言,这套思路能用吗?

能,思路和语言无关。需要替换的只是读取代码结构的工具:Java、Go、TypeScript、C# 都有能在不运行程序的情况下分析导入或引用的办法,很多语言生态里也有专门的依赖规则检查工具。判断标准始终是同两条:信号可判定、违规有后果。

问:例外条目越来越多怎么办?

例外变多通常说明两件事之一:规则本身需要调整,或者项目里确实出现了新的合理用法。处理方式是定期回看白名单,看每条例外的撤回条件是否已经满足;如果某类例外重复出现三次以上,就应该把它从例外升级成规则的一部分,而不是继续零散地加条目。

问:既然有检查了,AGENTS.md 里还要写这条规则吗?

要,但可以写得更短。文档的作用是让执行者在动手之前就知道边界,避免先写出一版违规实现再返工。一句话就够,比如“services 层不直接依赖 repositories 实现,具体检查见 tests/architecture/test_layer_dependencies.py”。这样既省了篇幅,也让读者知道去哪儿看细节。

问:团队里有人说写检查是浪费时间,怎么回应?

用一个具体数字回应会比较容易:把最近一次同类违规的排查时间算出来,从发现到定位到修好,算进别人被打断的时间。然后再对比写这条检查需要的时间,通常比排查一次违规便宜得多。这不是要证明“检查一定划算”,而是把讨论从态度换成时间账。如果这条规则几乎不会被违反,那也确实不必写检查——那就把它从规则里降级成建议。

问:检查本身写错了,把合法代码判成违规怎么办?

这是最常见的第一次翻车方式,处理原则是“先改检查,再合代码”。具体做法:在检查里加一个最小的复现用例,说明这次为什么误报;修改检查让这个用例通过;重新在历史代码上跑一遍,确认没有引入新误报。不要通过给合法代码加例外来绕开它,那样你会失去对白名单的信任。

问:这条规则应该只写进 AGENTS.md,还是也写进任务单?

两者都可以写,但作用不同。AGENTS.md 里的版本是长期约定,告诉任何一次任务“这个项目的边界在哪里”;任务单里的版本是本次强调,适合那些特别容易在本次改动里被触碰的边界。如果某条规则经常需要写进任务单,说明它可能缺少一条检查,或者项目里缺少一个方便的正确入口。

问:规则很多的项目,怎么排优先级?

按“违规代价 × 可判定性 × 发生频率”排序,先做乘积最高的那一条。经验上,跨层依赖、绕过公共入口、手工修改生成文件这三类规则排得比较靠前,因为它们一旦被违反,会持续吸引更多违规,而且都能被静态判定。风格类规则通常排得很靠后,因为它们既不常被违反,违反的代价也小。

问:一条检查写完之后,多久需要回头看看它?

给它一个固定的复查点,比如每季度,或者每次结构调整之后。复查三件事:它还在 CI 里跑吗?它的例外条目有没有可以删掉的?它的规则表述和检查实现还有没有一致?这三件事都不需要很长时间,但如果不做,检查会慢慢变成摆设——最常见的形式是它还在跑,只是没人再看它的结果。

问:文档和检查会不会互相重复,造成维护负担?

会有一点重复,但这一份重复值得留。文档那一份是给人在动手之前看的,检查那一份是给机器在动手之后用的。为了避免两边越写越远,可以让文档里的字数少于检查里的信息:文档写“一句话 + 检查文件路径”,细节全放检查。这样更新时主要改检查,文档里那句话说清去向就行。真正需要避免的是两份各自维护一大段细节,那种情况下几乎一定会分叉。

八、动手练习与小结

这次的练习产出是一个“规则转检查”的对照表,加上一条真正能跑的检查。

第一步,从你项目里挑三条文字规则,按下面的模板填一遍:

规则 1:________________________________
可判定信号:______________ 检查方式:______________
规则 2:________________________________
可判定信号:______________ 检查方式:______________
规则 3:________________________________
可判定信号:______________ 检查方式:______________

第二步,从三条里选信号最明确的那一条,把它写成一条真的能跑的检查。可以是测试,可以是一段脚本,也可以是一条搜索命令,只要它能在违规时以非 0 退出码失败。

第三步,做两次验证:在当前的违规代码上跑(如果有),确认它会红;修好之后跑,确认它会绿。两次都做完,这条规则才算从文字变成了硬约束。

自检时问四个问题:这条规则的适用范围写清了吗?违规时给出的信息里有位置和替代做法吗?这条检查在 CI 里是必须通过的吗?例外有没有撤回条件?四个问题都能回答,这条规则就站得住了。

再补一个更实际的验收动作:把这条检查拿给一个没参与的人(或者另一个 Agent)看,只给它这条检查的报错信息,让它说出“应该怎么改”。如果它能说对,说明你的错误信息在承担规则说明书的角色;如果它说的是“我知道了”,但动手方向不对,说明错误信息里缺了替代做法那一段。

回到开头那个事务需求。如果当时存在一条架构检查,Agent 第一次尝试导入会话工厂时就会收到一条明确的失败信息,里面写着应该改用仓库接口提供的能力。它不需要背下规则,也不会在三个月后发现规则是一句空话。规则的意义从来不在文件里,而在它被违反的那一刻有没有东西发生。

有一句话值得记下来:软约束负责让正确的事更容易发生,硬约束负责让错误的事无法完成。两句话都很重要,但如果你只能先做一件,先做后面那件,因为它更确定。确定了边界之后,再回头把文档写短、写准,效果会比一开始就把规则列成长清单好得多。

下一篇会接着这个问题往下走一步:同一个团队里,有些错误确实只能靠“提前说清”来减少,有些错误只能靠“事后检查”来发现。分清这两种情况,你才知道该补文档还是该补门禁。

这一篇讲的是把文字变成检查。下一篇要处理一个相近但方向相反的问题:有些错误应该尽量在发生之前就减少,有些错误只能在发生之后被识别,这两种控制的区别决定了你应该补文档还是补门禁。

赞(0)
未经允许不得转载:网硕互联帮助中心 » 软约束与硬约束:AGENTS.md 写了“禁止跨层调用”,够吗?
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!