数据截至 (上游 commit e2d772072efa)
智能上下文管理:项目地图与自动上下文
30 秒导读: 大项目的源码几十上百万 token,一次塞不进模型窗口。Plandex 的办法是先给整个仓库建一张 项目地图(codebase map)——用 tree-sitter 把每个文件压成"只留函数/类/类型的签名、丢掉函数体"的 目录。地图很便宜(一个大文件也许只剩几十行签名),却足够让一个叫 architect 的角色"先看目录、再决定 翻哪几本书",最后只把真正相关的少数文件全文加载进来。这就是它敢宣称"2M token 有效窗口 / 只加载需要的 东西"的底层机制。
本章只讲上下文怎么被地图化、又怎么被按需选中。它上游的规划/执行主循环见
01-tell-loop.md;architect 用哪个模型、角色怎么编排见
04-models-roles.md;用户在 CLI 里手动 load 上下文的命令见
05-diff-sandbox-cli.md。
1. 这是什么(零基础也能懂)
1.1 要解决的问题:窗口装不下整个仓库
编码 agent 最尴尬的一件事:你让它"给这个项目加个功能",但项目有 800 个文件、200 万 token 的代码。 模型的上下文窗口就那么大,而且每个 token 都要花钱、都会稀释注意力。全塞进去——装不下,也烧钱; 只塞几个文件——又可能漏掉关键的那一个。
1.2 核心直觉:先给仓库做一张"目录"
想象你走进一座图书馆找资料。你不会把每本书从头读一遍,你会先看目录卡片:每本书的书名、章节标题、 作者。看完卡片,你才决定抽哪三本书出来精读。
Plandex 的项目地图就是这套卡片。对每个源文件,它不保留完整内容,只抽出签名层:
- 有哪些函数?函数叫什么、参数和返回值是什么(但不含函数体)。
- 有哪些类 / 结构体 / 类型?字段和方法签名是什么。
- 有哪些顶层变量、常量、CSS 选择器、HTML 结构标签?
一个几百行的文件,压成地图后可能只剩十几行签名。整个仓库的地图,因此比全量源码小一到两个数量级。
1.3 用起来什么样
真实跑一下 Plandex 内置的 mapper(app/server/syntax/file_map/cli)去映射一个 Go 文件,输出长这样:
### /tmp/ex.go (0 🪙)
type Server struct
func (s *Server) Start(ctx context.Context) error
func New(port int) *Server
注意:Server 的字段、Start 的函数体全没了,只剩**"这个文件里有什么"的骨架。文件名后面那个
(0 🪙) 是这个文件全文加载的话要花多少 token**——地图把这个价签也标出来,好让后面的决策者按预算取舍
(这里是 CLI 直接调用、没算 token,所以显示 0;真实流程里是真实数字,见 §3.2)。
1.4 一句话类比
项目地图 = 廉价的全局索引。 就像数据库的索引:你不必扫全表,先查索引定位到少数几行,再去取那几行的 完整数据。architect 角色先"查索引"(读地图)决定该加载哪些文件,再让系统"取数据"(全文加载)。token 就这样花在了刀刃上。
本节到此为止,不碰代码细节。记住三件事:地图 = 只有签名的索引;它标了每个文件的 token 价签;有个角色 先读地图再决定拉谁的全文。
2. 顶层全景(它大概怎么转)
2.1 一张图:从源文件到"只加载需要的东西"
从上到下是数据流。左半边是建地图(把源码压成索引),右半边是用地图(architect 按需选文件)。
建地图(便宜的索引) 用地图(按需拉全文)
───────────────────── ─────────────────────
源文件 foo.go architect 角色(Context 阶段)
│ │ 读整张地图 + 目录树
▼ │ "我要动 server,那就要
① tree-sitter 解析成语法树 │ server.go 和它的类型文件"
│ MapFile() ▼
▼ ② 输出 ### Files 列表
② 只抽签名、丢实现 (文件路径放进反引号)
│ Definition{Signature,...} │
▼ ▼
③ 拼成一张大地图 ③ checkAutoLoadContext 解析路径
│ CombinedMap() + token 价签 │ 只认项目里真实存在的路径
▼ ▼
┌──────────────────────────┐ ④ Tasks 阶段:把选中文件
│ 整仓地图(几十~几百行签名) │──────────────▶ 的【全文】加载进上下文
└──────────────────────────┘ architect 读它 (地图此时退场)
怎么读这张图: 左边一次性把仓库压成地图;右边每轮对话里,architect 先读地图这张"便宜的全局视图", 挑出真正相关的文件,系统再只加载那几个文件的全文。地图是廉价的全局,全文是昂贵的局部,两者分工。
2.2 部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
MapFile | 把单个文件解析成"签名列表"(FileMap) | app/server/syntax/file_map/map.go |
Definition | 一条签名记录:类型 + 签名文本 + 行号 + 子定义 | app/server/syntax/file_map/map.go:22 |
| 节点配置表 | 声明式规定"哪些语法节点算定义、边界在哪" | app/server/syntax/file_map/nodes_config.go |
CombinedMap | 把多个文件的地图拼成一张,带 ### path (n 🪙) 标题 | app/shared/file_maps.go:9 |
| token 计数 | 用 tiktoken 精确数 token,或用"字节÷4"快估 | app/shared/tokens.go |
resolveCurrentStage | 判定这轮走不走 Context 阶段(即要不要用地图) | app/server/model/plan/tell_stage.go:22 |
| architect prompt | 指挥模型"先读地图、再输出 ### Files" | app/server/model/prompts/architect_context.go |
checkAutoLoadContext | 从模型回复里捞出反引号路径、决定加载谁 | app/server/model/plan/tell_context.go:377 |
| 大上下文 fallback | token 超了就换更大窗口的模型;地图前缀做缓存 | app/shared/ai_models_large_context.go |
2.3 主线走一遍(高层)
- 建地图:用户把项目加入上下文时,每个受支持的文件被
MapFile压成签名(可并发,见multi.go), 再由CombinedMap拼成一张带 token 价签的整仓地图。 - 判定阶段:新一轮 tell 开始,
resolveCurrentStage判断——如果开了自动上下文、且有地图,就先进 Context 阶段(Planning 阶段的第一子相)。 - architect 读地图:此阶段只把地图 + 目录树 + 手动加载的上下文喂给模型(全文文件此时不给),
模型据此产出一个
### Files列表,把要加载的路径放进反引号。 - 按需拉全文:
checkAutoLoadContext从回复里解析这些路径,只保留项目里真实存在、且还没加载的, 把它们标记为 auto-load。 - Tasks 阶段:进入 Planning 的第二子相,这次加载选中文件的全文(地图退场),模型基于真代码做详细规划。
3. 核心原理(逐个机制,由浅入深)
3.1 tree-sitter 项目地图:只抽签名,不抽实现
它要解决的小问题
怎么把一个几百行的源文件,自动压成"只有骨架"的几行签名?而且要支持几十种语言,不能给每种语言手写一个解析器。
思路 / 直觉
用 tree-sitter(一个增量式语法解析库,能把源码解析成语法树 CST)。所有语言都解析成同构的"节点树", 于是可以写一套通用的遍历逻辑:走一遍树,遇到"定义类"的节点(函数、类、类型……),只截取从开头到函数体 之前的那段文本当签名,函数体整个丢掉。
关键是"函数体从哪开始"这个边界。对每种语言,tree-sitter 给的节点类型名不一样(Go 里函数体叫 block,
Python 里叫 block 但缩进不同,等等),Plandex 用一张声明式配置表把这些差异吸收掉,而不是写死 if-else。
图示:一个函数如何被"砍成"签名
源码: func New(port int) *Server { ← 签名部分,保留
return &Server{Port: port} ← 函数体(implementation boundary 之后),丢弃
}
语法树: function_declaration
├─ "func" "New" parameter_list result ← 截到这里
└─ block { ... } ◀── findImplementationBoundary 找到它,end = 它的起点
从左到右: 遍历命中 function_declaration 这个定义节点 → 找到它的"实现边界"节点(block)→
签名 = [节点起点, 边界起点) 这段字节。函数体被整段跳过。