跳转到内容

阶段 4:工具与副作用

本阶段的位置:阶段 2 的循环里有一句“执行工具”,阶段 3 说明了工具说明和工具结果怎样进入上下文。现在把中间这一段完整拆开。 源码目录:$PI_SRC。本文路径都相对于这个目录。 核心问题:模型只会生成文字,它说要读文件、跑命令时,Pi 怎样判断这句话能不能执行、怎样执行、又怎样把结果安全地送回模型? 先记住一句话:模型提出工具调用,不等于工具已经执行。 从“提议”到“真的发生”,中间隔着一整条流水线。

一、这一阶段到底在学什么

跳转到“一、这一阶段到底在学什么”

如果只用一句话概括 Agent 和普通聊天机器人的区别,那就是:

普通聊天机器人只能“回答世界”,Agent 还能通过工具“改变世界”。

这里的“改变”可以是读一个文件、搜索代码,也可以是修改文件、运行命令、调用外部服务。能力越大,风险也越大。所以工具这一章不是简单地学“怎样多接几个 API”,而是要同时回答三个问题:

  1. 模型怎样知道有哪些工具可用?
  2. 模型说“我要调用”之后,Pi 怎样把它变成一次可信的执行?
  3. 执行结果怎样回到模型,而不会把上下文撑爆或把系统弄坏?

学完本章,你应该能画出下面这条线,并解释每一段:

系统提示词里有一份“工具菜单”
│
▼
模型输出 toolCall:工具名 + 参数
│
▼
找工具 → 校验参数 → 权限检查 → 真正执行
│
▼
截断大结果 → 生成 toolResult → 写入会话
│
▼
下一轮投影把 toolResult 带上 → 模型继续判断
  • 能说清“模型生成的工具调用”和“实际执行”为什么不是一回事;
  • 能解释参数校验、权限检查、执行方式、截断和回填各自在防什么;
  • 能独立写一个自定义工具,在真实会话里调用成功;
  • 能指出一个工具的危险边界,而不是只说“它很好用”。

二、先把“工具”这个词讲成人话

跳转到“二、先把“工具”这个词讲成人话”

2.1 工具不是模型的一部分

跳转到“2.1 工具不是模型的一部分”

模型本身只会根据上下文预测下一段文字。它不能直接打开你的硬盘,也不能自己启动进程。

真正能做事的是一段运行在 Pi 进程里的程序。为了便于理解,可以把一个工具想成餐厅里的一张点菜单:

菜单上的东西 工具里的对应物 作用
菜名 name 模型用这个名字点“菜”
菜品介绍 description 告诉模型什么时候该用它
可选配料和格式 parameters(schema) 规定参数长什么样
厨房真正做菜 execute() 收到参数后执行真实操作
上菜 返回结果 变成 toolResult 回到模型

模型看到的是菜单,不是厨房。它输出的是“我要一份番茄炒蛋,少盐”,而不是自己把菜做出来。

2.2 一次工具调用包含两件完全不同的事

跳转到“2.2 一次工具调用包含两件完全不同的事”

这是本章最重要的区分:

阶段 谁在做 产物 是否已经产生真实影响
模型提出调用 模型 toolCall { name, arguments } 没有,只是文字
Pi 执行调用 Pi 与工具代码 toolResult { content, details, ... } 可能已经读写文件、启动进程

所以看到模型说“我将删除这个文件”时,绝不能直接理解成“文件已经删了”。Pi 还要找到工具、检查参数、经过钩子,最后才决定是否真的执行。

2.3 工具声明和工具结果是两种消息

跳转到“2.3 工具声明和工具结果是两种消息”

在上下文里,它们的位置也不同:

  • 工具声明:放在系统提示或请求的工具列表中,告诉模型“你能用什么”;
  • 工具调用:助手消息里的一段 toolCall,表示“我想用什么”;
  • 工具结果:一条 toolResult 消息,表示“实际发生了什么”;
  • 工具渲染:终端里给人看的样式,不影响模型看到的内容。

如果把这三者混成一团,后面就会不断出现“模型为什么知道文件内容”“为什么工具报错却没有崩”之类的疑惑。


三、一条工具调用的八道关

跳转到“三、一条工具调用的八道关”

先看总图。不同工具的具体实现不同,但主干都经过类似流程:

① 定义 工具有什么名字、说明、参数、执行函数
② 暴露 这次请求允许模型看见哪些工具
③ 校验 模型写的参数是不是合法、完整
④ 权限 这次动作是否被规则或用户允许
⑤ 执行 顺序执行还是并行执行
⑥ 截断 大结果怎样裁到上下文能承受的范围
⑦ 回填 结果怎样变成 toolResult 进入会话
⑧ 渲染 人怎样在终端里看见调用和结果

这张表先建立一个整体印象:

关口 要回答的问题 如果失败会怎样
定义 这个工具到底是什么? 没有说明书或没有执行逻辑,模型无法可靠使用
暴露 这次模型能看见它吗? 工具太多会浪费上下文;未启用工具不应被调用
校验 参数可信吗? 返回错误结果,让模型下一轮修正
权限 这个动作允许发生吗? 拦截执行,生成一条“被阻止”的结果
执行 谁来运行,能否并行? 可能冲突、卡住或产生副作用
截断 结果会不会太大? 可能撑爆上下文、拖慢请求、增加费用
回填 结果怎样进入历史? 模型看不到结果,循环无法继续判断
渲染 人能不能看懂发生了什么? 不影响模型,但会严重影响调试和信任

接下来逐关展开。不要背函数名,先记住每一关在防什么问题。

3.1 第一关:定义——工具必须有“说明书”和“执行体”

跳转到“3.1 第一关:定义——工具必须有“说明书”和“执行体””

一个工具至少要有四样东西:

{
name: "hello",
description: "向指定的人打招呼",
parameters: Type.Object({
name: Type.String({ description: "要打招呼的人名" }),
}),
execute: async (_toolCallId, params) => ({
content: [{ type: "text", text: `Hello, ${params.name}!` }],
details: { greeted: params.name },
}),
}

其中:

  • name 是模型调用时使用的唯一名字;
  • description 是写给模型看的“什么时候用、有什么限制”;
  • parameters 是参数的结构说明,也是运行时校验的依据;
  • execute() 是真正执行的代码。

注意 description 不是装饰。模型只能根据它判断该不该调用这个工具,所以“读取文件”这种模糊描述,远不如“读取文本或图片;大文件用 offset/limit 继续读”有用。

一个工具还可以有:

  • outputSchema:给程序调用方看的结构化结果形状;
  • promptSnippet / promptGuidelines:在系统提示里补充使用规则;
  • renderCall / renderResult:终端显示方式;
  • executionMode:这个工具能否与其他工具并行;
  • prepareArguments:在正式校验前做兼容性整理。

3.2 第二关:暴露——这次请求到底给模型看哪些工具

跳转到“3.2 第二关:暴露——这次请求到底给模型看哪些工具”

工具“存在于系统里”和“这次模型能看见它”是两件事。Pi 有一个活动工具集,只有活动工具才会被声明给模型。

默认工具通常是 read、bash、edit、write。可以通过设置或命令行改变:

终端窗口
# 只给只读工具,适合代码审查
.\pi-test.ps1 --tools read,grep,find,ls --print "审查这个项目"
# 在默认工具上增加 codemode
.\pi-test.ps1 --tools +codemode
# 这次运行完全不给工具
.\pi-test.ps1 --no-tools --print "只根据我给你的文字回答"

用户级和项目级的 defaultTools 也会影响活动集合。一个列表如果只写 +name 或 -name,表示在继承的选择上增删;写普通名字,则表示重新指定一组。

为什么要控制暴露?

  • 工具越多,工具说明占的 token 越多;
  • 用不到的工具会分散模型注意力;
  • 只读场景下关掉写工具,能减少误操作面。

但要注意:减少工具暴露是一种能力约束,不是安全沙箱。 只要某个工具仍然可用,它通常就以 Pi 进程的操作系统权限运行。

3.3 第三关:校验——模型写出的参数不能直接相信

跳转到“3.3 第三关:校验——模型写出的参数不能直接相信”

模型可能给出:

  • 缺少必填字段;
  • 把数字写成字符串;
  • 多出 schema 不认识的字段;
  • 生成到一半被截断的 JSON;
  • 看起来合理、实际越界的路径或命令。

因此执行前要做 schema 校验。parameters 不只是给模型看的说明,也是运行时检查的合同。

如果校验失败,正常路径不是让整个进程崩掉,而是生成一条错误 toolResult:

Tool read failed: missing required property "path"

模型下一轮看到这条错误,可能重新发出正确参数。这体现了 Agent 循环的特点:错误是下一轮决策的输入,不是必然终止系统的异常。

需要区分两种错误:

错误类型 例子 处理方式
可反馈给模型的调用错误 参数缺字段、工具不存在、文件不存在 变成 isError: true 的结果,继续循环
系统级故障 进程崩溃、会话文件损坏、无法恢复的运行时错误 可能终止当前运行或向上报错

工具内部的普通失败通常也应“返回一个失败结果”,而不是只把错误写进正文。这样模型、界面和程序调用方都知道这是一次失败,而不是一段普通文本。

3.4 第四关:权限——参数合法,不代表动作应该发生

跳转到“3.4 第四关:权限——参数合法,不代表动作应该发生”

这是最容易被低估的一关。

参数校验回答的是“格式对不对”,权限回答的是“该不该让它发生”。例如:

命令格式完全合法:rm -rf ./important-folder

格式没问题,但后果可能不可接受。

Pi 提供 beforeToolCall 这一层钩子,扩展可以监听 tool_call,检查工具名和参数,然后返回:

{ block: true, reason: "这个命令需要人工确认" }

被阻止的调用不会执行,而是变成一条错误结果回给模型。非交互模式下,如果没有界面可以询问用户,正确的默认行为通常是拦截危险动作,而不是默默放行。

这里必须区分两个概念:

概念 管什么 不管什么
项目信任(project trust) 是否加载项目提供的设置、扩展、技能等资源 不限制已启用工具能访问哪些文件
运行时权限/审批 某一次工具调用是否允许执行 不是操作系统级沙箱

Pi 官方文档明确提醒:项目信任不是完整的安全边界,工具和扩展仍以 Pi 进程的权限运行。 真正想隔离风险,需要使用容器、虚拟机或受限账户等操作系统级手段。

3.5 第五关:执行——顺序还是并行,不是随便选

跳转到“3.5 第五关:执行——顺序还是并行,不是随便选”

一条助手消息可以同时要求多个工具调用。例如:

请并行读取 a.ts、b.ts、c.ts,然后比较它们的差异。

Pi 支持两种执行方式:

  • 顺序(sequential):一个工具准备、执行、收尾后,再开始下一个;
  • 并行(parallel):先按顺序准备和检查,然后让允许并行的工具同时执行。

为什么不能全部并行?

  • 两个工具可能同时修改同一个文件;
  • 一个工具可能依赖另一个工具刚创建的状态;
  • 共享内存状态可能被并发破坏;
  • 某些外部服务不适合同时发多个请求。

所以默认策略之外,还可以给单个工具设置 executionMode: "sequential"。内置的文件修改工具还会使用文件级互斥队列,避免同一文件的读改写互相覆盖。

并行执行还有一个细节:tool_execution_end 可能按完成先后出现,但工具结果消息最终要按助手消息中的调用顺序写入会话。否则模型会把“哪个结果属于哪个调用”对错号。

3.6 第六关:截断——工具输出不是越多越好

跳转到“3.6 第六关:截断——工具输出不是越多越好”

假设一条命令输出了 10 MB 日志。粗略按“4 个字符约 1 token”估算,就是数百万 token。结果可能直接超过模型上下文,即使没超过,也会让请求变慢、变贵、噪声变多。

Pi 的内置文件和命令工具通常采用两个上限,谁先到就按谁截断:

  • 最多 2000 行;
  • 最多 50 KB。

不同工具保留的位置不同:

  • read 更像“从头读文件”,所以保留开头,并提示用 offset 继续;
  • bash 更像“看命令结果”,所以通常保留结尾,因为错误和汇总常在最后;
  • 被截断的完整输出可以写入临时文件,结果里告诉模型完整文件在哪。

这就形成了一个很重要的设计:截断不是丢数据,而是把“模型当前必须看的摘要”与“需要时还能取的完整数据”分开。

3.7 第七关:回填——结果怎样回到上下文

跳转到“3.7 第七关:回填——结果怎样回到上下文”

工具执行完成后,Pi 会生成一条工具结果消息,核心字段包括:

字段 给谁看 是否进入模型上下文
content 模型 是,文本或图片
details 界面、状态恢复 通常不作为模型正文
structuredContent codemode 等程序调用方 不是模型正文
isError 模型和程序 是,表示失败
usage 成本统计 不直接作为正文

一个常见误解是“工具的所有返回值都会进模型”。实际上并非如此:

  • 模型主要读 content;
  • details 可以保存给界面渲染或恢复状态;
  • structuredContent 给脚本等程序化调用方使用;
  • 渲染器怎样画,不会反过来改变模型上下文。

