Zero 架构 · 第五课

系统提示:一次拼装、可缓存的分段组合

第一课的循环第一行就播种了对话: SeedMessagesWithImages(buildSystemPrompt(options), prompt, images) (loop.go:130)。 那个 buildSystemPrompt 决定了模型「开机」时看到的一切 —— 它的人格、可用的 约定、当前工作目录、安全策略。这一课拆开它。

对齐 mission:系统提示常被当成一坨硬编码字符串。Zero 把它做成可组合、可预算、 可缓存的分段结构 —— 这本身就是一个值得学的架构决策。

核心洞察:一次拼装,每回合共享

buildSystemPrompt每次运行开头只调用一次,结果作为唯一的 system 回合,被之后每一个回合复用。 system_prompt.go:62

为什么「一次」很重要 system 回合每回合字节完全一致 → 命中 provider 的 prompt 缓存。如果每回合 重算(哪怕内容相同但顺序/空白有别),缓存就会失效,每回合都要为这几千 token 重新付费。 「built once per run」这句注释不是随口一提,是省钱的关键。

拼装的形状:一串可选分段

函数体极其朴素 —— 一个字符串切片,每段独立判断「要不要加」,最后用 \n\n 拼起来: system_prompt.go:70

sections := []string{core}                          // 核心「编码工艺」指令
if a := modelPromptAddendum(model); a != "" { ... }  // 按模型家族的小贴士
if s := sessionRuntimeContext(options); ... {}       // 当前 provider/model
if p := approvedCommandPrefixContext(...); ... {}     // 已批准的命令前缀
if seed := workspaceSeedContext(cwd); ... {}          // 工作区种子
if ws := workspaceContext(cwd); ... {}                // cwd/OS/shell/git/项目约定/repo map
if d := specialistDelegationContext(...); ... {}      // 可委派的 specialist 列表
if style := responseStyleContext(...); ... {}         // /style 选择的回复风格
if policy := confirmationPolicy; ... {}               // 安全确认策略(embed)
return strings.Join(sections, "\n\n")

关键约定:每个分段函数在无内容时返回空串,拼装处用 if != "" 跳过。 所以「没有 git 仓库」「没有 specialist」「风格是默认」这些情况不占一个字节 —— 缺席的上下文零成本,而不是留一堆「N/A」占位。

静态 vs 动态:两种来源

静态:编译进二进制

核心指令和确认策略是两个 Markdown 文件,用 //go:embed 直接嵌进二进制: system_prompt.mdconfirmation_policy.mdsystem_prompt.go:19 这样「编码工艺」这类不变内容能被产品人员当文档来读、来改,而不必混在 Go 字符串里。

动态:每次运行从环境算出

workspaceContext 现算 cwd、runtime.GOOS、按 OS 给出 shell 语法 (Windows cmd 还是 /bin/sh)、git 分支,再拼上项目约定和 repo map。 system_prompt.go:190

两个值得单独看的分段

① 按模型家族微调(modelPromptAddendum)

同一份核心提示,针对不同模型家族追加一小段 <model_guidance>: system_prompt_models.go:42

这是「一个核心 + 少量厂商特调」的策略 —— 不为每家写一整套提示,只补差异。

② 摄入项目约定(projectGuidelines)

这正是 Zero 尊重 CLAUDE.md 式约定的地方。它按名字找项目上下文文件 —— AGENTS.md / ZERO.md / .zero/AGENTS.md (大小写无关,兼容 AGENTS.MD),沿 gitRoot→cwd 的目录链逐级读取。 system_prompt.go:38system_prompt.go:223

但它有严格的字节预算:每文件 8 KiB、总计 32 KiB,超了就 UTF-8 安全地 截断并标 … (truncated)system_prompt.go:42 一个巨大的 AGENTS.md 不能把整个上下文窗口吃光 —— 用户约定重要,但不能无限膨胀。

为什么这么设计 系统提示是组合而非拼接:独立分段 + 逐段预算 + 一次拼装。 收益是三重的 —— 可缓存(每回合字节一致)、可预算(没有哪一段能撑爆窗口)、可维护(静态 文档归文档、动态上下文归代码)。对照一坨手写的巨型字符串,这就是把提示当工程产物对待。

动手回忆

buildSystemPrompt 每次运行调用几次,为什么?

当前工作区没有 git、没有 specialist,系统提示里会怎样?

为什么项目约定文件(AGENTS.md 等)要有每文件 8KiB / 总 32KiB 的预算?

接下来该读的一手源码

less +62  ../zero/internal/agent/system_prompt.go        # buildSystemPrompt 拼装骨架
less +190 ../zero/internal/agent/system_prompt.go        # workspaceContext 动态部分
less +42  ../zero/internal/agent/system_prompt_models.go # 按家族的 addendum
bat ../zero/internal/agent/system_prompt.md              # 核心「编码工艺」指令本体

读的顺序:先看 buildSystemPrompt 那份 sections 清单(:70), 认出「一段 = 一个可选关注点」;再随便点开一两个分段函数,验证「无内容→空串→被跳过」这个 一致的约定。最后读一遍 system_prompt.md 本体,看核心人格到底怎么写。

我是你的老师 —— 随时问我。 适合现在追问: 「workspaceSeedContext 和 repo map 是什么、怎么生成的?」、 「confirmation_policy.md 里的安全策略和第二课的权限关卡怎么呼应?」, 或者「prompt 缓存在 provider 层是怎么落地的?」(那会引向 provider 适配那一课)。