前端的 AI 学习之路 02 之 Provider 与 Structured Output - 规范化模型输入输出

一、LLM 适配器 Provider

这是一个让上层业务代码与具体 LLM 厂商解耦的操作也就是目的是支持 "换模型不改业务代码"。

1.1、为啥需要适配器接入 LLM?

当今市面上的 LLM 厂商有几十家------OpenAI、Anthropic、Google、阿里通义、字节豆包、本地 Ollama......每家的 API 协议、鉴权方式、参数命名都不完全一样。如果你的业务代码直接 fetch 某家 API,一旦想换模型(比如从 GPT-4 换成 Claude),就得改遍所有调用点。

适配器模式(Adapter Pattern) 解决的就是这个问题:定义一套统一的接口,每个厂商实现自己的适配器,业务代码只面向接口编程。

markdown 复制代码
你的业务代码(只认 BaseChatModel 接口)
        │
        ├─ ChatOpenAI(适配 OpenAI API)
        ├─ ChatAnthropic(适配 Anthropic API)
        ├─ ChatOllama(适配本地推理)
        └─ ChatGoogleGenerativeAI(适配 Gemini)

换模型 = 换一行实例化代码,业务逻辑零改动。这也是为什么可以把"用 Claude 还是本地 Ollama"做成一个运行时配置项(如 provider: 'anthropic' | 'openai' | 'ollama'),而不是写死在代码里。

1.2、适配器 provider 的能力

LangChain 把这套适配器抽象成 BaseChatModel 基类(在 @langchain/core/language_models/chat_models),所有厂商适配器都继承它,提供一致的能力:

能力 方法 说明
非流式调用 invoke(messages) 阻塞返回完整 AIMessage
流式调用 stream(messages) 返回 AsyncIterable<AIMessageChunk>
工具调用 bindTools(tools) 让模型知道有哪些工具可用,返回绑定后的模型
结构化输出 withStructuredOutput(schema) 强制模型按 Schema 返回(见第二节)
Token 用量 response.usage_metadata 调用后返回 input/output/cache 等用量
批量调用 batch(messagesList) 并发处理多组消息

所有适配器的实例化方式高度一致:

typescript 复制代码
import { ChatAnthropic } from '@langchain/anthropic';
import { ChatOpenAI } from '@langchain/openai';
import { ChatOllama } from '@langchain/ollama';

// 同样的构造参数风格,只换类名
const models = {
  anthropic: new ChatAnthropic({
    model: 'claude-sonnet-4-20250514',
    temperature: 0.7,
    maxTokens: 4096,
  }),

  openai: new ChatOpenAI({
    model: 'gpt-4o',
    temperature: 0.7,
    maxTokens: 4096,
  }),

  // 本地 Ollama:零隐私外泄,离线可用
  local: new ChatOllama({
    model: 'qwen2.5:14b',
    temperature: 0.7,
  }),
};

// 业务代码:不知道也不关心底层是哪家
async function ask(model: any, question: string) {
  const response = await model.invoke([
    { role: 'user', content: question },
  ]);
  return response.content;
}

// 同一段业务逻辑,无缝切换底层模型
await ask(models.anthropic, '你好'); // 用 Claude
await ask(models.openai, '你好');    // 用 GPT-4o
await ask(models.local, '你好');     // 用本地模型

一个常见的封装模式:写一个 createModel(config) 工厂函数,根据 config.provider 字段动态选择适配器,把"选哪个 provider"这件事收敛到单一真源。新增一个厂商支持,只需要在这里加一个 case 分支。

1.3、模型路由 / 智能选模型

不是所有任务都需要 GPT-4o / Claude Sonnet 这类大模型------按任务复杂度自动选模型能省 50%+ 成本:

typescript 复制代码
import { ChatAnthropic } from '@langchain/anthropic';
import { ChatOpenAI } from '@langchain/openai';

// 任务分类器:判断任务复杂度
function selectModelByComplexity(task: string) {
  // 简单任务(分类、提取、翻译)→ 用小模型
  const simplePatterns = [/翻译.*为/, /分类.*:/, /提取.*关键词/];
  if (simplePatterns.some(p => p.test(task))) {
    return new ChatAnthropic({
      model: 'claude-haiku-4-5', // 小模型,便宜 10x
    });
  }
  // 复杂任务(推理、创作、规划)→ 用大模型
  return new ChatAnthropic({
    model: 'claude-sonnet-4-20250514',
  });
}

async function ask(task: string, input: string) {
  const model = selectModelByComplexity(task);
  return await model.invoke([{ role: 'user', content: input }]);
}

更高级:基于成本 + 延迟的路由

css 复制代码
// 模型目录(成本、延迟、能力标签)
const MODEL_CATALOG = {
  'haiku': { cost: 0.25, latency: 0.5, capability: ['classification', 'extraction'] },
  'sonnet': { cost: 3, latency: 2, capability: ['reasoning', 'coding', 'writing'] },
  'opus': { cost: 15, latency: 5, capability: ['complex-reasoning', 'research'] },
};

