跟着OpenCode学习Pi Coding Agent-04-Provider模式

第 4 章:万能遥控器

故事场景

你已经能跟 DeepSeek 流畅对话了。老板走过来看了一眼,说:

"挺好的。不过下个月我们可能换成月之暗面------他们新出的模型测评分很高。"

你心里一惊。当前代码里,DeepSeek 的信息散落在三四个文件里:

baseURL 在这里,model 名字在那里,apiKey 的环境变量名又不一样。

要换一家 AI 公司?你会是一场噩梦:每个文件搜一遍,改 URL、改模型名、改密钥变量名。

你需要的是一个万能遥控器 。按"开机"键------不管是索尼电视还是三星电视------它都能开。

代码说"我要对话"------不管是 DeepSeek 还是月之暗面------它都能调。

这就是 Provider 模式。Pi 支持 30+ 家 AI 公司,靠的就是这一层抽象。

本章我们只实现 1 个 Provider(DeepSeek),但接口设计要能容纳 30 个。

这一章要做什么

  1. 补充 Model 类型------定义"每个 AI 大脑长什么样"
  2. 把"怎么跟 AI 公司通信"抽象成 Provider 接口
  3. 把"去哪里找模型"抽象成 Models 集合
  4. 把认证逻辑独立出来------auth resolver
  5. createModels()createProvider() 两个工厂函数组装一切

做完后,切换 AI 公司只需改一行代码:

typescript 复制代码
// 之前(散落各处的配置)
const client = new OpenAI({ apiKey: "...", baseURL: "https://..." });
const response = await client.chat.completions.create({ model: "...", ... });

// 之后(集中管理)
const models = createModels();
models.setProvider(deepseekProvider());   // DeepSeek
// models.setProvider(moonshotProvider()); // 将来换成月之暗面------只改这一行

const model = models.getModel("deepseek", "deepseek-v4-flash")!;
const stream = models.stream(model, context);

实现

第一步:补充 Model 类型

Provider 需要知道"每个模型长什么样"------名字、价格、上下文窗口大小。

我们先在 src/types.ts 末尾追加 Model 类型定义(和第二章的风格一致------类型先行):

typescript 复制代码
// ─── 模型描述符 ───
// 每一个"大脑型号"都有一套完整的"说明书"
export const Model = Type.Object({
  // 基本信息
  id: Type.String(),              // "deepseek-v4-flash"
  name: Type.String(),            // "DeepSeek V4 Flash"
  provider: Type.String(),        // 所属公司:"deepseek"

  // 能力说明
  reasoning: Type.Boolean(),      // 是否支持推理/思考过程暴露
  input: Type.Array(              // 支持哪些输入类型
    Type.Union([Type.Literal("text"), Type.Literal("image")])
  ),

  // 价格(每百万 token,单位美元)
  cost: Type.Object({
    input: Type.Number(),         // 输入价格
    output: Type.Number(),        // 输出价格
    cacheRead: Type.Number(),     // 缓存命中读取价格(DeepSeek 支持)
    cacheWrite: Type.Number(),    // 缓存写入价格
  }),

  // 容量限制
  contextWindow: Type.Number(),   // 最多能"记住"多少 token 的上下文
  maxTokens: Type.Number(),       // 一次最多输出多少 token
});

export type Model = Static<typeof Model>;

有了 Model,下一步就是定义"谁来提供这个模型"------Provider 接口。

第二步:Provider 和 Models 接口

创建 src/models.ts

typescript 复制代码
import type {
  Model, Context, Message,
  AssistantMessageEvent, AssistantMessage,
} from "./types.js";

// ─── 认证相关类型 ───
// 每个 Provider 必须告诉我们:怎么拿出 API Key、怎么在请求里带上它

export interface ApiKeyAuth {
  type: "apiKey";
  // 从环境变量里找出 API Key 的函数
  resolve: () => { apiKey: string } | undefined;
}

export interface ProviderAuth {
  apiKey: ApiKeyAuth;
}

// ─── Provider 接口 ───
// 这就是"万能遥控器"的按钮定义
// 每个 AI 公司都要提供这些"功能按钮":
// 1. 我是谁(id, name)
// 2. 怎么认证(auth)
// 3. 我支持哪些模型(getModels)
// 4. 怎么跟我对话(stream)

export interface Provider {
  // 基本信息
  readonly id: string;         // 唯一标识,如 "deepseek"
  readonly name: string;       // 显示名,如 "DeepSeek"

  // 我的服务器地址
  readonly baseUrl?: string;

  // 认证方式------目前只支持 API Key,后面可以扩展 OAuth
  readonly auth: ProviderAuth;

  // 返回我支持的模型列表
  getModels(): readonly Model[];

