给Code Agent加约束:从AGENTS.md开始

发布时间:2026/7/6 4:10:42
给Code Agent加约束:从AGENTS.md开始 换句话说AGENTS.md 的核心价值不是说明项目而是约束生成。README 解决的是“人如何理解项目”AGENTS.md 解决的是“Agent 在这个项目里应该如何做判断”。这两者看起来接近本质上却完全不同。README 可以介绍项目背景、启动方式、依赖安装、目录结构AGENTS.md 更应该告诉 Agent这个项目里哪些写法是不被接受的哪些架构选择已经被团队决定过哪些历史事故不能再次发生哪些领域规则必须按固定方式处理。这也是我现在越来越强烈的一个感受Agent 时代工程能力的核心不只是“会不会写代码”而是“能不能把隐性的工程判断显性化”。因为 Agent 最擅长的是生成但生成天然有发散性。没有约束它会从通用平均水平出发有了约束它才可能从你的项目经验出发。项目不是代码文件的集合而是一组被约束组织起来的代码。AGENTS.md 要做的就是把这组约束写下来。用 AGENTS.md 约束代码风格第一种约束最容易理解代码风格约束。但这里说的“风格”不是缩进几个空格、函数名用 snake_case 还是 camelCase 这种表层格式而是更深一层的设计哲学。一个团队可能偏 DDD一个团队可能偏 Clean Architecture一个团队可能喜欢贫血模型加应用服务编排也可能坚持领域对象要承载行为。这些差异不是简单的审美差异而是团队对“好代码”的判断标准不同。人类工程师加入团队后会通过 Code Review、老代码、文档和踩坑慢慢理解这些判断标准。Agent 也一样。区别在于如果你不显式告诉它它就只能根据当前上下文里最常见、最平均、最容易补全的写法继续往下写。比如一个登录系统里已有一个普通的 User 数据对象和一个 AuthService。现在需求来了用户连续 3 次密码错误后账户临时锁定 30 分钟锁定期间即使密码正确也不能登录。Claude Code的输出如图如果没有 AGENTS.mdAgent 往往会先把功能补完整。比如它会给 User 增加 failed_attempts、locked_until 字段也会补上 is_locked()、record_failure()、record_success() 方法并在 AuthService 里注入 clock 方便测试。这个实现不能说差它已经意识到锁定状态和失败次数应该和 User 有关。但问题是它只是做了局部修补并没有真正进入更深层的领域对象约束。User 仍然是可变对象状态变化通过 record_failure()、record_success() 直接修改自身AuthService 仍然负责流程判断和状态保存登录结果也仍然只是 bool用户不存在、密码错误、账户锁定这些失败原因都被压平了。所以没有显式约束时Agent 未必会写出明显糟糕的代码。它甚至可能写出一段看起来还不错、也能跑通测试的代码。但它默认追求的是“把功能补上”而不是“让代码回到项目认可的设计哲学里”。短期看可用长期看领域边界和失败语义会一点点变得含糊。把代码风格写进 AGENTS.md我用 EOElegant Objects,Yegor Bugayenko 的 OOP 设计哲学的部分哲学作为约束目标。这里用 EO 不是为了证明 EO 一定更好而是因为它足够有态度适合做约束效果的演示。换成 DDD、Clean Architecture、洋葱架构逻辑是一样的关键不是采用哪一种风格而是把项目认可的判断标准写清楚。这段约束的重点是“判断标准清楚”。它没有写一大堆机械规定比如“不允许 setter”“字段必须 readonly”“方法名必须如何如何”。它只是告诉 Agent这个项目判断领域对象好坏的标准是什么。## 领域对象设计哲学 1. 对象有行为不只是数据。业务逻辑应当存在于持有相关状态的对象上 而不是外部服务把数据拿出来处理完再塞回去。 2. 创建之后不可变。状态变化通过返回新对象表达。 3. 用类型表达约束不用注释或运行时检查。 修改已有领域对象时把它带入合规同样的需求在这个 AGENTS.md 存在时Agent 更可能生成这样的结构Cluade Code输出如图同样是账户锁定需求有了这组约束后Agent 更可能把锁定状态机放回 User把状态变化表达为返回新的 User把登录失败原因用 LoginResult 之类的类型显式表达而不是继续返回一个 bool。AuthService 也会从“业务规则容器”退回到“应用层编排器”。表面上看这只是代码结构不同本质上看是业务知识归属发生了变化。锁定规则到底属于 User还是属于 AuthService失败原因到底应该被类型表达还是被布尔值压平状态变化到底是直接修改还是返回新对象这些问题都不是 Agent 能凭空知道的它需要项目给出约束。所以AGENTS.md 的第一层价值是把团队的代码哲学变成 Agent 每次生成代码时的默认判断标准。规则只能覆盖你写到的场景原则才能延伸到你没写到的场景。好的 AGENTS.md 不只是告诉 Agent“不要这样写”更重要的是告诉它“为什么这个项目要这样判断”。用 AGENTS.md 固化项目历史决策第二种约束更重要也更容易被忽略项目历史决策。很多时候Agent 写出来的代码并不是“不专业”而是“不知道你们项目已经决定过什么”。这件事和模型强不强没有关系。再强的模型也不可能天然知道你们过去因为短信重复发送出过事故你们被合规审计要求过手机号不能进日志你们线上服务是 async 栈不能在服务层使用同步 HTTP你们团队约定所有业务失败都用 Result 表达而不是异常。这些不是通用知识而是项目知识。更准确地说它们是项目记忆。比如让 Agent 实现一个短信验证码发送函数失败需要重试确保用户能收到。没有约束时它可能写出一段通用工程质量很不错的代码区分可重试错误和不可重试错误设置 timeout使用指数退避自定义异常类型甚至还会主动反问你几个实现细节。站在通用 Code Review 角度这段代码可能是合格的甚至是优秀的。但放到你的项目里它仍然可能踩中一堆历史上的坑没有 idempotency_key网络抖动后可能给用户发多条验证码指数退避没有 jitter高并发时可能形成重试洪峰使用 random 生成验证码不适合安全敏感场景手机号明文进入日志存在 PII 合规问题使用同步 requests.post如果项目是 async 栈会阻塞事件循环用异常表达业务失败如果项目约定使用 Result 模式也不符合项目风格。这些问题Agent 不是完全不懂而是不知道在这个项目里哪些事情已经是硬约束。所以这类内容非常适合写进 AGENTS.md## 外部服务调用 1. 副作用必须幂等。任何“做了就会改变外部状态”的调用 发短信、扣款、下单、发邮件、写第三方系统必须接受 idempotency_key 参数并透传到下游网关。重试通过同一个 key 安全重放。 2. 失败语义必须保留。返回 Result[T, ErrorCode] 错误用具名常量区分。异常只用于“本不该发生”的状态。 3. 重试有抖动。退避用 exponential backoff full jitter。 thundering herd 是默认假设不是边界情况。 4. 不可重试的错误立即返回。4xx429 除外、Unauthorized、BadRequest 不消耗重试次数。 ## 异步 - 服务层默认 async/awaitHTTP 调用统一用 httpx.AsyncClient。 - 任何外部 IO 必须显式传 timeout禁止使用默认值。 ## 日志与 PII - 日志只记录元数据调用对象、用时、状态码、错误码。 - PII 字段手机号、邮箱、身份证、token、金额、姓名必须脱敏。 - 使用 logger.info(msg, extra{...}) 走结构化字段。 ## 安全敏感随机数 任何“被预测就会产生安全问题”的值例如验证码、token、session id、 密码重置链接必须使用 secrets 模块。random 仅用于非安全场景。对此生成的代码如下这类约束不是在教 Agent 写代码而是在告诉 Agent这个项目里哪些选择已经不需要重新讨论。有了这些约束后同样的短信发送需求Agent 的起点就不一样了。它不再只是写一个“通用上还不错”的发送函数而是会自然带上幂等键、Result 类型、full jitter、async HTTP、PII 脱敏和安全随机数。这些东西不是锦上添花而是项目曾经用事故、审计、线上问题和团队共识换来的经验。这里有一个很重要的分工模型负责变量AGENTS.md 负责常量。模型可以根据需求生成不同实现这是变量但项目里哪些选择已经被确认哪些坑不能再踩哪些边界不能突破这是常量。没有常量Agent 每次都像一个新来的聪明实习生从通用最佳实践重新猜一遍。写得不一定差但一定不够像这个项目。AGENTS.md 的第二层价值就是把项目记忆变成可执行的上下文让 Agent 不再从通用平均值起步而是从团队已经沉淀过的经验起步。约束领域知识AGENTS.md 是入口Skills 是分层手册第三种约束是专项领域知识AGENTS.md 适合放项目里的高频共识但不适合放所有细节。如果把所有规则、所有领域知识、所有特殊业务流程都塞进去它很快会变成一份巨大的文档。Agent 每次任务都要读token 被浪费重点也会被稀释。约束不是越多越好约束也需要结构。更合理的方式是分层AGENTS.md 放常驻约束Skills 放专项知识。AGENTS.md 像项目宪法负责告诉 Agent这个项目的基本边界是什么哪些原则长期有效遇到某类问题应该去哪里找更细的规则。Skills 像专项手册只在特定任务出现时进入上下文。比如金额计算。一个看起来很简单的需求商品单价 × 数量得到小计加上 10% 税最后取整到分。代码很短也很直观但在真实项目里金额计算往往不是数学问题而是规则问题精度如何保留舍入模式是什么币种如何表达跨币种能不能直接相加数据库如何存储展示和落库是否使用同一规则。这些内容如果都放进 AGENTS.md会过重但如果完全不写Agent 又很容易按通用直觉处理。更好的方式是在 AGENTS.md 里放入口## Skills按需加载仅在相关任务出现时读取 - 处理金额计算先读 skills/money.md然后把细节放进 skills/money.md# 金额处理 ## 类型 - 必须用 DecimalPython/ BigDecimalJava严禁 float。 - 金额必须和币种一起传递。统一类型是 Money(amount, currency)。 - 跨币种运算必须显式经过 ConversionRate没有汇率上下文不允许相加。 ## 精度与舍入 - 内部计算保 4 位小数。 - 展示和落库时按币种规则取整 USD/CNY/EUR 2 位、JPY 整数、BHD/KWD 3 位。 - 舍入模式统一 ROUND_HALF_EVEN不要依赖语言默认行为。 ## 数据库存储 - DECIMAL(18, 4)。 - 币种独立列 VARCHAR(3)遵循 ISO 4217。 ## 禁止事项 - 不要用 sum(prices) 直接累计金额必须使用 Money.sum_safely(prices)。 - 不要临时写 round(x * tax_rate, 2) 算税必须走 TaxCalculator。 - 不要写 if amount 100 这种隐式比较必须写成 amount Money(100, USD)。这样设计的好处是AGENTS.md 保持简洁但它能把 Agent 引导到正确的专项知识上。常驻上下文里不需要塞满所有细节只需要告诉 Agent遇到金额问题不要凭直觉写先读 money.md。这背后其实也是一种约束哲学不是把所有规则一次性压给 Agent而是让约束按任务逐步展开。高频原则常驻低频细节按需加载。这样既不会牺牲上下文效率也不会让重要规则在关键场景缺席。AGENTS.md 是入口Skills 是分层AGENTS.md 负责让 Agent 知道“这里有约束”Skills 负责告诉 Agent“这类问题的约束具体是什么”。约束的本质降低生成方差而不是限制生成能力到这里再回头看 AGENTS.md它其实不是一个文档技巧而是一种工程治理方式。Agent 的强项是生成。它可以很快给出一个版本也可以根据反馈快速修改。但生成能力越强另一个问题越明显如果没有稳定的判断标准它每次生成都会有方差。有时候很好有时候一般有时候符合项目习惯有时候偏到通用写法有时候注意到了安全和合规有时候只完成了表面需求。AGENTS.md 要解决的不是“Agent 会不会写”而是“Agent 每次写的时候能不能稳定地朝同一个方向收敛”。这就是约束的本质降低方差。约束不是把 Agent 变笨而是让它少走不该走的路。没有约束Agent 面对的是无限可能有了约束它面对的是项目允许的可能性空间。好的约束不会压制创造力反而会让创造力变得可用。因为工程里的自由从来不是“想怎么写就怎么写”而是在清楚边界内做出高质量选择。一个项目真正成熟的标志不是文档很多而是关键判断不再依赖某个人临场解释。新人加入项目不需要每次问“这个地方为什么不能这样写”Agent 修改代码也不需要每次重新猜“这个团队到底偏什么风格”。这些判断被写下来、被复用、被执行项目才会从“靠人记住”变成“靠系统继承”。所以AGENTS.md 更深层的意义在于它把团队脑子里的工程秩序转换成 Agent 可读取、可执行、可延续的项目约束。