第六章 Agent 怎样反复思考与执行
6.1 谁保存对话,谁推动下一步
前几章把 Pi 当作能读代码的助手。本章进入它的发动机。Agent 可以译作“代理程序”:它根据模型的输出调用工具,把结果交还模型,再决定是否继续。模型产生建议,程序落实调用,两者往复才形成可工作的助手。这里的“思考”指模型生成响应和选择下一步,不意味着我们能观察模型内部全部过程。
先把两个容易混淆的名字拆开。Agent 是有状态的外壳,保存消息、当前模型、工具集合以及正在执行的调用。agent-loop 是推进工作的循环。好比一本实验记录册与实验流程:前者记住做过什么,后者安排接下来做什么。终端界面、会话文件等上层功能可以订阅事件,但不必重新实现这个循环。
使用者调用 prompt() 时,字符串会转为带时间戳的用户消息。Agent 拍下当前上下文和工具集合的快照,建立本次运行的取消控制器,再进入循环。运行开始、轮次开始、消息开始和消息结束都有明确事件。一次“轮次”通常是一条模型回答,加上这条回答要求的工具执行;一次用户请求可以包含多轮。
循环并不直接指定某家模型服务。它接收 streamFn,也就是“给定模型、上下文和选项,返回流式响应”的函数。当前 Models.streamSimple 符合这个约定。把函数传进来称为依赖注入:循环只知道怎样使用能力,不负责创建那项能力。真实供应商、代理服务或测试用的模拟供应商因此可以沿用同一套流程。
6.2 跟着一次修改走完循环
假设用户要求:“修正配置读取,并检查类型。”下面是为了理解流程设计的示例,不是本书执行过的操作记录。开始时历史里有系统指令和用户请求。模型第一轮生成一个读取文件的工具调用,内容大意是“读取 config.ts”。程序拿到的是工具名和结构化参数,而不是一段必须猜测含义的自然语言。
读取工具把文件内容作为 toolResult 消息返回。循环把助手消息和工具结果加入历史,再发起第二次模型请求。模型这时同时看到用户目标、自己刚才的调用和真实文件内容,才能给出有依据的修改。若它要求调用编辑工具,程序执行编辑,再把修改结果加入历史。第三轮模型可以要求执行项目规定的检查命令。
检查工具返回的结果可能包含错误:例如某个调用传入字符串,目标参数却要求数字。第四轮模型读到这段结果,可能再次读取相关代码并编辑。只有后来一轮不再提出工具调用,也没有排队输入或继续执行的决定,循环才自然结束。模型说“我检查过了”与工具真的返回检查结果,是两种不同证据;工程应以前者之外的实际结果判断是否完成。
把这个过程压成短线索,就是“用户请求 → 读取 → 文件结果 → 编辑 → 编辑结果 → 检查 → 检查结果 → 回答或再修改”。每个箭头都可能跨过网络请求或异步工具执行。循环的价值在于把这些阶段明确连接,让失败、取消、界面更新与历史记录都有可观察的位置。
在真正调用模型之前,还有两个上下文入口。transformContext 可以先裁剪或补充 Agent 消息;convertToLlm 再把应用自定义消息转成模型认识的标准消息。比如界面的通知卡片可以留在应用记录里,却不直接发送给模型。这是一个明确的转换边界;它本身不会自动决定哪些旧消息应该删除,具体策略由宿主,也就是集成并运行这些库的应用,提供。
6.3 工具需要先校验,再执行
工具声明说明名称、描述和参数结构;可执行工具还提供 execute 函数。参数结构可以规定路径必须是字符串、数量必须是数字。模型虽然按结构生成参数,仍可能遗漏字段或给错类型。循环会先找到工具,按需要预处理参数,再按声明的参数结构检查。未知工具、校验失败和执行时抛出的错误会成为错误工具结果,供模型下一轮修正。
beforeToolCall 在校验之后运行,可以阻止一次调用;afterToolCall 在执行之后运行,可以处理返回内容或错误标记。这类预留回调称为钩子。它们是宿主接入检查与策略的位置,而不是某种自动具备的权限系统。写集成代码时,如果绕过这条路径直接调用工具函数,相应钩子也不会凭空执行。
同一条助手消息可能要求读取三个互不依赖的文件。默认并行模式会依次完成调用预检查,再让允许的工具并发执行。哪个先完成执行及 afterToolCall 处理,就可以先发出完成事件。但交回历史的结果仍按助手原来的调用顺序排列。这样界面能及时显示完成情况,而模型看到的记录顺序保持稳定,不会因为磁盘偶然快慢而改变。
若这批调用里任何一个工具声明只能顺序执行,整批就顺序运行。例如某工具依赖前一个工具刚刚更新的共享状态,宿主可用这一设置表达约束。并行只是安排执行的方式,不负责证明工具彼此独立。还有一项保守处理:如果模型输出因长度限制被截断,消息里的工具参数可能不完整,循环会把这批调用报为错误,要求重新发出,不直接执行可疑参数。
6.4 取消、插话和后续任务
取消通过 AbortSignal 传递。这是一个共享的“请停止”信号,会送到模型请求和工具。Agent 的 abort() 发出信号,相关实现需要在等待网络、执行命令等位置响应。发出取消不等于撤销已经完成的文件写入,也不保证一个忽略信号的外部操作立刻消失。因此取消描述的是控制请求,实际副作用仍需由工具与宿主管理。
运行途中出现新输入时,直接再次 prompt() 会被拒绝,以免两条循环同时改同一份状态。可以使用 steering 队列:把纠正方向的消息排进去,在当前助手轮次及其工具完成后插入,再让模型响应。例如“只修改配置,不要改文档”可以在下轮生效。它不会跳过当前批次尚未结束的工具,也不是强制取消的替代品。
follow-up 队列用于“做完之后再处理”的输入。循环在没有更多工具调用、也没有 steering 消息时,才取后续消息。例如“修复后再解释原因”可以接在当前任务自然停下的位置。两类队列都能选择一次取一条或全部取出;它们解决的是输入何时进入上下文,而不是模型最终一定怎样理解这条要求。
事件订阅还承担状态同步。Agent 会先根据消息结束、工具开始或结束等事件更新自身状态,再等待订阅者按注册顺序处理。这里的等待保证属于底层 Agent.subscribe();外层 AgentSession.subscribe() 的公开监听器不会等待返回的 Promise,两种接口应分别理解。最后的 agent_end 表示本次循环不会再产生事件;prompt() 和 waitForIdle() 还会等待这个事件的异步订阅者结束。这个细节让宿主能完成最后一次保存,再把运行视为真正空闲。
6.5 小结与练习
理解 Agent,重点是分清三件事:模型提出下一步,循环安排调用,状态外壳保存并发布结果。自动化来自这条可重复的链,而可靠性来自真实工具结果、参数校验以及宿主明确提供的策略。
练习一:读取工具返回“文件不存在”,循环一定结束吗?参考答案:不一定。错误会作为工具结果进入上下文,模型可以更换路径或请求其他信息;后续是否继续由循环状态和配置共同决定。
练习二:三个并行读取按乙、甲、丙完成,历史结果是什么顺序?参考答案:若模型原先声明甲、乙、丙,历史仍按甲、乙、丙排列,工具完成事件可以按实际完成顺序出现。
练习三:要立即停止当前工作,应使用 steering 还是取消?参考答案:应发出取消信号。steering 在正常轮次边界插入指令;取消也不能自动回滚已发生的修改。