Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 03 节: 多模型适配 (Provider Registry)

模型无关性的本质是:统一内部表示 + 边界转换。内部永远使用同一种消息格式,只在调用各 Provider 的瞬间做格式翻译。上下文组装、降级路由都建立在这个抽象之上。

本节改动全景

相比第 02 节,本节将 LLM 调用层从主循环中完全剥离:

改动第 02 节第 03 节
LLM 调用直接调用 OpenAI SDKchatWithFallback(messages, chain)
消息类型OpenAI.Chat.ChatCompletionMessageParam[]统一 Message[] 接口
上下文管理无,消息无限增长自动截断 + 压缩摘要
多模型支持单一 Provider可注册任意 Provider,错误自动降级

工具系统(extractJSONtoolRegistry)完整复用,主循环结构不变。


文件结构

src/
  providers/
    types.ts      — Message / Provider 统一接口定义
    openai.ts     — OpenAI Provider 实现
    claude.ts     — Claude Provider 实现(格式转换核心)
    registry.ts   — 注册表 + chatWithFallback 降级路由
  context.ts      — Token 估算 / 截断 / 压缩
  tools.ts        — 工具系统(从第 02 节复用)
  index.ts        — 主循环

1. 统一接口:Provider 抽象

问题:耦合在 SDK 类型上

第 02 节的消息数组类型是 OpenAI.Chat.ChatCompletionMessageParam[]——这是 OpenAI SDK 的私有类型,一旦想切换到 Claude,整个消息历史的类型都要改。

解决方案:定义内部统一类型

// src/providers/types.ts
export interface Message {
  role: 'system' | 'user' | 'assistant';
  content: string;
}

export interface Provider {
  name: string;
  contextWindow: number;   // 模型 token 上限
  chat(messages: Message[]): Promise<string>;
}

关键设计Provider 接口只暴露一个 chat 方法,接收统一的 Message[],返回字符串。每个 Provider 实现内部负责把 Message[] 翻译成自己的 API 格式——格式差异被封装在 Provider 边界内,主循环对此无感知


2. 格式转换:OpenAI vs Claude

这是本节最核心的工程问题。两家 API 的消息格式存在本质差异:

字段OpenAIAnthropic (Claude)
system 消息放在 messages 数组首位messages 中提取,作为独立顶层字段
role 取值system / user / assistant只允许 user / assistant
调用方式client.chat.completions.create({messages})client.messages.create({system, messages})

OpenAI Provider(直接映射)

// src/providers/openai.ts
async chat(messages: Message[]): Promise<string> {
  const completion = await client.chat.completions.create({
    model,
    messages: messages.map(m => ({ role: m.role, content: m.content })),
  });
  return completion.choices[0].message.content ?? '';
}

OpenAI 的格式与内部 Message 天然兼容,几乎是透传。

Claude Provider(格式转换)

// src/providers/claude.ts
async chat(messages: Message[]): Promise<string> {
  // Anthropic 要求 system 作为独立顶层字段,不能混在 messages 里
  const system = messages.find(m => m.role === 'system')?.content ?? '';
  const turns  = messages
    .filter(m => m.role !== 'system')
    .map(m => ({ role: m.role as 'user' | 'assistant', content: m.content }));

  const response = await client.messages.create({
    model,
    max_tokens: 8096,
    system,           // ← 独立传入
    messages: turns,  // ← 不含 system
  });

  const block = response.content[0];
  return block.type === 'text' ? block.text : '';
}

这段代码是 Provider 机制的价值体现:调用方传入统一的 Message[],格式转换完全在 Provider 内部完成,主循环对 OpenAI 和 Claude 的调用代码完全相同。


3. 上下文组装器

问题:消息历史无限增长

第 02 节的 messages 数组随对话轮次无限增长,迟早会超出模型的 context window 上限,触发 API 报错。

三层处理流程

assembleContext(messages, provider)
        │
        ▼
  1. 估算 token 数
        │
  超出上限?
   ├─ 否 → 直接返回
   │
   └─ 是 → truncate()
              │
          仍超限?(极少发生)
           ├─ 否 → 返回
           │
           └─ 是 → compress() → truncate() → 返回

Token 估算

// src/context.ts
function estimateTokens(text: string): number {
  return Math.ceil(text.length / 4);  // 4 字符 ≈ 1 token(粗估)
}

无需引入 tokenizer 依赖,粗估足够指导截断决策。对中文会低估(中文约 2 字符/token),但截断时保留 10% headroom 可以弥补。

截断策略

保留 system 消息(不可丢),从末尾向前尽量多保留对话轮次:

function truncate(messages: Message[], limit: number): Message[] {
  const system = messages.filter(m => m.role === 'system');
  const turns  = messages.filter(m => m.role !== 'system');

  let budget = limit - messagesTokens(system);
  let kept = 0;

  for (let i = turns.length - 1; i >= 0; i--) {
    const cost = estimateTokens(turns[i].content) + 4;
    if (budget - cost < 0) break;
    budget -= cost;
    kept++;
  }

  return [...system, ...turns.slice(turns.length - kept)];
}

