跳转到内容

新功能怎么接进来

这是Pi《源码精读》的第八集,新功能怎么接进来?这次的例子,改自包里的第六和第三十个示例。第六个示例装了三包代码:files 里是读文件、写文件两个工具,search 里是一个搜索工具,snippets 往系统提示词里加一段工具说明;第三十个示例用了另一包代码,名字叫 versioned,只放一个 version 工具,却把装扩展、换代码、重启都演了一遍:对话存进 SQLite,趁调用跑到一半换代码,再重启一次。

先看第三十个例子。对话里选了 versioned,存储里就记着这个名字。后来我们趁 version 工具还没返回,把同名扩展换成 v2,再把程序关掉重开。名字一直没变,代码却换了两回。这一包有名字的代码,就叫扩展,新功能都装在扩展里:工具、系统提示词的段、钩子、包装、任务,都是扩展的字段。工具还能标 deferred,先不给模型看,等 modelTools 点名再放出来;callers 则能把它限制成只给模型调,或只给别的工具调。工具的参数用 pi-schema 的 obj()、str() 写,defineTool 的 args 由它的 Static 推导。

图 8-1 扩展是一个有名字的代码包

图 8-1 · 扩展是一个有名字的代码包:工具、提示词段、钩子、包装、任务都是它的字段

扩展写好了,要装进注册表。注册表就是程序放扩展的地方,一般在启动时装好,也可以随时换;换代码,就是再装一个同名对象。装一个同名的扩展,会把原来那个整个换掉,位置不变,一次换完,中间不会出现没有它的时刻。看代码。

源码对照 · packages/durable/src/harness/registry.ts:73

install(extension: Extension<Tool>): void {
validateExtension(extension);
const current = this.#current.installed();
const index = current.findIndex((installed) => installed.name === extension.name);
const next =
index < 0 ? [...current, extension] : current.map((installed, at) => (at === index ? extension : installed));
this.#publish(next);
}

按名字找到旧的那一格,整格换成新对象;找不到才追加。publish 一次换掉整份快照,所以换代码不是打补丁,是整包替换。

图 8-2 同名扩展整包原位换掉

图 8-2 · 同名扩展按 name 找到旧格,整包原位换掉,一次 publish 换完整份快照

第六集讲过,系统提示词的那几段,就是从扩展里来的。开场白和工作目录来自 coding,那条规矩来自另一个扩展 agents-md;terse 只再包装 coding 的开场白,不单独占一段。

图 8-3 三段提示词各归各的扩展

图 8-3 · 开场白和工作目录来自 coding,那条规矩来自 agents-md;terse 只再包装开场白

注册表只在程序里,不存进存储。对话要选用扩展时,存下的只是名字,不是代码;不挑,扩展这一栏就空着,默认用 host 的 settings.extensions,它没设时才是所有装好的扩展。第六个例子就是后者:files、search、snippets 三包都装好,先不选扩展;第一次 configure 之后,pi.agent 里只有模型、thinkingLevel 和工具名,没有扩展这一栏;第三十个例子明确选了 versioned,存下的就是那个名字。看代码,不管传进来的是对象、数组,还是 add 和 remove,落盘之前都先取出 name。

源码对照 · packages/durable/src/harness/agent.ts:130

const extensions = change.extensions;
set(
"extensions",
extensions === undefined || extensions === null
? extensions
: isList(extensions)
? names(extensions)
: {
...(extensions.add === undefined ? {} : { add: names(extensions.add) }),
...(extensions.remove === undefined ? {} : { remove: names(extensions.remove) }),
},
);
// …

configure() 和 root() 的 agent 选项最后都走到这里,names() 把扩展对象变成字符串数组;扩展这一项落盘时,只有名字。

图 8-4 对话里只存扩展名

图 8-4 · 对话里只存扩展名:选了写名字,不选就空着,默认跟随 host 的 settings.extensions,没设时用全部已装扩展

每次要用,就拿这个名字去注册表里找。找到了才用,找不到就跳过。程序重启以后,存储里还是那个 versioned;我们查一下。

存储实况 · SQLite(示例 30 的保留库;示例本身用临时目录,跑完会删除库)

SELECT d.id, d.kind,
json_extract(r.content, '$.model') AS model,
json_extract(r.content, '$.extensions') AS extensions
FROM documents d JOIN document_revisions r ON r.document_id = d.id
WHERE d.kind = '"pi.agent"' AND r.kind = 'base'
ORDER BY r.seq DESC
LIMIT 1;
id kind model extensions
6 “pi.agent” {“provider”:“faux”,“modelId”:“faux-1”} [“versioned”]

这个例子一次跑完:先关掉第一个 Harness,再用空注册表打开同一个库,模拟新进程。在保留库上,重启前后各查一次,都是这一行。整条记录里没有工具定义,也没有 execute 函数,只有一个模型名和一个扩展名。

图 8-5 重启后名字还在,工具要重装

图 8-5 · 重启后注册表是空的,名字查不到就跳过;装上 v3,工具才回来

可新程序没装它,所以一个工具都没有。重启后先打印 [],把 v3 装上,再打印 [ 'version' ]。那换代码的时候,正在跑的工具会怎样?例子里,v1 的工具调用还没返回,我们换上 v2 再放行,这次调用返回的还是 v1;下一次提问,才返回 v2。看代码。

源码对照 · packages/durable/src/harness/scheduler.ts:2616

#runtime(invocation: Invocation, phase: Phase): ErasedRuntime {
// Resolved at most once per phase handler, at first use, with the invocation's context; fixed for the phase.
const agent = (): Promise<Agent> => {
if (invocation.ended) return Promise.reject(endedError(invocation));
if (phase.agent === undefined) {
phase.agent = this.#agent(invocation.conversationId, phase.snapshot(), invocation.context);
// A caller that stops waiting must not leave the shared resolution's failure unobserved.
phase.agent.catch(() => {});
}
return phase.agent;
};
// …

每个任务阶段(代码里叫 phase)第一次用到 agent 时才解析,而且最多一次,之后固定在这个阶段里;阶段跑完、做下一次决定时才换新的注册表快照。所以一次调用不会被半路换掉,下一次才用新代码。

图 8-6 旧调用跑完,新调用换码

图 8-6 · 跑着的调用用旧快照跑完,下一次决定才换新代码

为什么不把代码也存进存储,只存名字有个好处,代码可以随时改,随时升级,存储里的对话,却不用跟着动。换了新代码,新程序启动时,把扩展再装上,对话就能接着用,用的是新代码。这一集记住一句话,新功能装在扩展里,代码不进存储,对话里只留扩展的名字。

图 8-7 对话不用跟着代码升级

图 8-7 · 代码可以随时升级,对话里的名字不用跟着动

下一集我们来看,多个agent是怎么一起干活的,假如你交给agent一大堆资料,他嫌活太多,就叫来另一个agent帮忙,帮手读到一半,你改了主意,按下了停止,主 agent 把那个帮手停了,帮手会不会没人管,接着问模型,接着花钱。

正在加载交互演示…