Site logo
Published on

Pi 源码解读(四):换一个模型,为什么不用重写 Agent?

Authors
  • avatar
    Name
    Stone
    Twitter
本篇目录

第三课讲清了“模型提出工具请求,Pi 执行工具”。现在往前追一步:同样是修改 Vue 页面标题,换一家模型服务,为什么读文件、改文件的执行流程不用跟着重写?

NOTE

Agent 使用自己的统一格式;pi-ai 负责对接不同模型服务,并把结果转成这套格式。

可以把它理解成一个双向翻译层:出去时翻译请求,回来时翻译结果。

本课对照 Pi v0.84.1。继续使用教学场景,只讲模型接入的主线;伪代码不需要运行。

一、问题不是“把请求地址换一下”那么简单

你还是提出:

把 src/App.vue 的页面标题从 Hello 改成你好,其他地方不要动。

假设我们分别让模型 A 和模型 B 处理这件事。两者都可能请求读取文件,但它们使用的接口不一定相同:消息怎么组织、工具怎么描述、输出如何分批返回,都可能有差别。Pi 的模型层区分服务提供方和底层 API 实现,就是为了处理这类差异。1

如果没有适配层,Agent Loop 就得一边推进任务,一边判断:“A 的工具请求在哪里?B 的结果又该怎么解析?”每多接一家,就可能多出一批判断。

这像你做前端时,对接两个返回格式不同的后端。更容易维护的做法,通常不是让每个页面分别判断,而是先在接口层转换,再交给页面使用。这里只是职责上的类比,不表示 Pi 使用了前端的请求库。

二、它统一的不是答案,而是“沟通方式”

发出去:同一份任务材料,转换成对方能接收的格式

Agent 准备的材料主要是规则、消息记录和工具说明。对接具体服务时,再按照相应接口组织请求。Pi 的 Context 类型就把这些部分组织在一起。2

这里不用重新发明一套“读取 Vue 文件”的流程。上一课的 read 工具仍然负责读取,模型层负责把它的说明交给模型。

收回来:不同服务的输出,变成 Agent 能理解的消息

模型可能输出文字,也可能提出工具请求。Pi 用自己的助手消息表示这些内容,因此 Agent 可以继续判断:“有没有工具请求?需要执行哪个工具?”不必把这些判断全部绑在某一家接口的原始字段上。2

统一的是消息和事件的表达,不是把两个模型的回答变得一模一样。

可以用一句话记住方向:任务材料 → 适配后的请求 → 模型服务 → 统一后的输出 → Agent 继续处理。

三、Pi 怎么知道该找哪一家服务?

先分清两个名字:Model 是选用的具体模型;Provider 是负责提供模型调用能力的服务单元。 一个 Provider 可以提供多个模型。

本课版本里,Models 集合持有已注册的 Provider。发起调用时,它会根据模型找到对应 Provider,处理鉴权信息,再交给 Provider 调用。你可以把这个集合理解成“知道不同模型该交给谁处理的接线台”。3

主流程可以压缩成下面这段伪代码。这里把多个模块合作的过程合在了一起,不是某个函数的原文:

给定模型和当前任务材料:
    找到负责这个模型的服务
    准备访问凭据和请求配置
    按对应接口发送请求
    把返回内容转成统一消息或事件
    交给 Agent,继续原来的执行流程

这不代表 Pi 会自动替你挑最好的模型,也不代表一家失败就一定自动换另一家。 本节解释的是:已经选定模型后,请求如何到达正确的服务。自动选模型或故障切换,是另外的策略问题。

四、流式输出,也要有统一的说法

第二课讲过,模型的一次回答可以分批返回。现在问题变成:不同服务分批返回的格式不同,Agent 怎么处理?

Pi 定义了一套统一事件。例如 text_delta 表示新增了一段文字,toolcall_end 表示一个工具请求内容已接收完成;done 和 error 则表达这次模型响应的结束状态。先理解它们的意思,不用背完整事件表。2

在我们的例子里,可以这样理解:模型先输出“我先读取文件”,随后给出读取请求;模型层把这些内容整理成统一的更新,Agent 再按自己的流程处理。

这里尤其注意,toolcall_end 表示:工具请求已经接收完整,不是文件已经读完。 真正读取文件,仍然是第三课讲的工具执行阶段。这是从事件和执行职责的区分直接得出的结论。

五、换模型之后,什么不变,什么仍然可能变化?

可以复用的是执行框架。 仍然是把材料交给模型、接收工具请求、执行工具、把结果交回去。适配层的意义,就是减少模型接口变化对这条流程的影响。

不能保证相同的是模型能力与行为。 Pi 的模型描述保留了输入类型、上下文窗口、最大输出等信息。统一的调用入口,不会凭空让一个不支持图片的模型具备看图能力。2

再想一个交接场景:模型 A 已经读过文件,你准备让模型 B 接着修改。

不能只把模型名字改掉,然后指望 B 自动知道 A 之前看到了什么。需要把后续工作所需的消息和工具结果交给 B,并处理格式兼容。 Pi 支持跨模型继续对话,但这是上下文的交接,不是把一个模型的内部状态搬进另一个模型。1

至于 B 是否提出同样的修改方案、能否一次完成任务,仍然要看实际输出,不能由“接口接通了”推导出来。

六、回到源码,只验证“请求交给谁”

这次先看 models.ts,找到 streamSimple:它如何找到 Provider,又如何把调用交出去? 看清这条线,再到 types.ts 看统一返回的消息和事件。

需要定位时,再展开这三个路标

ModelsImpl.streamSimple:把调用交给对应 Provider。

requireProvider:根据模型找到已注册的 Provider。

AssistantMessageEvent:不同实现共同遵守的输出事件类型。

前两个在 packages/ai/src/models.ts,最后一个在 packages/ai/src/types.ts。第一遍不用展开每一家服务的底层实现。

面试时,可以这样讲

对照源码看过之后,你可以用自己的话表达:

我看了 Pi 的模型适配层。它把 Agent 使用的消息和工具说明,与不同模型服务的接口格式隔开;请求交给对应 Provider,返回结果则统一成 Agent 能处理的消息和事件。所以换模型时,工具执行和循环逻辑可以复用。但统一接口不等于能力完全一致,仍然要考虑上下文长度、输入类型,以及旧消息能否兼容地交给新模型。

一个不用写代码的小练习

模型 A 支持图片,模型 B 只支持文字。调用入口完全一样,是否就可以把带图片的任务原样交给 B?

想过之后再看答案

不可以仅凭接口相同就作出保证。统一入口解决的是调用方式,不会改变模型能力。系统需要检查目标模型能力;必要时选择合适的模型,或者明确处理无法支持的输入,而不是假装图片已经被理解。


这一课记住:模型适配层统一沟通方式,Agent Loop 继续负责推进任务。

参考源码

Footnotes

  1. Pi v0.84.1 · packages/ai/README.md ↩ ↩2

  2. Pi v0.84.1 · packages/ai/src/types.ts ↩ ↩2 ↩3 ↩4

  3. Pi v0.84.1 · packages/ai/src/models.ts ↩