---
title: AI SDK Provider 抽象：能力表与协议边界
description: 核对 ChatCrystal 的七项 Provider 注册、AI SDK 7 模型工厂和结构化输出，用无网络 mock 验证 Responses、Chat Completions、Embedding 与 schema 失败。
image: "https://zengliangyi.com/og/blog/ai-sdk-provider-abstraction.png"
---

统一模型接口让业务更容易复用，实际兼容性仍取决于协议、鉴权、模型能力和输出验证。ChatCrystal 的固定实现使用 AI SDK 7.0.97；本文核对当前工厂与调用路径，再通过完全替换 fetch 的样本观察协议选择，避免把“OpenAI 兼容”当作所有端点都兼容。

## 当前注册表有七项 Provider

[providers.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/providers.ts)使用 Map 注册工厂，接口还包含 supportsModelDiscovery。以下是这份实现的能力表，不是对服务商全部未来产品能力的断言。

| 注册名 | 语言模型路径 | Embedding 工厂 | 发现模型 / Base URL 要求 |

| --- | --- | --- | --- |

| ollama | createOpenAI(…).chat(model) | 有 | 支持 / 要求，工厂有本地默认值 |

| openai | createOpenAI(…)(model) | 有 | 支持 / 可选 |

| orcarouter | 固定网关上的 createOpenAI(…)(model) | 无 | 支持 / 不要求，忽略传入 baseURL |

| anthropic | createAnthropic(…)(model) | 无 | 支持 / 不要求 |

| google | createGoogle(…)(model) | 有 | 支持 / 不要求 |

| azure | createAzure(…)(model) | 有 | 不支持 / 要求 |

| custom | createOpenAI(…)(model) | 有 | 支持 / 要求 |

除 ollama 外，注册元数据都要求 API Key。Ollama 工厂传占位 key，去除 URL 末尾斜杠后追加 /v1；这并不能证明任意代理、鉴权设置或已带 /v1 的地址都正确。Anthropic 与 OrcaRouter 在本项目没有 Embedding 工厂，不应概括为服务商永远不可能提供相应能力。

## 同一个 SDK 工厂也可能选择不同协议

[getLanguageModel](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/llm.ts)读取 appConfig.llm；[embedding.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/embedding.ts)的私有 getEmbeddingModel 读取另一份配置，缺少工厂时抛错。语言和 Embedding Provider 可以分别设置，仍需检查模型对应能力与索引兼容性。

固定的 @ai-sdk/openai 4.0.65 中，直接 provider(model) 使用 Responses 路径，显式 .chat(model) 使用 Chat Completions。当前 Ollama 选后者，OpenAI、OrcaRouter 与 Custom 选前者。一个只实现 /chat/completions 的兼容服务，并未因此证明能接受 Custom 的 /responses 请求。

这些能力标志和工厂类型不代表已经完成端点可达、真实鉴权、结构化输出或指定模型能力验证。未知 Provider 会由 getProvider 抛错；返回模型对象也还没有证明一次真实请求能成功。

## 当前结构化摘要调用与原稿接口不同

[summarize.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/summarize.ts)使用 generateText、Output.object 和 SummarizeSchema，传 instructions、prompt、maxOutputTokens=4096、显式 maxRetries=3，随后把 tags 转小写。这里没有原稿示例中的 generateObject 调用。

schema 的 describe 提供自然语言说明，不等于实际的 max、min 或数量约束；例如“标题20字以内”没有对应 z.string().max(20)。结构化输出通过也不证明总结事实正确。[结构化 JSON 提示](/blog/structured-json-prompts/)进一步区分 schema 与语义验收。

