Vercel AI SDK 多 Provider 统一抽象

0 阅读7分钟

本文面向:想用一套代码支持多个 LLM / Embedding 服务商的 AI 应用开发者。
预计阅读时间:12 分钟
最终效果:理解 Vercel AI SDK 的统一 API 层与 ChatCrystal 的 Provider 工厂模式,掌握 generateText / generateObject / embed 与多 Provider 切换的完整方案。

一个真实的困境

开发 AI 应用时,你迟早会遇到这个问题:换模型

最初用 Ollama 本地跑通原型,成本为零。上线后发现本地模型效果不够,切到 OpenAI。用了一阵子想试试 Claude 的长上下文能力,又想换 Anthropic。每次切换,你都要:

  • 学习新的 API 格式(OpenAI 的 /v1/chat/completions、Anthropic 的 /v1/messages、Google 的 generateContent
  • 处理不同的认证方式(API Key、OAuth、Bearer Token)
  • 适配不同的响应结构(choices[0].message vs content[0].text)
  • 处理不同的流式协议(SSE 格式差异、chunk 结构差异)

这不是技术难度的问题,而是重复劳动的问题。每个 Provider 的 API 本质上做同一件事——发送 prompt、接收回复——但接口设计各不相同。

Vercel AI SDK(ai 包)解决了这个问题。它提供了一层统一抽象,让你的业务代码只写一次,底层 Provider 随时可切换。

ChatCrystal 的 Provider 矩阵

ChatCrystal 支持 6 种 Provider,覆盖了从本地到云端的完整场景:

Provider类型语言模型Embedding 模型认证
Ollama本地推理支持支持无需 API Key
OpenAI云端支持支持API Key
Anthropic云端支持不支持API Key
Google云端支持支持API Key
Azure云端支持支持API Key + Endpoint
CustomOpenAI 兼容支持支持API Key + Base URL

注意 Anthropic 不支持 Embedding 模型——这是由模型服务商的能力决定的,不是 SDK 的限制。

Provider 工厂模式

ChatCrystal 的 Provider 注册集中在 server/src/services/providers.ts。核心设计是一个 Map<string, ProviderEntry>,每个 Provider 实现统一的接口:

interface ProviderEntry {
  name: string;
  displayName: string;
  supportsEmbedding: boolean;
  requiresApiKey: boolean;
  requiresBaseURL: boolean;
  createLanguageModel(config: ProviderConfig): LanguageModel;
  createEmbeddingModel?(config: ProviderConfig): EmbeddingModel;
}

每个 Provider 注册时提供两个工厂函数:createLanguageModelcreateEmbeddingModel。调用方不需要知道底层用的是哪个 SDK——只需要拿到一个 LanguageModelEmbeddingModel 实例。

以 Ollama 为例:

providers.set('ollama', {
  name: 'ollama',
  displayName: 'Ollama',
  supportsEmbedding: true,
  requiresApiKey: false,
  requiresBaseURL: true,
  createLanguageModel({ baseURL, model }) {
    const url = baseURL || 'http://localhost:11434';
    const ollama = createOpenAI({
      baseURL: `${url}/v1`,
      apiKey: 'ollama',
      name: 'ollama'
    });
    return ollama(model);
  },
  createEmbeddingModel({ baseURL, model }) {
    const url = baseURL || 'http://localhost:11434';
    const ollama = createOpenAI({
      baseURL: `${url}/v1`,
      apiKey: 'ollama',
      name: 'ollama'
    });
    return ollama.textEmbeddingModel(model);
  },
});

关键细节:Ollama 虽然不是 OpenAI,但它实现了 OpenAI 兼容的 /v1 端点。所以 ChatCrystal 直接用 @ai-sdk/openai 适配器对接 Ollama,只需把 baseURL 指向 Ollama 的地址。apiKey 传一个占位值 'ollama',因为 Ollama 不需要认证。

Custom Provider 也用同样的方式工作——任何实现了 OpenAI 兼容 API 的服务(比如 vLLM、LocalAI、LiteLLM)都可以通过 Custom Provider 接入。

统一 API 层

Provider 工厂解决了"怎么创建模型"的问题,AI SDK 的上层 API 解决了"怎么使用模型"的问题。

generateText:文本生成

最基础的调用方式。给一个 prompt,返回文本:

import { generateText } from 'ai';

const { text } = await generateText({
  model: getLanguageModel(),
  system: '你是一个技术助手',
  prompt: '解释什么是 HNSW 算法',
});

ChatCrystal 在对话摘要流程中使用 generateText 的变体 generateObject,因为需要结构化输出。

generateObject:结构化输出

这是 AI SDK 最强大的能力。你用 Zod 定义一个 schema,LLM 返回严格符合该结构的 JSON:

import { generateObject } from 'ai';
import { z } from 'zod';

const schema = z.object({
  title: z.string().describe('简洁的标题,20字以内'),
  summary: z.string().describe('2-4 段 markdown 摘要'),
  key_conclusions: z.array(z.string()).describe('3-5 个关键结论'),
  code_snippets: z.array(z.object({
    language: z.string(),
    code: z.string(),
    description: z.string(),
  })).describe('0-3 个关键代码片段'),
  tags: z.array(z.string()).describe('3-6 个小写英文标签'),
});

const { object } = await generateObject({
  model: getLanguageModel(),
  schema,
  system: SYSTEM_PROMPT,
  prompt: transcript,
});

generateObject 内部做了几件事:

  1. 将 Zod schema 转换为 JSON Schema
  2. 将 JSON Schema 注入到 prompt 中,引导 LLM 按结构输出
  3. 解析 LLM 返回的 JSON,用 Zod 校验
  4. 如果校验失败或请求出错,按 maxRetries 自动重试(ChatCrystal 显式设为 3 次)

这意味着你的代码拿到的 object 已经是类型安全的 TypeScript 对象,不需要手动 JSON.parse 和类型断言。

embed:向量嵌入

用于生成文本的向量表示:

import { embed } from 'ai';

const { embedding } = await embed({
  model: getEmbeddingModel(),
  value: 'Fastify 插件注册机制',
});
// embedding: number[] (长度取决于模型,如 1536)

ChatCrystal 在两个场景使用 embed

  1. 生成笔记向量:将笔记文本分块,每块调用 embed 生成向量,存入 vectra 索引
  2. 搜索查询向量:用户输入搜索词,调用 embed 生成查询向量,在 vectra 中做相似度搜索

模型创建的统一入口

ChatCrystal 通过两个函数封装了模型创建,分别位于不同的模块中:

// 语言模型(server/src/services/llm.ts — 导出)
export function getLanguageModel(): LanguageModel {
  const { provider, ...config } = appConfig.llm;
  return getProvider(provider).createLanguageModel(config);
}
// Embedding 模型(server/src/services/embedding.ts — 模块内部私有函数)
function getEmbeddingModel() {
  const { provider, ...config } = appConfig.embedding;
  const entry = getProvider(provider);
  if (!entry.createEmbeddingModel) {
    throw new Error(`Provider "${provider}" does not support embeddings. Use ollama, openai, google, azure, or custom.`);
  }
  return entry.createEmbeddingModel(config);
}

getLanguageModel() 是公开导出的,供摘要生成等模块调用。getEmbeddingModel()embedding.ts 的内部函数,不对外导出——外部模块通过调用 generateEmbeddings()semanticSearch() 等公开 API 间接使用它。

配置来自 config.json,结构如下:

{
  "llm": {
    "provider": "openai",
    "model": "gpt-4o-mini",
    "apiKey": "sk-..."
  },
  "embedding": {
    "provider": "ollama",
    "model": "nomic-embed-text",
    "baseURL": "http://localhost:11434"
  }
}

LLM 和 Embedding 可以使用不同的 Provider。这是有意为之的——很多人用 Ollama 本地跑 Embedding(免费、低延迟),同时用 OpenAI 的 GPT-4 做摘要生成(效果更好)。

OpenAI 兼容:事实标准

在 6 个 Provider 中,Ollama、Custom 和 OpenAI 本身都使用 @ai-sdk/openai 适配器。这不是巧合,而是因为 OpenAI 的 API 格式已经成为事实标准

几乎所有本地推理框架(Ollama、vLLM、LocalAI、LiteLLM、llama.cpp server)都实现了 OpenAI 兼容的 /v1/chat/completions/v1/embeddings 端点。这意味着 @ai-sdk/openai 适配器不仅能对接 OpenAI 官方 API,还能对接整个生态。

AI SDK 的 Provider 设计也反映了这个现实:

  • @ai-sdk/openai — 最通用,覆盖 OpenAI + 所有兼容实现
  • @ai-sdk/anthropic — Anthropic 独有的消息格式(system prompt 分离、tool_use 结构)
  • @ai-sdk/google — Google 独有的多模态格式(parts 数组)
  • @ai-sdk/azure — Azure OpenAI 的端点格式差异(部署名 vs 模型名)

对于大多数应用,@ai-sdk/openai 一个适配器就能覆盖 80% 的场景。ChatCrystal 选择支持全部 6 个,是为了让用户有最大的灵活性。

错误处理与边界情况

AI SDK 统一了 API 格式,但不能统一所有行为。几个需要注意的差异:

速率限制:OpenAI 和 Anthropic 有不同的速率限制策略。AI SDK 不内置重试,ChatCrystal 在服务层通过 p-queue 控制并发和请求频率。

上下文窗口:每个模型的上下文窗口不同(GPT-4o 128K、Claude 200K、Ollama 取决于模型)。AI SDK 不做截断,ChatCrystal 在 prepareTranscript() 中主动截断到 32000 字符(通过 config.ts 中的 maxInputChars 配置)。

Embedding 维度:不同 Embedding 模型输出的向量维度不同(nomic-embed-text 是 768,text-embedding-3-small 是 1536)。vectra 索引在创建时确定维度,切换 Embedding 模型需要重建索引。

认证方式:Ollama 不需要 API Key,Azure 需要 Endpoint URL 而不是 Base URL。Provider 工厂函数封装了这些差异,但配置界面需要根据 Provider 动态显示/隐藏字段。

小结

Vercel AI SDK 的价值不在于它做了什么复杂的事,而在于它把一件简单的事标准化了。generateTextgenerateObjectembed 三个函数覆盖了 90% 的 AI 调用场景,Provider 适配器覆盖了 90% 的模型服务。

ChatCrystal 的 Provider 工厂模式在此基础上又加了一层:它让切换 Provider 变成改一行配置的事,而不是改一行代码的事。用户在设置页面选择 Provider、填入 API Key、选择模型,系统自动组装出正确的 LanguageModelEmbeddingModel 实例。

这种分层抽象——AI SDK 统一 API 格式,Provider 工厂统一配置方式——让 ChatCrystal 能用最少的代码支持最多的模型服务。新增一个 Provider 只需要实现 ProviderEntry 接口并注册到 Map 中,不超过 30 行代码。


项目地址:github.com/ZengLiangYi…

如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。