学习 Pi Coding Agent:系统提示词与工具设计深度解析

3 阅读26分钟

学习 Pi Coding Agent:系统提示词与工具设计深度解析

📍 导航指南

根据你的背景,选择合适的阅读路径:


目录

第一部分:系统提示词解析 🧠

第二部分:工具描述特点 🔧

第三部分:代码细节分析 💻

附录


引言

在尝试构建我的 learn-claude-code 智能体时,我最初选择了业界主流的 LangChain。然而,我很快就撞到了“智力天花板”:即便使用了最强的模型,智能体在处理我布置的任务时依然显得笨拙——它经常无法准确理解我的意图,在工具调用中反复打转,或者干脆因为逻辑顺序混乱而罢工。

我曾一度怀疑是否模型的能力尚不足以支撑复杂的编码任务。直到我将同样的模型迁移到了 Pi 框架下。

令我惊讶的是,原本在 LangChain 中“表现平平”的模型,在 Pi 的环境下仿佛突然开启了智力加速。它开始能够精准捕捉意图,流畅地执行多步任务,并高质量地完成我交给它的每一项挑战。

这次转变让我意识到:瓶颈往往不在于模型本身的“智力”,而在于框架的设计哲学。

深入分析后,我发现核心症结在于 “过度控制”“引导不足” 的错位:

  1. 框架约束 vs. 自主执行:硬编码的限制(如 max_iterations)往往成了复杂任务的绊脚石。
  2. 模糊定义 vs. 决策边界:不够严密的提示词和描述,让模型在长上下文环境下迷失了方向。
  3. 粗放实现 vs. 精巧设计:工具层面的防御不足,将过多的复杂度转嫁给了模型。

其中,“框架约束”的差异最为典型:

LangChain 倾向于**“设围栏”**,通过 max_iterations 强制截断 Agent Loop 以防止死循环:

# LangChain:基于外部计数器的“防守型”设计
agent = AgentExecutor(
    agent=...,
    tools=tools,
    max_iterations=10,             # 硬上限:10 次工具调用
    early_stopping_method="force"  # 强制截断,无论任务是否处于“半成品”状态
)

这种设计在处理重构、多文件迁移等复杂任务时显得力不从心。调小了容易强制中断,调大了又难以真正解决死循环的焦虑。

相比之下,Pi 的设计思路是 “给地图”,信任模型并在逻辑上彻底放权,靠引导而非限制来终止循环

// Pi agent-loop.ts:基于模型意图的“内生型”终止
while (hasMoreToolCalls || pendingMessages.length > 0) {
    const message = await streamAssistantResponse(...);

    // 模型决定不再调用工具时(返回纯文本),循环自然退出
    const toolCalls = message.content.filter(c => c.type === "toolCall");
    hasMoreToolCalls = toolCalls.length > 0;
}

这种设计哲学将重心从“如何限制模型”转向了“如何引导模型”。当模型判断任务完成,它会主动通过回复纯文本来结束对话。

那么,在没有任何硬性死循环保护的情况下,Pi 是如何通过系统提示词与工具描述,让模型始终保持在正确轨道上的? 这正是本文要拆解的核心秘密。

Pi coding-agent 是一个由 @mariozechner 开发的轻量级 AI 编码助手框架。其核心设计哲学在于:用最少的代码实现最核心的功能


第一部分:系统提示词解析 🧠

提示词的三层结构

Pi 的系统提示词由三层构成,顺序本身就是设计:

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:
- read: Read file contents
- bash: Execute bash commands
...

Guidelines:
- Prefer grep/find/ls tools over bash for file exploration
- Use read to examine files before editing
...
第一层:角色定义 (Identity)
"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."

逐词分析:

  • You are → 直接定义身份,比 "Act as" 更确定,模型不会把自己当成在"扮演"角色
  • expert → 设定能力预期,模型在做决策时会更自信,减少不必要的犹豫和确认
  • coding assistant → 限定领域是编码,不是通用助手,缩小了模型的行为范围
  • operating inside pi → 告知运行环境,模型知道自己在一个特定框架内,会优先使用框架提供的工具
  • a coding agent harness → 解释 pi 是什么,消除歧义,"harness" 暗示这是一个工具集成框架
  • reading files, executing commands, editing code, and writing new files → 列举四个核心能力,与后面的工具列表形成呼应,让模型提前建立"我能做什么"的认知

