第四讲. 把指令拆分到不同文件里
你开始认真对待 harness 了,这很好。你建了个 AGENTS.md,把能想到的所有规则、约束、历史教训都塞了进去。一个月后这个文件膨胀到了 300 行,两个月 450 行,三个月 600 行。然后你发现 agent 的表现反而变差了:改一个小 bug,agent 花大量上下文处理无关的部署指令;关键的安全约束埋在第 300 行,被直接忽略了;文件里有三条互相矛盾的代码风格规则,agent 每次随机选一条。
这就是"巨型指令文件"陷阱。觉得什么都有用,什么都往里装,结果想找一条具体规则得把整个文件翻一遍。写了 600 行,但真正跟当前任务相关的可能只有三分之一。
问题的根源:一个恶性循环
最常见的恶性循环是这样的:agent 犯了个错,你说"加条规则防止这个",加到 AGENTS.md,暂时管用。然后 agent 又犯了另一个错,再加一条。重复下去,文件膨胀到不可控。
这其实是很自然的反应,每次出问题就"加条规则"感觉很合理。但累积效应是灾难性的。让我们看看具体出了什么问题。
上下文预算被吃掉了。 Agent 的上下文窗口是有限的。假设你的 agent 有 200K tokens 的窗口(Claude 的标准),一个膨胀的指令文件可能占掉 10-20K。看起来还有不少余量?但一个复杂的任务可能需要读几十个源文件,工具执行的输出也占上下文,对话历史也在累积。到真正需要理解代码的时候,预算已经不够了。
中间迷失。 "Lost in the Middle"这篇论文(Liu et al., 2023)清楚地证明了:LLM 对长文本中间部分的信息利用效率显著低于两端。你的 AGENTS.md 有 600 行,第 300 行写的是"所有数据库查询必须用参数化查询",这是安全硬约束。但它被埋在中间,agent 几乎一定会忽略它。
优先级冲突。 文件里混合了不可违反的硬约束("不得使用 eval()")、重要的设计指导("优先使用函数式风格")、和某个特定场景的历史教训("上周修了一个 WebSocket 内存泄漏,注意类似的模式")。这三条规则的重要性完全不同,但在文件里看起来一模一样。Agent 没有可靠的信号来区分哪个是红线,哪个只是建议。
维护衰减。 大文件天生难维护。指令过时了没人删,因为删除的后果不确定("也许别的地方依赖这条规则?"),但加新指令是无成本的。结果文件只增不减,信噪比持续下降。这和软件里的技术债务积累是同一个问题。
矛盾累积。 不同时期加的指令之间开始出现矛盾:一条说"用 TypeScript 严格模式",另一条说"某些遗留文件允许用 any"。Agent 每次随机选一条遵循。
核心概念
- 指令膨胀:指令文件一旦占到上下文窗口的 10-15%,就开始挤占代码阅读和任务推理的预算。一个 600 行的
AGENTS.md可能占用 10,000-20,000 tokens,对 128K 的窗口来说就是 8-15%。 - 长文本中间信息容易被忽略:Liu 等人 2023 年的研究表明,LLM 对长文本中间部分的信息利用效率明显低于两端。埋在 600 行文件第 300 行的关键约束,被忽略的概率非常高。
- 指令信噪比(SNR):文件中与当前任务相关的指令占总指令的比例。做 bug 修复时被要求读 50 行部署指令,SNR 就很低。
- 入口文件:短小的入口文件,作用是引导 agent 去找更详细的文档,而不是自己包含所有内容。50-200 行就够了。
- 按需展开:先给概要信息,需要的时候再给详细信息。好的 harness 设计和好的 UI 设计一样,不把所有选项一次性砸到用户脸上。
- 分不清轻重:当所有指令以相同格式和位置呈现时,agent 分不清哪些是不可违反的硬约束,哪些只是建议性的软约束。
指令文件架构
flowchart LR
Mono["一个超长 AGENTS.md"] --> MonoLoad["改一个小 bug<br/>也得把部署说明和历史备注全读一遍"]
MonoLoad --> MonoRisk["关键规则埋在中间<br/>很容易漏掉"]
Router["短 AGENTS.md"] --> Topics["按任务去读 API / 数据库 / 测试文档"]
Topics --> RoutedResult["把更多上下文留给代码阅读<br/>和验证"]
flowchart TB
File["600 行指令文件"] --> Top["顶部<br/>快速开始 + 硬约束"]
File --> Mid["中部<br/>第 300 行的安全规则"]
File --> Bot["底部<br/>明确的结束检查清单"]
Top --> Seen["高概率被记住"]
Bot --> Seen
Mid --> Missed["高概率被稀释或忽略"]
拆分思路
核心原则:常用信息放手边,偶尔用的收起来,用不上的别带。
入口文件 AGENTS.md 控制在 50-200 行,只放最常用的东西:项目概览(一两句话说清楚这是什么)、首次运行命令(make setup && make test)、全局硬约束(不超过 15 条不可违反的规则)、指向专题文档的链接(一行描述加适用条件)。
# AGENTS.md
## 项目概览
Python 3.11 FastAPI 后端,PostgreSQL 15 数据库。
## 快速开始
- 安装:`make setup`
- 测试:`make test`
- 完整验证:`make check`
## 硬约束
- 所有 API 必须走 OAuth 2.0 认证
- 所有数据库查询必须用 SQLAlchemy 2.0 语法
- 所有 PR 必须通过 pytest + mypy --strict + ruff check
## 专题文档
- API 设计规范 (`docs/api-patterns.md`) — 添加新端点时必读
- 数据库操作约束 (`docs/database-rules.md`) — 涉及数据库修改时必读
- 测试标准 (`docs/testing-standards.md`) — 编写测试时参考
每个专题文档 50-150 行,按主题放在 docs/ 目录下或对应模块目录旁。Agent 只在需要时才去读。用收纳袋整理行李的思路:内衣一个袋,洗漱一个袋,充电器一个袋,找东西不用翻整个箱子。
还有些信息直接放在代码里更合适,比如类型定义、接口注释、配置文件里的说明。Agent 读代码的时候自然能看到,不用再在指令里重复一遍。
每条指令都应该标明来源("为什么加这条规则?")、适用条件("这条规则在什么时候需要?")、过期条件("什么情况下可以删掉这条规则?")。定期审计,删掉过时的、冗余的、矛盾的条目。像管理代码依赖一样管理你的指令,用不上的依赖就该删掉,不然它们只会拖慢系统。
如果某条指令必须在入口文件里,放顶部或底部,不要放中间。"中间迷失"效应告诉我们,LLM 对长文本中间部分的信息利用效率显著低于两端。但更好的做法是把指令放到专题文档里,让 agent 按需加载。
OpenAI 和 Anthropic 都隐性支持拆分的做法。OpenAI 说入口文件应"短小且以路由为导向",Anthropic 说长运行 agent 的控制信息应"简洁且高优先级"。两家都在说同一件事:别把什么都塞进一个文件里。
实际案例
一个 SaaS 团队的 AGENTS.md 从最初的 50 行膨胀到 600 行。内容混合了技术栈版本、编码规范、历史 bug 修复笔记、API 使用说明、部署流程、和团队成员的个人偏好,什么都有,但很难快速找到跟当前任务相关的部分。
Agent 表现开始明显下降:简单 bug 修复任务中 agent 花大量上下文处理无关的部署指令;安全约束"所有数据库查询必须用参数化查询"埋在第 300 行,经常被忽略;三条矛盾的代码风格规则导致 agent 随机选择。
团队做了一次拆分重构:
AGENTS.md裁剪到 80 行:只保留项目概览、运行命令、15 条全局硬约束- 创建专题文档:
docs/api-patterns.md(120 行)、docs/database-rules.md(60 行)、docs/testing-standards.md(80 行) - 入口文件添加指向专题文档的链接
- 历史笔记要么转成测试用例,要么删除
重构后:同一任务集的成功率从 45% 提升到 72%。安全约束遵循率从 60% 提升到 95%,因为规则从文件中间移到了入口文件顶部,不再被"中间迷失"了。
核心要点
- "加条规则"是短期的止痛药,长期的毒药。每次加规则前想想,这条规则放专题文档是不是更合适。
- 入口文件是路由器,不是百科全书。50-200 行,只放概览、硬约束和链接。
- 利用"中间迷失"效应:重要信息放文件顶部或底部,不重要的移到专题文档。
- 像管理技术债一样管理指令膨胀。定期审计,每条指令要有来源、适用条件和过期条件。
- 拆分之后信噪比提升,agent 把更多上下文预算花在实际任务上,而不是处理无关指令。
延伸阅读
- OpenAI: Harness Engineering
- Anthropic: Effective Harnesses for Long-Running Agents
- Lost in the Middle: How Language Models Use Long Contexts
- HumanLayer: Harness Engineering for Coding Agents
- Nielsen Norman Group: Progressive Disclosure
练习
- 信噪比审计:拿你当前的入口指令文件,列出所有指令条目。选 5 个不同的常见任务类型,标注每条指令是否跟该任务相关。计算每个任务类型的 SNR。那些对大多数任务都是噪声的指令,移到专题文档里。
- 按需展开重构:如果你有一个超过 300 行的指令文件,把它拆成:(a) 不超过 100 行的入口文件,(b) 3-5 个专题文档。重构前后各跑同一组任务(至少 5 个),对比成功率。
- 中间迷失验证:在一个长指令文件里,把一条关键约束分别放在顶部、中间、底部各跑一组任务(每组至少 5 次),看遵循率有没有差别。你可能会惊讶于位置效应有多大。
代码示例
AGENTS.md
AGENTS.md
从这里开始
- 阅读
docs/ARCHITECTURE.md - 阅读
docs/PRODUCT.md - 使用
npm run dev启动应用 - 在标记工作完成之前使用
npm run check
严格规则
- 在阅读
docs/ARCHITECTURE.md之前,不要更改 Electron 主进程/预加载/渲染器的边界 - 未经验证不要将功能标记为已完成
- 为下一个会话留下干净的状态
指令文件反模式
指令文件反模式
- 将所有仓库知识放入一个文件中
- 在多个地方重复同一条规则
- 编写从未被审查的过时规则
- 编写过于具体的条件指令,以至于很少适用
- 在启动上下文中嵌入冗长的工具手册
split-vs-monolithic.ts
/**
* split-vs-monolithic.ts
*
* Creates a monolithic instruction file (~200 lines) and then shows how
* splitting into 4 focused files dramatically reduces the context needed
* for any single query. Simulates an "agent" searching for a specific rule
* and measures how many lines it must read in each approach.
*
* Run: npx tsx docs/lectures/lecture-04-why-one-giant-instruction-file-fails/code/split-vs-monolithic.ts
*/
// ---------------------------------------------------------------------------
// Simulated monolithic instruction file (200 lines of rules)
// ---------------------------------------------------------------------------
const monolithicInstructions: { lineNumber: number; section: string; content: string }[] = [];
// Section 1: Project Overview (lines 1-50)
for (let i = 1; i <= 50; i++) {
monolithicInstructions.push({
lineNumber: i,
section: "Project Overview",
content: i === 25 ? "This project uses React 18 with TypeScript strict mode." : `Overview detail line ${i}`,
});
}
// Section 2: Code Style Rules (lines 51-100)
for (let i = 51; i <= 100; i++) {
monolithicInstructions.push({
lineNumber: i,
section: "Code Style",
content:
i === 72
? "RULE: Always use explicit return types on exported functions."
: i === 78
? "RULE: Use const assertions for immutable arrays."
: `Style rule detail line ${i}`,
});
}
// Section 3: Testing Standards (lines 101-150)
for (let i = 101; i <= 150; i++) {
monolithicInstructions.push({
lineNumber: i,
section: "Testing",
content:
i === 120
? "RULE: Every new endpoint must have integration tests."
: i === 135
? "RULE: Test files must mirror the source file structure."
: `Testing detail line ${i}`,
});
}
// Section 4: Deployment Rules (lines 151-200)
for (let i = 151; i <= 200; i++) {
monolithicInstructions.push({
lineNumber: i,
section: "Deployment",
content:
i === 175
? "RULE: Never deploy on Fridays. Deploy window is Tue-Thu 10am-3pm."
: `Deployment detail line ${i}`,
});
}
// ---------------------------------------------------------------------------
// Split instruction files (4 focused files)
// ---------------------------------------------------------------------------
const splitInstructions: Record<string, { lineNumber: number; section: string; content: string }[]> = {
"01-project-overview.md": monolithicInstructions.filter((l) => l.section === "Project Overview"),
"02-code-style.md": monolithicInstructions.filter((l) => l.section === "Code Style"),
"03-testing.md": monolithicInstructions.filter((l) => l.section === "Testing"),
"04-deployment.md": monolithicInstructions.filter((l) => l.section === "Deployment"),
};
// ---------------------------------------------------------------------------
// Simulated queries -- the agent needs to find specific rules
// ---------------------------------------------------------------------------
interface Query {
description: string;
targetRule: string;
relevantSection: string;
}
const queries: Query[] = [
{
description: "Find the rule about return types",
targetRule: "explicit return types",
relevantSection: "Code Style",
},
{
description: "Find the deployment window rule",
targetRule: "deploy on Fridays",
relevantSection: "Deployment",
},
{
description: "Find the integration test rule",
targetRule: "integration tests",
relevantSection: "Testing",
},
{
description: "Find the test file structure rule",
targetRule: "mirror the source file",
relevantSection: "Testing",
},
];
// ---------------------------------------------------------------------------
// Search simulation
// ---------------------------------------------------------------------------
function searchMonolithic(query: Query): { linesRead: number; found: boolean } {
// Agent must scan from the top, reading each line until it finds the rule.
// In the worst case it reads all lines.
let linesRead = 0;
let found = false;
for (const line of monolithicInstructions) {
linesRead++;
if (line.content.toLowerCase().includes(query.targetRule.toLowerCase())) {
found = true;
break;
}
}
return { linesRead, found };
}
function searchSplit(query: Query): { linesRead: number; found: boolean; fileAccessed: string } {
// Agent knows which file to look in based on the section.
// It only reads lines from that one file.
const fileMap: Record<string, string> = {
"Project Overview": "01-project-overview.md",
"Code Style": "02-code-style.md",
Testing: "03-testing.md",
Deployment: "04-deployment.md",
};
const targetFile = fileMap[query.relevantSection];
const lines = splitInstructions[targetFile];
let linesRead = 0;
let found = false;
for (const line of lines) {
linesRead++;
if (line.content.toLowerCase().includes(query.targetRule.toLowerCase())) {
found = true;
break;
}
}
return { linesRead, found, fileAccessed: targetFile };
}
// ---------------------------------------------------------------------------
// Reporting
// ---------------------------------------------------------------------------
function pad(s: string, len: number): string {
return s.length >= len ? s : s + " ".repeat(len - s.length);
}
function run(): void {
console.log("\n" + "=".repeat(90));
console.log(" MONOLITHIC vs SPLIT INSTRUCTION FILES");
console.log("=".repeat(90));
console.log("\n Monolithic file: 1 file, " + monolithicInstructions.length + " lines total");
console.log(" Split files: 4 files, ~" + Math.round(monolithicInstructions.length / 4) + " lines each\n");
const header = `| ${pad("Query", 42)}| ${pad("Monolithic (lines)", 20)}| ${pad("Split (lines)", 15)}| ${pad("File Accessed", 22)}| Savings`;
const sep = `|${"-".repeat(44)}|${"-".repeat(22)}|${"-".repeat(17)}|${"-".repeat(24)}|${"-".repeat(10)}`;
console.log(header);
console.log(sep);
let totalMono = 0;
let totalSplit = 0;
for (const q of queries) {
const mono = searchMonolithic(q);
const split = searchSplit(q);
totalMono += mono.linesRead;
totalSplit += split.linesRead;
const savings = Math.round(((mono.linesRead - split.linesRead) / mono.linesRead) * 100);
console.log(
`| ${pad(q.description, 42)}| ${pad(String(mono.linesRead), 20)}| ${pad(String(split.linesRead), 15)}| ${pad(split.fileAccessed, 22)}| ${savings}%`
);
}
console.log(sep);
const avgMono = Math.round(totalMono / queries.length);
const avgSplit = Math.round(totalSplit / queries.length);
console.log(
`| ${pad("AVERAGE", 42)}| ${pad(String(avgMono), 20)}| ${pad(String(avgSplit), 15)}| ${pad("(targeted file)", 22)}| ${Math.round(((avgMono - avgSplit) / avgMono) * 100)}%`
);
console.log("\n" + "=".repeat(90));
console.log(" KEY INSIGHT");
console.log("=".repeat(90));
console.log(" With a monolithic file, the agent must scan up to " + monolithicInstructions.length + " lines for every query.");
console.log(" With split files, it reads only the relevant " + Math.round(monolithicInstructions.length / 4) + "-line file.");
console.log(" This means less context window usage, fewer hallucinations, and faster execution.\n");
}
run();