  // 发起流式对话------这是我们第 3 章做的事情,但要包成通用接口
  stream(
    model: Model,
    context: Context,
    options?: { apiKey?: string; signal?: AbortSignal }
  ): ReadableStream<AssistantMessageEvent>;
}

// ─── Models 集合 ───
// 管理多个 Provider 的"登记册"
// 你可以往里注册 DeepSeek、月之暗面、OpenAI...
// 然后统一查询"哪个模型在哪个公司?"

export interface Models {
  // 列出所有已注册的 Provider
  getProviders(): readonly Provider[];

  // 按名字查一个 Provider
  getProvider(id: string): Provider | undefined;

  // 查某个公司旗下的所有模型
  getModels(provider?: string): readonly Model[];

  // 精确查询一个模型:公司名 + 模型名
  getModel(provider: string, id: string): Model | undefined;

  // 发流式请求------找到模型所属的 Provider,委托给它
  stream(model: Model, context: Context): ReadableStream<AssistantMessageEvent>;

  // 等全部回复(和流式相反,用于脚本场景)
  complete(model: Model, context: Context): Promise<AssistantMessage>;
}

// ─── 可变版本的 Models(可以往里加 Provider) ───
export interface MutableModels extends Models {
  setProvider(provider: Provider): void;
  deleteProvider(id: string): void;
}

第三步:创建 Models 集合的实现

继续在 src/models.ts 里写:

typescript 复制代码
// ─── Models 集合的具体实现 ───

class ModelsImpl implements MutableModels {
  // 用 Map 存 Provider------key 是 "deepseek",value 是 Provider 对象
  private providers = new Map<string, Provider>();

  // 注册一个 Provider(重复名字的后注册会覆盖前面的)
  setProvider(provider: Provider): void {
    this.providers.set(provider.id, provider);
  }

  deleteProvider(id: string): void {
    this.providers.delete(id);
  }

  getProviders(): readonly Provider[] {
    return Array.from(this.providers.values());
  }

  getProvider(id: string): Provider | undefined {
    return this.providers.get(id);
  }

  // 查模型------先找到 Provider,再从它的模型列表里找
  getModels(provider?: string): readonly Model[] {
    if (provider) {
      return this.providers.get(provider)?.getModels() ?? [];
    }
    // 没指定公司名------返回所有公司的所有模型
    const allModels: Model[] = [];
    for (const p of this.providers.values()) {
      allModels.push(...p.getModels());
    }
    return allModels;
  }

  // 精确查一个模型
  getModel(providerId: string, modelId: string): Model | undefined {
    return this.getModels(providerId).find((m) => m.id === modelId);
  }

  // ─── 发请求 ───
  // 整个调用链:找到 Provider → 解析认证 → 调用 Provider 的 stream 方法

  stream(model: Model, context: Context): ReadableStream<AssistantMessageEvent> {
    // 先找到这个模型属于哪个 Provider
    const provider = this.providers.get(model.provider);
    if (!provider) {
      // 找不到------创建一个"报错流"
      return this.createErrorStream(
        `未知的 Provider: ${model.provider}`
      );
    }

    // 解析认证------找出 API Key
    const authResult = provider.auth.apiKey.resolve();
    if (!authResult) {
      return this.createErrorStream(
        `${provider.name} 没有配置 API Key`
      );
    }

    // 委托给 Provider 自己去处理请求
    return provider.stream(model, context, { apiKey: authResult.apiKey });
  }

  // complete 就是 stream 的简化版------等流跑完,取最终结果
  async complete(model: Model, context: Context): Promise<AssistantMessage> {
    const stream = this.stream(model, context);
    const reader = stream.getReader();

    let finalMessage: AssistantMessage | null = null;

    while (true) {
      const { done, value: event } = await reader.read();
      if (done) break;

      if (event.type === "done" || event.type === "error") {
        finalMessage = event.message;
      }
    }

    if (!finalMessage) {
      throw new Error("流结束但没有收到 done 或 error 事件");
    }

    return finalMessage;
  }

  // 创建一个只包含错误事件的流------用于报错时的统一处理
  private createErrorStream(errorMessage: string): ReadableStream<AssistantMessageEvent> {
    return new ReadableStream({
      start(controller) {
        controller.enqueue({ type: "start" as const });
        controller.enqueue({
          type: "error" as const,
          message: {
            role: "assistant" as const,
            content: [],
            usage: { input: 0, output: 0, total: 0 },
            stopReason: "error" as const,
            errorMessage: errorMessage,
            timestamp: Date.now(),
          },
        });
        controller.close();
      },
    });
  }
}

// ─── 工厂函数:创建一个空的 Models 集合 ───
export function createModels(): MutableModels {
  return new ModelsImpl();
}

第四步:实现 DeepSeek Provider

创建 src/providers/deepseek.ts------这是我们的第一个 Provider 实现:

typescript 复制代码
import OpenAI from "openai";
import type { Provider, ProviderAuth } from "../models.js";
import type { Model, Context, AssistantMessageEvent } from "../types.js";
import { streamCompletion, EventStream } from "../event-stream.js";

// ─── DeepSeek 支持的模型列表 ───
// 这是硬编码的"模型目录"------产线环境里可以从 API 动态获取
const DEEPSEEK_MODELS: Model[] = [
  {
    id: "deepseek-v4-flash",
    name: "DeepSeek V4 Flash",
    provider: "deepseek",
    reasoning: true,         // 支持思考模式(通过 toggle 开关)
    input: ["text"],
    cost: {
      input: 0.14,           // $0.14 / 百万输入 token
      output: 0.28,          // $0.28 / 百万输出 token
      cacheRead: 0.0028,     // 缓存命中:$0.0028
      cacheWrite: 0.14,
    },
    contextWindow: 1048576,  // 1M 上下文窗口
    maxTokens: 8192,         // 默认输出 8K(最大可达 384K)
  },
  // V4 Pro------更强但更贵,同样支持思考模式
  {
    id: "deepseek-v4-pro",
    name: "DeepSeek V4 Pro",
    provider: "deepseek",
    reasoning: true,         // 支持思考模式
    input: ["text"],
    cost: {
      input: 0.435,          // $0.435 / 百万输入 token
      output: 0.87,          // $0.87 / 百万输出 token
      cacheRead: 0.003625,   // 缓存命中:$0.003625
      cacheWrite: 0.435,
    },
    contextWindow: 1048576,  // 1M 上下文窗口
    maxTokens: 8192,         // 默认输出 8K(最大可达 384K)
  },
];

// ─── 认证配置 ───
const auth: ProviderAuth = {
  apiKey: {
    type: "apiKey",
    // 从环境变量里找出密钥
    // Pi 的做法:先查 DEEPSEEK_API_KEY,作为备选可以用通用的 OPENAI_API_KEY
    resolve: () => {
      const key = process.env.DEEPSEEK_API_KEY;
      if (!key) return undefined;
      return { apiKey: key };
    },
  },
};

// ─── 创建 Provider ───
// 注意:这个函数返回的是 Provider 接口,不是具体实现
// 这就是抽象的意义------调用方不需要知道内部用了"openai" npm 包
export function deepseekProvider(): Provider {
  return {
    id: "deepseek",
    name: "DeepSeek",
    baseUrl: "https://api.deepseek.com",

    // 认证方式
    auth,

    // 模型列表
    getModels: () => DEEPSEEK_MODELS,

    // 流式对话------这里才是真正调用 DeepSeek API 的地方
    stream(model, context, options) {
      const client = new OpenAI({
        apiKey: options?.apiKey,
        baseURL: "https://api.deepseek.com",
      });

      // 把 Context → OpenAI API 的消息格式
      const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [];
      if (context.systemPrompt) {
        messages.push({ role: "system", content: context.systemPrompt });
      }
      for (const msg of context.messages) {
        if (msg.role === "user") {
          messages.push({
            role: "user",
            content: typeof msg.content === "string" ? msg.content : "(非文本内容)",
          });
        } else if (msg.role === "assistant") {
          const textParts = msg.content
            .filter((c) => c.type === "text")
            .map((c) => c.text)
            .join("");
          if (textParts) {
            messages.push({ role: "assistant", content: textParts });
          }
        } else if (msg.role === "toolResult") {
          messages.push({
            role: "tool",
            tool_call_id: msg.toolCallId,
            content: typeof msg.content === "string" ? msg.content : "(工具结果)",
          });
        }
      }

      // 使用 AbortSignal 支持取消请求
      const signal = options?.signal;

      // 返回一个 Promise...但异步函数不能直接返回 ReadableStream
      // 所以用一个"手动创建的流"来桥接
      // Pi 用了一个更优雅的 async generator 模式,这里简化了
      return new ReadableStream<AssistantMessageEvent>({
        async start(controller) {
          try {
            // 如果没有信号或者还没被取消,执行请求
            if (signal?.aborted) {
              controller.enqueue({
                type: "error" as const,
                message: {
                  role: "assistant" as const,
                  content: [],
                  usage: { input: 0, output: 0, total: 0 },
                  stopReason: "aborted" as const,
                  errorMessage: "请求已被取消",
                  timestamp: Date.now(),
                },
              });
              controller.close();
              return;
            }

            const stream = await client.chat.completions.create({
              model: model.id,
              messages,
              stream: true,
            });

            // 接入我们第 3 章写的 EventStream
            const eventStream = streamCompletion(stream);

            for await (const event of eventStream) {
              controller.enqueue(event);
            }
            controller.close();
          } catch (error: any) {
            controller.enqueue({
              type: "error" as const,
              message: {
                role: "assistant" as const,
                content: [],
                usage: { input: 0, output: 0, total: 0 },
                stopReason: "error" as const,
                errorMessage: error?.message ?? "未知错误",
                timestamp: Date.now(),
              },
            });
            controller.close();
          }
        },
      });
    },
  };
}