小结:角色定义用两句话完成了三件事:确定身份、限定领域、告知环境。放在提示词最前面,是因为 LLM 的注意力对开头内容权重最高。

💡 通俗点说:大模型在处理长文本时,并不是“一视同仁”地阅读,而是像人一样有侧重点。它对文本开头和结尾的内容通常记得最牢,理解得最深刻。因此,将核心身份放在最前面,能确保模型在整个对话过程中始终不忘自己的“人设”。

第二层:工具列表 (Available Tools)
Available tools:
- read: Read file contents
- bash: Execute bash commands (ls, grep, find, etc.)
- edit: Make surgical edits to files (find exact text and replace)
- write: Create or overwrite files
- grep: Search file contents for patterns (respects .gitignore)
- find: Find files by glob pattern (respects .gitignore)
- ls: List directory contents

逐句分析:

read: Read file contents

  • "Read file contents" 动词开头,直接说功能,没有废话
  • 没有说"Read text files",暗示也支持其他类型(实际上支持图片)

bash: Execute bash commands (ls, grep, find, etc.)

  • 括号内举了三个例子,帮助模型理解 bash 的典型使用场景
  • 但这三个例子恰好都有专用工具(grep/find/ls),配合 Guidelines 的 "Prefer" 规则,引导模型优先用专用工具

edit: Make surgical edits to files (find exact text and replace)

  • "surgical"(外科手术式)是核心词,暗示精确、最小改动,不是大范围替换
  • 括号内说明工作机制:"找到精确文本再替换",模型知道怎么用之前就知道约束是什么

write: Create or overwrite files

  • "or overwrite" 明确了破坏性,提醒模型 write 会覆盖整个文件,谨慎使用
  • 对比 edit 的"surgical",write 的描述暗示了更大的影响范围

grep: Search file contents for patterns (respects .gitignore) find: Find files by glob pattern (respects .gitignore)

  • 两个工具都标注了 "respects .gitignore",告知模型不会搜索 node_modules、.git 等目录
  • 模型知道这一点后,就不会期望在这些目录中找到结果,避免误判

ls: List directory contents

  • 最简单的描述,功能单一,不需要额外说明

小结:工具列表的每条描述都在做两件事:说清楚能做什么,同时暗示什么时候该用、什么时候不该用。从注意力机制的角度看,工具描述是模型在选择工具时的"检索锚点"——描述越精准,模型匹配到正确工具的概率越高,错误调用越少。

第三层:行为准则 (Guidelines)
Guidelines:
- Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)
- Use read to examine files before editing. You must use this tool instead of cat or sed.
- Use edit for precise changes (old text must match exactly)
- Use write only for new files or complete rewrites
- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did
- Be concise in your responses
- Show file paths clearly when working with files

逐句分析:

Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)

  • "Prefer X over Y" 给出优先级而不是禁止,模型在边界情况下仍有灵活性,不会卡住
  • "for file exploration" 限定了场景,bash 在其他场景(如运行测试、安装依赖)仍然可用
  • 括号内给出两个理由 "faster, respects .gitignore",模型理解"为什么"后执行更准确,而不是机械遵守

Use read to examine files before editing. You must use this tool instead of cat or sed.

  • 两句话,第一句说流程(先读后改),第二句说禁止(不能用 cat/sed)
  • "You must" 是整个 Guidelines 里唯一的强制语气,专门针对模型最常见的错误:用 bash 的 cat 读文件
  • "instead of cat or sed" 点名了具体的替代行为,不给模型留歧义空间

Use edit for precise changes (old text must match exactly)

  • "precise changes" 呼应工具描述里的 "surgical",强化精确改动的概念
  • 括号内的约束 "old text must match exactly" 是操作前提,模型选择 edit 时会立即想起这个限制,减少因模糊匹配导致的报错

