返回课程首页

1

桌面 Agent 的第一句回答

从一次最小模型调用认识 pi-ai、Provider、Model 与 Context。

源码基线:Pi v0.82.0 · commit 518855d

我们先不做工具、不做会话持久化,也不引入 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) 并显示返回的文字。

这段代码的调用路径可以压缩成五步:

  1. createModels() 创建模型集合。
  2. setProvider() 注册 OpenAI Provider。
  3. getModel() 从 Provider 的模型目录中找到具体模型。
  4. 构造 Context,加入系统提示词和用户消息。
  5. completeSimple() 完成请求并返回 AssistantMessage

3. Models 不是“模型”,而是模型集合

变量名 models 容易让新手误解。它不是某一个大模型,而是一个运行时集合。

Models 接口提供了以下几组能力:

  • 保存已经注册的 Provider。
  • 查询 Provider 和 Model。
  • 解析 Provider 所需的认证信息。
  • 把请求路由给拥有该 Model 的 Provider。
  • 提供 streamcompletestreamSimplecompleteSimple

源码中的说明非常直接:

/**
 * 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.ts
  • packages/ai/src/providers/openai.ts
  • packages/ai/src/api/openai-responses.lazy.ts
  • packages/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 中的 UserMessageAssistantMessage

用户消息可以是普通字符串,也可以由文本和图片内容块组成。助手消息则可能同时包含:

  • 普通文字;
  • 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

也就是说:

  1. completeSimple() 调用 streamSimple()
  2. streamSimple() 返回事件流;
  3. .result() 等待事件流结束;
  4. 最终得到完整的 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. 自测

读完本章后,尝试回答:

  1. 为什么 Provider 和 API 实现不是同一个概念?
  2. Models 为什么需要先注册 Provider?
  3. 为什么 AssistantMessage.content 是内容块数组?
  4. completeSimple()streamSimple() 是否走两套完全不同的实现?
  5. 如果要继续第二轮对话,第一轮回答应该保存到哪里?

本章源码依据

  • packages/ai/src/types.ts
  • packages/ai/src/models.ts
  • packages/ai/src/index.ts
  • packages/ai/README.md 的 Quick Start、Providers and Models、Auth 部分
  • 根目录 README.md 的包职责说明