夜雨聆风学习资料网

ARTICLE · 1123226

Pi 源码拆解:AI 说“改好了”,文件到底是谁改的?

Pi 源码拆解:AI 说“改好了”,文件到底是谁改的?

给 AI 一句话:“把 App.vue 的标题从 Hello 改成你好,其他文件别动。”

它先说“我看看文件”,过了一会儿又说“改好了”。你打开编辑器,标题真的变了。

这段过程很自然,但仔细想一下:模型不是在生成文字吗?文件是谁打开的?修改是谁写进去的?AI 又凭什么知道自己改成功了?

我读 Pi 源码时,最想弄清楚的就是这几步。我们不妨跟着一次修改 App.vue 的小任务,看一条回答怎么变成真正的文件操作。

先记住这两个角色的分工:模型根据已有信息提出下一步,Pi 负责调用工具,再把结果交回模型。

本文对照 Pi v1.0.0 源码,先从一次改标题的小任务讲起。

先把“改标题”这件事完整走一遍

为了看清过程,我们先讨论一条最简单的成功路径:读文件、改标题、回复用户。

第一次问模型:现在应该做什么?

Pi 把你的需求和可用工具的说明交给模型。

文件内容还没有提供给模型,所以它可能先提出:

用 read 工具,读取 src/App.vue。

Pi 收到这个请求后,调用读取工具。假设读到的标题是:

<h1>Hello</h1>

到这里,文件才真的被读取。接着,Pi 把读到的内容放进对话记录,供下一次模型调用使用。

第二次问模型:文件已经读到了,接下来呢?

这一次,模型拿到的材料多了文件内容。

它可以根据实际代码提出:

用 edit 工具,把

<h1>Hello</h1>

替换成

<h1>你好</h1>

Pi 调用修改工具,检查旧文字能否被明确找到,然后执行替换。工具返回的修改结果也会被记下来。

这一步没有被提前写死成“第二轮必须修改”。模型看过上一轮的结果,才提出下一步。

第三次问模型:修改结果回来了,怎样回复?

假设工具返回修改成功,而且当前没有其他工作,模型可以回答:

已将 App.vue 的标题改为你好。没有运行测试。

这个例子里,你只说了一句话,背后却有 三次模型调用、两次工具执行。三次只是这里选的一条路径,实际也可能需要检查差异、重新读取或运行验证。

看这张图时,留意每次工具执行之后的结果:上一步的结果,成为了下一步的材料。 这就是任务能够往下走的原因。

Pi 里面,谁负责哪一段?

刚才我们一直说“Pi 做了什么”。实际上,这些事情由不同部分负责。

像开发一款前端产品一样,界面负责输入和显示,业务层组装功能,请求层处理接口,任务流程也需要自己的代码。

要做的事

Pi 里的主要部分

源码目录

跟不同模型服务沟通

pi-ai

packages/ai

问模型、执行工具,让任务继续

pi-agent-core

packages/agent

把项目、工具、会话组装成编程助手

pi-coding-agent

packages/coding-agent

在终端接收输入、显示过程

pi-tui

packages/tui

这些名字先当路标,不用背。

这张图表达的是分工:编程助手组织任务,运行部分调用模型和工具,界面把过程显示出来。

也因此,如果把终端换成网页聊天窗口,执行部分可以继续复用。网页需要新的通信和显示代码,但不必因此重新写一套“问模型、执行工具、回传结果”的流程。

后面遇到 Harness,先把它理解成“围绕模型搭起来的运行框架”:模型之外,还需要程序组织工具、历史和任务过程。

read 到底是什么?为什么它能读到文件?

模型需要先知道“有哪些工具可以用”。

以read为例,Pi 会向模型说明:这个工具叫 read,用来读取文件,调用时需要提供文件路径。

一个工具可以先分成两部分来看:

部分

给谁用

read 的例子

工具说明

给模型看

名字、用途、路径等参数规则

执行函数

由程序调用

真正读取文件并返回内容

这很像接口文档。你看文档,知道该传什么参数;真正查询数据的函数仍然在服务端。

模型看的是说明,Pi 持有实际执行函数。

模型交来的是一张“请求单”