Use write only for new files or complete rewrites

  • "only" 明确边界,防止模型用 write 做小改动(会覆盖整个文件,丢失其他内容)
  • "complete rewrites" 给出了 write 的合理使用场景,不是简单禁止

When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did

  • 针对一个具体场景:任务完成后的总结输出
  • "do NOT"(大写)强调,因为模型很容易在总结时用 bash echo 或 cat 来"展示"结果
  • "plain text directly" 告诉模型正确做法,不只是说不能做什么

Be concise in your responses

  • 控制输出长度,减少 token 消耗,也让用户更容易阅读

Show file paths clearly when working with files

  • 可读性要求,用户能追踪模型操作了哪些文件,方便 review 和调试

小结:Guidelines 的 7 条规则覆盖了三个层面:工具选择优先级(1)、工具使用流程(2-4)、输出格式(5-7)。顺序符合模型的思考流程。从注意力机制看,每条规则都给出了"为什么"而不只是"不许做什么"——理由本身会成为模型的注意力锚点,让规则在更多上下文中被正确激活。

写好系统提示词的技巧总结

通过对 Pi 系统提示词的逐句分析,可以提炼出以下可直接复用的写作技巧:

1. 结构顺序:Identity → Tools → Guidelines → Context

先定义身份,再列工具,再给规则,最后放动态上下文。这个顺序让模型在读到规则时,已经建立了"我是谁、我有什么能力"的认知框架,规则才能被正确理解和执行。

2. 角色定义要包含三个要素

"You are an expert coding assistant operating inside pi, a coding agent harness."
         ↑ 能力预期        ↑ 领域限定              ↑ 运行环境
  • 能力预期(expert):让模型更自信地做决策
  • 领域限定(coding assistant):缩小行为范围,减少跑偏
  • 运行环境(operating inside pi):告知上下文,影响工具选择

3. 工具描述用"动词 + 对象 + 关键约束"结构

read:  Read file contents
edit:  Make surgical edits to files (find exact text and replace)
write: Create or overwrite files
  • 动词开头,直接说功能
  • 括号内补充机制或约束,不打断主句
  • 关键词选择要暗示使用边界("surgical" 暗示精确,"overwrite" 暗示破坏性)

4. 规则用 "Prefer X over Y" 而不是 "Don't use Y"

"Prefer grep/find/ls tools over bash for file exploration""Don't use bash for file exploration"

"Prefer" 给出优先级,模型在边界情况下仍有灵活性,不会卡住。"Don't" 过于绝对,遇到没有专用工具的场景时模型会犹豫。

5. 规则要给出理由,不只是命令

"Prefer grep/find/ls over bash (faster, respects .gitignore)""Prefer grep/find/ls over bash"

理由让模型理解"为什么",在新场景下能举一反三,而不是机械遵守。

6. 强制规则用 "must",但只用一次

"You must use this tool instead of cat or sed."

整个 Guidelines 只有一处 "must",专门针对最常见的错误行为。如果每条规则都用 "must",强调效果会被稀释。

7. 括号是补充细节的最佳位置

"Use edit for precise changes (old text must match exactly)"
"Execute bash commands (ls, grep, find, etc.)"
"Search file contents for patterns (respects .gitignore)"

括号内的信息不打断主句的流畅性,但会在模型选择该工具时被激活,成为决策的一部分。

8. 动态上下文放最后

Current date: {date}
Current working directory: {cwd}

日期和路径是每次构建时才确定的动态信息,放在最后追加。靠近用户的第一条消息,在对话开始时有位置优势。随着对话推进这个优势会减弱,但这两条信息本身也不需要持续高权重。

9. 利用自注意力的位置效应:开头和结尾权重最高

LLM 的自注意力机制对 token 序列的开头和结尾有更高的关注度(primacy effect + recency effect)。Pi 的提示词结构正是利用了这一点:

💡 通俗点说:大模型在处理信息时,往往对“第一句话”和“最后一句话”印象最深。就像我们读长文章,开头定调,结尾收官,中间的细节反而容易被模糊处理。

[开头] 角色定义 + 工具列表   ← 高权重区,放核心身份和能力
[中间] Guidelines            ← 权重相对低,放详细规则
[结尾] 日期 + 工作目录       ← 高权重区,放动态上下文