工具结果写入会话后,下一次投影会把它带上,模型再决定:

  • 信息够了,给出最终回答;
  • 还缺东西,继续调用工具;
  • 遇到错误,换一种方法。

3.8 第八关:渲染——模型看“内容”,人看“过程”

跳转到“3.8 第八关:渲染——模型看“内容”,人看“过程””

同一次工具调用有两类观众:

  • 模型需要的是可理解的结果内容;
  • 人需要的是“它在干什么、花了多久、哪里失败了”。

renderCall 可以显示调用参数,renderResult 可以折叠长结果、突出错误、显示耗时。渲染层出问题通常不会改变 Agent 的决策,但会严重影响人是否信任它。

一个成熟工具通常会把“语义”和“显示”分开:

execute() 决定真实发生了什么
content 决定模型能看到什么
details 决定界面能补充什么
renderer 决定人怎样看见它

四、工具失败之后,为什么循环还继续

跳转到“四、工具失败之后,为什么循环还继续”

初学者常问:

工具都报错了,为什么 Pi 不直接停?

因为很多错误对模型来说是可恢复的信息:

第一次调用:read("src/old-name.ts")
结果:文件不存在
模型下一步:先 ls("src"),找到真实文件名,再读

如果一遇到“文件不存在”就终止,Agent 就失去了最基本的纠错能力。

但有三类情况需要特别处理:

  1. 参数校验失败:不执行工具,返回错误结果;
  2. 权限拦截:不执行工具,返回被阻止的原因;
  3. 用户中止:通过 AbortSignal 取消正在运行的工具和子进程,结果标记为 aborted。

因此,“工具失败”和“运行失败”不是同义词。前者往往只是下一轮的线索,后者才是整个运行无法继续。


五、设置、信任和平台:不要混成一层

跳转到“五、设置、信任和平台:不要混成一层”

实际启动时,工具选择大致会受到这些层次影响:

内置默认工具
→ 用户级设置
→ 项目级设置(项目受信任后)
→ 本次命令行参数

数值型设置通常是后者覆盖前者;工具列表则要区分“替换整组”和“只做加减”。这也是为什么 ["+codemode"] 会保留默认工具,而 ["read", "grep"] 会改成一组新选择。

5.2 信任和审批不是同一个门

跳转到“5.2 信任和审批不是同一个门”

再强调一次:

  • 项目信任:启动时决定“这个项目能不能加载它提供的资源”;
  • 工具审批:运行时决定“这一次具体动作能不能执行”;
  • 系统隔离:决定“即使执行了,它最多能碰到哪些文件、网络和进程”。

三者解决的是不同层次的问题。把项目标成“信任”并不会自动让所有命令都安全;在项目里拒绝信任,也不会自动阻止已经启用的工具访问工作目录外的路径。

5.3 Windows 为什么没有把循环改一遍

跳转到“5.3 Windows 为什么没有把循环改一遍”

原生 Windows 上,Pi 可以额外提供一个 powershell 工具。它和 bash 共享“启动子进程、流式收集输出、截断结果、处理中止”的骨架,只是具体 shell 不同。

这说明工具层的边界设计是对的:

引擎只认识“工具接口”,不需要知道今天是 bash、PowerShell 还是别的执行器。

平台差异留在工具实现里,循环和上下文层不用跟着长分支。


六、亲手写一个最小自定义工具

跳转到“六、亲手写一个最小自定义工具”

最简单的做法是放到用户扩展目录:

~/.pi/agent/extensions/hello.ts

Windows 上通常是:

C:\Users\<你的用户名>\.pi\agent\extensions\hello.ts
import { Type } from "typebox";
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
const helloTool = defineTool({
name: "hello",
label: "Hello",
description: "向指定的人打招呼。只在用户明确要求测试 hello 工具时使用。",
parameters: Type.Object({
name: Type.String({ description: "要打招呼的人名" }),
}),
async execute(_toolCallId, params) {
return {
content: [{ type: "text", text: `Hello, ${params.name}!` }],
details: { greeted: params.name },
};
},
});
export default function (pi: ExtensionAPI) {
pi.registerTool(helloTool);
}

这段代码做了三件事:

  1. 用 Type.Object 定义参数;
  2. 用 execute 返回模型可见的 content;
  3. 在扩展工厂里把工具注册进 Pi。

开发时可以只加载这个文件:

终端窗口
cd $PI_SRC
.\pi-test.ps1 --extension $HOME\.pi\agent\extensions\hello.ts