AI SDK 的[generateText 文档](https://ai-sdk.dev/docs/reference/ai-sdk-core/generate-text)说明 maxRetries 的默认值是 2。项目显式设置的 3 不能写成 SDK 默认三次重试，更不能概括为任何 schema 错误都会重新生成。SDK 请求重试和外层[任务队列重试](/blog/pqueue-task-queues/)还可能叠加，分别核对可重试错误和次数。

## 无网络验证三种路径与一次 schema 失败

独立目录固定安装 `npm install --ignore-scripts --save-exact ai@7.0.97 @ai-sdk/openai@4.0.65 zod@4.6.2`，保存 lesson.mjs，Node 24+ 运行。fetch 完全替换为本地 Response 构造器，API Key 与模型名都是 fixture，**不访问任何真实模型或网络端点**。



```js
import   assert   from   'node:assert/strict'  ;  
  import   { createOpenAI }   from   '@ai-sdk/openai'  ;  
  import   { embed, generateText, Output }   from   'ai'  ;  
  import   { z }   from   'zod'  ;  
  
  const   paths   =   [];  
  let   content   =   JSON  .  stringify  ({ title:   'fixture'   });  
  const   provider   =   createOpenAI  ({  
    baseURL:   'https://fixture.invalid/v1'  ,  
    apiKey:   'fixture-only'  ,  
    fetch  :   async   (  input  )   =>   {  
      const   path   =   new   URL  (  String  (input)).pathname;  
      paths.  push  (path);  
      let   body;  
      if   (path.  endsWith  (  '/embeddings'  )) {  
        body   =   {  
          object:   'list'  ,  
          data: [{ object:   'embedding'  , index:   0  , embedding: [  1  ,   0  ] }],  
          model:   'fixture'  ,  
          usage: { prompt_tokens:   1  , total_tokens:   1   },  
        };  
      }   else   if   (path.  endsWith  (  '/chat/completions'  )) {  
        body   =   {  
          id:   'chat_fixture'  ,  
          created:   1  ,  
          model:   'fixture'  ,  
          choices: [{ index:   0  , message: { role:   'assistant'  , content }, finish_reason:   'stop'   }],  
          usage: { prompt_tokens:   1  , completion_tokens:   1  , total_tokens:   2   },  
        };  
      }   else   {  
        body   =   {  
          id:   'resp_fixture'  ,  
          created_at:   1  ,  
          model:   'fixture'  ,  
          output: [  
            {  
              type:   'message'  ,  
              id:   'msg_fixture'  ,  
              role:   'assistant'  ,  
              content: [{ type:   'output_text'  , text: content, annotations: [] }],  
            },  
          ],  
          usage: { input_tokens:   1  , output_tokens:   1   },  
        };  
      }  
      return   new   Response  (  JSON  .  stringify  (body), {  
        status:   200  ,  
        headers: {   'Content-Type'  :   'application/json'   },  
      });  
    },  
  });  
  const   output   =   Output.  object  ({ schema: z.  object  ({ title: z.  string  () }) });  
  const   responses   =   await   generateText  ({  
    model:   provider  (  'fixture'  ),  
    output,  
    prompt:   'fixture'  ,  
    maxRetries:   0  ,  
  });  
  const   chat   =   await   generateText  ({  
    model: provider.  chat  (  'fixture'  ),  
    output,  
    prompt:   'fixture'  ,  
    maxRetries:   0  ,  
  });  
  const   vector   =   await   embed  ({  
    model: provider.  embeddingModel  (  'fixture'  ),  
    value:   'fixture'  ,  
    maxRetries:   0  ,  
  });  
  assert.  deepEqual  (responses.output, { title:   'fixture'   });  
  assert.  deepEqual  (chat.output, { title:   'fixture'   });  
  assert.  deepEqual  (vector.embedding, [  1  ,   0  ]);  
  assert.  deepEqual  (paths, [  '/v1/responses'  ,   '/v1/chat/completions'  ,   '/v1/embeddings'  ]);  
  content   =   JSON  .  stringify  ({ title:   42   });  
  const   before   =   paths.  length  ;  
  await   assert.  rejects  (  
    async   ()   =>   {  
      const   invalid   =   await   generateText  ({  
        model:   provider  (  'fixture'  ),  
        output,  
        prompt:   'fixture'  ,  
        maxRetries:   3  ,  
      });  
      return   invalid.output;  
    },  
    (  error  )   =>   error.name   ===   'AI_NoObjectGeneratedError'  
  );  
  assert.  equal  (paths.  length   -   before,   1  );  
  console.  log  (  'protocol selection and schema rejection pass without network'  );
```



三次正常调用观察到 /responses、/chat/completions、/embeddings；结构化正文和固定向量与期望相同。后续 HTTP 200 返回 title 数字，schema 要求字符串，即使 maxRetries=3，也在一次 mock 请求后拒绝为 AI_NoObjectGeneratedError。该反例验证这个指定错误，不代表所有错误都不可重试。

把 .chat 换成直接 provider 调用，路径断言失败；把 title schema 改成接受任意值，拒绝断言失败。恢复后应通过。这个 mock 只证明客户端的协议和验证行为，没有证明外部服务真的支持它们。

## 更换 Provider 的验收清单要落到能力

检查最终请求 URL、请求体和鉴权头；分别试普通文本、所需结构化 schema、Embedding、异常状态、无效输出、限流和取消。只有真实端点测试才能得出服务兼容结论；本篇没有成本、排名、质量或耗时比较。

maxInputChars 在当前配置默认 32000，是字符预算而非模型 token 上限。[prepareTranscript](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/transcript.ts)按字符预算选取回合并标记省略：不超过三回合的路径会截断；多回合路径保留首回合和末两回合，最终拼接没有再次硬截断，因此不保证严格字符上限，也不能证明适配任意模型的上下文窗口。更换 Embedding 模型即使维度相同，也可能换了向量空间，应验收索引重建与[持久化状态](/blog/vectra-index-recovery/)，不能用一次模型对象创建替代迁移完成。

分享：

##  相关文章

[##  五种 AI 对话解析器：字段保留与损失边界

从 Claude Code、Codex、Cursor、Trae 和 Copilot 的实际解析器建立验收矩阵，检查工具标记、思考文本、顺序与快照覆盖范围。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#TypeScript

#Node.js](/blog/ai-conversation-parser-boundaries)    [##  SourceAdapter 内部链路：身份、内容哈希与重新导入

对照 ChatCrystal 的注册表、导入器和远端载荷，验证会话身份、内容哈希、元数据捷径与 parserVersion 的实际边界。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#TypeScript

#Node.js](/blog/source-adapter-internals)    [##  代码规范工具：核对 Biome、ESLint 与格式化的真实分工

阅读 ChatCrystal 的实际脚本与 Flat Config，用离线 ESLint 诊断验证规则，再区分语法检查、类型检查、格式化和 CI 门禁。          曾亮懿       2026年10月6日      阅读约 4 分钟

#ChatCrystal

#TypeScript

#Node.js](/blog/lint-format-tooling)

##  从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录，欢迎交流实现细节。

[访问我的 GitHub](https://github.com/ZengLiangYi)

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "ZengLiangYi Blog",
  "alternateName": [
    "Zeng Liangyi",
    "ZengLiangYi",
    "zengliangyi.com"
  ],
  "url": "https://zengliangyi.com",
  "description": "曾亮懿的个人网站。记录 AI Native 探索、开发者工具与全栈产品实践。"
}
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "AI SDK Provider 抽象：能力表与协议边界",
  "description": "核对 ChatCrystal 的七项 Provider 注册、AI SDK 7 模型工厂和结构化输出，用无网络 mock 验证 Responses、Chat Completions、Embedding 与 schema 失败。",
  "url": "https://zengliangyi.com/blog/ai-sdk-provider-abstraction/",
  "image": "https://zengliangyi.com/images/blog/ai-sdk-provider-abstraction.svg",
  "datePublished": "2026-10-07T00:00:00.000Z",
  "dateModified": "2026-10-07T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/blog/ai-sdk-provider-abstraction/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "首页",
      "item": "https://zengliangyi.com/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "文章",
      "item": "https://zengliangyi.com/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "AI SDK Provider 抽象：能力表与协议边界",
      "item": "https://zengliangyi.com/blog/ai-sdk-provider-abstraction/"
    }
  ]
}
```