实践建议:

  • 最重要的约束放开头(角色、能力边界)
  • 次要的规则放中间
  • 每次对话都需要感知的动态信息放结尾

10. 利用自注意力的关键词激活:用强语义词触发正确行为

自注意力会在相似语义的 token 之间建立关联。Pi 在工具描述和 Guidelines 中刻意使用强语义词,让两者形成呼应,强化模型的行为:

💡 通俗点说:这就像给模型设置了“关键词提醒”。当一个强有力的词(如“手术式”)多次出现时,模型在做每一个决策时都会联想到这个词背后的约束条件,从而更精准地执行指令。

工具描述:  "Make surgical edits""surgical" 激活"精确"语义
Guidelines:"Use edit for precise changes""precise""surgical" 形成关联

工具描述:  "Create or overwrite files""overwrite" 激活"破坏性"语义
Guidelines:"Use write only for complete rewrites""rewrites" 强化边界

同一个概念在提示词中出现两次(工具描述 + Guidelines),模型在选择工具时两处都会被激活,约束更稳定。

11. 利用自注意力的稀释效应:强调词要节制使用

自注意力的权重是相对的——如果所有规则都用强调词,每条规则的权重反而会被平均化,失去区分度:

❌ 稀释效应
You MUST use read. You MUST use edit. You MUST NOT use bash. You MUST be concise.
→ 每条规则权重相同,模型无法判断哪个更重要

✅ Pi 的做法
普通规则用 "Prefer" / "Use" / "Be"
只有最关键的一条用 "must""You must use this tool instead of cat or sed."
→ 这条规则在所有规则中权重最高,模型会优先遵守

第二部分:工具描述特点 🔧

每个工具有两层描述:工具级描述(告诉模型这个工具是什么)和参数级描述(告诉模型每个参数怎么填)。两层描述共同决定模型能不能正确使用工具。

工具列表的排序逻辑

工具顺序按使用频率从高到低排列:

高频工具(核心操作)
├── read  → 最常用,几乎每个任务都要读文件
├── bash  → 次常用,执行各种命令
├── edit  → 高频,修改代码
└── write → 中频,创建新文件

专用工具(特定场景)
├── grep  → 搜索场景
├── find  → 查找场景
└── ls    → 浏览场景

从自注意力的角度看,工具列表靠前的条目权重更高。把高频工具放前面,模型更容易"找到"它们,减少选错工具的概率。

逐条工具描述分析

read

工具描述:

Read the contents of a file. Supports text files and images (jpg, png, gif, webp).
Images are sent as attachments. For text files, output is truncated to 2000 lines
or 512KB (whichever is hit first). Use offset/limit for large files.
When you need the full file, continue with offset until complete.

参数描述:

path:   "Path to the file to read (relative or absolute)"
offset: "Line number to start reading from (1-indexed)"
limit:  "Maximum number of lines to read"

逐句分析:

  • Supports text files and images (jpg, png, gif, webp) → 主动告知支持范围,模型不会因为"不确定能不能读图片"而选择其他方式
  • Images are sent as attachments → 说明图片的返回方式,模型知道图片不会以文本形式返回,不会尝试解析乱码
  • output is truncated to 2000 lines or 512KB → 明确截断上限,模型知道大文件会被截断,不会误以为读到的就是完整内容
  • Use offset/limit for large files → 直接告诉模型遇到大文件该怎么做,不需要模型自己推断
  • When you need the full file, continue with offset until complete → 给出完整读取的操作模式,模型知道可以"翻页"

参数描述:

  • offset: "Line number to start reading from (1-indexed)" → "(1-indexed)" 消除歧义,模型不会从 0 开始传
  • limit: "Maximum number of lines to read" → "Maximum" 说明是上限,不是固定行数

小结:read 的描述解决了三个潜在问题:不知道支持图片、不知道会截断、不知道怎么读大文件。每句话都在防止一种具体的误用。


bash

工具描述:

Execute a bash command in the current working directory. Returns stdout and stderr.
Output is truncated to last 2000 lines or 512KB (whichever is hit first).
If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.