function selectModel(requiredCapability: string, maxCost = 5) {
  const candidates = Object.entries(MODEL_CATALOG)
    .filter(([_, m]) => m.capability.includes(requiredCapability) && m.cost <= maxCost);

  // 从候选中选最便宜的(满足能力的前提下)
  candidates.sort((a, b) => a[1].cost - b[1].cost);
  return candidates[0][0];
}

1.4、Fallback / 容灾

主 Provider 故障时自动切备 Provider------保证服务可用性:

javascript 复制代码
import { ChatAnthropic } from '@langchain/anthropic';
import { ChatOpenAI } from '@langchain/openai';
import { ChatOllama } from '@langchain/ollama';

const primary = new ChatAnthropic({ model: 'claude-sonnet-4-20250514' });
const fallback1 = new ChatOpenAI({ model: 'gpt-4o-mini' });
const fallback2 = new ChatOllama({ model: 'qwen2.5:14b' }); // 本地兜底,永不挂

// ① 简单版:链式 fallback
async function invokeWithFallback(input: any) {
  for (const model of [primary, fallback1, fallback2]) {
    try {
      return await model.invoke(input);
    } catch (err) {
      console.warn(`Provider ${model.constructor.name} 失败:`, err.message);
      // 继续试下一个
    }
  }
  throw new Error('所有 Provider 都挂了');
}

// ② 进阶版:带超时 + 自动重试
async function invokeWithResilience(input: any) {
  const providers = [
    { name: 'anthropic', model: primary, timeout: 30_000 },
    { name: 'openai', model: fallback1, timeout: 20_000 },
    { name: 'ollama', model: fallback2, timeout: 60_000 }, // 本地慢一点
  ];

  for (const { name, model, timeout } of providers) {
    try {
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), timeout);

      const result = await model.invoke(input, { signal: controller.signal });
      clearTimeout(timer);
      return { provider: name, result };
    } catch (err) {
      console.warn(`[${name}] 失败: ${err.message},切下一个`);
    }
  }
  throw new Error('所有 Provider 都不可用');
}

Fallback 的成本考量 :fallback 通常用更便宜/更稳定的备选(如本地 Ollama),而不是更贵的。因为 Fallback 触发时已经是降级场景,能用即可,不是追求质量。典型的三级 fallback 设计是"云端 Anthropic → 云端 OpenAI → 本地 Ollama"。


二、结构化结果 Structured Output

让模型按 Schema 返回可校验对象,而不是让你从自由文本中猜测和提取。

2.1、结构化返回的需求原因

LLM 默认输出的是自由文本 ------你问"给我用户的姓名和年龄",它可能回答 "张三,25岁""姓名:张三,年龄:25""张三(25)"......格式完全不固定。

如果你要把它喂给下游程序(存数据库、调另一个 API、渲染 UI 表单),你得写脆弱的正则/字符串解析,稍微换个格式就崩。这是 Agent 系统里最常见的 bug 来源。

Structured Output(结构化输出) 机制让你声明一个 Schema(用 Zod / JSON Schema),模型保证按这个结构返回,你可以直接 JSON.parse 后用类型安全的字段访问。

方式 输出示例 解析难度
自由文本 "张三今年25岁" 😩 要写正则,易碎
手动提示 JSON ```json\n{"name":"张三","age":25}\n``` 😟 模型可能加 markdown 包裹,要清洗
Structured Output {"name":"张三","age":25} ✅ 保证是合法 JSON 且符合 Schema

2.2、规范模型回答结构化返回

LangChain 通过 withStructuredOutput(schema) 方法实现。传入一个 Zod schema,返回一个新的模型实例,它的 invoke / stream 会直接返回符合 schema 的对象(而不是 AIMessage 字符串)。

css 复制代码
import { ChatAnthropic } from '@langchain/anthropic';
import { z } from 'zod';

// 1. 用 Zod 定义你想要的输出结构
const UserSchema = z.object({
  name: z.string().describe('用户的姓名'),
  age: z.number().int().min(0).max(150).describe('用户的年龄'),
  hobbies: z.array(z.string()).describe('用户的爱好列表'),
});

// 2. 绑定结构化输出
const model = new ChatAnthropic({ model: 'claude-sonnet-4-20250514' });
const structuredModel = model.withStructuredOutput(UserSchema);

// 3. 调用:返回值直接是符合 Schema 的对象,无需解析
const result = await structuredModel.invoke([  { role: 'user', content: '我叫张三,今年25岁,喜欢编程、爬山和看电影。' },]);

// TypeScript 知道 result 的类型是 { name: string; age: number; hobbies: string[] }
console.log(result.name);     // "张三"
console.log(result.age);      // 25
console.log(result.hobbies);  // ["编程", "爬山", "看电影"]

底层原理withStructuredOutput 其实是把 schema 转成厂商专属的"工具调用"格式(如 OpenAI 的 function calling、Anthropic 的 tool_use),让模型"调用"一个虚拟工具,工具的参数就是结构化数据。不同厂商实现细节不同,但 LangChain 屏蔽了这些差异。