它提出读取时,可以先用中文理解成:

调用编号:read-1工具名称:read参数:path = src/App.vue

真实程序里,这是结构化的 Tool Call,工具调用请求。工具名、参数和编号都有明确字段,Pi 可以按它们处理。

所以,“我准备看看文件”这句普通文字,还不能证明文件已经被读了。真正让程序动手的,是那条可识别的工具请求。

Pi 收到请求后,才开始执行

主流程可以这样看:

收到 read 请求    找到名叫 read 的工具    检查参数是否符合要求    调用它的执行函数    得到文件内容,或者得到错误    把结果交回模型

这里的参数规则在代码里叫 Schema。先把它理解成表单的填写规则:路径需要填,类型也要正确。

真正的文件读取,可以在read.ts里找到。下面几行把默认的读取操作接到了文件系统函数上,重点看fsReadFile(path):

这才是内容从文件进入程序的地方。模型没有在远端执行这段 JavaScript。

内置 read 面向文件,也支持图片。要访问网页,得使用能做那件事的工具;工具叫 read,并不代表它什么都能读。

工具执行完,还要补上一张“回执”

假设读取成功,回执可以用中文理解成:

对应调用编号:read-1结果:文件里有 <h1>Hello</h1>是否出错:否

这就是 Tool Result,工具结果。编号把结果和请求接起来:读了两个文件时,每份内容都得知道属于哪次调用。

如果文件不存在,结果里就写清错误。下一轮模型才有机会检查路径、寻找文件或询问用户。

到修改这一步,edit还要确认“旧文字”能在文件里明确定位。文件里如果有很多个 Hello,只说替换 Hello 就不够清楚;指定<h1>Hello</h1>这一段会更明确。

这里把三件事分开就好:请求填得对不对、修改目标找不找得到、这次修改应不应该被允许。 参数检查解决不了所有问题,允许修改哪些文件还需要另外的执行规则。

Agent Loop:为什么要一轮一轮地问模型?

现在再回头看整个过程,循环就容易理解了。

第一轮不知道文件内容,先读。第二轮知道标题是什么,才改。第三轮知道修改结果,才回复。

下一步依赖上一步的结果,所以需要继续问模型。 这段推进过程,就叫 Agent Loop。Loop 是“循环”。

先只看普通成功路径:

记下用户需求重复:    把当前材料交给模型    记下模型输出    如果模型请求工具:        执行工具        把工具结果记下来        继续下一轮    否则:结束

这段伪代码里,最关键的是“把工具结果记下来”。

你可以把记录想成一张工作便签。开始时只有“改标题”,后来多了“文件的实际内容”,再后来多了“修改结果”。每一轮模型看到的便签都更新了。

源码里,把结果放回记录的几行也很直接:

这里的currentContext.messages.push(result)就是在补充下一轮需要的记录。

假如读取工具成功了,但这里漏掉了文件内容,模型下一轮就没有通过这条链路得知文件里是什么。终端上打印“读取成功”,不能代替回传内容。

固定脚本可以提前安排“先读,再改,再检查”。Agent Loop 提供执行规则,让模型根据新结果提出接下来的具体动作。

实际源码还会检查用户插话、后续任务和停止条件。后面再展开这些情况,先把主流程抓牢。

换成另一个模型,为什么还能继续用这些工具?

到这里可以问一个新问题:刚才的模型如果换了,read 和 edit 也要重写吗?

通常不用。工具仍然在 Pi 这边执行,需要适配的是 Pi 与模型服务之间的通信。

不同服务的接口,消息格式、工具声明和流式响应可能不同。pi-ai会处理这些差异,让运行部分继续使用 Pi 的统一消息和工具请求格式。

对前端开发来说,这像给几个接口做一层适配:外部返回的字段不同,进入应用之后,尽量整理成应用认识的形式。

因此,后面的任务流程仍然可以问:模型有没有提出工具请求?请求了哪个工具?结果是什么?

不过,统一接口不会让模型能力也变得一样。模型能处理多长的内容、是否支持图片、怎样理解工具结果,仍然会有区别。切换时交接的是消息和结果

