# Step 43: 上下文工程 —— 环境注入 + @文件展开
一句话导读:模型不是"知道"你的项目长什么样,它只是在猜。这一步把 cwd、平台、目录树主动写进系统提示,把
@文件的真实内容附进消息——用"喂事实"取代"让它猜"。
# 一、这一步做了什么(What)
在 step42 基础上叠加两件事,都属于上下文工程(context engineering):
- 环境注入:启动时算一次
envContext(),把「工作目录 / 平台 / 目录树(深度 2、最多 40 行)」拼成一个# Environment段,注入系统提示。 - @文件展开:主循环里对含
@的输入调expandMentions(),把@相对路径指向的真实文件内容读出来,附到这条消息后面。
对应真实源码 src/context.ts 的 getSystemContext()(cwd / 平台 / 目录结构 / git 状态)+ @ 提及展开。
# 二、面试官视角:为什么要做上下文工程?(Why)
面试题:模型有 Read / Grep 工具,想看什么自己去读就行,为什么还要主动把目录树和文件内容塞进上下文?
因为**"能读到"不等于"知道去读"**,更不等于"读得准"。
step37 踩过一个真实的坑:模型把路径里的「牛马」瞎写成「牛班」,导致 Read 失败。根因不是工具不行,而是模型对真实路径一无所知,只能凭训练记忆猜。它甚至不知道当前目录下有哪些文件、自己跑在 Windows 还是 Linux 上。
上下文工程解决的正是这个"信息真空":
| 维度 | 不做上下文工程 | 做上下文工程 |
|---|---|---|
| 路径 | 模型凭记忆猜,容易写错 | 目录树直接摆在系统提示里,照着抄 |
| 探索成本 | 想看文件先 Read,一次工具往返 | @文件 一步到位,内容随消息就位 |
| 平台差异 | 不知道是 win32 还是 posix,命令可能写错 | Platform: win32 明示 |
一句话:上下文工程 = 在模型开口前,先把"背景事实"喂给它,从源头消除"猜错",而不是等它猜错了再靠工具报错去纠。
# 三、原理:它是怎么工作的(How)
# 能力① 环境信息注入
envContext() 递归走目录树,但做了三重克制,避免把整棵树塞爆上下文:
const SKIP = new Set(["node_modules", ".git", "log", ".sessions", ...]);
function walk(dir, prefix, depth, maxDepth, out, cap) {
if (depth > maxDepth || out.length >= cap) return; // 深度/条数双闸
entries = readdirSync(dir, { withFileTypes: true })
.filter((e) => !SKIP.has(e.name)) // 跳过噪音目录
.sort(...); // 目录在前、文件在后
for (const e of entries) {
if (out.length >= cap) { out.push(prefix + "…(更多省略)"); return; }
out.push(prefix + (e.isDirectory() ? e.name + "/" : e.name));
if (e.isDirectory()) walk(join(dir, e.name), prefix + " ", depth + 1, ...);
}
}
export function envContext(): string {
const tree: string[] = [];
walk(process.cwd(), " ", 1, 2, tree, 40); // 深度 2、最多 40 行
return ["Working directory: " + process.cwd(),
"Platform: " + process.platform,
"Directory structure (depth 2):", ...tree].join("\n");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
三重克制:跳过重目录(node_modules/.git)、限深度(2 层)、限条数(40 行,超了写「…更多省略」)。
# 能力② @文件展开
export function expandMentions(input: string): MentionResult {
const re = /@(\S+)/g;
while ((m = re.exec(input)) !== null) {
const abs = join(process.cwd(), m[1]);
if (existsSync(abs) && statSync(abs).isFile()) { // ★只展开真实存在的文件
let content = readFileSync(abs, "utf-8");
if (content.length > 4000) content = content.slice(0, 4000) + "\n…(已截断)";
blocks.push("【附带文件 @" + m[1] + "】\n```\n" + content + "\n```");
}
// 不存在的 @xxx 原样留着,当普通文字,不报错
}
}
2
3
4
5
6
7
8
9
10
11
12
# 数据流
启动:envContext() 算一次 → 拼进 buildSystemPrompt 的 # Environment 段(整个会话固定)
每轮:用户输入含 @?
→ expandMentions():@package.json 存在 → 读内容(截断4000) → 附成【附带文件】块
→ @不存在.txt / @someone → 保持原样(当文字)
→ 拼好的 text 进对话历史,终端提示"已附带文件:package.json"
2
3
4
5
# 题眼:一次算 vs 每轮算
环境信息只在启动算一次——cwd 和目录树在一个会话里基本不变,每轮重算是浪费。而 @文件 是每轮按需——它跟用户当轮意图绑定。这个"哪些信息是稳定的、哪些是动态的"的切分,正是上下文工程的核心判断。真实版把 git 状态也放进环境块并随改动刷新(因为 git 状态是变化的),我们从简只放静态部分。
# 四、深入追问(面试常见 follow-up)
Q:为什么 @ 展开只对"真实存在的文件"生效,不存在就当普通文字?
A:因为 @ 在日常文本里含义太多——邮箱 a@b.com、@某人、@2x 图。如果见 @ 就当文件读,会疯狂误伤。用「文件真实存在」作为唯一放行条件,是宁可漏、不可错:漏了大不了模型自己去 Read;错了会把一堆无关文字当文件内容塞进去,反而污染上下文。
Q:环境信息注入系统提示,会不会每次请求都重复占 token? A:会,但这正是它该待的地方。系统提示在整个会话里稳定不变,配合 prompt caching(step 前面做过),这段固定前缀能被缓存命中,重复注入的实际成本很低。反过来,把它放进每轮 user 消息才是浪费——那样既不稳定、又缓存不了。
Q:目录树只给深度 2、40 行,深层文件模型看不到怎么办? A:环境注入的目标是给全局方位感(有哪些顶层模块、大致结构),不是给全量清单。需要看深层细节时,模型有 Grep/Glob/Read 去精确定位。"背景喂概览、工具查细节" 是清晰的分工——把全树塞进系统提示既爆上下文又没必要。
Q:@文件 和让模型自己调 Read,有什么本质区别?
A:@ 是用户主动、确定性地把内容摆上桌,零工具往返、零猜测;Read 是模型被动、概率性地决定去读,多一次往返且可能读错路径。@ 适合"用户已经明确知道要看哪个文件"的场景,把这个确定信息直接用掉,不劳模型再决策。
# 五、踩坑 / 设计权衡
- 截断兜底:单个
@文件超 4000 字截断、目录树超 40 行截断。一个 3 万行的文件若整段附上会瞬间吃满上下文,得不偿失——截断是必要的"防爆闸"。 - 静态从简、动态从缺:真实版环境块含 git 状态并随改动刷新,我们只做静态部分。取舍点在于:git 状态是会变的,做它就得考虑何时刷新、刷新开销,教学版先跳过。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
envContext() | getSystemContext():cwd / 平台 / 目录结构 / git 状态 / 是否 git 仓库 |
# Environment 注入系统提示 | 真实版作为 systemContext 注入 |
@文件 展开(超 4000 字截断) | 真实版的 @ 提及(文件/目录,带补全 UI) |
| 只展开真实存在的文件 | 一致(避免误伤 @人名/邮箱) |
| 还缺:git 状态、文件名补全 UI、@目录、@符号定位 | 真实版都有 |
# 七、一句话总结
上下文工程 = 在模型开口前把"背景事实"喂进去:静态的环境信息(cwd/平台/目录树)启动时算一次注入系统提示,动态的 @文件 每轮按需展开且只认真实文件,用确定性信息取代模型的猜测,从源头消除"路径写错""不知道有啥文件"这类问题。
# 下一节预告
背景喂好了,下一步是 step44 真实压缩——把 step24 那个拼字符串的简陋版升级成结构化摘要 + compact_boundary 分界。