跳转到内容

调用工具的背后

图 2-1 一次工具调用:把 world 改成 durable

图 2-1 · 调用工具的背后:读 → 改 → 验证

这是Pi《源码精读》的第二集,调用工具的背后。这次的例子,改自包里的第 16 个示例,16-coding-tools.ts,文件里写着 hello world。我们让Agent改一下,把 world 换成 durable。模型还是假的,但它会像真模型那样调用工具。他先说要读这个文件,读完又说要改这个文件,最后才回答改好了。当前示例在这两步之后还排了两次 bash:一次把改完的文件 cat 出来,一次给 /tmp/1gb.txt 计时;后者没有那个文件时,会拿到一条错误结果。这一集的主线,还是读和改。

跑完以后,文件真的变成了 hello durable。先打个比方,餐厅里,厨师开出一张单子,交给跑腿的去取东西。每张单子都留着底,谁去取?取回来,什么都查得到。在Agent里,厨师就是模型,单子就是一次工具调用,跑腿的就是工具任务。

图 2-2 类比:厨师(模型)开单子(工具调用),跑腿的(工具任务)去执行

图 2-2 · 厨师开单,跑腿的去取;记录本留底

模型说要读 notes.txt,就会新建一个工具任务,专门去做这件事。模型这次说的话,会作为一条记录,写进记录本。新建的工具任务也在同一次存档里一起存好。做完以后,工具任务把结果也写成一条记录,下次再问模型,模型就能看到它。工具任务现在是第 3 版:第 1 版只接模型发起的调用,第 2 版还能接工具在执行时自己再发起的调用,也就是嵌套调用,第 3 版把模型没被提供的工具调用也接成任务(unoffered)。这一集说的,是模型发起、且在工具清单里的那一种。

还有一个习惯,工具任务动手之前,先给自己记一笔进度。上一集说过,任务会存下自己的下一步,这里也一样。这一笔写的是,下一步要去执行,要用的参数是这些,再加一项重放策略。参数就是做这件事需要的具体信息,比如要读的文件路径 notes.txt;重放策略默认是 unsafe,意思是重启后看到这一笔,不自动再跑。存好这一笔,他才真的去读文件。

注意,这一笔是工具任务自己的进度,不写进记录本。就像出门办事之前,先在门上贴张便条,我去取快递了,他有什么用?假如做到一半,程序突然被杀掉。重新启动以后,看到这一笔,就知道这件事可能已经开始做了。至于接下来怎么处理,留到讲崩溃的那一集。

图 2-3 工具任务先写 checkpoint:phase、arguments、replay,再执行

图 2-3 · 动手之前,先写下下一步;checkpoint 不写进记录本

跑的时候,我们把它从任务自己的状态里查了出来。读文件那次存下的参数是文件路径,改文件那次存下的参数是,把 world 这个词换成 durable。把整个过程串起来,是一个循环。先问模型,模型要调用工具,Durable就开一个工具任务。

图 2-4 工具调用循环:模型 → 工具调用 → 工具任务 → 执行 → 结果入记录本 → 再问模型

图 2-4 · 一次工具调用绕一圈,回到模型

工具、任务,记下进度,再动手,结果写进记录本,然后再问一次模型。读和改这两步,是这一集的主线;示例里后面那两次 bash,走的是同一条路。等这些工具都做完,再问一次模型时,他这次不要工具,直接给出最终回答,这次问答就结束了。

图 2-5 一次工具调用留下请求和结果两条记录

图 2-5 · 记录本里,一次调用两条记录:请求 + 结果

跑完以后,翻开记录本,这个例子里,每次调用工具都留下两条记录。

一条是模型说要用什么工具,一条是工具做完的结果。去掉程序自己加的 pi.system 条目,按顺序是你的问题,读文件的一对,改文件的一对,后面两次 bash 各一对,最后是模型的回答。以后翻记录本,就能看清agent一步一步做了什么。

图 2-6 这次运行的记录本顺序:问题、read、edit、两次 bash、回答

图 2-6 · 一次问答,六组记录;每次工具调用两条

这一集的代码只看一个文件,就是tool.ts。

图 2-7 tool.ts:先更新 slot(deferred),再写下 execute checkpoint,commit 后才 run

图 2-7 · 先写 checkpoint,再 run

在这里,工具任务先存一次档,把参数记进自己的进度。存好以后,他才真正执行工具,再把结果写成一条记录。这一集,记住一句话就够了。模型每要一次工具,Durable 都会开一个工具任务。工具在本次清单里,就先记下要做的事再执行;不在清单里,任务标成 unoffered,不执行工具,直接以 tool_unavailable 结算。两种都会把结果写进记录本。

这段代码的第二段,把这一笔记成 intent;落到任务上就是 checkpoint。

源码对照 · packages/durable/src/harness/tool.ts:183

await runtime.commit(
async (tx) => {
const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId);
if (slot !== undefined) slot.status = "running";
// …
return undefined;
},
context,
PROGRESS_COMMIT,
);
await runtime.commit(() => {
const intent = { phase: "execute", arguments: final, replay: tool.replay ?? "unsafe" } as const;
return { status: "running", checkpoint: intent };
}, context);
await run(runtime, task.input, call, tool, final, context);

这段就是“先存一笔,再动手”。final 是校验后的参数;工具没声明重放策略时,replay 取 unsafe;第二段 commit 返回以后,才调用 run。第一段只把 pi.live 里的 slot 标成 running,是 deferred 的展示进度,可能不单独落盘;第二段才把 checkpoint 写进任务。

先看存下来的样子。示例用 SQLite,数据库文件落在当前目录的 pi-durable-coding-data/SQLite/session.sqlite。同一个数据库再次运行会复用旧对话,记录会累加;要复现干净结果,换一个空目录运行。读和改都太快;用 PI_DURABLE_PAUSE_MS=2000 再跑,读、改会在结算前各停两秒,任务一直留在 running;这时在另一个终端查,就能看到这一笔。

图 2-8 任务 running 时有 checkpoint;terminal 后 checkpoint 消失,记录本结果留下

图 2-8 · checkpoint 只在 running 时看得到

存储实况 · SQLite(示例 16,读/改任务)

SELECT status, record ->> '$.state.checkpoint' AS checkpoint
FROM tasks
WHERE record ->> '$.kind' = 'pi.tool' AND status = 'running';
查到的时刻 status checkpoint
读 notes.txt 结算前 running {"phase":"execute","arguments":{"path":"notes.txt"},"replay":"unsafe"}
改 notes.txt 结算前 running {"phase":"execute","arguments":{"path":"notes.txt","edits":[{"oldText":"world","newText":"durable"}]},"replay":"unsafe"}

(两次分别查,各得到一行。)

任务一结算,status 就变成 terminal,checkpoint 也随之消失。所以这一笔只在任务跑着的时候看得到。记录本那边不一样,结果会留在记录本里:

SELECT record ->> '$.model[0].toolName' AS tool,
record ->> '$.model[0].content[0].text' AS text
FROM entries
WHERE record ->> '$.kind' = 'pi.tool-result'
AND record ->> '$.model[0].toolName' IN ('read', 'edit')
ORDER BY id;
tool text
read hello world\n
edit Successfully replaced 1 block(s) in notes.txt.

下一集,我们来看另一个常见的场景。agent正在忙的时候,你又说了一句话,会怎样?你让agent看看一个文件里写了什么,他正在忙。这时,你又想起一件事,补了一句,回答,写成一行就好。你补的这句话会去哪?他这一次能听到吗?

正在加载交互演示…