我们先不做工具、不做会话持久化,也不引入 Agent Loop。第一章只完成一件事:
用户在桌面应用的输入框中写下一句话,应用把这句话交给模型,再显示模型返回的文字。
这个例子很小,但它刚好能把 Pi 最底层的四个概念串起来:
- Provider:哪一家模型服务,以及怎样调用它。
- Model:这次具体选择哪个模型。
- Context:这次请求要交给模型的完整上下文。
- Message:上下文中的一条用户消息、助手消息或工具结果。
1. 先看桌面应用与 Pi 的边界
桌面应用负责输入框、发送按钮和消息列表。本章使用的 pi-ai 不负责这些桌面界面。它接收结构化的模型请求,并把不同模型服务的响应整理成统一的数据结构。
flowchart LR
UI["桌面应用<br/>输入与显示"] -->|"Context"| Models["Models<br/>认证与路由"]
Models --> Provider["Provider<br/>服务方运行时"]
Provider --> API["API implementation<br/>协议转换"]
API --> LLM["模型服务"]
LLM --> API
API --> Provider
Provider -->|"AssistantMessageEventStream"| Models
Models -->|"AssistantMessage"| UI
这里有一个重要边界:
pi-ai是统一模型 API,不是完整 Agent。
仓库根目录把 pi-ai 描述为统一的多 Provider LLM API;真正负责工具循环和状态管理的是 pi-agent-core。因此第一章只使用 pi-ai,是刻意把“模型调用”和“Agent 运行时”分开。
2. 最小代码
下面的代码只注册 OpenAI Provider,然后完成一次非流式请求。为了让示例保持清楚,本章暂时不加入工具。
运行前需要安装 @earendil-works/pi-ai,并在启动桌面应用的进程环境中配置 OPENAI_API_KEY。这是因为 openaiProvider() 明确使用该环境变量解析 API Key。示例中的模型 ID 需要存在于当前 Provider 的模型目录;如果目录已经变化,请换成 models.getModels("openai") 中实际存在的模型。
import { type Context, createModels } from "@earendil-works/pi-ai";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
const models = createModels();
models.setProvider(openaiProvider());
const model = models.getModel("openai", "gpt-4o-mini");
if (!model) {
throw new Error("找不到指定模型");
}
async function ask(userInput: string) {
const context: Context = {
systemPrompt: "你是桌面助手,请直接、简洁地回答。",
messages: [
{
role: "user",
content: userInput,
timestamp: Date.now(),
},
],
};
const response = await models.completeSimple(model, context);
if (
response.stopReason === "error" ||
response.stopReason === "aborted"
) {
throw new Error(response.errorMessage ?? "模型调用失败");
}
return response.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("");
}
console.log(await ask("你好,请用一句话介绍自己。"));
这段代码是桌面应用中的“对话服务层”,暂时用硬编码输入和 console.log 代替输入框与消息列表。后续接入桌面 UI 时,按钮事件只需要调用 ask(inputValue) 并显示返回的文字。
这段代码的调用路径可以压缩成五步:
createModels()创建模型集合。setProvider()注册 OpenAI Provider。getModel()从 Provider 的模型目录中找到具体模型。- 构造
Context,加入系统提示词和用户消息。 completeSimple()完成请求并返回AssistantMessage。
3. Models 不是“模型”,而是模型集合
变量名 models 容易让新手误解。它不是某一个大模型,而是一个运行时集合。
Models 接口提供了以下几组能力:
- 保存已经注册的 Provider。
- 查询 Provider 和 Model。
- 解析 Provider 所需的认证信息。
- 把请求路由给拥有该 Model 的 Provider。
- 提供
stream、complete、streamSimple和completeSimple。
源码中的说明非常直接:
/**
* Runtime collection of providers plus auth application and stream
* convenience. Providers own stream behavior; `Models` resolves auth and
* delegates each request to the provider that owns the model.
*/
export interface Models {
// ...
}
换成通俗的话:
Provider 知道“具体怎么调用”,Models 知道“这次应该交给哪个 Provider,并在调用前补齐认证”。
源码位置:packages/ai/src/models.ts 中的 Models 接口
4. Provider 到底是什么
Pi 源码把 Provider 定义为“具体的运行时单元”。一个 Provider 至少负责:
- 自己的标识和名称;
- 自己的认证方式;
- 自己当前可用的模型列表;
- 自己的流式请求行为。
精简后的接口如下:
export interface Provider<TApi extends Api = Api> {
readonly id: string;
readonly name: string;
readonly auth: ProviderAuth;
getModels(): readonly Model<TApi>[];
stream(
model: Model<TApi>,
context: Context,
options?: StreamOptions,
): AssistantMessageEventStream;
streamSimple(
model: Model<TApi>,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream;
}
源码位置:packages/ai/src/models.ts 中的 Provider 接口
Provider 不等于底层网络协议。例如多个 Provider 可以共享 OpenAI-compatible API;一个 Provider 也可以根据 Model 的 api 字段分派到不同协议实现。Pi 把“服务提供方”和“传输协议实现”分开,是为了避免把品牌、认证和模型目录与某一种 HTTP 协议绑死。
5. Provider Factory 怎样把这些部分组装起来
openaiProvider() 是本章实际使用的 Provider Factory。它没有把所有逻辑重新实现一遍,而是把四类零件交给 createProvider():
export function openaiProvider(): Provider<"openai-responses"> {
return createProvider({
id: "openai",
name: "OpenAI",
baseUrl: "https://api.openai.com/v1",
auth: {
apiKey: envApiKeyAuth(
"OpenAI API key",
["OPENAI_API_KEY"],
),
},
models: Object.values(OPENAI_MODELS),
api: openAIResponsesApi(),
});
}
源码位置:packages/ai/src/providers/openai.ts 中的 openaiProvider
这四类零件分别是:
- Provider 身份和基础地址;
OPENAI_API_KEY认证规则;OPENAI_MODELS模型目录;openAIResponsesApi()API 实现。
createProvider() 接受的 api 可以是单个 API 实现,也可以是以 model.api 为键的实现映射。OpenAI Provider 当前只传入一个 openAIResponsesApi(),所以这条具体路径直接调用它持有的 API 实现;只有混合 API Provider 才需要按 model.api 在映射中选择。
源码位置:packages/ai/src/models.ts 中的 createProvider
6. Pi 为什么没有直接绑定所有模型 SDK
packages/ai/src/index.ts 的注释明确说明:核心入口保持无副作用,不导入生成的模型目录、Provider Factory、API Registry、OAuth 实现和兼容层。Provider Factory 通过 @earendil-works/pi-ai/providers/* 子路径单独导入,API 实现再通过 lazy wrapper 在第一次请求时加载。
这带来两个直接结果:
- 只使用 OpenAI 的应用不需要主动注册其他 Provider;
- 注册 Provider 与真正加载对应 API SDK 是两个阶段。
因此 Pi 不是在应用启动时把所有模型 SDK 绑成一个大入口,而是用统一类型连接可单独注册、可延迟加载的 Provider 和 API implementation。
源码位置:
packages/ai/src/index.tspackages/ai/src/providers/openai.tspackages/ai/src/api/openai-responses.lazy.tspackages/ai/src/api/lazy.ts
7. Model 是一次请求的能力说明书
Model 不只是模型 ID。源码中的 Model 接口还记录:
api:使用哪一种 API 实现;provider:模型属于哪个 Provider;baseUrl:请求地址;reasoning:是否支持推理;input:支持文本还是图片;cost:输入、输出和缓存价格;contextWindow:上下文窗口;maxTokens:最大输出;headers和兼容配置。
// 与本章相关的精简字段;完整接口还包含
// thinkingLevelMap、compat 等可选配置。
export interface Model<TApi extends Api> {
id: string;
name: string;
api: TApi;
provider: ProviderId;
baseUrl: string;
reasoning: boolean;
input: ("text" | "image")[];
cost: ModelCost;
contextWindow: number;
maxTokens: number;
headers?: Record<string, string>;
}
源码位置:packages/ai/src/types.ts 中的 Model 接口
因此,桌面应用的模型选择至少要能定位 Provider ID 和 Model ID;运行时再从 Models 集合取得完整的 Model。它的能力信息可以帮助应用决定:
- 是否允许用户上传图片;
- 是否显示 Thinking 选项;
- 如何提示上下文窗口限制;
- 怎样统计大致成本。
这些字段的具体使用会在后续章节从源码继续展开。
8. Context 是这次调用看到的世界
Context 的源码定义只有三个字段:
export interface Context {
systemPrompt?: string;
messages: Message[];
tools?: Tool[];
}
源码位置:packages/ai/src/types.ts 中的 Context 接口
可以把它理解为:
systemPrompt:这次对话的基础规则;messages:模型在本次调用中能看到的消息;tools:模型在本次调用中可以选择的工具。
第一章没有提供 tools,所以模型只能生成内容,不能请求桌面应用执行动作。
还需要注意:Context 本身只是一个可传输的数据结构。completeSimple() 不会自动替你永久保存它。示例代码收到回答后,如果要继续多轮对话,需要由应用把 response 追加到 context.messages,或者在更高层使用 Session。会话保存会在第 11 章详细讨论。
9. Message 为什么不是一个字符串数组
Pi 的 Message 是三个类型的联合:
export type Message =
| UserMessage
| AssistantMessage
| ToolResultMessage;
源码位置:packages/ai/src/types.ts 中的 Message 类型
现在只关注前两个:
export interface UserMessage {
role: "user";
content: string | (TextContent | ImageContent)[];
timestamp: number;
}
export interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | ToolCall)[];
api: Api;
provider: ProviderId;
model: string;
usage: Usage;
stopReason: StopReason;
errorMessage?: string;
timestamp: number;
}
源码位置:packages/ai/src/types.ts 中的 UserMessage 和 AssistantMessage
用户消息可以是普通字符串,也可以由文本和图片内容块组成。助手消息则可能同时包含:
- 普通文字;
- Thinking;
- ToolCall。
这就是为什么示例不能直接写 console.log(response.content),而要根据内容块的 type 判断。
10. completeSimple() 实际上仍然从流开始
名字中的 complete 容易让人以为 Pi 还实现了一条独立的非流式请求路径。源码并不是这样。
async completeSimple(
model: Model<Api>,
context: Context,
options?: ModelsSimpleStreamOptions,
): Promise<AssistantMessage> {
return this.streamSimple(model, context, options).result();
}
源码位置:packages/ai/src/models.ts 中的 ModelsImpl.completeSimple
也就是说:
completeSimple()调用streamSimple();streamSimple()返回事件流;.result()等待事件流结束;- 最终得到完整的
AssistantMessage。
这项设计让流式调用和非流式调用共享同一条核心路径。第二章改成逐字显示时,不需要更换 Provider 或 Context,只需要直接消费这条事件流。
11. 请求在源码中怎样被路由
ModelsImpl.streamSimple() 的核心步骤是:
streamSimple(model, context, options) {
return lazyStream(model, async () => {
const provider = this.requireProvider(model);
const { requestModel, requestOptions } =
await this.applyAuth(model, options);
return provider.streamSimple(
requestModel,
context,
requestOptions,
);
});
}
源码位置:packages/ai/src/models.ts 中的 ModelsImpl.streamSimple
这段代码揭示了真实调用链:
sequenceDiagram
participant App as 桌面应用
participant Models as ModelsImpl
participant Provider as OpenAI Provider
participant API as OpenAI Responses API 实现
App->>Models: completeSimple(model, context)
Models->>Models: streamSimple(...)
Models->>Models: requireProvider(model)
Models->>Models: applyAuth(...)
Models->>Provider: provider.streamSimple(...)
Provider->>API: 调用持有的 API 实现
API-->>Provider: AssistantMessageEventStream
Provider-->>Models: 统一事件流
Models-->>App: result() 得到 AssistantMessage
applyAuth() 会先通过模型的 provider 找到 Provider,再合并 Provider 认证、模型请求头和本次调用选项。认证细节会放在第 4 章讲解,本章只需要记住:认证属于 Provider 和 Models 的协作,不属于桌面输入框。
12. 第一章的桌面应用结构
完成本章后,桌面应用可以先按以下方式分层:
desktop/
├── model-runtime.ts 创建 Models、注册 Provider、选择 Model
├── conversation.ts 构造 Context、发起请求、保存 Message
└── ui/
├── composer 收集用户输入
└── message-list 显示 UserMessage 和 AssistantMessage
这不是 Pi 仓库规定的目录,而是根据本章已经确认的职责边界给出的示例组织方式。真正需要遵守的是依赖方向:
界面 → 对话控制 → pi-ai
不要让按钮组件直接处理 Provider 认证,也不要让模型适配层直接修改界面组件。
13. 本章小结
本章只完成了一次回答,但已经建立了 Pi 模型层的基本心智模型:
Models保存 Provider,并负责认证和请求路由。Provider拥有模型目录、认证语义和流式行为。- Provider Factory 通过
createProvider()组合身份、认证、模型目录和 API 实现。 - 核心入口、Provider 子路径和 lazy API 将统一接口与具体 SDK 的加载分开。
Model描述具体模型及其能力。Context是一次请求提交给模型的完整上下文。Message使用结构化内容块,而不是简单字符串。completeSimple()最终仍然通过streamSimple()收集完整结果。pi-ai只负责统一模型交互,不负责完整的 Agent Loop。
下一章不改变这些对象,只把 completeSimple() 改为直接消费 streamSimple(),让回答逐字显示。
14. 自测
读完本章后,尝试回答:
- 为什么 Provider 和 API 实现不是同一个概念?
Models为什么需要先注册 Provider?- 为什么
AssistantMessage.content是内容块数组? completeSimple()和streamSimple()是否走两套完全不同的实现?- 如果要继续第二轮对话,第一轮回答应该保存到哪里?
本章源码依据
packages/ai/src/types.tspackages/ai/src/models.tspackages/ai/src/index.tspackages/ai/README.md的 Quick Start、Providers and Models、Auth 部分- 根目录
README.md的包职责说明