一、背景:为什么AI助手总写出“能跑但不对味”的代码
你让 Cursor 写一个新接口,90 秒后接口真能跑。但 diff 一看:项目明明零依赖,它却引入了一个校验库;团队去年就从 Jest 迁到 Node 原生测试,它却用 Jest 写用例;别的 handler 都委托给 service 层,它却在路由里直连数据库。
这不是模型笨,而是它看不见你们团队约定:那些约定只存在于老人口中、CR 评论里、一年半前的口头决策中。Agent 只能按“训练语料里所有仓库的平均样子”来猜。
加长 prompt 没用:每次都要重打一遍,每个人写的版本还不一样。正解是把约定写成仓库里的文件,随仓库加载、随代码一起维护——这就是上下文工程(Context Engineering)。
二、原理:上下文窗口是预算,不是仓库
Agent 工作时所有东西——系统提示、对话、打开过的文件、命令输出、堆栈——都挤在一个叫上下文窗口(Context Window)的缓冲区里,它是有限的。一次调试烧掉几万 token 很常见。
Anthropic 工程团队把随 token 增长、模型检索指定指令能力下降的现象叫 context rot(上下文腐烂):注意力预算被越摊越薄。所以写得越多≠越保险,每一行都在跟别的行抢注意力。Claude Code 官方文档也直言:臃肿的指令文件会导致 agent 直接无视里面的规则——“明明写了规则它老违反”,病根多半是文件太长。
一次真实任务的预算大概长这样:系统提示+工具定义约 12000 token,启动时加载的上下文文件约 4800 token,agent 打开的三个源码文件约 9000 token,一次带堆栈的测试输出约 3500 token。4800 token 的上下文文件正在跟修 bug 必需的堆栈抢位置;一个 600 token、只写“去哪看”的文件反而更优,因为 agent 自己读代码的能力很强。
上下文三层模型
- 常载层(always loaded):仓库根的单个文件(AGENTS.md),每次会话都读。只放所有任务都适用的东西,目标是 1 分钟能读完。
- 作用域层(scoped):嵌套文件,只在 agent 进入某目录时加载。如 API 层的规则放
src/AGENTS.md,只改前端的任务完全不付这笔 token。 - 按需层(on demand):普通文档,根文件只用路径引用,不内联。路径只花几个 token,背后的文档几千 token,只在真需要时才加载。就像新人:记住“有这份架构文档”,用时再去读。
三、环境准备
- Node 20+、Git,会用 Claude Code / Cursor / Copilot / Codex 其中之一。
- 新建演示项目(零依赖,方便对照实验):
mkdir context-demo && cd context-demo && npm init -y
mkdir -p src/api src/services src/lib tests docs/decisions scripts .github
node -v # 确认 >= 20
package.json 里准备好脚本位(后面 lint 和 sync 会用到):
{
"type": "module",
"scripts": {
"test": "node --test tests/",
"start": "node src/server.js",
"lint:context": "node scripts/context-lint.mjs",
"sync:context": "node scripts/sync-context.mjs"
}
}
四、分步实战
步骤 1:定单一事实源,一处写、多处生成
各工具文件名不统一:Claude Code 认 CLAUDE.md(还会沿目录树逐层拼接,支持 @path 引用),Cursor 用 .cursor/rules/*.mdc(带 YAML frontmatter,可按 glob 如 tests/**/*.js 生效,表达力最强但不通用),Copilot 认 .github/copilot-instructions.md。而 AGENTS.md 是最接近公共约定的格式(Agentic AI Foundation 治理,上述工具基本都读),支持嵌套、就近优先,聊天输入优先于一切。
做法:只手写 AGENTS.md,其余全部生成;只有 Cursor 的 glob 限定这种共享格式表达不了的东西才手写。
# 最糙的办法:软链接(Windows 协作者和某些 CI 易踩坑,演示可用)
ln -s AGENTS.md CLAUDE.md
更稳的是生成脚本,每个生成文件带横幅,防止有人改副本:
// scripts/sync-context.mjs
import { writeFileSync, readFileSync, mkdirSync } from 'node:fs';
const SOURCE = 'AGENTS.md';
const source = readFileSync(SOURCE, 'utf8');
const banner = `<!-- 由 \`npm run sync:context\` 从 ${SOURCE} 生成,请勿直改本文件 -->`;
const CLAUDE_EXTRAS = `\n## 仅 Claude Code\n- 超过 3 个文件的改动先开 plan mode,单行修复跳过。\n- 大范围探索先派 subagent,返回摘要别灌主上下文。\n`;
const targets = [
{ path: 'CLAUDE.md', render: () => `${banner}\n\n@${SOURCE}\n${CLAUDE_EXTRAS}` },
{ path: '.github/copilot-instructions.md', render: (s) => `${banner}\n\n${s}` },
];
for (const t of targets) {
const dir = t.path.split('/').slice(0, -1).join('/');
if (dir) mkdirSync(dir, { recursive: true });
writeFileSync(t.path, t.render(source));
console.log('wrote', t.path);
}
node scripts/sync-context.mjs && git status --short
因为 Claude Code 支持 @ 引用,它的生成文件只是一个指针加几行专属指令,100 多 token 搞定,不复制全文。
步骤 2:写根文件——逐行过“删除测试”
每加一行都问:删掉这行会导致 agent 犯错吗? 不会就删。反面教材:
## 代码风格
- 变量名要有意义 / 写干净可维护的代码 / 遵循 DRY / 用 const 不用 var
这些全删:模型早懂 const 是什么,也看得出 router.js 里是路由,2023 年哪个团队建的项目对决策零影响。而真正会导致犯错的东西——“本项目刻意零依赖”“测试跑的是 Node 原生 runner 别装 Jest”“handler 不准直连 store”——反而常常没写。
一份合格的根文件示例:
# AGENTS.md
任务管理 REST API 示例。本文件是 agent 指令的唯一事实源,
`CLAUDE.md` 与 `.github/copilot-instructions.md` 由 `npm run sync:context` 生成,只改本文件。
## 命令
- 无需安装,零依赖
- 跑测试:`npm test`
- 启动服务(3000 端口):`npm start`
- 检查上下文文件:`npm run lint:context`
- 重新生成工具专属文件:`npm run sync:context`
## 代码里看不出的约定
- 测试是 Node 内置 runner(`node --test`),禁止引入 Jest/Vitest。
- 刻意零依赖,优先用标准库解决,别加包。
- `src/api/` 的 handler 只返回 `{ data }` 或 `{ error: { code, message } }`,
HTTP 状态映射归 `src/router.js`,handler 里不写状态码。
- handler 禁止直触 store,读写任务一律走 `src/services/tasks.js`。
- store 是模块级内存状态,跨用例存活,建任务的用例必须在 `beforeEach` 调 `resetTasks()`。
## 完成标准
- 报完工前先跑 `npm test` 和 `npm run lint:context`,把输出贴出来而不是口头说通过了。
## 去哪看
- 架构与请求链路:`docs/architecture.md`
- 测试约定:`docs/testing.md`
- 为何用内存 store:`docs/decisions/0001-in-memory-store.md`
- 仅 API 层规则:`src/AGENTS.md`
注意每条约定都带了“为什么”,有理由的规则在没预料到的场景下依然能被正确类推。表格速查:该写的是 agent 猜不到的命令、与语言默认不同的约定、测试跑法、分支与 PR 礼仪、本项目特有的架构决策、环境怪癖;不该写的是读代码就看得出的东西、模型已懂的通用规范、详细 API 文档(给链接)、每个 sprint 都变的信息、文件树复读机、“写干净代码”式空话。
还要拿捏“高度”:太死(“handler 必须 40 行且第 3 行调 validate”)第一个例外就碎;太虚(“写可维护代码”)等于没说。正确高度是形状+边界+范例位置:“handler 只做解析校验然后委托给 src/services/,不直触 store,照抄 src/api/tasks.js 的形状。”
步骤 3:下沉作用域规则
检验法:在别的目录干活的人需要知道这条吗? 不需要就下移。示例 src/AGENTS.md:新增端点的五步、校验器返回“问题字符串数组而非抛异常、一次报全所有字段错误”的约定。后者值得写:光读 validate.js agent 分辨不出“返回数组”是刻意约定还是偶然实现,下次很可能顺手抛异常。
// src/lib/validate.js —— 约定的活例子
export const TITLE_MAX = 120;
export function validateTaskInput(input) {
if (typeof input !== 'object' || input === null || Array.isArray(input)) return ['body must be a JSON object'];
const problems = [];
if (typeof input.title !== 'string' || input.title.trim() === '') problems.push('title is required and must be a non-empty string');
else if (input.title.length > TITLE_MAX) problems.push(`title must be ${TITLE_MAX} characters or fewer`);
if (input.done !== undefined && typeof input.done !== 'boolean') problems.push('done must be a boolean when present');
return problems;
}
步骤 4:用“指向”代替“内联”,ADR 锁住意图
根文件末尾的“去哪看”四行路径是最便宜的设计:几 token 换来几千 token 文档的按需可达。架构决策记录(ADR)是装“理由”的天然位置,例如专门写一篇为什么 store 是 Map 而不是数据库,结尾加一句:“除非任务明说,否则不准加数据库/ORM/持久层,缺持久化是刻意选择不是待办。” 否则 agent 接到“让 API production-ready”会热情地装上 Postgres。
低频工作流(如加端点全流程)也别塞根文件,做成按需加载的 skill(.claude/skills/add-endpoint/SKILL.md,frontmatter 写 name 与 description),被问到才加载。
步骤 5:让上下文可校验——linter + hook + CI
文档腐烂而无人知是最大杀手:文件改名了,上下文还指着旧路径;typecheck 脚本删了,半年后 agent 还在试运行。做法:写个约 150 行、零依赖的 scripts/context-lint.mjs,做四件事:
- token 预算:常载文件各设上限(AGENTS.md 800、
CLAUDE.md300、copilot 文件 900、src/AGENTS.md400),按Math.ceil(len/4)估算,超了就把细节搬进docs/留路径。 - 路径存在性:提掉围栏代码块后,抓单反引号片段,长得像路径的必须在磁盘上存在。
- 脚本存在性:形如
npm test/npm run xxx的必须在 package.json 里。 - 生成一致性:dry-run 重跑 sync,生成文件与源不一致就失败,抓住“绕过横幅直改副本”的人。
// 核心片段示意
const estimateTokens = (t) => Math.ceil(t.length / 4);
function inlineCodeSpans(text) {
const prose = text.replace(/```[\s\S]*?```/g, '');
return [...prose.matchAll(/`([^`\n]+)`/g)].map((m) => m[1].trim());
}
npm run lint:context 健康时秒过;腐烂时一次报清:哪个脚本没了、哪条路径悬空、哪个生成文件过期,非零退出。接进 CI 只需几行,每次 PR 自动查。本地再加 hook(Claude Code 的 settings.json 或 git pre-push)跑 lint,不合规则不让过。
步骤 6:给 agent 一个“完成标准”,并做对照实验
根文件里“跑 npm test + lint:context,贴输出再报完工”这行,比所有风格指导加起来都提效:它把“看起来做完了”变成 agent 自己能执行的通过/失败信号,还把你从“验证环”里摘出来(看证据几秒,自己重跑几分钟)。
对照实验做法:同一任务(如加 PATCH /tasks/:id)跑两遍——A 组删掉上下文文件,B 组保留——对比:首次生成即合规率、测试一次通过率、你重写的行数。通常 B 组 handler 分层、校验形状、测试钩子一次性做对,A 组则出现 Jest 依赖、状态码散落 handler 等返工。把你第一次精简文件时删掉的 1/3 到一半行数记下来,那就是省下的注意力税。
五、常见坑
- 写成长篇论文:症状是 agent 反复违反明文规则。治法:逐行删除测试,先砍一半。
- 规则不可验证(“可读性好”“性能高”):agent 无法自判是否做到,改成可跑命令。
- 各工具各存一份手写副本:一个月内必分叉互斥。只手写源文件,副本生成+CI 校验。
- linter 把示例代码当真引用扫:先剥围栏块再扫,否则误报多了大家就关掉它。
- 只给形状不给理由:换个场景 agent 就不会类推。每条非显然约定跟一句为什么。
- ADR 缺“不要做什么”:不写“别加数据库”,agent 迟早帮你“补上”。
七、附录:对照实验手账与删除测试实操
对照实验做法是:同一任务(加 PATCH /tasks/:id,改标题与状态)跑两遍,A 组删掉上下文文件,B 组保留,同一模型、同一个起始分支。A 组实况是引入 joi 做校验(零依赖被打破)、用 Jest 写用例(根本跑不起来)、状态码写进 handler(与 router 的映射打架),测试三红,你重写约一百二十行。B 组实况是校验器返回数组的形状一次做对,beforeEach(resetTasks()) 没有漏,完成标准触发它自己跑了两遍测试并贴出输出,零返工。结论值得记进决策记录:省下的从来不是写代码的时间,而是返工与定位规约的时间,后者通常是前者的三到五倍。很多团队第一次做删除测试,能直接砍掉三分之一到一半的行数,砍掉的每一行都是以前在跟关键指令抢注意力的税。实操时建议逐行过三问:删掉这行会导致犯错吗,这行三个月后还成立吗,这行换个目录的任务还需要吗,三问都是否定的就删。剩下的规则每条补一句为什么,因为带理由的规则在没预料过的场景下依然能被正确类推,而光秃秃的禁令换个场景就失效。最后把检查接进提交钩子,让机器替你记住,每次提交前自动跑一遍上下文检查,失败就不让过,这样文件才不会在代码演进中悄悄腐烂。
token 估算实操补一句:不需要精确分词器,按字符数除以四估算即可,误差不影响决策,因为你要抓的是从四百涨到八百的翻倍信号,不是纠结小数点。估算脚本跑在每次提交前,超预算直接失败,逼着你把细节搬进按需文档,只留路径在常载层。
hook 参考(Claude Code settings.json,提交前自动跑检查):
{
"hooks": {
"PreToolUse": [{ "matcher": "commit", "command": "npm run lint:context" }]
}
}
八、总结
上下文工程的本质是预算分配:常载层极简、作用域层下沉、按需层只给路径;单一事实源加生成脚本消灭分叉;linter+CI 让正确性可验证;完成标准把验证环还给 agent。今天就可以做的第一步:给现有上下文文件估 token,逐行做删除测试,多数人第一遍能砍掉三分之一到一半,然后你会发现剩下的一半 agent 执行得更可靠了。 点击阅读原文
参考资料:原文见 freeCodeCamp《How to Manage Context Files in Your Codebase and Get Better Agent Output》。 点击阅读原文