参数描述:

command: "Bash command to execute"
timeout: "Timeout in seconds (optional, no default timeout)"

逐句分析:

  • in the current working directory → 告知执行上下文,模型知道相对路径是相对于 cwd,不会写错路径
  • Returns stdout and stderr → 明确返回内容,模型知道错误信息也会被捕获,不需要额外重定向 stderr
  • truncated to last 2000 lines → "last"(尾部截断)是关键词,模型知道保留的是最后的输出——对于编译错误这类场景,最后的输出最重要
  • If truncated, full output is saved to a temp file → 告知截断后的补救方案,模型知道可以去读临时文件获取完整输出

参数描述:

  • timeout: "Timeout in seconds (optional, no default timeout)" → "no default timeout" 明确说明没有默认值,模型不会假设有超时保护

小结:bash 描述的核心是"告知副作用和边界":执行位置、返回内容、截断方式、超时行为。每一条都在消除模型的不确定性。


edit

工具描述:

Edit a file by replacing exact text. The oldText must match exactly (including whitespace).
Use this for precise, surgical edits.

参数描述:

path:    "Path to the file to edit (relative or absolute)"
oldText: "Exact text to find and replace (must match exactly)"
newText: "New text to replace the old text with"

逐句分析:

  • by replacing exact text → 直接说明工作机制,模型知道这不是行号替换,而是文本匹配替换
  • must match exactly (including whitespace) → 括号内强调空白字符也要精确匹配,这是最常见的失败原因
  • precise, surgical edits → "surgical" 再次出现(系统提示词里也有),通过关键词重复强化"精确改动"的语义

参数描述:

  • oldText: "Exact text to find and replace (must match exactly)" → 工具描述和参数描述都强调 "must match exactly",双重强调,模型在填写 oldText 时会更仔细

小结:edit 的描述在工具级和参数级都重复了 "must match exactly",利用自注意力的关键词激活,让这个约束在模型选择和使用工具时都被触发。


write

工具描述:

Write content to a file. Creates the file if it doesn't exist, or overwrites it if it does.
Use this for new files or complete rewrites, not for small edits (use edit instead).

参数描述:

path:    "Path to the file to write (relative or absolute)"
content: "Content to write to the file"

逐句分析:

  • Creates the file if it doesn't exist, or overwrites it if it does → 明确两种场景的行为,模型不会对"文件已存在时会怎样"产生疑问
  • Use this for new files or complete rewrites → 正面说明适用场景
  • not for small edits (use edit instead) → 括号内直接指向替代工具,模型在做小改动时会被引导去用 edit

小结:write 的描述主动划定了边界,并且指向了替代工具。这种"不适合用 X,请用 Y"的写法比单纯禁止更有效,因为给了模型明确的下一步。


grep

工具描述:

Search file contents for a pattern. Returns matching lines with file paths and line numbers.
Respects .gitignore. Output is truncated to 100 matches or 512KB (whichever is hit first).
Long lines are truncated to 2000 chars.

参数描述:

pattern:     "Search pattern (regex or literal string)"
path:        "Directory or file to search (default: current directory)"
glob:        "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'"
ignoreCase:  "Case-insensitive search (default: false)"
fixedString: "Treat pattern as literal string instead of regex (default: false)"
context:     "Number of lines to show before and after each match (default: 0)"
limit:       "Maximum number of matches to return (default: 100)"

逐句分析:

  • Returns matching lines with file paths and line numbers → 明确返回格式,模型知道结果包含位置信息,可以直接用于后续操作
  • Respects .gitignore → 行为边界,模型不会期望在 node_modules 里找到结果
  • Long lines are truncated to 2000 chars → 针对 minified 文件的特殊保护,防止单行撑爆上下文

参数描述:

  • pattern: "Search pattern (regex or literal string)" → 告知支持两种模式,模型知道可以用正则
  • glob: "Filter files by glob pattern, e.g. '*.ts'" → 举例说明格式,消除歧义
  • fixedString: "Treat pattern as literal string instead of regex" → 解释了为什么需要这个参数:有时候搜索内容本身包含正则特殊字符
  • context: "Number of lines to show before and after each match" → 说明了参数的作用方向(前后各 N 行),不会误解为只看后面

