开源编程 Agent pi 的系统提示词是怎么拼出来的:固定段、动态段与替换位

2026-07-29

本文基于 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 responsesShow file paths clearly when working with files。整个过程用一个 Set 去重,重复的守则不会出现两次。

三、动态段:六个替换位分别从哪填

把固定正文当骨架,剩下的都是运行时填进去的。下面这张表按你排查问题时会走的顺序列出来。

组成部分它负责什么对应仓库位置你什么时候会碰到它
默认正文角色定义、工具清单骨架、守则骨架、pi 文档指路packages/coding-agent/src/core/system-prompt.ts想知道默认到底说了什么,或准备整块替换时
工具一行说明与守则每个工具往提示词里贡献的 promptSnippetpromptGuidelinespackages/coding-agent/src/core/tools/(bash.ts、edit.ts、read.ts、write.ts、grep.ts、find.ts、ls.ts)自定义工具没被模型正确使用时
追加段--append-system-promptAPPEND_SYSTEM.md 的文本src/core/resource-loader.tsdiscoverAppendSystemPromptFile想加规矩但不想推翻默认提示词时
项目上下文文件AGENTS.md / CLAUDE.md 的内容,包进 <project_context>src/core/resource-loader.tsloadProjectContextFiles项目约定没生效、或想确认加载了哪几个文件时
技能清单技能的名称、描述、文件路径,包进 <available_skills>packages/agent/src/harness/system-prompt.tsformatSkillsForSystemPrompt;应用层同名逻辑在 src/core/skills.tsformatSkillsForPrompt技能死活不被触发时
当前工作目录结尾一行 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.mdAGENTS.MDCLAUDE.mdCLAUDE.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.mdAPPEND_SYSTEM.mdsettings.jsonextensionsskillspromptsthemes 并列。而按官方安全文档的说法,AGENTS.mdCLAUDE.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 支持 descriptionargument-hint 两个键。description 省略时,加载器会拿正文第一个非空行顶上,超过 60 字符截断加省略号——这段兜底逻辑在 packages/agent/src/harness/prompt-templates.tsloadTemplateFromFile 里。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.mdCLAUDE.md 同时存在时,按候选顺序只有前者被读,后者静默失效——你以为两份都生效了,实际改了个没人看的文件。

收束

想自己核一遍,按这个顺序读四个文件就够:packages/coding-agent/src/core/system-prompt.ts 看骨架和分叉,src/core/agent-session.ts_rebuildSystemPrompt 看谁在什么时候调它,src/core/resource-loader.tsSYSTEM.mdAPPEND_SYSTEM.md、上下文文件各自的发现规则,src/core/prompt-templates.ts 看模板与参数替换。四个文件加起来不算长,读完你对”这句话为什么会出现在提示词里”就有了确定答案。

顺手做个自检:你的项目跑起来后,系统提示词里应该有几段?工具清单里有几个工具、是不是你以为的那几个?上下文文件加载了几个、路径对不对?技能列了几条?这四个数字如果你能不看界面就答出来,说明你真的掌握了这套拼装逻辑;答不出来,多半是某个静默失效的口子在等着你。

本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的上下文压缩开源编程 Agent pi 的技能机制

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。