
一、一条写在文件第一行的规则三个月后被违反了还是shop订单服务。项目开工那天你们在AGENTS.md的“什么不能做”一节写下第一条规则- 不允许 domain 层和 services 层直接依赖数据库驱动或 repositories 的实现细节这条规则一直有效。它写在项目根目录任何一次任务开始前Agent 都会读到它。三个月里大大小小的改动都遵守了这条约定订单、支付、退款相关的用例都规规矩矩地通过仓库接口访问数据。然后有一天你要上线一个新的审计需求。Agent 的任务是让取消订单时写入的审计事件和状态变更在同一个事务里完成。它读了代码发现事务边界在仓库实现里而用例层拿不到事务对象。于是它在src/orders/services/cancel_order.py顶部加了一行fromorders.repositories.postgresimportsession_factory然后打开会话、开启事务、写审计事件、提交。功能是对的评审的时候这段代码看起来也挺自然不就是多拿了一个会话吗。麻烦出现在两天后。团队把订单仓库的实现从 PostgreSQL 换成了内存版用于跑一批快速集成测试。所有用例瞬间失败因为那个从postgres模块导入的会话对象在内存版里不存在。这时候大家才想起那条规则并且发现一件让人不太舒服的事规则一直都在谁都知道可它从来没有拦下过任何一次提交。修复花的不是很多时间一共改了三处。真正花时间的是讨论这条规则到底要不要保留如果保留怎么保证它下次不再被无声地违反讨论的最后结论也很简单——把它写成一条检查。这篇剩下的部分就是在讲这条检查长什么样。这件事的关键不在于“模型不听话”。恰恰相反它大概率读到过那条规则也理解它。它违反规则的原因非常务实眼下的任务需要事务而那条路径最顺手。规则和方便之间规则输了。这里还有一个容易被忽视的细节那次违规的代码本身质量不差。命名清楚、异常路径完整、事务边界也考虑到了。它唯一的错误是走了一条被约定排除的路。正因为如此评审的人也会犹豫——代码看起来没问题为什么要打回去当规则只是文字时这种犹豫几乎注定会以“先合进去再说”结束。这就是这一篇要讲的东西文字约定和可执行检查是两种不同性质的约束。前者提高正确行为的概率后者决定错误行为有没有后果。少了后者规则会在最需要它的时候恰好失守。二、先把词讲明白软约束soft constraint写在文档、任务单、注释或者口头约定里的规则比如“不要跨层调用”“保持函数短小”。它通过影响人的判断和模型的行为来起作用违规时不会有任何自动反应。它的好处是便宜、灵活代价是不确定。硬约束hard constraint由机器判定的检查违规时流程直接失败。比如一条测试、一个静态检查、一个 CI 必须通过的步骤。它不需要任何人记得也不接受解释。它的好处是确定代价是需要有人先把它写出来。跨层调用上层代码跳过约定的中间层直接使用更底层的实现细节。日常类比你去餐厅吃饭正常流程是跟服务员点单跨层调用相当于你直接走进厨房从冰箱里拿食材。多数时候也能吃上饭只是后厨的规矩、库存记录、卫生流程全部被跳过了。依赖方向谁可以引用谁。一个常见的约定是“外层可以依赖内层内层不能依赖外层”接口层可以调用用例层用例层可以调用领域对象但领域对象不应该反过来知道数据库、HTTP 或者消息队列的存在。这条约定可以用一句话写出来也可以用一条检查来验证。静态检查不运行程序只读代码结构就能做的检查。最典型的是看导入语句哪个文件导入了哪个模块。因为它不需要数据库、不需要网络所以又快又稳适合当门禁。架构测试用测试的形式来检查结构约定比如“src/orders/domain下的文件不允许导入数据库驱动”。它和功能测试用同一套运行方式只是断言的对象不是业务行为而是代码的形态。例外机制允许出现的破例但必须写清位置和理由比如“只有src/orders/repositories/postgres.py允许导入驱动”。例外机制的价值是让规则保持可执行同时不逼着人绕开检查。规则腐化规则还在文档里但已经和代码现实不一致。常见的发生方式是有人为了通过检查修改了白名单或注释却没有更新规则本身。腐化之后规则不再是约束只是历史遗迹。三、为什么“写下来”只能提高概率先把两个概念分开知道和发生。软约束影响的是“知道”这一侧它让正确的做法更容易被想到。可规则能不能被遵守还取决于另外两个变量眼下有没有更省事的做法以及违规的后果是什么。在开头那个例子里两个变量都对规则不利。更省事的做法就在手边导入会话工厂三行代码解决问题。违规的后果是零没有人拦没有检查红评审时这段代码看起来还挺合理。再往深一层看还有一个结构性问题当唯一“执行规则”的人是模型自己时规则的解释者和执行者是同一方。它需要一边完成任务一边记得不去做那件方便的事还要在两者冲突时选择麻烦的那条路。这不是模型的道德问题而是任何执行者都会面临的取舍。人类工程师在没有检查的项目里行为模式完全一样。模型在这个取舍上还有两个特点值得记住。第一它读到规则的效果受上下文影响前面讲过的位置效应在这里同样成立信息在长上下文中的位置会影响被利用的程度Liu 等2024Lost in the MiddleTACL 12:157–173。第二没有外部反馈时它很难通过自我检查发现自己的越界这一点在 Huang 等2023arXiv: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_idorder.id) async with session_factory() as session: async with session.begin(): await repo.save(order, sessionsession) await audit.record(order.cancelled, order_idorder.id, sessionsession)改动只有几行意图也很正当让状态变更和审计事件落在同一个事务里。问题是它换来了两个后果。第一用例层现在知道了数据库实现的存在将来换存储就得改用例层。第二测试环境里用的假实现没有session参数所以这条路径在单元测试里走不到。第二个后果解释了为什么单元测试全绿。单元测试注入的是内存版的仓库和审计记录器它们不需要事务也不会因为多了一个参数而失败。真正的问题只在集成环境里暴露那里用的是真实实现而真实实现的会话工厂来自一个被替换掉的模块。4.2 找到可判定的信号要把这条规则硬起来第一步是找到一个机器能读的信号。这里最合适的信号就是导入语句哪个文件导入了哪个模块。规则的表述也可以同步精确一点从“不要跨层调用”变成规则src/orders/domain 与 src/orders/services 下的文件 不得导入 orders.repositories 下的任何模块这句话有两个组成部分作用范围哪两个目录和禁止对象哪一类模块。两部分都写具体检查才可能准确。像“不要跨层调用”这样的表述范围到底是哪些目录、什么算跨层都需要读者自己补全。4.3 第一版一条正则命令最快的硬约束是把它写成一条命令ifrg-nfrom orders\.repositoriessrc/orders/services src/orders/domain;thenechoservices/domain 禁止直接依赖 repositories 实现请通过传入的 repo 接口访问exit1fi这条命令跑起来很快也能拦住最常见的一种写法。但它的覆盖面有限下面这些写法它抓不到importorders.repositories.postgres# 直接 import 模块from..repositoriesimportpostgres# 相对导入fromorders.repositoriesimportpostgresaspg# 带别名modimportlib.import_module(orders.repositories.postgres)# 字符串导入规则文字里写的是“不得导入”而命令只认一种字符串形态这个缺口迟早会被填上。更稳的做法是直接分析语法结构而不是匹配文本。4.4 第二版一条架构测试Python 自带把源码解析成语法树的工具不需要运行代码就能读出所有导入。下面这条测试放在tests/architecture/下和功能测试一起运行# tests/architecture/test_layer_dependencies.pyimportastfrompathlibimportPath SRC_ROOTPath(__file__).resolve().parents[2]/srcFORBIDDEN_PREFIXorders.repositoriesCHECKED_LAYERS(domain,services)defimported_modules(path:Path)-set[str]:读出这个文件里出现的所有导入模块名只看语法不执行代码。treeast.parse(path.read_text(encodingutf-8),filenamestr(path))modules:set[str]set()fornodeinast.walk(tree):ifisinstance(node,ast.Import):modules.update(alias.nameforaliasinnode.names)elifisinstance(node,ast.ImportFrom):ifnode.level:# 相对导入先还原成绝对名字packagelist(path.relative_to(SRC_ROOT).with_suffix().parts[:-1])keeplen(package)-(node.level-1)prefix..join(package[:keep])modules.add(f{prefix}.{node.module}ifnode.moduleelseprefix)elifnode.module:modules.add(node.module)returnmodulesdeftest_domain_and_services_do_not_import_repositories():offenders:dict[str,list[str]]{}forlayerinCHECKED_LAYERS:forpathin(SRC_ROOT/orders/layer).rglob(*.py):badsorted(mforminimported_modules(path)ifmFORBIDDEN_PREFIXorm.startswith(FORBIDDEN_PREFIX.))ifbad:offenders[str(path.relative_to(SRC_ROOT))]badassertnotoffenders,(domain 层和 services 层不允许直接依赖 repositories 实现请通过注入的仓库接口访问数据repr(offenders))这段代码里有三处值得解释。第一ast.parse只读语法结构不执行文件所以它不需要数据库、不需要网络也不会被运行时问题干扰。第二相对导入需要还原from ..repositories import postgres光看字面是看不出层级的代码里用当前文件所在包的位置还原出绝对名字。第三断言失败时输出了违规文件路径和具体的模块名这就是下一轮修复需要的输入。先在有违规的代码上跑一次确认它会红pytest-qtests/architecture/test_layer_dependencies.pyFAILED 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修好的一版fromorders.domain.errorsimportCancelNotAllowed,OrderNotFoundfromorders.domain.orderimportOrderStatusasyncdefcancel_order(*,order_id:str,repo,audit)-None:orderawaitrepo.get(order_id)iforderisNone:raiseOrderNotFound(order_id)iforder.statusisnotOrderStatus.PENDING:raiseCancelNotAllowed(order_idorder.id,statusorder.status)asyncwithrepo.transaction():order.statusOrderStatus.CANCELLEDawaitrepo.save(order)awaitaudit.record(order.cancelled,order_idorder.id)变化的只有一行事务从repo.transaction()来具体是哪一种数据库、用哪个会话工厂由实现层决定。用例层仍然只认识仓库接口内存实现也可以提供一个空的事务上下文于是单元测试和集成测试走的是同一条路。再跑一次那条架构测试pytest-qtests/architecture/test_layer_dependencies.py1 passed in 0.19s到这里规则从“写在文档里的一句话”变成了“一条会失败的检查”。它的强度变了以前它靠每个人记得现在它靠代码现实说话。顺便说明为什么这里选静态检查而不是在运行时加一段断言。运行时检查只能覆盖被执行到的代码路径一条很少被调用的分支里的违规可以潜伏很久静态检查读的是全部源码不受调用路径影响。代价是静态检查可能误报——它看到一个导入就认为违规哪怕那个导入只在类型标注里用到。误报可以用明确的例外条目处理这比漏报容易接受得多。4.6 另一个选择现成的依赖检查工具手写 AST 测试的优点是零依赖、完全可控、报错信息由你自己写。缺点是当层次约定变复杂时测试本身会开始膨胀。这时候可以考虑现成的依赖检查工具例如 import-linter 这类专门用来表达“哪些包不能依赖哪些包”的工具。两条实践建议。第一先在小范围试跑确认它对你们项目的目录结构理解正确再把它接进 CI直接上全量配置容易出现大量误报而误报会让人开始绕过检查。第二工具的配置文件也是一种规则载体改配置的时候要同步改AGENTS.md里的说法否则两边会出现两套事实。选哪一种不重要重要的是有并且被执行。4.7 接进门禁才算完成一条只在你手动运行时才生效的检查强度介于软约束和硬约束之间。要让它变成硬约束把它放进已有的验证入口#!/usr/bin/env bash# scripts/verify.shset-euopipefail pytest-qtests/architecture pytest-qtests/orders ruff check.然后在 CI 里调用同一个脚本。这样做的额外好处是本地和 CI 跑的是同一条命令出现分歧时容易排查。这里顺便区分两类不同的门禁。架构测试管的是代码结构比如“谁导入了谁”命令规则管的是命令本身能不能跑比如禁止强制推送、要求数据库迁移先审批。后者的写法是放在rules/目录下的.rules文件里用prefix_rule(pattern[...], decisionallow|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 第一次尝试导入会话工厂时就会收到一条明确的失败信息里面写着应该改用仓库接口提供的能力。它不需要背下规则也不会在三个月后发现规则是一句空话。规则的意义从来不在文件里而在它被违反的那一刻有没有东西发生。有一句话值得记下来软约束负责让正确的事更容易发生硬约束负责让错误的事无法完成。两句话都很重要但如果你只能先做一件先做后面那件因为它更确定。确定了边界之后再回头把文档写短、写准效果会比一开始就把规则列成长清单好得多。下一篇会接着这个问题往下走一步同一个团队里有些错误确实只能靠“提前说清”来减少有些错误只能靠“事后检查”来发现。分清这两种情况你才知道该补文档还是该补门禁。这一篇讲的是把文字变成检查。下一篇要处理一个相近但方向相反的问题有些错误应该尽量在发生之前就减少有些错误只能在发生之后被识别这两种控制的区别决定了你应该补文档还是补门禁。