聊得很久之后,Pi 怎么记住前面做过什么?

假设标题已经改了,但还没有检查页面。你接着说:

继续检查一下,别改其他文件。

单看这句话,模型不知道要检查什么。Pi 得把之前的需求、修改结果等信息一起提供给它。

这就需要区分三个词:

名字

先这样理解

标题任务里的例子

Message,消息

一条记录

用户需求、读取请求、工具结果

Session,会话

整本工作笔记

这段交互积累下来的历史

Context,上下文

这一轮交给模型的材料

本轮规则、相关记录和工具说明

工作笔记可以很长,这一轮给模型的材料却有容量限制。 保存历史和准备模型输入,是两件事。

为什么历史用 JSONL 保存?

JSONL 可以按字面理解:一行一条 JSON。

下面只演示格式,字段用了便于理解的中文,实际会话条目会有更多信息:

{"步骤":"用户需求","内容":"把标题改成你好"}{"步骤":"读取结果","内容":"标题是 Hello"}{"步骤":"修改结果","内容":"替换成功"}

聊天记录会一条条增加,这种格式适合正常追加:多一条记录,就往末尾写一行。读取时也能按行处理。

普通 JSON 也能保存聊天。Pi 选择 JSONL,是因为它很适合这种逐渐增长的记录

历史越来越长,不能每次全部交给模型怎么办?

Pi 会把较早的一部分整理成摘要,再保留近期消息。这件事叫 Compaction,上下文压缩。

可以把它理解成写交接说明。例如:

目标:把 App.vue 的标题从 Hello 改成你好。约束:不要修改其他文件。进展:修改工具已返回成功。待办:还没有检查页面,也没有运行测试。

下一轮模型拿到摘要和近期消息,就有机会继续处理,而不必再次阅读早先的每一段对话。

摘要要保留能接着做事的信息。“处理了一些页面问题”虽然短,却没说清改了什么、还剩什么。

如果摘要把“修改成功”写成“测试通过”,下一轮还可能接着这个错误前提做事。所以压缩并不保证一点信息都不丢

旧历史还在吗?重新打开会话会恢复文件吗?

旧历史仍然保存在会话记录里,变化的是下一轮采用哪些材料。

Pi 也支持会话分支。先理解为:从一段旧对话重新往下聊,后续使用那条路径上的记录。

但恢复对话不等于回滚代码。你磁盘上的 App.vue 可能已经变了,必要时还得重新读取。笔记记录的是发生过什么,当前文件则要以真实状态为准。

已经有工具,为什么还要 Skill 和 Extension?

继续用 Vue 项目。团队提出两个要求:

检查组件时,先说明问题,再给最小修改建议。 不要修改 package-lock.json。

前一句是在讲工作方法,后一句如果要用程序阻止,就需要接入执行过程。

三者的分工可以这样看:

名字

主要解决什么

这个例子里做什么

Tool,工具

怎么真的执行动作

read 读取文件,edit 修改文件

Skill

按什么方法做一类工作

提供检查 Vue 组件的方法和资料

Extension,扩展

怎么给程序增加能力或介入过程

注册工具,或在修改前检查请求

它们可以配合。一个 Extension 可以注册 Tool,也可以只给已有工具加检查。

Skill 是需要阅读的工作说明

Pi 先把 Skill 的名称、描述和位置告诉模型。需要时,再读取完整的SKILL.md。

这很像先看目录,再打开相关章节。这样不用一开始就把所有说明都交给模型。

你也可以用/skill:名字显式加载。安装了 Skill,只说明资料已经在那里;本轮模型是否读过正文,还要看加载过程。

Extension 可以在操作之前检查

Pi 提供工具调用前的事件。扩展可以检查即将修改的路径,返回阻止结果,让这次调用不进入执行函数。

Skill 里的“不要修改锁文件”进入模型输入,执行前检查则能针对已经提出的动作作出决定。

这里还有一个范围问题:只检查 edit,不会自动覆盖 write 或命令行里的修改。若要真正限制访问范围,需要把可能修改文件的入口和运行环境一起考虑。Pi 默认使用启动进程的权限。

页面上的文字和状态,是怎么更新的?

