数据截至 (上游 commit afe54827dd65)
03 · 上下文来源与可扩展性
本章讲什么: 模型看到的那一大坨 system prompt 是怎么拼出来的, 以及 Crush 用哪四种机制往里加东西:项目记忆文件、skills、MCP、LSP。
1. System prompt:一份 Go 模板
结构
主提示词是一个 434 行的 Go 模板文件 internal/agent/templates/coder.md.tpl,
用 text/template 渲染(internal/agent/prompt/prompt.go:82 Build)。
模板顶部是静态的行为规范(<critical_rules>、<communication_style> 等),
底部才是运行时注入的动态部分:
| 注入内容 | 模板变量 | 来源 |
|---|---|---|
| 工作目录 | {{.WorkingDir}} | 配置的 cwd |
| 是否 git 仓库 | {{.IsGitRepo}} | 检查 .git 是否存在 |
| 平台 | {{.Platform}} | runtime.GOOS |
| 日期 | {{.Date}} | 当前时间 |
| git 状态摘要 | {{.GitStatus}} | 实跑 git 命令(分支、状态、近期提交) |
| 已配置的 LSP | {{.Config.LSP}} | 配置 |
| 可用 skills | {{.AvailSkillXML}} | skills 发现结果 |
| 项目记忆文件 | {{range .ContextFiles}} | 见 §2 |
| 全局记忆文件 | {{range .GlobalContextFiles}} | 见 §2 |
组装逻辑集中在 internal/agent/prompt/prompt.go:165 promptData。
git 信息是每次建 prompt 时真的跑 git 拿的(getGitBranch / getGitStatusSummary /
getGitRecentCommits,同文件 259-289 行),不是缓存的。
运行时还会再追加一段
模板渲染完不是终点。Run 里还会把所有已连接 MCP 服务器的 instructions
包进 <mcp-instructions> 标签追加到 system prompt 末尾(internal/agent/agent.go:666-678)。
所以同一个会话,MCP 连上前后模型看到的系统提示词是不一样的。
2. 项目记忆文件:认别家的门牌
Crush 默认会读一串「AI 指令文件」当上下文,清单写死在
internal/config/config.go:28 defaultContextPaths:
| 生态 | 文件 |
|---|---|
| 通用约定 | AGENTS.md / agents.md / Agents.md |
| Claude | CLAUDE.md、CLAUDE.local.md |
| Gemini | GEMINI.md、gemini.md |
| Copilot | .github/copilot-instructions.md |
| Cursor | .cursorrules、.cursor/rules/ |
| Crush 自己 | CRUSH.md、crush.md、Crush.md 及各自的 .local.md |
它读别家的文件,这是刻意的兼 容策略——你已经为 Claude Code 或 Cursor 写好的项目说明, Crush 直接拿来用,不要求你再写一份。
全局层面还会读用户配置目录下的 CRUSH.md 和上一级的 AGENTS.md
(internal/config/load.go:546-552)。目录路径(如 .cursor/rules/)会被递归展开成文件列表
(internal/agent/prompt/prompt.go:110 processContextPath)。
3. Skills:只给目录,不给正文
它要解决的小问题
你想教 agent 一套专门流程(比如「本项目怎么发版」)。 把全文塞进 system prompt 太贵——用不到的时候也在烧 token。
思路:渐进式披露
Crush 的做法是目录与正文分离:system prompt 里只有一份「有哪些 skill、各自什么时候用、 正文在哪个文件」的清单,正文一个字都不进去。
怎么读这张图:方框内是注入 system prompt 的全部 skills 内容; 方框之下是模型自己走的后续动作,只有走到最后一步,SKILL.md 正文才进上下文。
system prompt 里只放一份清单
┌──────────────────────────────────────────────┐
│ <available_skills> │
│ <skill> │
│ <name>release-flow</name> │
│ <description>何时该用它</description> │
│ <location>/abs/path/SKILL.md</location> │
│ </skill> │
│ </available_skills> │
└──────────────────────────────────────────────┘
│
模型判断「这次用得上」
▼
主动调 view 工具读 <location>
▼
正文才进入上下文,同时被 Tracker 记一笔
XML 生成在 internal/skills/skills.go:299 ToPromptXML;
主提示词第 14 条规则明确要求「匹配到就必须先 view 它的 <location> 再动手,
<description> 只是触发器,不许凭它臆测 skill 的行为」
(internal/agent/templates/coder.md.tpl 的 <critical_rules> 第 14 条)。
来源与优先级
| 来源 | 位置 | 说明 |
|---|---|---|
| 内置 | internal/skills/builtin/(crush-config、crush-hooks、jq) | 用 go:embed 打进二进制(internal/skills/embed.go:23 DiscoverBuiltin) |
| 全局 | GlobalSkillsDirs()(internal/config/load.go:1334) | 加载配置时自动追加进 SkillsPaths(internal/config/load.go:598-603) |
| 项目 | ProjectSkillsDir(workingDir)(internal/config/load.go:1380) | 同上,追加在全局之后(internal/config/load.go:606) |
顺序即优先级:内置先进列表、用户 skill 后进,去重时后来者胜,
所以同名时用户 skill 覆盖内置,并打一条 warn 日志
(internal/agent/prompt/prompt.go:188-199;去重在 internal/skills/skills.go:365 Deduplicate)。
带 disable-model-invocation 的 skill 不进清单——它只能由用户手动触发。
加载追踪
skills.Tracker(internal/skills/tracker.go:15)记录本次会话哪些 skill 被真正读过。
view 工具读到 skill 文件时调 MarkLoaded(internal/agent/tools/view.go:270),
每轮结束打一条使用日志(internal/agent/coordinator.go:1538 logTurnSkillUsage)。
4. MCP:异步连接,不拖慢首个 prompt
生命周期
workspace 启动
│
mcp.Initialize(ctx, ...) ← 每个服务器一个 goroutine,互不阻塞
│
┌─────┴──────┐
│ │
连上 连不上/需授权
注册工具 置为对应状态,发事件
发 EventToolsListChanged
│
Coordinator 收到 → SetTools 换掉当前工具表
关键取舍在 internal/agent/coordinator.go:228-246 的一大段注释里说得很清楚:
| 场景 | 是否等 MCP 初始化完 | 理由 |
|---|---|---|
| 交互模式 | 不等 | 曾经因为等最慢的服务器超时,导致第一条消息卡住整个 TUI |
非交互(crush run) | 等(WaitForInit) | 只有一次机会拿工具表,缺工具就等于任务失败 |
交互模式下漏掉的 MCP 工具会在后续轮次自动补上——因为 PrepareStep 每一步都重新
a.tools.Copy() 取最新工具表(internal/agent/agent.go:814)。
传输与鉴权
支持 stdio / http / sse 三种传输(internal/config/config.go:185 MCPType)。
HTTP 类支持 OAuth:待授权的服务器进入 pending 状态,UI 给出授权链接,
授权完成再重连(internal/agent/tools/mcp/init.go:400 PendingAuthMCPs、:426 BeginAuth)。
权限
MCP 工具默认全部需要权限确认(internal/agent/tools/mcp-tools.go:99 Tool.Run),
只有 Docker MCP 的几个管理类工具在白名单里免确认
(internal/agent/tools/mcp-tools.go:15 whitelistDockerTools)。
agent 配置还能按服务器、按工具名做白名单(AllowedMCP),
过滤逻辑在 internal/agent/coordinator.go:768-789。
5. LSP:按需自动拉起
思路
不是启动时把所有语言服务器都拉起来,而是模型碰到某个文件时才尝试
(internal/lsp/manager.go:104 Start)。
判断能不能自动启动,走一串由便宜到昂贵的检查(internal/lsp/manager.go:253 canAutoStart):
命令名太泛?(node/python/java/npx/deno...)
├── 是 ──► 跳过(除非用户显式配置)
└── 否
文件类型 / 根标记文件匹配吗? ← 便宜,先做
├── 否 ──► 跳过
└── 是
最近判定过"没装"吗? ← 有冷却期,避免反复找
├── 是 ──► 跳过
└── 否
PATH 里找得到命令吗? ← 最贵,最后做
├── 否 ──► 标记不可用,跳过
└── 是 ──► 启动
排序的理由源码里直接写死在注释里(internal/lsp/manager.go:262-267,真实源码):
// Filtering by file type is cheap and usually rejects a server before the
// root marker check. Do both before searching PATH, which can require a stat
// for every directory in PATH for every bundled server.
if !handles(server, filePath, workDir) {
return false
}
两个细节值得学:
- 顺序是按代价排的:查 PATH 可能要对每个 PATH 目录、每个内置服务器各做一次 stat, 所以排在文件类型过滤之后(上面这段注释)。
- 「太泛的命令」黑名单(
skipAutoStartCommands,internal/lsp/manager.go:123):node、python、java、deno、dotnet这类命令谁机器上都有,凭它存在就拉起服务器几乎必错, 所以要求用户显式配置。
还有一条隐含边界:Start 只处理工作目录之内的文件
(internal/lsp/manager.go:108 的 fsext.HasPrefix 检查)。
6. 配置:分层合并 + 可执行的 crushrc
6.1 分层顺序
后加载的覆盖先加载的(internal/config/load.go:915 lookupConfigs):
系统级配置
↓
全局用户配置(crush.json 与同目录 crushrc)
↓
全局数据目录 JSON(机器状态,永不当脚本执行)
↓
从工作目录向上找到的项目配置(离 cwd 越近优先级越高)
↓
workspace 配置(.crush 目录里的,优先级最高)
同一目录内的优先级:.crushrc > crushrc > .crush.json > crush.json
(internal/config/load.go:927-936 的 configNames 及其上方注释)。
向上查找有边界(internal/config/load.go:1315 projectBoundary),不会一路找到根目录。
6.2 crushrc:配置是一段脚本
这是 Crush 一个挺特别的设计:crushrc 不是 JSON,而是一段真的会被执行的 shell 脚本
(internal/shellconfig/load.go:33 LoadShellConfig)。
crushrc 源码
│ 用和 bash 工具同一个内嵌解释器跑
▼
脚本调用 provider / model / mcp 等"配置 builtin"
│ 按执行顺序改一个 ConfigBuilder
▼
builder 序列化成 JSON
│
▼
和其它配置文件一起走正常合并流程
好处是配置里能用 $VAR、$(command)、source、条件分支——
比如按 CRUSH_VERSION 做特性检测(脚本里能读到这个变量,internal/shellconfig/load.go:46)。
代价是执行任意代码的风险,Crush 用两道约束控制:
- 数据目录的 JSON 永远不当脚本执行——注释里点名说它是「机器持有的可写状态」
(
internal/config/load.go:916-919注释)。 - 30 秒硬超时(
internal/shellconfig/load.go:20loadTimeout)。 因为配置加载发生在启动关键路径上、还握着配置存储的写锁,脚本卡住会锁死整个程序。
同一目录同时存在 JSON 和 crushrc 且顶层键有重叠时会打 warn;不重叠则认为是有意共存,
不打扰用户(internal/config/load.go:1007-1017)。
6.3 环境自适应的默认值
配置加载最后还会按环境调整(internal/config/load.go:88-101):
| 检测到 | 调整 |
|---|---|
| 不在 git worktree 内 | 限制文件遍历:深度 2、条目 100 |
| Apple Terminal | 打开透明模式 |
第一条是防呆:在非仓库目录(比如家目录)启动时,glob/补全不至于把整块硬盘扫一遍。
7. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 主提示词模板 | internal/agent/templates/coder.md.tpl | (模板) |
| 提示词组装 | internal/agent/prompt/prompt.go | Prompt.Build、promptData、loadContextFiles、processContextPath |
| 各家记忆文件清单 | internal/config/config.go | defaultContextPaths |
| Skills 数据结构与 XML | internal/skills/skills.go | Skill、Discover、ToPromptXML、Deduplicate、Filter |
| 内置 Skills | internal/skills/embed.go | DiscoverBuiltin |
| Skills 目录来源 | internal/config/load.go | GlobalSkillsDirs、ProjectSkillsDir |
| Skills 使用追踪 | internal/skills/tracker.go | Tracker.MarkLoaded |
| MCP 初始化 | internal/agent/tools/mcp/init.go | Initialize、WaitForInit、PendingAuthMCPs、BeginAuth |
| MCP 工具包装 | internal/agent/tools/mcp-tools.go | GetMCPTools、Tool.Run、whitelistDockerTools |
| LSP 自动启动 | internal/lsp/manager.go | Manager.Start、startServer、canAutoStart、skipAutoStartCommands |
| 配置分层 | internal/config/load.go | Load、lookupConfigs、projectBoundary |
| 可执行 crushrc | internal/shellconfig/load.go | LoadShellConfig、loadTimeout |