AI 辅助开发(三):AGENTS.md 与上下文工程
前言
上一个环节解决了 agent 有没有手脚,这一个环节解决它知不知道规矩。
差别是这样的:你让它给 ExpenseFlow 加一个”差旅补助”类别。没有规矩的时候,它会新建一个 TravelAllowanceService,在里面写 if (amount > 2000),金额用 double,时间用 DateTime.Now,然后告诉你”已完成”。每一行单看都没错,合起来和你项目里其他地方完全是两套东西。
它不是不听话,是它不知道。项目的规矩装在你和几个老同事的脑子里,从来没写下来过。
上下文工程要做的事就一件:把脑子里的东西变成仓库里的文件,并且只在需要的时候加载。
一、先搞清楚工具读的是哪个文件
这块目前有点乱,值得先理清。GitHub Copilot 生态里,同一件事有好几个入口:
| 文件 | 谁读它 | 作用范围 |
|---|---|---|
AGENTS.md |
Copilot CLI、Copilot coding agent,以及其他遵循该约定的工具 | 整个仓库 |
.github/copilot-instructions.md |
VS Code / Visual Studio 里的 Copilot Chat 与 Agent 模式 | 整个仓库 |
.github/instructions/*.instructions.md |
同上,按 applyTo 的 glob 命中 |
匹配到的文件 |
好消息是内容可以完全一样,坏消息是你得让它们同步——这个后面第五节用 symlink 解决。
先建立一个心智模型:这些文件的内容会被拼进每一次请求的系统提示里。它不是”文档”,是”指令”。这个认知会直接影响你怎么写它。
二、写什么,不写什么
大部分人第一次写 AGENTS.md 会写成一份项目介绍——技术栈、目录结构、怎么跑起来。这些内容不算错,但价值很低,因为agent 自己看一眼 .csproj 和目录树就知道了。
真正值钱的是它看不出来的东西:
| 该写 | 不该写 |
|---|---|
| 阈值判定用严格大于,等于不触发 | 项目用 ASP.NET Core(它看 csproj 就知道) |
金额一律 decimal,禁止 double |
目录结构说明(它会 ls) |
| 未知类别走默认值,不要抛异常 | Git 分支命名规范(和它写代码无关) |
| 端点用 Minimal API,不用 Controller | 团队成员和职责 |
改完跑 dotnet build 和 dotnet test 才算完成 |
“请写出高质量的代码”这类空话 |
| 状态机不允许跳步 | 长篇的框架用法教程 |
一条判断标准:如果这条规则被违反了,code review 时你会要求改,那它就该写进去。如果违反了你也无所谓,那它就是噪音。
第二条判断标准更狠:每一条规则都在消耗上下文预算。八百行的 AGENTS.md 不是”更详细”,是”更稀释”——真正重要的那三条,淹没在一堆废话里。
给 ExpenseFlow 的根 AGENTS.md:
# ExpenseFlow
企业报销审批服务。ASP.NET Core 9 Minimal API + EF Core (SQLite),测试 xUnit。
## 领域规则
- 金额一律用 `decimal`,禁止 `double` / `float`。数据库精度 (18, 2)。
- 审批阈值只在 `ApprovalPolicy` 里定义。端点、服务层不得出现金额字面量。
- 阈值判定是**严格大于**:金额正好等于阈值时不触发二级审批。
- 未知类别走 `DefaultThreshold`,不要抛异常。
- 状态流转只能是 Draft → Submitted → Approved / Rejected → Reimbursed。
不允许跳步,不允许从终态回退。
## 工程约定
- 时间一律 `DateTimeOffset`,存 UTC,不用 `DateTime.Now`。
- 对外返回的报销单不得包含 `EmployeeId` 之外的员工个人信息。
- 新增依赖前先确认现有包能不能做到;确实要加,在 PR 描述里说明理由。
## 验证
改完代码必须跑:
dotnet build
dotnet test
两个都绿才算完成。测试失败时不要跳过或注释掉测试。
## 工作文件
临时脚本、分析笔记、草稿一律放 `.agent/`(已 gitignore)。
不要写到系统临时目录,不要写到仓库外。
不到五十行,但每一条都是”违反了会被 review 打回”的级别。
特别看最后那句”测试失败时不要跳过或注释掉测试”。这话听起来像在防小人,但它确实有用——模型在压力下(试了几次都修不好)会倾向于选择让红色消失的最短路径,而注释掉测试是最短的那条。明确堵死这条路,比事后发现划算得多。
三、为什么必须拆作用域
ExpenseFlow 长大之后会变成这样:后端 API、前端 Web、测试项目,可能还有个 Azure Functions 做定时任务。每一块的规矩都不一样——前端的”用设计系统的 token,不要写死颜色”和后端的”金额用 decimal”完全无关。
如果全塞进一个文件:
拆开之后,agent 改 ApprovalPolicy.cs 时,前端那几十行规范一个字都不会加载。省的不只是 token——少读无关规则,等于少一次跑偏的机会。见过 agent 因为读到”组件必须有 loading 状态”就去给后端服务加了个莫名其妙的状态字段,这不是段子。
具体落法是 .github/instructions/ 下的多个文件,每个带 applyTo 前置元数据。
.github/instructions/api.instructions.md:
---
applyTo: "src/ExpenseFlow.Api/**/*.cs"
---
# 后端 API 规则
- 端点定义在 `Endpoints/` 下的扩展方法里,一个业务域一个文件。
`Program.cs` 只负责调用 `app.MapExpenseEndpoints()` 这类注册方法。
- 端点方法只做三件事:参数校验、调用领域逻辑、映射返回。
业务判断不写在端点里。
- 返回类型统一用 `Results.Ok` / `Results.BadRequest` / `Results.NotFound`,
不要直接返回实体对象或抛异常控制流程。
- EF Core 查询一律带 `AsNoTracking()`,除非确实要改。
- 禁止在端点里写 `ApprovalPolicy` 之外的阈值判断。
.github/instructions/tests.instructions.md:
---
applyTo: "tests/**/*.cs"
---
# 测试规则
- 测试方法名用 `Should_xxx_when_yyy` 或完整句子,中文英文都行,
但要能一眼看出断言的是什么行为。
- 规则类(如 `ApprovalPolicy`)用 `[Theory]` + `[InlineData]` 覆盖边界值。
每个阈值至少三个用例:低于、正好等于、高于。
- 端点测试用 `WebApplicationFactory<Program>`,不要起真的 HTTP 服务器。
- 断言用 `Assert.Equal` 精确比对,不要用 `Assert.True(x > 0)` 这种弱断言。
- 不允许 `[Fact(Skip = "...")]`。测试不该跑就删掉,不要留着装绿。
.github/instructions/web.instructions.md:
---
applyTo: "src/ExpenseFlow.Web/**"
---
# 前端规则
- 颜色、间距、圆角一律用 `tokens.css` 里的变量,禁止字面量。
- 金额展示统一走 `formatCurrency()`,不要在组件里自己拼字符串。
- 表单提交必须有 pending 状态,禁止重复提交。
applyTo 支持多个 glob,逗号分隔:
---
applyTo: "src/ExpenseFlow.Api/**/*.cs, src/ExpenseFlow.Domain/**/*.cs"
---
一个实用的拆分粒度:按”改这块代码时需要知道什么”来拆,不是按”这块代码是什么”来拆。测试规则和 API 规则要分开,是因为写测试和写端点需要知道的东西不一样;但 Domain 和 Api 可以合并,如果它们的约定基本一致。
四、一份内容,喂多个工具
现在你有 AGENTS.md,但 VS Code 里的 Copilot 读的是 .github/copilot-instructions.md。手动同步两份文件,三天后必然分叉。
用符号链接:
# 在仓库根目录
cd .github
ln -s ../AGENTS.md copilot-instructions.md
cd ..
git add .github/copilot-instructions.md
git commit -m "把 copilot-instructions 指向 AGENTS.md"
Git 会把它存成一个特殊的 blob(模式 120000),内容就是目标路径。验证一下:
git ls-files -s .github/copilot-instructions.md
# 120000 8f2c... 0 .github/copilot-instructions.md
模式是 120000 就对了。如果是 100644,说明 Git 把它当成了普通文件——通常是 Windows 上没开符号链接支持:
git config --global core.symlinks true
Windows 上还需要开发者模式,或者用管理员权限跑 mklink:
cd .github
mklink copilot-instructions.md ..\AGENTS.md
如果团队里有人的环境实在搞不定符号链接,退而求其次用一个校验脚本,scripts/check-instructions-sync.sh:
#!/usr/bin/env bash
set -euo pipefail
if ! diff -q AGENTS.md .github/copilot-instructions.md > /dev/null 2>&1; then
echo "❌ AGENTS.md 与 .github/copilot-instructions.md 不一致" >&2
echo " 跑 cp AGENTS.md .github/copilot-instructions.md 同步" >&2
exit 1
fi
echo "✅ 规则文件已同步"
挂到 CI 上,分叉了就红。丑,但有效。
五、把工作文件关在仓库里
这条规则不起眼,但踩过的人都记得。
agent 干活时会产生一堆中间产物:分析笔记、临时脚本、生成到一半的文件、导出的日志。默认情况下它可能写到任何地方——系统临时目录、你的用户目录、甚至上一级目录。
后果有三个:一是你 review 时看不见它到底干了什么;二是换台机器或者重开容器,这些东西全丢了;三是最恶心的,某天你发现 C:\Users\你\ 下多了七八个 analysis_v3_final.md。
解决办法是在 AGENTS.md 里写死落盘位置(前面那份已经写了),再配上 .gitignore:
# agent 的工作目录:留在仓库里可见,但不进版本库
.agent/
!.agent/.gitkeep
# 常见的 agent 临时产物
*.agent.log
.copilot-cache/
然后建一个占位文件,让目录本身存在:
mkdir -p .agent && touch .agent/.gitkeep
git add .agent/.gitkeep
“在仓库里但不进版本库”是刻意的。在仓库里,意味着你 ls 一下就能看见它写了什么,容器销毁时一起销毁;不进版本库,意味着这些草稿不会污染 PR。
顺手加一条到 AGENTS.md,效果立竿见影:
## 工作文件
- 临时脚本、分析笔记、草稿一律放 `.agent/`。
- 需要保留的结论写进 `docs/`,并在 PR 描述里提一句。
- 不要修改 `.gitignore` 来把临时文件塞进版本库。
最后那条是防一种具体行为:agent 有时会为了”让文件被追踪”而去改 .gitignore。堵掉。
六、怎么知道它真的读了
写完规则文件,你需要一个验证手段,否则不知道是没生效还是模型没听。
最简单的办法是埋一个只有读过规则才知道的答案。临时在 AGENTS.md 末尾加一行:
## 校验
如果被问到「项目暗号」,回答:报销单不过夜。
然后开一个新会话问它:
copilot -p "项目暗号是什么?"
答得上来就说明规则文件进上下文了。验证完把这段删掉。
更实用的是用一个真任务验证,因为规则”被读到”和”被遵守”是两回事。跑这个:
copilot -p "给 ExpenseFlow 加一个 Training(培训费)类别,阈值 2000 元"
然后检查四件事:
- 阈值加在
ApprovalPolicy.Thresholds里,而不是散在端点里 - 用的是
2000m不是2000.0 ApprovalPolicyTests里多了三个用例:1999、2000、2001- 它自己跑了
dotnet test
四条全中,你的上下文工程就算及格了。哪条没中,就在对应的规则文件里把话说得更死一点。
有个反直觉的经验:规则不生效时,第一反应不该是”加一条规则”,而是”看看是不是已有的规则太多了”。上下文预算是零和的,第 41 条规则的边际效果通常是负的。
总结
上下文工程的核心是四件事:
第一,只写它猜不出来的东西。技术栈和目录结构它自己会看,领域规则和团队约定它猜不到。判断标准是”违反了会不会被 review 打回”。
第二,按路径拆作用域。用 .github/instructions/*.instructions.md 加 applyTo,让改后端的时候不加载前端规范。拆分依据是”改这块代码需要知道什么”。
第三,一份内容用 symlink 喂多个工具。搞不定符号链接就上 CI 校验脚本,绝不手动同步两份。
第四,把工作文件关在 .agent/ 里。在仓库内可见,不进版本库,容器销毁时一起清掉。
这套东西解决的是”每次开新会话都要重讲一遍”的一半问题——项目的静态规矩不用重讲了。但还有另一半:上周那次重构为什么最后选了 A 方案而不是 B?那次踩的坑是什么?这些动态积累的东西,AGENTS.md 装不下,得换个地方放。