跳到正文
曾亮懿 ZengLiangYi Blog

AI SDK Provider 抽象:能力表与协议边界

核对 ChatCrystal 的七项 Provider 注册、AI SDK 7 模型工厂和结构化输出,用无网络 mock 验证 Responses、Chat Completions、Embedding 与 schema 失败。

曾亮懿

曾亮懿

阅读约 4 分钟
目录 展开 收起

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

当前注册表有七项 Provider

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读取 appConfig.llm;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使用 generateText、Output.object 和 SummarizeSchema,传 instructions、prompt、maxOutputTokens=4096、显式 maxRetries=3,随后把 tags 转小写。这里没有原稿示例中的 generateObject 调用。

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

AI SDK 的generateText 文档说明 maxRetries 的默认值是 2。项目显式设置的 3 不能写成 SDK 默认三次重试,更不能概括为任何 schema 错误都会重新生成。SDK 请求重试和外层任务队列重试还可能叠加,分别核对可重试错误和次数。

无网络验证三种路径与一次 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,不访问任何真实模型或网络端点。

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按字符预算选取回合并标记省略:不超过三回合的路径会截断;多回合路径保留首回合和末两回合,最终拼接没有再次硬截断,因此不保证严格字符上限,也不能证明适配任意模型的上下文窗口。更换 Embedding 模型即使维度相同,也可能换了向量空间,应验收索引重建与持久化状态,不能用一次模型对象创建替代迁移完成。

分享:

相关文章

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

从 Claude Code、Codex、Cursor、Trae 和 Copilot 的实际解析器建立验收矩阵,检查工具标记、思考文本、顺序与快照覆盖范围。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #TypeScript #Node.js

SourceAdapter 内部链路:身份、内容哈希与重新导入

对照 ChatCrystal 的注册表、导入器和远端载荷,验证会话身份、内容哈希、元数据捷径与 parserVersion 的实际边界。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #TypeScript #Node.js

代码规范工具:核对 Biome、ESLint 与格式化的真实分工

阅读 ChatCrystal 的实际脚本与 Flat Config,用离线 ESLint 诊断验证规则,再区分语法检查、类型检查、格式化和 CI 门禁。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #TypeScript #Node.js

从文章到代码

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