开源编程 Agent pi 的系统提示词是怎么拼出来的:固定段、动态段与替换位
本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。
pi 的系统提示词里真正写死的只有一段模板文本,而这段文本是可以被整块换掉的——一旦你放了 SYSTEM.md 或者传了 --system-prompt,工具清单、工具守则、pi 自身文档路径这三块会一起消失,但项目上下文、技能清单、当前工作目录仍然照常拼上去。 这个”部分替换、部分强制”的切分方式,是理解它整套提示词机制的入口。
pi 是 earendil-works 开源的编程 Agent,MIT 许可证,主仓库在 https://github.com/earendil-works/pi ,截至 2026 年 7 月在 GitHub 上约 8 万 star。它的提示词拼装没有藏在什么模板引擎里,就是一个返回字符串的函数,你可以一行行读完。
站内的 AI 编程提示词写法 和 上下文工程 讲的是通用方法论——怎么写、放什么、留多少预算;这篇不重复那些,只做一件事:把一个真实开源项目的系统提示词组装过程摊开,让你看到方法论落到代码里长什么样、哪些位置留了口子、哪些地方它干脆不管。
一、先定位:拼装发生在哪个函数里
packages/coding-agent/src/core/system-prompt.ts 导出 buildSystemPrompt,签名接受一个 BuildSystemPromptOptions。它不去发现资源:项目上下文文件的内容、技能列表、自定义提示词、工具说明,全部由调用方预先加载好、以纯数据的形式塞进来。函数内部唯一一处碰磁盘的地方,是解析 pi 自己那三个文档路径(下一节会讲),除此之外就是字符串拼接。签名长这样:
export interface BuildSystemPromptOptions {
/** Custom system prompt (replaces default). */
customPrompt?: string;
/** Tools to include in prompt. Default: [read, bash, edit, write] */
selectedTools?: string[];
/** Optional one-line tool snippets keyed by tool name. */
toolSnippets?: Record<string, string>;
/** Additional guideline bullets appended to the default system prompt guidelines. */
promptGuidelines?: string[];
/** Text to append to system prompt. */
appendSystemPrompt?: string;
/** Working directory. */
cwd: string;
/** Pre-loaded context files. */
contextFiles?: Array<{ path: string; content: string }>;
/** Pre-loaded skills. */
skills?: Skill[];
}
调用方是 packages/coding-agent/src/core/agent-session.ts 里的私有方法 _rebuildSystemPrompt。它做三件事:从工具注册表里捞出每个激活工具的一行说明和守则、从资源加载器里取出自定义提示词/追加文本/技能/上下文文件、把这些拼成 options 交给 buildSystemPrompt,同时把 options 本身存下来。
这个设计有个直接后果:系统提示词不是启动时算一次就完事的。setActiveToolsByName 会重新调 _rebuildSystemPrompt,扩展在运行中追加了技能或模板目录之后也会触发重建。你在会话中途关掉某个工具,提示词里对应的那行说明和守则会跟着消失。
二、固定段:默认正文里究竟写死了什么
不给 customPrompt 时,模板正文以这句开头:
You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.
后面接三块:Available tools: 列表、Guidelines: 列表,以及一段关于 pi 自身文档的指路。第三块值得单独说——它把 README、docs 目录、examples 目录的绝对路径写进了提示词,路径来自 src/config.ts 里的 getReadmePath()、getDocsPath()、getExamplesPath(),都是基于包目录解析出来的。指路那段还明确规定了触发条件:只在用户问 pi 本身、它的 SDK、扩展、主题、技能或 TUI 时才读,并且把具体话题映射到具体文件(扩展看 docs/extensions.md、技能看 docs/skills.md、提示词模板看 docs/prompt-templates.md,等等)。
这是个挺克制的做法:不把文档内容塞进上下文,只塞路径加检索条件,让模型按需自己去读。代价是模型必须有 read 类工具,而且得真的按条件触发。
Guidelines: 这块的组装逻辑比看上去绕。有一条条件规则:只有当 bash 在激活工具里、而 grep、find、ls 三个都不在时,才加上 Use bash for file operations like ls, rg, find。也就是说 pi 认为你既然装了专用检索工具,就不该再被引导去 bash 里 grep。随后追加调用方传进来的 promptGuidelines,最后恒定加两条:Be concise in your responses 和 Show file paths clearly when working with files。整个过程用一个 Set 去重,重复的守则不会出现两次。
三、动态段:六个替换位分别从哪填
把固定正文当骨架,剩下的都是运行时填进去的。下面这张表按你排查问题时会走的顺序列出来。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 默认正文 | 角色定义、工具清单骨架、守则骨架、pi 文档指路 | packages/coding-agent/src/core/system-prompt.ts | 想知道默认到底说了什么,或准备整块替换时 |
| 工具一行说明与守则 | 每个工具往提示词里贡献的 promptSnippet 与 promptGuidelines | packages/coding-agent/src/core/tools/(bash.ts、edit.ts、read.ts、write.ts、grep.ts、find.ts、ls.ts) | 自定义工具没被模型正确使用时 |
| 追加段 | --append-system-prompt 与 APPEND_SYSTEM.md 的文本 | src/core/resource-loader.ts 的 discoverAppendSystemPromptFile | 想加规矩但不想推翻默认提示词时 |
| 项目上下文文件 | AGENTS.md / CLAUDE.md 的内容,包进 <project_context> | src/core/resource-loader.ts 的 loadProjectContextFiles | 项目约定没生效、或想确认加载了哪几个文件时 |
| 技能清单 | 技能的名称、描述、文件路径,包进 <available_skills> | packages/agent/src/harness/system-prompt.ts 的 formatSkillsForSystemPrompt;应用层同名逻辑在 src/core/skills.ts 的 formatSkillsForPrompt | 技能死活不被触发时 |
| 当前工作目录 | 结尾一行 Current working directory: ... | system-prompt.ts 末尾,反斜杠会被换成正斜杠 | 跨平台路径行为存疑时 |
工具那一格有个容易忽略的规则,注释里写得很直白:A tool appears in Available tools only when the caller provides a one-line snippet.。代码里体现为对 selectedTools 做一次过滤,只留下 toolSnippets 里有值的名字。但守则不受这个过滤限制——只要工具在激活列表里,它的 promptGuidelines 就会进 Guidelines。所以完全可能出现”守则里提到某个工具,但工具清单里没有它”的情况。
上下文文件的拼法是每个文件包一层带路径属性的标签:
prompt += "\n\n<project_context>\n\n";
prompt += "Project-specific instructions and guidelines:\n\n";
for (const { path: filePath, content } of contextFiles) {
prompt += `<project_instructions path="${filePath}">\n${content}\n</project_instructions>\n\n`;
}
prompt += "</project_context>\n";
发现规则在 loadContextFileFromDir 里:每个目录按 AGENTS.md、AGENTS.MD、CLAUDE.md、CLAUDE.MD 的顺序找,命中第一个就返回,不会同时收一个目录下的两份。按官方文档的说法,加载来源是全局的 ~/.pi/agent/AGENTS.md、从当前目录往上走的各级父目录,以及当前目录。想彻底关掉用 --no-context-files 或 -nc。关于这类文件本身怎么写,可以对照 约定文件写法 那篇的思路。
技能那一段的格式在 packages/agent/src/harness/system-prompt.ts 里,走的是 XML 风格,并且对名称、描述、路径都做了实体转义(&、<、>、"、'):
const lines = [
"The following skills provide specialized instructions for specific tasks.",
"Read the full skill file when the task matches its description.",
"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
"",
"<available_skills>",
];
标记了 disableModelInvocation 的技能会被滤掉,不进提示词。这块设计的意图很清楚:系统提示词里只放索引,正文让模型自己 read 进来——所以代码里加了个前置条件,只有 read 工具可用时才拼技能段。
四、替换还是追加:两条路径的差别
buildSystemPrompt 开头有个分叉:customPrompt 一旦有值,函数直接走另一条短路径——拿 customPrompt 当正文,接上追加段、项目上下文、技能段、工作目录,然后返回。默认正文里的角色定义、工具清单、守则、文档指路全部不会出现。
自定义提示词有两个来源。命令行是 --system-prompt <text>,文件是 .pi/SYSTEM.md(项目级)或 ~/.pi/agent/SYSTEM.md(全局)。追加走的是 --append-system-prompt <text>(可以重复传,多段用空行连接)以及同两个位置的 APPEND_SYSTEM.md。
有个实用细节藏在 resolvePromptInput 里:命令行传进来的字符串,如果恰好是一个存在的路径,就会被当文件读;读不到就退回当字面文本用,只打一条警告。所以 --append-system-prompt ./rules.md 和 --append-system-prompt "写测试前先跑一遍" 走的是同一个参数。
项目级和全局级的优先级不对称:.pi/SYSTEM.md 只有在项目被信任时才会被选中,否则跳过去用全局那份。trust-manager.ts 里那份需要信任的资源清单写得很明白,SYSTEM.md 和 APPEND_SYSTEM.md 跟 settings.json、extensions、skills、prompts、themes 并列。而按官方安全文档的说法,AGENTS.md 和 CLAUDE.md 不受项目信任约束,除非你关掉上下文加载。这条差异对拿到陌生仓库就开跑的人是有意义的——顺带一提,这类”仓库自带文本进入模型上下文”的风险面,站内 提示注入防御 有单独展开。
还有第三条路径:扩展。before_agent_start 事件的回调可以返回 systemPrompt 字段替换掉这一轮的提示词,多个扩展之间是链式的,后面的能看到前面改过的结果。事件里同时给出 systemPromptOptions,也就是 pi 自己拿去构建提示词的那份结构化数据。会话对象内部用一个覆盖字段承接它,回调没返回时就重置回基础提示词——所以这种修改只对当前这一轮生效,不会粘着。
五、提示词模板:一套完全独立的机制
容易混淆的一点:pi 里的 prompt template 跟系统提示词不是一回事。模板是你在编辑器里敲 /name 时展开成一段用户提示的 Markdown 片段,文件名去掉 .md 就是命令名,review.md 对应 /review。它进的是对话消息,不进系统提示词。
加载位置按官方文档是:全局 ~/.pi/agent/prompts/*.md、项目 .pi/prompts/*.md(同样要项目被信任)、包里的 prompts/ 目录或 package.json 中的 pi.prompts 项、设置里的 prompts 数组、以及命令行 --prompt-template <path>(可重复)。关掉用 --no-prompt-templates。目录发现是非递归的,只认直接子级的 .md,子目录要显式加。
frontmatter 支持 description 和 argument-hint 两个键。description 省略时,加载器会拿正文第一个非空行顶上,超过 60 字符截断加省略号——这段兜底逻辑在 packages/agent/src/harness/prompt-templates.ts 的 loadTemplateFromFile 里。argument-hint 按文档约定用尖括号表示必填、方括号表示可选,只影响自动补全下拉里的显示。
参数替换是纯正则做的。packages/coding-agent/src/core/prompt-templates.ts 里的 substituteArgs 用一条正则同时处理默认值、切片和简单占位,支持 $1/$2、$@、$ARGUMENTS、${N:-default}、${@:-default}、${@:N}、${@:N:L}。注释里特意声明了一句:替换只在模板字符串上做一遍,参数值和默认值里如果含 $1、$@ 这类模式,不会被递归展开。参数切分由 parseCommandArgs 负责,支持单双引号包裹,所以 /component Button "click handler" 会被切成两个参数。
这里有个真实存在的分层差异:packages/agent/src/harness/prompt-templates.ts 里也有一份 substituteArgs,但它的实现只覆盖 $N、${@:N}、${@:N:L}、$ARGUMENTS、$@,没有 :- 默认值那一支。带默认值的写法是应用层实现的能力。你如果基于底层包自己搭 harness,别默认两边行为一样。
六、边界与代价:它明确不管的事
不做任何自动裁剪。 上下文文件有多大就原样拼多大,技能有多少条就列多少条,buildSystemPrompt 里没有任何长度检查、截断或摘要逻辑。你在 AGENTS.md 里塞一份三千行的规范,它会一字不落进系统提示词。压缩是会话另一侧的事,跟这里没关系——上下文该怎么分配,可以参考 上下文管理。
不做冲突仲裁。 全局 AGENTS.md 和项目 AGENTS.md 说了相反的话,两份都会进去,谁赢由模型决定。promptGuidelines 只做字符串级去重,语义上打架的两条守则会并排躺着。
替换是整块的,不是分块的。 没有”只换掉 Guidelines 这一节”的官方口子。想细粒度改,只有走扩展的 before_agent_start,自己拿 systemPromptOptions 重新拼一遍。
技能段和 read 工具强绑定。 关掉 read,技能清单直接不出现,没有告警。
跨平台只做了一件事:把 cwd 里的反斜杠换成正斜杠。上下文文件路径、技能路径进提示词时保持原样。
至于这套设计适不适合你,取决于你要的是可预测还是可调优。它把提示词做成了一个纯函数加几个明确的注入点,好处是任何一段文本你都能定位到来源;代价是没有内置的动态编排——不会根据任务类型换提示词,不会按 token 预算做取舍。这些要你自己在扩展层做。
七、上手与避坑清单
别用 SYSTEM.md 表达”再加一条规矩”。 会踩是因为名字看着像追加。实际它走的是替换分支,你的工具清单和守则会整块蒸发,模型可能突然开始用 cat 而不是 read 工具。加规矩用 APPEND_SYSTEM.md 或 --append-system-prompt。
项目里的 .pi/SYSTEM.md 没生效,先查信任状态。 会踩是因为文件明明在,语法也没错。但项目未被信任时这个路径整个跳过,退到全局那份,过程里没有醒目提示。命令行 --approve(-a)可以为这次运行信任项目本地文件。
自定义工具的说明没进提示词,先看有没有给 promptSnippet。 会踩是因为工具能调用、能执行,一切正常,只是模型不知道它存在。工具进 Available tools 的唯一条件就是有一行 snippet;promptGuidelines 是另一条通路,两者互不替代。
技能不触发,先确认 read 工具在不在激活列表里。 会踩是因为技能文件、frontmatter、目录位置都对,问题出在提示词根本没拼技能段。顺带查一下有没有误设 disableModelInvocation,那类技能只能显式调用。
别在模板参数里玩嵌套占位。 会踩是因为 bash 里习惯了变量展开。substituteArgs 明确声明不递归替换,你传进去的含 $1 的字符串会原样留在最终文本里。要动态内容就在模板正文里写清楚,让模型自己去查。
模板目录别建子文件夹。 会踩是因为文件多了自然想分类。目录发现是非递归的,子目录里的 .md 直接不被看见;要用就在设置的 prompts 数组里或包清单里逐个显式声明。
上下文文件别一个目录放两份。 会踩是因为 AGENTS.md 和 CLAUDE.md 同时存在时,按候选顺序只有前者被读,后者静默失效——你以为两份都生效了,实际改了个没人看的文件。
收束
想自己核一遍,按这个顺序读四个文件就够:packages/coding-agent/src/core/system-prompt.ts 看骨架和分叉,src/core/agent-session.ts 的 _rebuildSystemPrompt 看谁在什么时候调它,src/core/resource-loader.ts 看 SYSTEM.md、APPEND_SYSTEM.md、上下文文件各自的发现规则,src/core/prompt-templates.ts 看模板与参数替换。四个文件加起来不算长,读完你对”这句话为什么会出现在提示词里”就有了确定答案。
顺手做个自检:你的项目跑起来后,系统提示词里应该有几段?工具清单里有几个工具、是不是你以为的那几个?上下文文件加载了几个、路径对不对?技能列了几条?这四个数字如果你能不看界面就答出来,说明你真的掌握了这套拼装逻辑;答不出来,多半是某个静默失效的口子在等着你。
本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的上下文压缩 和 开源编程 Agent pi 的技能机制。