小结:grep 的参数最多,每个参数描述都在解释"为什么需要这个参数",而不只是说"这个参数是什么"。


find

工具描述:

Search for files by glob pattern. Returns matching file paths relative to the search directory.
Respects .gitignore. Output is truncated to 1000 results or 512KB (whichever is hit first).

参数描述:

pattern: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'"
path:    "Directory to search in (default: current directory)"
limit:   "Maximum number of results (default: 1000)"

逐句分析:

  • Returns matching file paths relative to the search directory → 明确返回的是相对路径,模型不会误以为是绝对路径
  • Respects .gitignore → 与 grep 一致,行为边界说明

参数描述:

  • pattern: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'" → 举了三个例子,覆盖了简单匹配、递归匹配、路径前缀匹配三种常见场景,模型不需要猜 glob 语法

小结:find 和 grep 的描述结构对称,模型通过对比就能理解区别:grep 搜内容返回行,find 搜文件名返回路径。


ls

工具描述:

List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories.
Includes dotfiles. Output is truncated to 500 entries or 512KB (whichever is hit first).

参数描述:

path:  "Directory to list (default: current directory)"
limit: "Maximum number of entries to return (default: 500)"

逐句分析:

  • with '/' suffix for directories → 告知输出格式,模型能区分文件和目录,不需要额外判断
  • Includes dotfiles → 主动说明包含隐藏文件,模型不会因为看不到 .env 而误以为不存在
  • truncated to 500 entries → 截断上限,模型知道大目录会被截断

小结:ls 的描述虽然简短,但每句话都在消除一个潜在疑问:目录怎么标识、隐藏文件包不包含、会不会截断。


工具描述的整体设计规律

1. 工具描述 = 功能 + 行为边界 + 使用建议

功能:    "Search file contents for a pattern"
行为边界:"Respects .gitignore. Output truncated to 100 matches"
使用建议:"Use offset/limit for large files"

2. 参数描述 = 是什么 + 默认值 + 消除歧义

是什么:  "Number of lines to show before and after each match"
默认值:  "(default: 0)"
消除歧义:"(1-indexed)" / "(regex or literal string)"

3. 关键约束在工具描述和参数描述中重复出现

edit 工具描述:  "must match exactly (including whitespace)"
edit oldText 参数:"must match exactly"
→ 双重强调,利用自注意力关键词激活,约束更稳定

4. 主动说明"不适合的场景"并指向替代工具

write: "not for small edits (use edit instead)"
→ 比单纯禁止更有效,给了模型明确的下一步

第三部分:代码细节分析 💻

选取 edit 工具作为典型案例。它是最容易出错的工具——模型需要提供精确的 oldText,任何细微差异都会导致失败。Pi 的实现通过多层防御,把大量潜在的 bug 消灭在工具内部,而不是让模型去处理。

edit 工具的完整执行流程

用户调用 edit(path, oldText, newText)
         ↓
1. 文件存在性检查
         ↓
2. 读取文件 → Buffer → UTF-8 字符串
         ↓
3. 剥离 BOM
         ↓
4. 统一行尾符为 LF
         ↓
5. 精确匹配 oldText
         ↓ 失败
6. 模糊匹配(normalizeForFuzzyMatch)
         ↓ 失败
   → 报错:文本未找到
         ↓ 成功
7. 唯一性检查(出现多次则报错)
         ↓
8. 执行替换
         ↓
9. 检查替换是否有实际变化
         ↓
10. 恢复原始行尾符
         ↓
11. 写回文件(保留 BOM)
         ↓
12. 返回 diff 给模型

每一步都在消灭一类 bug,下面逐层分析。


第 1 层:文件存在性检查

try {
    await ops.access(absolutePath);
} catch {
    reject(new Error(`File not found: ${path}`));
    return;
}

消灭的 bug:模型有时会传入不存在的路径(拼写错误、路径推断错误)。提前检查并报错,而不是等到读取时才失败,错误信息更清晰:File not found: src/foo.ts 比底层 IO 错误更容易让模型理解和纠正。


