阶段 4:工具与副作用
本阶段的位置:阶段 2 的循环里有一句“执行工具”,阶段 3 说明了工具说明和工具结果怎样进入上下文。现在把中间这一段完整拆开。 源码目录:
$PI_SRC。本文路径都相对于这个目录。 核心问题:模型只会生成文字,它说要读文件、跑命令时,Pi 怎样判断这句话能不能执行、怎样执行、又怎样把结果安全地送回模型? 先记住一句话:模型提出工具调用,不等于工具已经执行。 从“提议”到“真的发生”,中间隔着一整条流水线。
一、这一阶段到底在学什么
跳转到“一、这一阶段到底在学什么”如果只用一句话概括 Agent 和普通聊天机器人的区别,那就是:
普通聊天机器人只能“回答世界”,Agent 还能通过工具“改变世界”。
这里的“改变”可以是读一个文件、搜索代码,也可以是修改文件、运行命令、调用外部服务。能力越大,风险也越大。所以工具这一章不是简单地学“怎样多接几个 API”,而是要同时回答三个问题:
- 模型怎样知道有哪些工具可用?
- 模型说“我要调用”之后,Pi 怎样把它变成一次可信的执行?
- 执行结果怎样回到模型,而不会把上下文撑爆或把系统弄坏?
学完本章,你应该能画出下面这条线,并解释每一段:
系统提示词里有一份“工具菜单” │ ▼模型输出 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 就失去了最基本的纠错能力。
但有三类情况需要特别处理:
- 参数校验失败:不执行工具,返回错误结果;
- 权限拦截:不执行工具,返回被阻止的原因;
- 用户中止:通过
AbortSignal取消正在运行的工具和子进程,结果标记为 aborted。
因此,“工具失败”和“运行失败”不是同义词。前者往往只是下一轮的线索,后者才是整个运行无法继续。
五、设置、信任和平台:不要混成一层
跳转到“五、设置、信任和平台:不要混成一层”5.1 工具选择的层次
跳转到“5.1 工具选择的层次”实际启动时,工具选择大致会受到这些层次影响:
内置默认工具 → 用户级设置 → 项目级设置(项目受信任后) → 本次命令行参数数值型设置通常是后者覆盖前者;工具列表则要区分“替换整组”和“只做加减”。这也是为什么 ["+codemode"] 会保留默认工具,而 ["read", "grep"] 会改成一组新选择。
5.2 信任和审批不是同一个门
跳转到“5.2 信任和审批不是同一个门”再强调一次:
- 项目信任:启动时决定“这个项目能不能加载它提供的资源”;
- 工具审批:运行时决定“这一次具体动作能不能执行”;
- 系统隔离:决定“即使执行了,它最多能碰到哪些文件、网络和进程”。
三者解决的是不同层次的问题。把项目标成“信任”并不会自动让所有命令都安全;在项目里拒绝信任,也不会自动阻止已经启用的工具访问工作目录外的路径。
5.3 Windows 为什么没有把循环改一遍
跳转到“5.3 Windows 为什么没有把循环改一遍”原生 Windows 上,Pi 可以额外提供一个 powershell 工具。它和 bash 共享“启动子进程、流式收集输出、截断结果、处理中止”的骨架,只是具体 shell 不同。
这说明工具层的边界设计是对的:
引擎只认识“工具接口”,不需要知道今天是 bash、PowerShell 还是别的执行器。
平台差异留在工具实现里,循环和上下文层不用跟着长分支。
六、亲手写一个最小自定义工具
跳转到“六、亲手写一个最小自定义工具”6.1 放到哪里
跳转到“6.1 放到哪里”最简单的做法是放到用户扩展目录:
~/.pi/agent/extensions/hello.tsWindows 上通常是:
C:\Users\<你的用户名>\.pi\agent\extensions\hello.ts6.2 一个完整例子
跳转到“6.2 一个完整例子”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);}这段代码做了三件事:
- 用
Type.Object定义参数; - 用
execute返回模型可见的content; - 在扩展工厂里把工具注册进 Pi。
6.3 加载并验证
跳转到“6.3 加载并验证”开发时可以只加载这个文件:
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在事件流里找:
- 助手消息里的
toolcall_start/delta/end; tool_execution_start;tool_execution_end;- 工具结果消息;
- 下一次
turn_start。
然后回答:工具结果是模型生成的,还是 Pi 执行后生成的?
实验 2:只读模式与写模式的差别
跳转到“实验 2:只读模式与写模式的差别”.\pi-test.ps1 --tools read,grep,find,ls --print "不要修改任何文件,只说明这个仓库做什么"再对比默认工具集。思考:
- 少暴露写工具,改变了什么?
- 为什么这不等于把整个进程放进沙箱?
实验 3:故意制造一次工具错误
跳转到“实验 3:故意制造一次工具错误”让模型读一个不存在的文件,观察它是否会先列目录、再重试。你要重点看的是:
- 错误有没有变成一条工具结果;
- 循环有没有继续;
- 模型怎样利用错误信息修正下一步。
八、常见误区
跳转到“八、常见误区”| 误区 | 正确理解 |
|---|---|
| 模型说调用工具,工具就已经执行了 | 模型只生成调用意图;Pi 还要校验、审批并执行 |
| 参数校验通过就安全了 | 校验只管格式;危险动作还需要权限策略和隔离 |
| 项目受信任,工具就安全 | 信任只影响项目资源加载,不是文件系统沙箱 |
| 工具报错会终止整个 Agent | 普通错误变成 isError 结果,模型可以据此重试 |
| 所有返回值都会进入模型上下文 | 模型主要读 content;details 和 structuredContent 另有用途 |
| 并行一定更快 | 有共享状态或写入冲突时必须顺序执行 |
| 截断就是丢数据 | 常见做法是截断模型可见部分,同时保留完整输出路径 |
| 渲染器会影响模型判断 | 渲染主要服务人;上下文由 content 和投影决定 |
| 工具越多越强 | 工具越多越占上下文,也扩大风险面;按任务选择 |
九、通关自检
跳转到“九、通关自检”合上文档,尝试不看资料回答:
- 模型生成的
toolCall和 Pi 生成的toolResult有什么区别? - 一次工具调用从“找到工具”到“进入下一轮”要经过哪些关口?
- 为什么参数合法不代表可以执行?
- 顺序执行和并行执行各自适合什么情况?
- 大输出为什么要截断?截断后完整内容通常放在哪里?
content、details、structuredContent分别给谁使用?- 项目信任为什么不能替代容器或操作系统沙箱?
- 工具失败后,循环为什么通常还能继续?
建议产出:
- 一张八道关的生命周期图;
- 一个能在 Pi 里真实调用的自定义工具;
- 一份 JSON 事件流,圈出“调用 → 执行 → 结果 → 下一轮”;
- 一段 3 分钟讲解,说明工具为什么是 Agent 最大的能力边界和风险边界。
下一站是阶段 5:模型接入。工具负责“做”,模型适配层负责回答“不同厂商的模型和服务,怎样被同一套循环使用”。
配套学习路线阶段 4 · 基于 v1.3.0(71023aec)