hello 我是逆境不可逃
我最近在学习 PiAgent ok废话不说,开始
让 Pi"读取 package.json 并解释 workspace"时,模型不会自己打开本地文件。第一次请求通常只会返回一个 toolCall:调用什么工具、参数是什么。Pi 执行工具后,把结果包装成 ToolResult 放回上下文,再请求一次模型,最终得到基于真实文件内容的回答。
这就是 Pi Agent Loop 处理工具调用的基本过程。一次完整运行称为 Agent Run,其中可能包含多个 Turn,也可能请求模型多次。
为什么一次 Prompt 会调用模型两次
先看最常见的一条路径:
text
用户输入 Prompt
-> 模型返回 toolCall
-> Pi 执行工具
-> ToolResult 写入上下文
-> 模型读取工具结果并生成最终回答
这里可以顺便区分两个概念:
- Agent Run:启动或继续 Agent 后的一次完整运行,从运行开始到运行结束。
- Turn:一次 Assistant 响应,以及紧随其后的工具调用和工具结果。
在没有追加消息和额外停止条件的情况下,如果模型第一轮直接回答,整个 Run 只有一个 Turn。如果第一轮要求读取文件,第二轮给出最终回答,整个 Run 就包含两个 Turn。
模型和工具在同一段循环中交替工作。每次拿到工具结果,模型都能据此决定下一步动作。
输入如何进入 Agent Loop
从终端输入到循环核心,主要经过四层:
text
交互层:接收用户输入
-> 会话层:整理输入,处理模型和会话配置
-> Agent 状态层:维护消息和运行状态
-> 循环层:请求模型、执行工具、判断是否继续
这种分层让每部分只承担一类职责。例如,把终端输入换成网页输入,模型如何请求工具、如何接收结果的规则仍然可以复用;修改聊天记录的保存方式,也不必改动工具循环。
核心循环做了什么
循环开始前,Pi 会把用户输入加入上下文。这里的"上下文"可以理解为下一次请求模型时携带的对话记录,包含用户消息、模型回复和工具结果。
先只看普通工具调用这条路径,省略追加消息和额外停止条件,流程可以写成下面的伪代码:
text
把用户输入加入对话记录
重复执行:
携带对话记录,请求模型
把模型回复加入对话记录
如果回复中没有工具调用:
结束循环
执行模型请求的工具
把工具结果加入对话记录
进入下一轮
这里必须保存两部分内容:模型发出的工具请求,以及工具返回的结果。下一次请求时,模型才能知道"之前准备做什么"和"实际发生了什么":
text
用户:读取 package.json
模型:请求读取工具,路径为 package.json
工具:返回文件内容
模型拿到这段历史后,才有条件解释文件内容、修正参数,或者决定继续调用另一个工具。
Pi 还需要处理运行过程中到来的新消息,因此实际使用了两层循环:
- 内层推进当前任务,同时处理 steering 消息,也就是运行中的补充指示,例如"先别修改文件,只分析原因"。
- 外层在当前任务准备结束时检查 follow-up 消息,也就是排队等待后续处理的请求,例如"做完以后再解释测试结果"。
这也解释了为什么"模型没有请求工具"不一定意味着整个 Run 立刻结束:还要看有没有等待处理的新消息。
工具失败后为什么还能继续
执行工具前,Pi 要确认工具存在、参数合法,还会调用执行前后的钩子。钩子就是留给其他逻辑介入的检查或处理位置,例如在执行前阻止某次调用。处理完成后,结果会统一包装成 ToolResult。
工具不存在、参数错误或执行抛出异常时,Pi 通常不会让整个 Agent Run 直接崩溃,而是生成一个带 isError=true 的 ToolResult。这个结果同样会进入上下文,模型可以看到错误原因,再决定修改参数还是换一种做法。
例如,读取文件失败后,模型可能根据"文件不存在"的错误重新检查路径,再发起一次读取。错误信息因此也能成为下一步决策的依据,不过模型能否修正成功并没有保证。
isError 回答"工具执行是否失败",terminate 回答"工具执行之后是否停止循环"。Pi 只有在非空工具批次中的所有结果都要求终止时,才通过这个条件停止后续模型请求。
假设模型同时请求两个工具,只有一个结果要求终止,另一个没有要求终止,就不会触发这个批次终止条件。是否最终继续,还要检查其他停止条件。
事件流如何连接执行、界面和会话
Agent Loop 在运行时会发出几组事件:
text
运行开始 / 运行结束
本轮开始 / 本轮结束
消息开始 / 消息更新 / 消息完成
工具开始 / 工具进度更新 / 工具结束
事件是一种通知:循环每推进一步,就告诉订阅者发生了什么。界面可以据此显示"工具正在执行",状态层可以记录哪些工具还没结束,会话层可以保存已经完成的消息。
以流式输出为例,模型可能先产生"你",再更新成"你好"。Pi 会暂存当前正在生成的消息,供界面持续刷新;收到消息完成事件后,才把完整回复加入正式历史。
如果每次更新都向历史中追加一条消息,下一轮模型就可能同时看到"你"和"你好"两条回复。把生成中的内容与已完成的消息分开,可以避免这类重复。
这种事件设计让循环层只负责报告进度。界面如何展示、消息如何保存,都由各自的订阅者处理。
用最小实验观察两轮请求
为了确认工具结果确实进入下一轮模型请求,本地实验用一个按预设返回结果的模拟模型,以及一个把输入原样返回的工具:
- 第一次模型调用固定返回工具请求;
- 工具执行后返回一条
ToolResult; - 第二次模型调用读取上下文并返回最终文本。
把实验记录按消息含义展开,结果如下:
text
模型请求次数:2
第一次请求:用户消息
第二次请求:用户消息 -> 模型的工具请求 -> 工具结果
最终对话记录:用户消息 -> 模型的工具请求 -> 工具结果 -> 模型最终回答
事件顺序可以压缩成一行:
text
运行开始 -> 第 1 轮(工具调用) -> 第 2 轮(最终回答) -> 运行结束
这次运行只有一个 Agent Run,却包含两个 Turn 和两次模型请求。第二次请求中出现 toolResult,说明工具输出已经成为模型可见的正式上下文,而不是只在本地执行完就丢弃。
这套设计的代价
工具回填让模型能够根据外部结果继续处理任务,但它也带来几项实际成本:
- 一次 Prompt 可能触发多次模型请求,延迟和 Token 消耗随 Turn 数增加;
- 循环必须有清晰的退出条件,否则模型可能在失败工具和重复计划之间来回尝试;
- 并行工具调用可以减少等待时间,但两个工具同时修改同一个文件时,还需要处理冲突;
- 异步事件监听器位于运行链路上,监听器过慢也会拖长整个 Run。
例如,同一个文件路径反复读取失败时,继续调用模型会消耗时间和费用,却不一定产生新信息。因此,错误反馈之外还需要停止机制,限制无效尝试。
总结
Pi 的一次工具调用闭环可以概括为:模型提出工具请求,运行时校验并执行工具,把结果写回上下文,然后再次请求模型。当没有新工具调用和待处理消息,或者命中停止条件时,运行结束。
理解这条链路后,几个容易混淆的问题也就清楚了:一次 Prompt 不等于一次模型请求;一个 Agent Run 可以包含多个 Turn;工具错误既可以反馈给模型继续处理,也可以通过独立的终止语义结束循环。
这套思路与具体编程语言无关。无论用 Java、Python 还是 TypeScript 实现,都需要回答同样的问题:谁执行模型提出的动作,结果怎样交回模型,失败后怎么办,以及整个过程什么时候结束。