第 2 层:读取文件为 Buffer

const buffer = await ops.readFile(absolutePath);
const rawContent = buffer.toString("utf-8");

设计细节:读取为 Buffer 而不是直接读字符串,是为了在第 3 层手动处理 BOM。如果用 fs.readFile(path, 'utf-8') 直接读字符串,Node.js 不会自动剥离 BOM,后续匹配会出问题。


第 3 层:剥离 BOM

const { bom, text: content } = stripBom(rawContent);
// stripBom: content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content }

消灭的 bug:Windows 上创建的文件可能带有 UTF-8 BOM(\uFEFF)。模型读文件时看不到这个不可见字符,生成的 oldText 自然不包含它。如果不剥离,精确匹配会失败。

注意:BOM 被单独保存,写回时会还原(bom + restoreLineEndings(newContent, ...)),不会破坏原文件格式。


第 4 层:统一行尾符

const originalEnding = detectLineEnding(content);   // 记录原始行尾符
const normalizedContent = normalizeToLF(content);   // 统一为 LF
const normalizedOldText = normalizeToLF(oldText);   // oldText 也统一
const normalizedNewText = normalizeToLF(newText);   // newText 也统一

消灭的 bug:Windows 文件用 \r\n,Unix 用 \n。模型生成的 oldText 通常是 \n,如果文件是 \r\n,精确匹配会失败。统一为 LF 后再匹配,消除跨平台差异。

写回时通过 restoreLineEndings(newContent, originalEnding) 恢复原始行尾符,文件格式不变。


第 5-6 层:精确匹配 → 模糊匹配

export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResult {
    // 先尝试精确匹配
    const exactIndex = content.indexOf(oldText);
    if (exactIndex !== -1) {
        return { found: true, usedFuzzyMatch: false, contentForReplacement: content, ... };
    }

    // 精确匹配失败,尝试模糊匹配
    const fuzzyContent = normalizeForFuzzyMatch(content);
    const fuzzyOldText = normalizeForFuzzyMatch(oldText);
    const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
    ...
}

normalizeForFuzzyMatch 做了什么:

text
  .normalize("NFKC")                    // Unicode 标准化
  .split("\n").map(l => l.trimEnd())    // 去掉每行末尾空格
  .join("\n")
  .replace(/[\u2018\u2019...]/g, "'")  // 智能引号 → 普通引号
  .replace(/[\u2013\u2014...]/g, "-")  // 各种破折号 → 普通连字符
  .replace(/[\u00A0\u2002...]/g, " ")  // 各种特殊空格 → 普通空格

消灭的 bug

场景原因模糊匹配如何处理
行尾有多余空格编辑器自动添加trimEnd() 去掉
智能引号 "hello"文档粘贴进来统一为 "hello"
破折号 vs -不同输入法统一为 -
不间断空格 \u00A0HTML 复制统一为普通空格
Unicode 全角字符中文输入法NFKC 标准化

这些都是模型无法感知的差异——模型看到的是渲染后的文本,实际文件里可能是不同的 Unicode 字符。


第 7 层:唯一性检查

const occurrences = fuzzyContent.split(fuzzyOldText).length - 1;
if (occurrences > 1) {
    reject(new Error(
        `Found ${occurrences} occurrences of the text in ${path}.
         The text must be unique. Please provide more context to make it unique.`
    ));
}

消灭的 bug:如果 oldText 在文件中出现多次,替换哪一处是歧义的。Pi 的选择是直接报错,并在错误信息里告诉模型怎么修复:"Please provide more context to make it unique"。

这个错误信息本身就是给模型的指令——模型收到后知道应该扩大 oldText 的范围,包含更多上下文。


第 9 层:空替换检查

if (baseContent === newContent) {
    reject(new Error(
        `No changes made to ${path}. The replacement produced identical content.`
    ));
}

消灭的 bugoldText === newText 的情况,或者模糊匹配后替换内容实际相同。防止模型误以为修改成功,实际上什么都没变。


第 12 层:返回 diff 给模型

