6.7 系统提示词与 Prompt Templates
本页分析版本earendil-works/pi@c13ffe12026-07-30本章解决什么问题:模型每一轮看到的那段「你是谁、你有什么工具、这个项目有什么规矩」的系统提示词(System Prompt),到底是由哪个函数、按什么顺序、从哪些文件拼出来的?
--system-prompt、AGENTS.md、SYSTEM.md各自作用在这条流水线的哪一段?以及:你在输入框里敲的/cl、/pr这类斜杠命令,是模型的能力还是客户端的字符串替换? 前置知识:3.2 消息、上下文与 token、3.7 Compaction、Extension 与 Skill、5.1 启动:pi 命令如何跑起来、6.4 工具系统:定义、校验与执行。 学习目标:读完后你能 ① 背出buildSystemPrompt的八个段落及其顺序;② 说清三条自定义通道(CLI 参数 /SYSTEM.md/ 扩展运行时覆盖)分别在哪一行接入;③ 解释 prompt template 为什么「模型完全不知道它存在」;④ 写出$1、$@、${2:-默认值}各自的替换规则;⑤ 指出 coding-agent 与 pi-agent-core 在提示词这件事上的分工差异。
建立直觉:系统提示词是一次纯粹的字符串拼接
初学者容易把系统提示词想象成某种运行时状态:以为 Pi 会「注册」工具、「安装」技能(Skill),模型那边有一份对应的清单。事实要朴素得多:Pi 把这些信息全部渲染成文本,拼成一个字符串,塞进请求的 system 字段。工具清单是文本,技能目录是 XML 文本,AGENTS.md 的内容是被 <project_instructions> 包起来的文本。
为什么值得强调这一点?因为一旦接受「它只是字符串拼接」,后面所有问题都变成了可回答的:拼接顺序是什么?谁负责拼?拼完存在哪、什么时候重拼?没有它会怎样——模型不知道自己有 edit 工具能改文件,也不知道这个项目要求提交前跑 lint。
AgentSession._baseSystemPrompt 里,全仓库只有两处会重建它:setActiveToolsByName(packages/coding-agent/src/core/agent-session.ts:939)和 extendResourcesFromExtensions(:2275)。所以在 pi 运行期间修改 AGENTS.md,当前会话不会自动跟着变——要么重启,要么用 /reload 命令(packages/coding-agent/src/core/slash-commands.ts:40)走一遍资源重载。 最小示例:三十行复现这套拼接
脱离 Pi,把拼接规则写成一个小函数,放进 build-prompt.mjs:
function buildPrompt({ customPrompt, tools = {}, appendText, contextFiles = [], cwd }) {
let prompt;
if (customPrompt) {
prompt = customPrompt; // 有自定义提示词就整体替换,默认段落一段都不要
} else {
const list = Object.entries(tools).map(([n, d]) => `- ${n}: ${d}`).join("\n") || "(none)";
prompt = `You are an expert coding assistant.\n\nAvailable tools:\n${list}`;
}
if (appendText) prompt += `\n\n${appendText}`;
for (const f of contextFiles) {
prompt += `\n\n<project_instructions path="${f.path}">\n${f.content}\n</project_instructions>`;
}
prompt += `\nCurrent working directory: ${cwd}`;
return prompt;
}
console.log(buildPrompt({
tools: { read: "Read file contents", bash: "Execute bash commands" },
appendText: "Always answer in Chinese.",
contextFiles: [{ path: "/repo/AGENTS.md", content: "提交前跑 npm test。" }],
cwd: "/repo",
}));node build-prompt.mjs 会打印一段完整提示词。这三十行里已经藏着 Pi 的两个关键设计:自定义提示词是「替换」而不是「插入」(customPrompt 分支里默认段落一段都不生成),以及追加段、项目上下文、工作目录这三节无论走哪条分支都会拼上。真实的 buildSystemPrompt 就是这个骨架加上四个额外段落。
回到 Pi 源码:buildSystemPrompt 的八个段落
整个系统提示词只有一个组装函数,输入是一个纯数据对象:
BuildSystemPromptOptions默认路径下,前四段是一整个模板字符串:
// packages/coding-agent/src/core/system-prompt.ts:121-138(节选,两处长句用 … 截断)
let prompt = `You are an expert coding assistant operating inside pi, a coding agent harness. …
Available tools:
${toolsList}
In addition to the tools above, you may have access to other custom tools depending on the project.
Guidelines:
${guidelines}
Pi documentation (read only when the user asks about pi itself, …):
// …(省略 132-138:README / docs / examples 三个绝对路径与阅读指引)let prompt两个容易被忽略的细节(源码事实)。其一,工具不是只要启用就会出现在清单里:system-prompt.ts:82-84 先用 toolSnippets?.[name] 过滤一遍,只有调用方给了一行说明的工具才会被列出,一个都没有时清单是字面量 (none)。其二,Guidelines 是动态生成的:只有在有 bash 而没有 grep/find/ls 时才加那条「用 bash 做文件操作」(system-prompt.ts:104-106),随后合并去重调用方传入的 promptGuidelines,最后固定追加两条通用条款(system-prompt.ts:116-117)。
后四段是无条件走到的尾部装配:
appendSection技能段的那个 hasRead 条件值得多说一句。技能清单里给的是「名字 + 描述 + 文件绝对路径」(packages/coding-agent/src/core/skills.ts:335-361),正文要靠模型自己用 read 工具去读。所以 read 工具被关掉时,把清单贴出来只会让模型看见一堆读不到的路径——干脆不贴。技能系统本身在 7.2 Skill 系统 详述,本章只需知道它是系统提示词的第七段。
自定义提示词走的是一条短路径:
customPrompt也就是说,--system-prompt "你只回答是或否" 会让模型看不到任何工具清单,但仍然看得到 AGENTS.md 和技能目录。这是很多人第一次用自定义提示词时踩的坑:以为是「换掉开头那句话」,实际是「换掉整个默认骨架」。
谁在调用它
_rebuildSystemPrompt调用链是这样闭合的:setActiveToolsByName(agent-session.ts:926)→ _rebuildSystemPrompt(:939)→ buildSystemPrompt(system-prompt.ts:28);结果一份存进 _baseSystemPrompt,一份写进 agent.state.systemPrompt(agent-session.ts:939-940)。/reload 走的是另一条路但殊途同归:AgentSession.reload(:2602)→ _refreshToolRegistry(:2455)→ 末尾再调一次 setActiveToolsByName(:2545)。
还有一条运行时旁路:每次 prompt() 发消息前,Pi 会把当前 _baseSystemPrompt 和它的 options 一起交给扩展的 before_agent_start 事件(agent-session.ts:1225-1230);如果扩展返回了 systemPrompt,这一轮就用扩展给的值,否则重置回基础值(agent-session.ts:1246-1253)。扩展机制见 7.1 Extension 系统。
三条自定义通道
CLI 参数在 packages/coding-agent/src/cli/args.ts:94-98 解析:--system-prompt 只保留最后一个值,--append-system-prompt 可重复、累积成数组。它们经 main.ts:725-726 传给 ResourceLoader,最终在 reload 阶段落地:
systemPromptSource文件发现的顺序是「项目优先、全局兜底」:先看 cwd/.pi/SYSTEM.md(且项目必须已受信任),再看 agentDir/SYSTEM.md(resource-loader.ts:1022-1034);APPEND_SYSTEM.md 同理(:1036-1048)。
一个很实用的细节:CLI 参数的值既可以是文本,也可以是文件路径。
resolvePromptInput图 6.7-1 系统提示词的八段装配与三条自定义入口
主干是 buildSystemPrompt 内部的执行顺序,从上到下就是最终文本的先后顺序。虚线是三条外部输入:SYSTEM.md 走 customPrompt 分支做整体替换,APPEND_SYSTEM.md 只影响第五段,扩展则在结果生成之后整体覆盖。注意 CUS 与 D4 汇合到 T1——这说明后四段是两条分支的公共尾巴。
值得关注的是虚线的三个接入点深度不同:SYSTEM.md 改的是「有没有默认骨架」,APPEND_SYSTEM.md 改的是「骨架后面加什么」,扩展改的是「这一轮实际发出去的是什么」。想让模型永远说中文,用 APPEND_SYSTEM.md 一行就够;想彻底换掉 Pi 的人设,才需要 SYSTEM.md。
Prompt Template:模型不知道它存在
prompt template(提示词模板)是另一件事。系统提示词是给模型的背景,而模板是给用户的快捷方式:你敲 /cl,Pi 在把消息发出去之前,先把 /cl 替换成一整段事先写好的文字。模型收到的是展开后的文本,它既不知道有个叫 /cl 的命令,也不知道发生过替换。
模板就是一个 Markdown 文件。文件名去掉 .md 就是命令名:
loadTemplateFromFile官方文档说明模板可以来自五个地方:全局 ~/.pi/agent/prompts/*.md、项目 .pi/prompts/*.md(需项目受信任)、pi package 的 prompts/ 目录、settings 的 prompts 数组、以及 CLI 的 --prompt-template <path>(来源:packages/coding-agent/docs/prompt-templates.md:7-17)。同一份文档还写明目录扫描是非递归的(docs/prompt-templates.md:95),这一点与实现一致——loadTemplatesFromDir(prompt-templates.ts:138-175)只看一层 .md 文件。
从源码结构看,实际运行时这五个来源并不是由 loadPromptTemplates 自己去扫的:DefaultResourceLoader 调它时固定传 includeDefaults: false(resource-loader.ts:699-704),默认目录全部由 package-manager 汇总成一份路径清单后再传进来。loadPromptTemplates 里的 includeDefaults: true 分支(prompt-templates.ts:235-238)留给直接调用这个函数的 SDK 使用者。
展开发生在发送之前:
expandPromptTemplatesubstituteArgs在 AgentSession.prompt() 里,展开是三步流水线的最后一步(agent-session.ts:1122-1156):先尝试扩展注册的命令(命中就直接执行,根本不发消息),再尝试 /skill:名字 展开,最后才是模板展开。steer()(:1342-1343)与 followUp()(:1362-1363)走同样的展开。
图 6.7-2 一次斜杠命令输入的三级展开
从上到下是 prompt 方法里三个拦截点的先后顺序,关注三个「返回」箭头:任何一级命中都会短路后面的处理。最后一条 Note 是本节的要点——展开完全发生在客户端,模型侧没有任何痕迹。对应源码是 agent-session.ts:1123、:1154、:1155 这三行。
真实终端里的样子可以对照本书的采集素材(真实采集,research/cli-captures/pi-tui-main.txt):Pi 仓库自带 5 个模板,启动横幅里显示为
[Prompts]
/cl, /is, /pr, /sa, /wr它们对应 _sources/pi/.pi/prompts/ 下的 cl.md、is.md、pr.md、sa.md、wr.md。这一段由 interactive-mode.ts:1552-1569 渲染,模板同时被注册成自动补全项(interactive-mode.ts:619-623),argument-hint 会显示在描述前面。
Focus on: {{focus}}(来源:packages/coding-agent/README.md:345)。但源码的 substituteArgs 只认 $ 系列占位符,这种双花括号写法不会被替换,会原样发给模型。要占位请用 $1 或 ${1:-默认值}。这是一处官方说明与源码不一致的地方。 顺带把模板和技能的分工说清楚:模板是客户端替换,用户显式触发,模型看不到机制;技能是系统提示词里的目录,模型自己判断要不要用 read 工具去读正文。两者恰好是「谁来决定加载」这个问题的两个答案。
两个包的分工
packages/agent(包名 @earendil-works/pi-agent-core)里也有 system-prompt.ts 和 prompt-templates.ts,但它们不是被 coding-agent 复用的——在 packages/coding-agent/src 下搜 formatSkillsForSystemPrompt、formatPromptTemplateInvocation 没有任何命中。从源码结构看,这是两套平行实现,分工如下(均为源码事实):
| 关注点 | coding-agent(产品层) | pi-agent-core harness(库层) |
|---|---|---|
| 系统提示词组装 | buildSystemPrompt 八段固定骨架 | 没有组装函数;应用给字符串或回调,默认句是 You are a helpful assistant.(agent-harness.ts:363-374) |
| 技能清单格式化 | formatSkillsForPrompt(skills.ts:335),第二句是「用 read 工具加载」 | formatSkillsForSystemPrompt(harness/system-prompt.ts:3-25),第二句改为「读完整的技能文件」,且无前导空行 |
| 模板占位符 | 支持 ${N:-默认值} 等默认值语法 | substituteArgs(harness/prompt-templates.ts:249-262)只支持 $N、$@、$ARGUMENTS、${@:N}、${@:N:L} |
| 模板数据结构 | name/description/argumentHint/content/sourceInfo/filePath | 只有 name/description?/content(harness/types.ts:78-85) |
| 资源来源 | 自己扫目录、读 settings、判断项目信任 | 应用通过 setResources() 注入,库不做任何发现 |
一种看法是:库层刻意不碰文件系统,才能同时服务 CLI、评测(evals)和第三方 SDK 使用者;代价是同类逻辑要写两遍,两边的行为差异(比如默认值语法)容易被误当成同一套。要用 harness 的模板能力,入口是 AgentHarness.promptFromTemplate(harness/agent-harness.ts:690-705)。
实践任务
目标:不需要任何 API Key,端到端验证 prompt template 的三件事——一个 Markdown 文件如何变成命令、如何出现在启动清单与自动补全里、$1 与 ${2:-默认值} 到底被替换成了什么。
前提:Pi 仓库(本书为 _sources/pi),依赖已安装。全程只读:模板与脚本都写在系统临时目录,不修改仓库任何文件。
步骤 1 · 写一个最小模板(bash/zsh;heredoc 的 'EOF' 必须带引号,否则 shell 会先把 $1 吃掉。fish 用户请改用编辑器创建同样内容的文件):
mkdir -p /tmp/pi-prompts
cat > /tmp/pi-prompts/sum3.md <<'EOF'
---
description: Summarize a file in three bullets
argument-hint: <path>
---
Read the file $1 and summarize it in exactly three bullet points.
Focus: ${2:-overall structure}
EOF步骤 2 · 启动 pi 并观察启动清单(在仓库根目录执行):
env PI_CODING_AGENT_DIR=/tmp/pi-demo-agent PI_OFFLINE=1 \
./pi-test.sh --no-env -na --verbose --prompt-template /tmp/pi-prompts四个附加开关各有用处:PI_CODING_AGENT_DIR 把全局配置目录指到临时位置,不碰你自己的 ~/.pi/agent;PI_OFFLINE=1 关掉启动期网络操作;--no-env 是 pi-test.sh 自带的开关,清空所有 API Key 环境变量;-na 让本次运行忽略项目本地文件,从而跳过「是否信任此项目」的交互提问;--verbose 强制展开启动清单(默认是紧凑一行,也可以进界面后按 ctrl+o 展开)。
预期现象(本书在锁定 commit 上实测,终端 120x45):启动横幅里出现
[Prompts]
path
/sum3同一屏还会有 [Context] 和 [Skills] 两段,内容取决于你自己机器上的文件,与本任务无关。
步骤 3 · 看自动补全:在输入框里敲 /sum,下拉里出现(实测):
→ sum3 <path> — [t] Summarize a file in three bullets<path> 是 frontmatter 里的 argument-hint,[t] 是来源标记:通过 --prompt-template 临时加载的资源 scope 默认是 temporary(source-info.ts:36),既非 user 也非 project 时标记就是 t(interactive-mode.ts:523-528)。看完先按 ctrl+c 清空输入框,再按 ctrl+d 退出(默认键位见 packages/coding-agent/src/core/keybindings.ts:67-68)。
步骤 4 · 直接验证替换结果(不依赖终端,任何环境都能跑)。把下面内容存成 /tmp/expand-demo.ts:
const mod = await import(`${process.cwd()}/packages/coding-agent/src/core/prompt-templates.ts`);
const { loadPromptTemplates, expandPromptTemplate } = mod;
const templates = loadPromptTemplates({
cwd: process.cwd(), agentDir: "/nonexistent",
promptPaths: [process.argv[2]], includeDefaults: false,
});
for (const t of templates) console.log("name:", t.name, "| hint:", t.argumentHint, "| desc:", t.description);
console.log(JSON.stringify(expandPromptTemplate("/sum3 src/main.ts", templates)));
console.log(JSON.stringify(expandPromptTemplate("/sum3 src/main.ts error-handling", templates)));
console.log(JSON.stringify(expandPromptTemplate("/nope hello", templates)));在仓库根目录运行(必须在根目录,脚本用 process.cwd() 定位源码):
./node_modules/.bin/tsx --tsconfig ./tsconfig.json /tmp/expand-demo.ts /tmp/pi-prompts预期现象(本书实测输出,逐字如下):
name: sum3 | hint: <path> | desc: Summarize a file in three bullets
"Read the file src/main.ts and summarize it in exactly three bullet points.\nFocus: overall structure"
"Read the file src/main.ts and summarize it in exactly three bullet points.\nFocus: error-handling"
"/nope hello"步骤 5 · 跑一遍模板的单元测试:
npm test --workspace=@earendil-works/pi-coding-agent -- test/prompt-templates.test.ts预期现象:Tests 92 passed (92)(本书实测)。用例名直接对应本章结论,例如 substituteArgs - positional defaults、substituteArgs - array slicing、expandPromptTemplate > should split template arguments on unquoted newlines。
如何判断成功:① 步骤 2 的清单里看到 /sum3;② 步骤 4 的三行输出与上面完全一致——第一行证明第二个参数缺失时 ${2:-overall structure} 用了默认值,第二行证明给了参数就用参数,第三行证明不匹配任何模板名的文本原样返回;③ 你能解释为什么 /nope hello 没有报错而是被当成普通消息。
常见错误:① heredoc 写成不带引号的 <<EOF,$1 被 shell 替换成空串,模板里就少了占位符;② 把模板放进 /tmp/pi-prompts/sub/ 子目录——目录扫描非递归(prompt-templates.ts:138-175),找不到;③ 步骤 4 不在仓库根目录执行,process.cwd() 拼出的路径不存在,报模块找不到;④ 省掉 --verbose 时清单是紧凑的一行 /sum3,不分组,不是出错;⑤ 忘记 npm test 后面的 -- 分隔符,文件名参数到不了 vitest,会跑整个包的测试。
对应源码位置:packages/coding-agent/src/cli/args.ts:158-160(--prompt-template 解析)、packages/coding-agent/src/main.ts:665,718(传给 ResourceLoader)、packages/coding-agent/src/core/resource-loader.ts:694-704(updatePromptsFromPaths)、packages/coding-agent/src/core/prompt-templates.ts:104(loadTemplateFromFile)、:269(expandPromptTemplate)、:70(substituteArgs)、packages/coding-agent/src/modes/interactive/interactive-mode.ts:1552(启动清单)、:619(自动补全注册)。
本章小结
- 系统提示词是一次纯字符串拼接,唯一组装函数是
buildSystemPrompt,八段顺序为:身份句 → 工具清单 → Guidelines → Pi 文档指引 → 追加段 →<project_context>→<available_skills>→ 当前工作目录。 - 工具只有带一行 snippet 才会出现在清单里;技能段只有
read工具启用时才拼上。 --system-prompt/SYSTEM.md是整体替换默认骨架(后四段仍在);--append-system-prompt/APPEND_SYSTEM.md是追加;扩展的before_agent_start可以在发送前整体覆盖这一轮的提示词。CLI 参数的值可以是文本,也可以是文件路径。- 结果缓存在
_baseSystemPrompt,只在工具集合变化或会话重载时重建,不是每轮现算。 - prompt template 是纯客户端展开:
/name args在prompt()里被替换成模板正文,模型对模板机制毫无感知;占位符支持$N、$@、$ARGUMENTS、${N:-默认值}、${@:N:L}等。目录扫描非递归,文件名即命令名。 - coding-agent 与 pi-agent-core harness 是平行实现而非复用;harness 不做资源发现,也不支持默认值语法。
- 关键术语:系统提示词(System Prompt)、项目上下文文件(AGENTS.md / CLAUDE.md)、提示词模板(Prompt Template)、占位符替换(Argument Substitution)、来源标记(SourceInfo scope)。
- 关键源码索引:
packages/coding-agent/src/core/system-prompt.ts(BuildSystemPromptOptions:8、buildSystemPrompt:28、customPrompt 分支:46、工具过滤:82、guidelines:104、默认骨架:121、尾部装配:140)、packages/coding-agent/src/core/agent-session.ts(setActiveToolsByName:926、_rebuildSystemPrompt:1021、prompt:1114、展开三步:1122-1156、before_agent_start覆盖:1246、_expandSkillCommand:1301)、packages/coding-agent/src/core/resource-loader.ts(resolvePromptInput:53、reload 决策:525、updatePromptsFromPaths:694、discoverSystemPromptFile:1022、discoverAppendSystemPromptFile:1036)、packages/coding-agent/src/core/prompt-templates.ts(parseCommandArgs:24、substituteArgs:70、loadTemplateFromFile:104、loadTemplatesFromDir:138、loadPromptTemplates:194、expandPromptTemplate:269)、packages/coding-agent/src/core/skills.ts:335(formatSkillsForPrompt)、packages/coding-agent/src/modes/interactive/interactive-mode.ts:523,619,1552、packages/agent/src/harness/system-prompt.ts:3、packages/agent/src/harness/prompt-templates.ts:249,265、packages/agent/src/harness/agent-harness.ts:363,690;测试packages/coding-agent/test/prompt-templates.test.ts(92 passed)、packages/coding-agent/test/system-prompt.test.ts(8 passed)、packages/agent/test/harness/prompt-templates.test.ts(5 passed)。 - 自测问题:① 用
--system-prompt "只回答是或否"启动后,模型还看得到AGENTS.md的内容吗?看得到工具清单吗?分别在哪一行决定?② 一个工具被--tools启用了,却没出现在Available tools里,最可能的原因是什么?③ 模板/x的正文里写$1,用户输入/x "a b" c,$1会被替换成什么?④ pi 正在运行时你改了AGENTS.md,要让当前会话看到新内容需要发生什么?只把 ResourceLoader 重新加载一遍够不够? - 下一章:6.8 配置系统——
settings.json的双层合并、项目信任,以及本章多次提到的agentDir到底怎么算出来。本章未展开的内容:技能的发现与加载规则(见 7.2 Skill 系统)、/skill:名字展开的完整格式、扩展如何注册斜杠命令(见 7.3 自定义工具与斜杠命令)。