越新的消息越重要:从最新轮次开始保留,超限后直接丢弃旧轮次。

压缩/摘要

当截断后仍超限(历史中有单条超长消息时可能发生),用 LLM 对旧消息做摘要:

async function compress(messages: Message[], provider: Provider): Promise<Message[]> {
  const KEEP_RECENT = 4;
  const toSummarize = turns.slice(0, -KEEP_RECENT);
  const recent      = turns.slice(-KEEP_RECENT);

  const summary = await provider.chat([{
    role: 'user',
    content: 'Summarize the following conversation history concisely:\n\n' +
      toSummarize.map(m => `${m.role}: ${m.content}`).join('\n'),
  }]);

  return [
    ...system,
    { role: 'user', content: `[Conversation summary]\n${summary}` },
    ...recent,
  ];
}

摘要本身消耗的 token 远少于原始消息,之后再经一轮 truncate 保证最终不超限。


4. Provider 注册表与错误降级

注册表(同第 02 节工具注册表的模式)

// src/providers/registry.ts
const providerRegistry = new Map<string, Provider>();

export function registerProvider(provider: Provider) {
  providerRegistry.set(provider.name, provider);
}

错误降级路由

export async function chatWithFallback(
  messages: Message[],
  chain: string[],   // Provider 名称列表,按优先级排列
): Promise<string> {
  const errors: string[] = [];

  for (const name of chain) {
    const provider = providerRegistry.get(name)!;
    const ctx = await assembleContext(messages, provider);  // ← 每个 Provider 独立组装上下文
    try {
      return await provider.chat(ctx);
    } catch (err: any) {
      console.warn(`[provider:${name}] failed — ${err.message}`);
      errors.push(`${name}: ${err.message}`);
    }
  }

  throw new Error(`All providers failed:\n${errors.join('\n')}`);
}

两个设计细节:

  1. 每个 Provider 独立组装上下文:不同 Provider 的 contextWindow 不同,比如Claude Haiku 4.5 是 200K,OpenAI GPT-4o 是 128K等,必须分别计算截断边界
  2. 所有 Provider 都失败才抛错:只要链条中有一个成功就返回,报错信息收集后统一抛出,方便排查

主循环调用(变化极小)

// 第 02 节
const completion = await client.chat.completions.create({ model, messages });
const reply = completion.choices[0].message.content ?? '';

// 第 03 节
const reply = await chatWithFallback(messages, providerChain);

主循环只改了这一行,工具分发逻辑完全不变。


架构对比

第 02 节                           第 03 节

index.ts                           index.ts
  ├─ OpenAI SDK(直接调用)  →       ├─ chatWithFallback(messages, chain)
  ├─ 消息类型:OpenAI 私有类型         │       │
  └─ 消息无上限增长                   │   providers/registry.ts
                                    │       ├─ assembleContext()  ← context.ts
                                    │       ├─ openai.ts (Provider)
                                    │       └─ claude.ts (Provider)
                                    │
                                    └─ messages: Message[]  ← 统一内部类型

增加新 Provider 只需:
  1. 实现 Provider 接口(格式转换封装在此)
  2. registerProvider(createXxxProvider())
  3. 加入 providerChain

知识点总结

知识点说明
统一内部消息格式内部维护与 SDK 无关的 Message[],格式转换封装在 Provider 边界内
格式转换是 Provider 的核心职责Claude 需提取 system 字段,OpenAI 直接映射——差异完全隔离在各自实现里
Token 粗估够用4 字符≈1 token 无需 tokenizer 依赖,配合 10% headroom 可安全截断
截断优先于压缩丢弃旧消息比 LLM 摘要便宜得多,压缩是最后手段
每 Provider 独立组装上下文contextWindow 不同,必须分别计算截断边界,不能跨 Provider 复用同一份 ctx
错误降级链按顺序尝试,第一个成功即返回;全部失败才抛错并汇总原因
主循环与 Provider 解耦主循环只调用 chatWithFallback,对 Provider 数量、类型、格式完全无感知

试一试

cd sections/03-provider-registry/nodejs
cp .env.example .env
# 填入 OPENAI_API_KEY 和 ANTHROPIC_API_KEY
npm install
npm start

.env 关键配置:

ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-opus-4-7

OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4o

PRIMARY_PROVIDER=claude     # 主 Provider
FALLBACK_PROVIDER=openai    # 降级 Provider
# 正常对话(走主 Provider claude)
You: 用一句话介绍你自己
xclaw: 我是 xclaw,一个由 Claude 驱动的 AI 助手...

# 工具调用仍正常(复用第 02 节的工具系统)
You: 列出 src 目录下的文件
xclaw uses [shell]: { command: 'ls src/' }
...
xclaw: src 目录下有以下文件...

# 验证降级:将 ANTHROPIC_API_KEY 改为无效值后重启
# 期望:Claude 报错后自动切换到 OpenAI,对话继续
[provider:claude] failed — 401 Unauthorized, trying next...
xclaw: ...(由 OpenAI 回答)

# 验证上下文截断:大量对话后不会报 context length 错误