const diffResult = generateDiffString(baseContent, newContent);
resolve({
    content: [{ type: "text", text: `Successfully replaced text in ${path}.` }],
    details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine }
});

diff 格式:

-3  const x = 1;
+3  const x = 2;

设计意图:模型收到 diff 后可以验证修改是否符合预期。如果改错了,模型可以立即发现并纠正,不需要再读一遍文件。


整体设计思路总结

edit 工具的防御设计体现了一个核心原则:把模型的常见错误转化为工具内部的容错逻辑,而不是依赖模型自己避免错误

模型的常见错误          →  工具的对应处理
─────────────────────────────────────────
不知道文件有 BOM        →  自动剥离 BOM
不知道行尾符差异        →  统一为 LF 再匹配
行尾有多余空格          →  模糊匹配容忍
智能引号/特殊字符       →  Unicode 标准化
oldText 不唯一         →  报错 + 告知如何修复
替换后无变化            →  报错提示

错误信息也是精心设计的——不只是说"失败了",而是告诉模型为什么失败怎么修复,让模型能自主纠错,减少人工干预。


常见问题 FAQ

Q: 为什么 Pi 的系统提示词这么短?

A: 短提示词的优势:

  • 减少 token 消耗(每次调用都要传)
  • 降低模型的认知负担
  • 更容易维护和调试

Pi 的哲学是:把复杂度放在工具实现里,而不是提示词里

Q: edit 工具的模糊匹配会不会导致误替换?

A: Pi 的设计是:先尝试精确匹配,只有失败时才用模糊匹配。而且模糊匹配只是 whitespace normalization,不会改变实际内容。

Q: 为什么没有 undo 功能?

A: Pi 假设用户在 Git 仓库中工作,可以用 git diffgit checkout 撤销。这是"依赖外部工具"而不是"重新发明轮子"的设计哲学。

Q: 7 个工具够用吗?

A: 对于 80% 的编码任务够用。Pi 的设计是"最小可用集",用户可以根据需要添加更多工具。

Q: 工具描述中的 "Respects .gitignore" 有什么用?

A: 这告诉模型:grep/find 不会搜索 node_modules、.git 等被忽略的目录。模型知道这一点后,就不会期望在这些目录中找到结果。


📝 结语

本文从三个层面拆解了 Pi coding-agent 的设计:

第一部分:系统提示词 — 三层结构(Identity → Tools → Guidelines → Context)不是随意排列,而是基于 LLM 自注意力的位置效应。角色定义放开头获得最高权重,动态上下文放结尾靠近用户消息,Guidelines 放中间。每条规则都给出理由而不只是命令,"must" 只用一次避免稀释,关键词在工具描述和 Guidelines 中重复出现形成语义呼应。

第二部分:工具描述 — 每个工具有两层描述:工具级说明功能和行为边界,参数级消除歧义和说明默认值。描述复杂度与工具复杂度匹配,括号专门用于补充约束,关键约束(如 "must match exactly")在工具描述和参数描述中双重出现,利用自注意力关键词激活强化约束。

第三部分:代码实现 — 以 edit 工具为例,12 层执行流程中每一层都在消灭一类模型常见错误:BOM 剥离、行尾符统一、模糊匹配(智能引号/特殊空格/Unicode 标准化)、唯一性检查、空替换检查、返回 diff 供模型自验证。错误信息不只说"失败了",而是告诉模型为什么失败和怎么修复。

核心思路:

系统提示词  → 利用注意力机制引导模型行为
工具描述    → 用精准语言消除模型的不确定性
代码实现    → 把模型的常见错误转化为工具内部的容错逻辑

三层设计共同作用,让模型在不需要"更聪明"的情况下,更准确地完成任务。

如果你在构建自己的 AI Agent:

  1. 系统提示词用三层结构,核心内容放开头,动态上下文放结尾
  2. 工具描述 = 功能 + 行为边界 + 使用建议,关键约束重复出现
  3. 参数描述消除歧义,说明默认值,举例说明格式
  4. 工具实现把模型的常见错误变成容错逻辑,错误信息告诉模型怎么修复
  5. 强调词节制使用,"must" 只用在最关键的一条规则上

参考资源: