ARTICLE · 1270898
AI Coding 时代的仓库核心实践
AI Coding 时代的仓库核心实践AI Coding 带来的变化,首先不是"写得更快",而是执行者换了。人遇到含糊会停下来问;Agent 不会,它会补一个看起来合理的解释,然后把它写成代码,而且语气确定。 这带来两个后果。第一,仓库里没有被写下来的意图,会被"现有代码的样子"自动填补;第二,没有被机器检查的约束,会在生成过程中被静默绕过。前者让偏差被复制,后者让偏差被放大。 所以仓库的核心职责不止是"存放代码",也是"约束执行者":让正确的意图在它动手前被读到,让偏离意图的改动在合并前失败。下面几条实践都服务于这两个目标;分层、命名、测试金字塔这类通用工程经验仍然有效,但不是本文要讨论的重点。 Agent 读代码的方式和人不同。人会怀疑:这里有点奇怪,可能不对。Agent 更倾向于从现有形态中归纳模式,再把模式当作规范复制到新代码,包括其中的偏差。 这意味着:不写架构意图,并不等于没有架构;只等于架构由现存代码临时决定。一处历史遗留的跨层调用,只要存在得够久,就会被下一次生成当作"这个项目就是这样写的"。 architecture.md 的价值就在这里。它不是一份完整的系统说明书,而是主干上唯一一份可以被反驳的现状模型。它只回答五件事:系统边界在哪、怎么分层、谁可以依赖谁、请求怎么走、新东西该放哪。最后一项最有用——它把"这次该动哪一层"从推理变成查表。 写法上有两条纪律。只写已经在主干上成立的东西,混入目标态就无法区分"已实现"和"计划";控制在两页以内,超过这个长度,维护成本会超过它带来的确定性。 它真正的风险是静默过期:架构很少变,代码一直在变,文档宣称的约束可能早就不存在。但这不是省略的理由,恰恰是需要触发机制的理由。三个触发点足够:结构变更(新增模块、跨层依赖、外部集成、主链路变化)必须同时更新文档;依赖检查的报错信息直接写明"若为有意变更,请更新文档并补 ADR";文件头写负责人与最后核对日期,让读者知道该给多少信任。 一份不会被任何东西反驳的文档,和没有文档的区别,只是前者更让人放心地犯错。 Agent 没有跨会话的记忆。每次进入仓库,它都要用有限上下文重新建立对项目的理解。文件越大、约定越分散,重建越慢,也越容易漏。 这解释了为什么根目录需要一个入口文件,以及为什么它必须短。AGENTS.md 是目前跨工具最通用的约定;个别工具仍读自己的文件名(CLAUDE.md、.cursor/rules/*.mdc),给它们放一行指针即可。只维护一份内容,复制的那一刻就开始漂移。 入口文件只写五类内容:仓库地图(三五个目录,各自一句职责)、命令(启动、验证、单层测试)、文档权威表(哪份是权威、哪份是参考、哪份已废弃)、铁律(不超过七条,每条可核验)、禁区(不能做什么)。控制在 150 行以内。 判断标准不是"还能不能再加点",而是"删掉它,Agent 会不会做错事"。完整目录树、依赖清单、最近改了什么,都能直接读出来,写进去只会过期;"要写高质量代码"不改变任何行为。入口文件一旦有一条过时,读者就会怀疑全部——这是它必须短的第二层原因。 披露分三层:根文件(全局)→ 每个可部署单元一份(命令与铁律可能不同)→ 代码与文档。冲突时,离被修改文件最近的那份优先。再往下按功能目录铺通常是浪费:部分工具不读嵌套文件,三层是稳妥的上限。 这里的关键不是"文档要齐全",而是信号密度。同样的窗口,放地图和命令,Agent 就能开工;放一堆历史说明,它读完仍然不知道该信哪份。 人与人的协作里,含糊由沟通兜底;人与 Agent 的协作里,含糊直接变成实现。所以要求必须被写成有确定真值的断言,再配一条能判定它的命令。 "可执行"常被误读成"什么都写成测试"。更准确的是三层:可运行(跑一条命令得到通过/失败)、可核验(不一定能跑,但有确定真值,能被脚本或 diff 判定)、可操作(拿到就能动手,不需要追问)。核心动作只有一个:把一个需要解释的形容词,换成一句有确定真值的断言。 例如:"接口要规范"改为"每个路由必须声明响应模型",扫描 OpenAPI,缺失即失败;"数据库改动要可控"改为"schema 只通过迁移脚本变更,迁移图单头,空库能升级到最新"——迁移图的头是没有后继的那个迁移,正常只能有一个,多头意味着两条变更都声称自己最新,部署时只会执行其中一条;"模块边界要清楚"改为"api 不得导入 adapters",由 import 规则检查。 规则的载体有强弱之分,选择依据是违反的代价,不是"它听起来重不重要"。
违反代价高且难以事后发现的,放到最上面几层;改回来很容易的,写在文档里就够了。把所有重要的事都塞进流水线,摩擦会大到人们开始绕过它,那本身就是失败。 反过来说,不可核验的内容不要伪装成约束。"为什么选 A 不选 B"没有真值,只有权衡,把它写成 ADR;强行做成测试,只会得到编码了偶然行为的脆弱测试。 测试也分两种角色。自动生成的测试擅长锁定当前行为,能告诉你"改这里会破坏什么";它不擅长表达应有行为,因为它的输入就是现有实现。生成测试铺覆盖面,少量手写测试守住不变量与业务规则。全部依赖生成,得到的只是"当前实现被永久固化",包括其中的错误。 一次提示词可以改二十个文件。如果这二十个文件跨了三个主题,出问题时你无法只撤掉其中一部分。 所以"一次提交一个主题"在 AI 时代不是风格问题,而是回滚能力的问题。回滚半径等于一个提交,定位问题靠 git log 就够;混合提交则只能整体回退,或者人工拆解。 同样的理由指向仓库形态:同一个产品的前端与后端放在同一个仓库。一次需求经常同时改契约和消费方;两个仓库意味着两个提交、两条流水线、两种合并顺序,中间必然出现"后端已合并、前端未跟进"的窗口。这个窗口里主干是坏的,而且没有单次 revert 能恢复一致——你需要回退两个仓库的两次提交,并保证顺序。AI 让这个窗口出现得更频繁:一次生成就可以同时触及契约和两侧实现,拆仓反而逼着人把它切成两次不同步的提交。 同仓并不自动带来原子性。还缺三件事:契约由代码生成并在合并前校验;"一次提交覆盖两侧"成为评审规则;流水线按路径触发,改后端才跑后端作业,改前端才跑前端作业。缺了这些,只是把两个不同步的仓库放进了同一个目录。 这一条最容易被口号化。准确的表述不是"所有东西都写进 Markdown",而是:每个事实只有一处权威。 接口形状的权威是代码生成的契约,不是手写文档;数据库结构的权威是迁移脚本,不是 ER 图;依赖版本的权威是锁文件,不是 pyproject.toml 里的范围;行为的权威是测试,不是描述。Markdown 负责意图、边界与取舍,不复述这些能被机器读出的事实——复述一份,就多一处会漂移的副本。 后端框架能自动产出接口描述(FastAPI 天然提供),就把它导出为仓库内的契约文件;前端类型由此生成;流水线校验生成结果与提交内容一致。手写接口文档一律不做,因为它没有任何机制保证与实现同时更新。 生成物要防的不是"写得不好",而是"和源不一致"。做法是"重新生成后无 diff":在干净工作区运行生成命令,再对生成物执行 git diff --exit-code。diff 为空通过;非空说明要么源改了忘了重新生成,要么有人手改了生成物。 这条检查成立有三个前提:生成物已入库,否则没有比对对象;生成是确定性的,即固定生成器版本、稳定排序、不写时间戳与绝对路径、固定换行符与 locale;生成命令只有一条,本地与流水线共用。 确定性本身只让差异可见,不阻止修改。完整链条有四段:生成确定 → 生成物入库 → 流水线对 diff 判失败 → 分支保护使这个检查不可绕过。少了最后一段,本地改完生成物可以直接推送;少了第二段,流水线没有基线可比。它防的是无意的漂移和顺手改生成物,不防恶意篡改——有写权限的人总能改源或改生成器,那要靠评审与权限控制。 它也有抓不到的东西:生成器升级导致的整体格式变化,应当作为独立提交处理;源本身写错,生成物会忠实地跟着错;生成物被路径过滤排除在流水线之外,检查根本没跑。所以路径规则要覆盖生成器脚本与源定义,而不只是生成物。 这里有一个常见混淆:把术语表、业务状态机、ER 图统称为"领域模型",放在同一个目录。 它们其实是两类东西。术语与不变量回答"一个词在业务上指什么、什么条件必须永远成立",来源是团队共识,只能手写;业务状态机回答"哪些状态与迁移合法",来源是业务规则,也只能手写。ER 图回答"物理上有哪些表、列、外键、索引",来源是数据库 schema,必须生成。从业务共识里生成不出 ER 图,从 schema 里也读不出业务语义。 因此它们应当分开:手写的领域语言放 docs/domain/,生成的 ER 图放 docs/generated/erd.md。ER 图也不属于 contracts/——契约的判据是"跨组件边界 + 被机器消费",ER 图是给人看的现状视图,前端构建不消费它。 判断有没有把视图当成源,只需问一句:改一处 schema,需要手工同步几个地方?答案超过一,就说明有一份副本被当成了事实。这条标准只适用于 ER 图这类生成视图;领域语言本来就不随加字段而变,它不变是正确的,不是失同步。 文档的问题从来不是"写得少",而是不同寿命的东西混在一起,读者无法判断手上这份还算不算数。
需求与规格需要状态:最小是草稿、生效、已被取代;评审中、已撤回、已归档按流程需要再加。被取代时必须写继任者。实现进度(未开始、进行中、已验收、已上线)不属于文档状态,它变化以天计,属于任务跟踪;文档状态变化以月计,回答的是"这份文件还算不算数"。两者混在一个字段里,读者就无法区分"已上线但已过时"和"生效但还没做"。对 Agent 来说,这不是理解问题——它不会按修改时间推断代际关系,只会把读到的第一份生效文档当作依据。 生效之后不要原地大改。澄清措辞可以;改变验收标准,应当产生新文档,把旧文档标为已被取代。因为实现和测试已经按旧标准写下,原地改写会让两套历史同时存在,而没有提交能说明哪一套先发生。 ADR 只记录单向门:撤销会丢数据、牵动外部系统、无法让新旧两版并行、或者回滚需要停机与多方排期。任一为"是",就按单向门处理。"重要"不是判据——选主数据库可能只是比较贵的双向门,一个不起眼的字段语义变更反而可能不可逆。 Agent 只看现状,不知道哪些路已经试过。没有 ADR,它会把历史选择当成唯一可能,或者重提一个已经被否决的方案,而否决理由早已不在任何人的上下文里。 一条 ADR 至少写四样:当时的备选;为什么选它、放弃了什么;逆转成本高在哪一步;什么信号出现时应该重新评估。最后一项让 ADR 从墓志铭变成触发器。 大改动跨多个会话,而 Agent 没有跨会话记忆。实施计划因此是这次变更的外部记忆:放在 docs/changes/<日期>-.md,只存进行中的变更,一页写清意图与明确不做什么、触及哪些长期载体、原子步骤及每步的验证命令、回滚方式、收尾要更新哪些文档。步骤勾选可以原地更新,那是过程状态;方案本身若变,追加一条决策记录而不是静默改写,否则后续会话会把被否的方案再提一遍。 合并时先抽取:取舍与备选内联进 ADR,需求变化进 specs,结构变化进 architecture.md,不变量进测试。长期文档必须自足,不靠指针。抽取之后计划不再承载唯一价值,于是树里不留它——树存现状,历史存变更,而计划描述的是变更:把意图、关键取舍、被否备选、回滚与文档更新写进合并提交的 message body。它比任何归档目录都可靠,因为与变更绑定、随 clone 传播、在 log 与 blame 里可见。 留在 HEAD 的过期计划会被 Agent 当成现状依据;删进历史的计划只在考古时被读到。后者才是它应有的位置。 代码生成的成本在下降,评审的成本没有。差距会变成"未评审的改动在主干上堆积"。解法不是让人看得更快,而是把机械判断交给流水线,把人的注意力留给机器判断不了的部分。 最先要建立的是四道闸门:测试(行为有没有回归)、构建(能不能交付)、契约无漂移(两侧对不对得上)、密钥扫描(有没有把凭据写进历史)。这四项覆盖了无法承受的盲区;类型检查、迁移可执行、lint 可以按风险补,但它们不应该挤占前四项的位置。 闸门只有在不可绕过时才有效。主干禁止直接推送,合并前必须通过检查;多人同时合并时启用合并队列,避免"各自通过、合起来坏"。本地钩子是次优替代,但它可以被 --no-verify 绕过,只能算过渡方案。 可复现环境是这一切的前提。锁文件记录每个直接与传递依赖的确切版本,必须入库;安装使用锁定模式(uv sync --locked、npm ci、pnpm install --frozen-lockfile);升级依赖是一个独立提交。Agent 尤其容易在"能跑就行"的目标下顺手装一个新版本,而这类变化不会出现在任何业务评审里。普通安装会在声明与锁文件不一致时顺手更新锁文件,把"环境不一致"变成"看起来装好了"。 评审留给四问:意图是否清楚?边界是否被跨过?失败路径是否处理?有没有引入新的隐式约定?格式、类型、测试覆盖这些机器能判断的,不应该占用评审注意力。 这些实践并不构成一张更长的清单。它们是一个闭环: 写下意图,让 Agent 在动手前读到(入口文件、architecture.md、领域语言);把意图变成可判定的约束,放在足够强的载体上(类型、数据库约束、流水线);让一次改动保持原子,回滚半径等于一个提交;让同一事实只有一处权威,生成物可重建、可校验;最后让验证不可绕过,人只审机器判断不了的部分。 如果要从零开始,我会先建立三样东西:根目录一份能开工的入口文件,加一份只写现状的 architecture.md;一条 make verify,把本地与流水线的判定统一;一条从代码到契约、从契约到前端类型的生成链,并对生成物做 diff 校验。 它们分别解决三个最贵的失败:Agent 不知道去哪、不知道信谁;验证靠记忆和口头约定;两侧对不上却在合并后才发现。 仓库不是文档库,也不是代码仓库。对 Agent 来说,它是操作界面:界面上的每个标签都必须指向真实存在的东西,每个按钮都必须有确定的后果。















































