前面讲的是程序怎么做事,现在把视角转回界面。

你看到模型的回答逐字出现,是因为一次回答可以分批返回。程序收到片段,就通知界面更新。这叫 流式输出。

“我先看看文件”出现在屏幕上,只说明文字已经到了。后面还可能有工具请求、文件读取和更多模型调用。

Pi 会发出各种 事件,告诉界面当前发生了什么。先把事件理解成进度通知就好:

开始一条消息收到新的文字片段准备处理读取工具读取结束,返回内容或错误本次运行收尾

界面根据这些通知,更新聊天文字、工具卡片和忙碌状态。

如果想接到 Vue 页面,可以用 RPC

RPC 是 Remote Procedure Call。这里先理解成:一个程序向另一个程序发命令,请它执行。

Pi 的 RPC 模式是一个持续运行的进程。你的后端向它的标准输入发送命令,再从标准输出接收响应和事件。

例如发送一句需求,命令长这样,每条末尾带换行:

{"id":"req-1","type":"prompt","message":"把 App.vue 的标题改成你好"}

接到网页上,一条容易理解的路径是:

Vue 页面发需求 → 自己的后端转交 Pi → Pi 执行 → 后端把过程传回页面。

Pi 与后端之间用它自己的 JSONL 命令协议。Vue 与后端怎样通信,可以按你的应用选择;Pi 的 RPC 本身不等于一个现成的 HTTP 接口。

“收到需求”和“做完任务”,界面要分清

发送 prompt 后返回成功,可以理解成输入已被接受或处理。它还不能证明标题已经改好了。

运行状态要继续看事件。其中两个名字值得认识:

事件

怎样理解

agent_end

本次底层运行结束,会话层可能还要重试、压缩或继续处理工作

agent_settled

本次会话运行不会再自动继续,可以结束忙碌状态

如果把第一个事件直接当成“全部做完”,界面可能在恢复处理期间提前变成空闲。

收到结束通知,也要再看实际做了什么。工具返回修改成功、检查过差异、测试通过,各自需要对应结果。

做事途中,你改主意了怎么办?

标题任务还在进行,你可能说三种话:

你说的话

作用

Pi 中的名字

“改成欢迎回来,不要改成你好了”

调整接下来要做的事

Steering

“改完以后,再解释一下这个组件”

排一件后续任务

Follow-up

“停止,不改了”

发出取消请求

Abort

前两种都会涉及排队,区别主要在消费时机:调整方向的消息用于后续推进,追加任务在当前工作准备结束时继续处理。

新的要求不能直接塞进已经发送出去的模型请求里。它影响接下来的过程,之前的操作也可能已经发生。

停止同样如此。Pi 会发出取消信号,但已经写入文件的内容不会自动恢复。继续处理时,先确认当前文件状态。

读文件失败,或者两个工具一起执行,会怎样?

先看失败发生在哪里:

情况

Pi 怎样处理

文件不存在

把读取错误交回模型,让它决定检查路径还是换个办法

模型服务暂时限流或过载

会话层判断是否值得重试,并按设置等待

模型输出被截断,里面还有工具请求

把这一条响应中的工具请求转成错误结果,避免执行可能不完整的参数

最后一种情况很值得注意。修改请求可能只传来一半,就算程序勉强解析出了参数,也不能据此确认内容完整。

因此,这里先不改文件,等后续处理拿到完整请求。能否恢复,还要看停止原因和会话设置。

两个文件可以一起读,但有依赖的步骤得排好顺序

比如 App.vue 和 Header.vue 可以独立读取,就有并行执行的空间。

Header.vue 先读完,界面可以先显示它的结果。每份结果仍然带调用编号,Pi 不靠谁先返回来猜它属于哪个文件;记录中也会按原请求顺序组织结果。

但“改完 App.vue,再验证修改”有先后关系。验证必须等修改完成,不能因为结果最后排成了“修改、验证”,就以为执行时也有正确顺序。

Pi 会检查这一批工具的执行模式。要求顺序执行时,就走顺序路径。同一文件的内置修改也有排队机制,协调当前进程里的修改操作。

基础流程看懂后,再看 Pi 1.0 多了什么