然后在交互界面里输入:

请调用 hello 工具,和“小明”打招呼。

你应该能观察到:

  • 模型生成了一个 hello 工具调用;
  • 参数里有 name: "小明";
  • 工具返回 Hello, 小明!;
  • 模型基于结果给出最终回答。

如果模型没有调用,先检查工具是否真的被加载、描述是否清楚,而不是先怀疑循环。

6.4 从“玩具工具”升级成“真工具”的检查表

跳转到“6.4 从“玩具工具”升级成“真工具”的检查表”
  • description 说清什么时候用、什么时候不要用;
  • 参数 schema 尽量窄,不要接受一大团自由文本;
  • 可能产生大量输出时,使用统一截断工具,并告诉模型完整结果在哪;
  • 可能修改文件的工具,处理同一文件的并发写入;
  • 失败时返回 isError: true 或抛出错误,不要只在正文里写“失败了”;
  • 有副作用的工具,考虑是否要串行执行;
  • 危险动作前,提供可拦截的权限钩子;
  • 如果需要给 codemode 使用,声明 outputSchema 并返回对应的 structuredContent;
  • 给最重要的结果补一个简单渲染,避免终端里只看到一坨 JSON。

七、三个可以立刻做的观察实验

跳转到“七、三个可以立刻做的观察实验”

实验 1:看一条工具调用的完整事件

跳转到“实验 1:看一条工具调用的完整事件”
终端窗口
cd $PI_SRC
.\pi-test.ps1 --mode json "读取 package.json 的前 20 行" > tools-events.jsonl

在事件流里找:

  1. 助手消息里的 toolcall_start/delta/end;
  2. tool_execution_start;
  3. tool_execution_end;
  4. 工具结果消息;
  5. 下一次 turn_start。

然后回答:工具结果是模型生成的,还是 Pi 执行后生成的?

实验 2:只读模式与写模式的差别

跳转到“实验 2:只读模式与写模式的差别”
终端窗口
.\pi-test.ps1 --tools read,grep,find,ls --print "不要修改任何文件,只说明这个仓库做什么"

再对比默认工具集。思考:

  • 少暴露写工具,改变了什么?
  • 为什么这不等于把整个进程放进沙箱?

实验 3:故意制造一次工具错误

跳转到“实验 3:故意制造一次工具错误”

让模型读一个不存在的文件,观察它是否会先列目录、再重试。你要重点看的是:

  • 错误有没有变成一条工具结果;
  • 循环有没有继续;
  • 模型怎样利用错误信息修正下一步。

误区 正确理解
模型说调用工具,工具就已经执行了 模型只生成调用意图;Pi 还要校验、审批并执行
参数校验通过就安全了 校验只管格式;危险动作还需要权限策略和隔离
项目受信任,工具就安全 信任只影响项目资源加载,不是文件系统沙箱
工具报错会终止整个 Agent 普通错误变成 isError 结果,模型可以据此重试
所有返回值都会进入模型上下文 模型主要读 content;details 和 structuredContent 另有用途
并行一定更快 有共享状态或写入冲突时必须顺序执行
截断就是丢数据 常见做法是截断模型可见部分,同时保留完整输出路径
渲染器会影响模型判断 渲染主要服务人;上下文由 content 和投影决定
工具越多越强 工具越多越占上下文,也扩大风险面;按任务选择

合上文档,尝试不看资料回答:

  1. 模型生成的 toolCall 和 Pi 生成的 toolResult 有什么区别?
  2. 一次工具调用从“找到工具”到“进入下一轮”要经过哪些关口?
  3. 为什么参数合法不代表可以执行?
  4. 顺序执行和并行执行各自适合什么情况?
  5. 大输出为什么要截断?截断后完整内容通常放在哪里?
  6. content、details、structuredContent 分别给谁使用?
  7. 项目信任为什么不能替代容器或操作系统沙箱?
  8. 工具失败后,循环为什么通常还能继续?

建议产出:

  • 一张八道关的生命周期图;
  • 一个能在 Pi 里真实调用的自定义工具;
  • 一份 JSON 事件流,圈出“调用 → 执行 → 结果 → 下一轮”;
  • 一段 3 分钟讲解,说明工具为什么是 Agent 最大的能力边界和风险边界。

下一站是阶段 5:模型接入。工具负责“做”,模型适配层负责回答“不同厂商的模型和服务,怎样被同一套循环使用”。


配套学习路线阶段 4 · 基于 v1.3.0(71023aec)