一、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 调用工具时参数类型正确,工具实现侧不用再做参数校验。