# Step 43: 上下文工程 —— 环境注入 + @文件展开

一句话导读:模型不是"知道"你的项目长什么样,它只是在猜。这一步把 cwd、平台、目录树主动写进系统提示,把 @文件 的真实内容附进消息——用"喂事实"取代"让它猜"。


# 一、这一步做了什么(What)

在 step42 基础上叠加两件事,都属于上下文工程(context engineering):

  1. 环境注入:启动时算一次 envContext(),把「工作目录 / 平台 / 目录树(深度 2、最多 40 行)」拼成一个 # Environment 段,注入系统提示。
  2. @文件展开:主循环里对含 @ 的输入调 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");
}
1
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 原样留着,当普通文字,不报错
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

# 数据流

启动:envContext() 算一次 → 拼进 buildSystemPrompt 的 # Environment 段(整个会话固定)
每轮:用户输入含 @?
   → expandMentions():@package.json 存在 → 读内容(截断4000) → 附成【附带文件】块
   → @不存在.txt / @someone → 保持原样(当文字)
   → 拼好的 text 进对话历史,终端提示"已附带文件:package.json"
1
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 分界。