第五步:用新架构重写入口

更新 src/index.ts

typescript 复制代码
import { createModels } from "./models.js";
import { deepseekProvider } from "./providers/deepseek.js";
import type { Context, Message, AssistantMessageEvent } from "./types.js";

// ─── 创建 Models 集合并注册 DeepSeek ───
const models = createModels();
models.setProvider(deepseekProvider());

// ─── 找一个模型 ───
const model = models.getModel("deepseek", "deepseek-v4-flash");
if (!model) {
  console.error("❌ 找不到模型 deepseek/deepseek-v4-flash");
  process.exit(1);
}

// ─── 构建上下文 ───
const context: Context = {
  systemPrompt: "你是一个编程助手。回答要简洁,用中文。",
  messages: [],
  tools: [],
};

// 把用户本轮的话追加到上下文里
const userMessage: Message = {
  role: "user",
  content: "用一句话解释什么是 Provider 模式,然后再写两行代码示例",
  timestamp: Date.now(),
};
context.messages.push(userMessage);

// ─── 发起流式对话 ───
console.log("🔌 正在通过 Provider 模式呼叫 DeepSeek...\n");

const stream = models.stream(model, context);
const reader = stream.getReader();

while (true) {
  const { done, value: event } = await reader.read();
  if (done) break;

  switch (event.type) {
    case "text_delta":
      process.stdout.write(event.delta);
      break;
    case "text_end":
      process.stdout.write("\n");
      break;
    case "done":
      console.log("\n✅ 完成!(用了", event.message.usage.input, "+", event.message.usage.output, "tokens)");
      break;
    case "error":
      console.log("\n❌ 错误:", event.message.errorMessage);
      break;
  }
}

为什么 Provider 模式这么重要

如果没有 Provider 模式 有了 Provider 模式
代码里到处散落 baseURLapiKey 读取 统一在 Provider 工厂里管理
换 AI 公司要改 N 个文件 换 AI 公司只改注册那行
没法"跑一半换个更强的模型" models.getModel(...) 随时切换
测试时用真金白银调 API 可以注册一个 faux provider 返回假数据
新加 AI 公司要从头写一遍 HTTP 调用 新 Provider 实现同一个接口即可

🔧 运行验证

bash 复制代码
npm start

你应该看到和之前完全一样的流式输出效果------但是背后已经多了一层 Provider 抽象。

试着在 src/index.ts 里打印所有注册的模型:

typescript 复制代码
// 加在 models.setProvider(...) 后面
console.log("📋 已注册的模型:");
for (const m of models.getModels()) {
  console.log(`   ${m.provider}/${m.id} --- ${m.name} (context: ${m.contextWindow} tokens)`);
}

你应该看到:

复制代码
📋 已注册的模型:
   deepseek/deepseek-v4-flash --- DeepSeek V4 Flash (context: 1048576 tokens)
   deepseek/deepseek-v4-pro --- DeepSeek V4 Pro (context: 1048576 tokens)

试试 V4 Pro(更强但更贵):

typescript 复制代码
const model = models.getModel("deepseek", "deepseek-v4-pro")!;

V4 将"思考模式"做成了模型内开关------同一个模型既能快速答,也能深度推理,不再像旧版那样拆成 Chat 和 Reasoner 两个独立模型。

📚 这一章你学到了

概念 一句话理解
Provider 接口 "万能遥控器"------不管什么牌子的 AI,都实现同一套按钮
Models 集合 AI 公司的"黄页"------查公司、查模型、统一呼叫
Auth resolver 从环境变量里自动找密钥的函数------不用每个请求手动传
Provider 工厂函数 每个 AI 公司写一个函数,返回实现了 Provider 接口的对象
createErrorStream() 把错误也包装成流------调用方不需要区分"正常流"和"报错"

🔗 对应的 Pi 源码

  • packages/ai/src/models.ts:32-72 --- Provider 接口
  • packages/ai/src/models.ts:79-128 --- Models / MutableModels 接口
  • packages/ai/src/models.ts:142-293 --- ModelsImpl 实现
  • packages/ai/src/providers/openai.ts --- OpenAI Provider 工厂(我们写的是 DeepSeek 版本)
  • packages/ai/src/auth/resolve.ts --- Auth resolver 实现

下一步

Part 1 完成!你现在有了一个可以流式对话、Provider 抽象的 LLM 通信层。

但这还只是一问一答------AI 不会"做"事情。

Part 2 开始,我们给 AI 装上大脑 ------让它能多轮思考、调用工具、管理状态。

我们将实现 Pi 最核心的部分:Agent 循环

下一章:Agent 的日记本 →