到这里,基础流程已经接起来了:模型提出动作,工具执行,结果交回;会话保存过程,界面显示进度,扩展和异常处理参与其中。

1.0 的新能力,可以继续放回这条线上看。下面先用用途来理解,不急着研究全部接口。

工具多了,先找到需要的工具

只用读文件、改文件几个工具时,说明还不多。

但如果接上许多外部服务,每次都把全部工具说明交给模型,材料会变长。Pi 现在可以先注册能力,等需要时再发现并加载工具说明。tool_search就负责搜索已经注册的工具,把匹配的工具加入后续模型可直接调用的集合。

下面这几行就是把匹配的工具加入可用集合,重点看setActiveTools:

可以这样理解:能力已经装好了,这一轮再找出需要用的部分。 搜索不会凭空产生一个新工具

Pi 也已经有原生 MCP 接入。MCP 可以先理解成连接外部工具服务的一种标准方式;当前版本支持 stdio 和 Streamable HTTP 服务

Codemode:让脚本组织一批动作

假设我们想检查两个组件里的标题。原来的流程可以分别请求读取,再把结果交回模型。

启用 Codemode 后,模型还可以写一段 JavaScript,让脚本组织这些调用和数据处理:

读取 App.vue 和 Header.vue从结果里筛选标题所在的行把筛选后的内容输出给模型

这段代码在 Pi 提供的环境里运行,通过工具真正读取文件。脚本可以并行调用,也可以先筛选大结果,减少需要交给聊天模型阅读的内容。

外面的循环仍然一样:模型请求 Codemode,Pi 执行,结果回来后,模型再判断下一步。

1.0 还让脚本调用分类模型和图片模型。比如先分类一批结果,再把需要的内容交回聊天模型。这里先知道它能组合这些能力即可

Virtual Model:一个选择入口,可以路由到不同模型

假设你希望规划时用模型 A,执行时用模型 B,又不想每次手动切换。

扩展可以提供一个逻辑模型,例如router/auto。你选择它之后,路由函数在每次请求前决定实际调用哪个模型。

这就叫 Virtual Model,虚拟模型。它本身不是一套新的模型权重,是一个选择实际模型的入口。切换策略由扩展提供,Pi 不会自动替所有任务安排好

规则和工具发生变化,也要留下记录

例如聊天途中增加了一个工具,后续请求需要知道它现在可用了。

1.0 可以用对话中的 system 消息记录提示词和工具的更新,让程序在恢复时重建这些状态。这里先记住用途:除了聊天内容,运行规则的变化也需要被组织起来。

另外,Cache Warming 会在满足条件时刷新 Anthropic 提示词缓存,减少缓存失效后的重复写入成本。它处理的是缓存,前面讲的 Compaction 处理的是上下文材料太长,两者用途不同。

终端的变化比较直观:新的主题,默认全屏。与此同时推出的 Pi Durable 是另外一个实验性包,面向长时间运行的 Agent 应用。

这些能力在通往 1.0 的几个版本中逐步加入。本文以最终的 v1.0.0 为准,不把每一项都当成发布当天才出现。

带着这三个问题,再打开源码

现在打开packages/agent/src/agent-loop.ts,带着这三个问题看:

你要找什么

源码路标

谁管“继续还是结束”?

runLoop

哪里向模型提问并接收输出?

streamAssistantResponse

哪里执行工具?

executeToolCalls

再到read.ts找实际读取函数,到循环里找工具结果如何加入消息列表。能把这几处连接起来,就完成了第一遍阅读。

如果现在要向别人解释 Pi,可以从标题任务说起:

模型先提出读取请求,Pi 调用工具,把文件内容交回来。模型根据内容再提出修改,Pi 执行,并把结果记回去。任务就是这样一轮一轮往下推进的。会话负责保存和整理材料,界面通过事件显示过程,扩展则能增加能力或介入执行。

之后遇到一个复杂功能,也继续追这条线:它在准备什么材料、执行什么动作,结果又交到了哪里?


源码版本:Pi v1.0.0。可继续对照 Pi 项目、Agent Loop 源码 和 1.0 发布说明

相关学习资料