💡 .describe() 很重要------它会被翻译进 schema 描述,告诉模型每个字段应该填什么。描述越清晰,模型输出越准。

2.3、流式结构化输出

stream 默认返回字符串 chunk,但如果你的 schema 是大对象(嵌套深、字段多),可以流式返回部分 JSON------边生成边解析,边给用户显示进度:

csharp 复制代码
import { ChatAnthropic } from '@langchain/anthropic';
import { z } from 'zod';
import { JsonOutputParser } from '@langchain/core/output_parsers';

// 定义 schema(假设有 5 个字段)
const ArticleSchema = z.object({
  title: z.string(),
  outline: z.array(z.string()),
  sections: z.array(z.object({
    heading: z.string(),
    content: z.string(),
  })),
  summary: z.string(),
  keywords: z.array(z.string()),
});

const model = new ChatAnthropic({ model: 'claude-sonnet-4-20250514' });
const parser = new JsonOutputParser();

// 用 stream + parser 边生成边解析
const stream = await model.stream([
  { role: 'user', content: '写一篇关于 RAG 的文章' },
]);

let partial: any = {};
for await (const chunk of stream) {
  // JsonOutputParser 会增量合并 JSON 片段
  partial = parser.parsePartialJson(chunk.content as string);
  console.log(`已生成: ${Object.keys(partial).join(', ')}`);
  // 实时显示已完成的字段
}

流式结构化的取舍 :对小 schema (3-5 字段)用 invoke 反而更快------LLM 一次性生成完整 JSON。对大 schema(10+ 字段、嵌套深)才用流式,能显著降低首字延迟。

2.4、错误边界处理

结构化输出不是 100% 可靠的。常见失败场景:

失败场景 现象 处理方式
模型生成不合法 JSON JSON.parse 抛异常 try-catch + 重试
字段类型不对(age 是字符串"25") Zod 校验失败 Zod 会自动 coerce 部分类型,或在 schema 里显式 .or(z.string().transform(Number))
模型乱加字段 多了 {"name":"张三","extra":"foo"} Zod 默认 .strict() 会拒绝;不 strict 则忽略
模型漏填必填字段 age Zod 抛错,捕获后给字段设默认值或重试

稳健的封装模式:

typescript 复制代码
async function safeStructuredInvoke<T>(
  model: any,
  schema: z.ZodSchema<T>,
  input: any,
  retries = 2,
): Promise<T | null> {
  const structured = model.withStructuredOutput(schema);
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const raw = await structured.invoke(input);
      // Zod 二次校验,确保类型安全
      return schema.parse(raw);
    } catch (err) {
      if (attempt === retries) {
        console.error(`结构化输出失败(重试 ${retries} 次后仍失败):`, err);
        return null; // 降级:返回 null,由调用方决定兜底逻辑
      }
      // 重试时在 prompt 里强调格式要求
      console.warn(`第 ${attempt + 1} 次结构化输出失败,重试中...`);
    }
  }
  return null;
}

// 使用
const UserSchema = z.object({
  name: z.string(),
  age: z.number(),
});
const user = await safeStructuredInvoke(model, UserSchema, [
  { role: 'user', content: '我叫李四,30岁' },
]);
if (user) {
  console.log(user.name, user.age); // 类型安全
} else {
  // 降级处理:提示用户或走自由文本路径
}

实战建议 :关键路径(如工具参数解析)一定要用结构化输出 + 重试;非关键路径(如生成文案)可以用自由文本 + 容错解析。在 Agent 调用工具的场景里,参数解析用 withStructuredOutput + Zod 校验几乎是标配------保证 Agent 调用工具时参数类型正确,工具实现侧不用再做参数校验。

相关推荐
JavaPub-rodert1 小时前
王仕宇RAG 实战教程(二):从 0 搭建一个可运行的 RAG 知识库——Embedding、Qdrant 与问答实战
人工智能·embedding
Setsuna_F_Seiei2 小时前
前端的 AI 学习之路 01 之 Agent API 调用 - 和 Agent 的基础对话
前端·人工智能·ai编程
咔咔学姐kk2 小时前
小白程序员必收藏:轻松入门AI Agent开发,大厂校招新风口!
人工智能·深度学习·ai·程序员·大模型·就业·大模型学习
2601_967097223 小时前
园区巡检机器人推荐:4项评估维度与主流产品横评
大数据·人工智能·信息可视化
stuartevil3 小时前
零基础怎么用AI文生漫剧做出一条完整视频
人工智能·音视频
极客猴子3 小时前
能提取抖音视频文案的APP推荐:短视频文案工具合集
人工智能·智能手机·音视频·语音识别
sel_93 小时前
【强化学习】Hands-on Modern RL项目实践|OPD 算法完整解析
人工智能·深度学习·算法·机器学习·语言模型
xushichang123_4 小时前
Agentic AI 应用 Token 开销居高不下,可借助哪些云平台与架构实现成本管控?
人工智能
世岩清上4 小时前
新能源宣传片怎么拍?如何用画面讲好你的绿色能源故事
大数据·人工智能·能源·宣传片·宣传片拍摄