第 18 课: Provider 架构 -- 以 OpenAI 为例

0 阅读5分钟

课程目标

精读 @langchain/openaiChatOpenAI 实现,理解 Provider 层如何实现 BaseChatModel 的核心抽象:消息格式转换、API 调用、流式响应处理、工具绑定。


18.1 Provider 的角色

在第 11 课中我们学习了 BaseChatModel 的抽象设计:Provider 只需实现 _generate() 方法。现在我们看 OpenAI 如何"填充"这个抽象。

BaseChatModel (抽象)
  └── BaseChatOpenAI<CallOptions>  (OpenAI 实现基类)
        └── ChatOpenAI  (对外暴露的具体类)

源码位置: libs/providers/langchain-openai/src/chat_models/base.ts


18.2 类结构与配置

export abstract class BaseChatOpenAI<
  CallOptions extends BaseChatOpenAICallOptions,
> extends BaseChatModel<CallOptions, AIMessageChunk>
  implements Partial<OpenAIChatInput>
{
  temperature?: number;
  topP?: number;
  frequencyPenalty?: number;
  presencePenalty?: number;
  model = "gpt-3.5-turbo";
  streaming = false;
  streamUsage = true;
  maxTokens?: number;
  apiKey?: OpenAIApiKey;

  client: OpenAIClient;          // OpenAI SDK 客户端实例
  clientConfig: ClientOptions;    // 客户端配置

  supportsStrictToolCalling?: boolean;  // 是否支持 strict 工具调用模式
  reasoning?: OpenAIClient.Reasoning;   // 推理模型选项
}

关键观察

  • BaseChatOpenAI 本身是泛型抽象类,CallOptions 参数允许子类扩展调用选项
  • 配置字段直接映射 OpenAI API 参数,降低心智负担
  • client 是原生 openai SDK 的实例,Provider 层是 SDK 的适配器

18.2.1 序列化支持

lc_serializable = true;

get lc_secrets(): { [key: string]: string } | undefined {
  return {
    apiKey: "OPENAI_API_KEY",
    organization: "OPENAI_ORGANIZATION",
  };
}

lc_secrets 声明哪些字段是敏感信息,序列化时会用环境变量名替代真实值。


18.3 消息格式转换

Provider 层的核心职责之一是将 LangChain 的统一消息格式转换为 OpenAI API 要求的格式。

18.3.1 LangChain 消息 -> OpenAI 格式

LangChain 的 BaseMessage 需要转换为 OpenAI 的 ChatCompletionMessageParam

LangChain 消息类型OpenAI role特殊处理
SystemMessagesystem直接映射
HumanMessageuser支持多模态 content
AIMessageassistant携带 tool_calls
ToolMessagetool需要 tool_call_id
FunctionMessagefunction遗留格式

18.3.2 角色映射

// libs/providers/langchain-openai/src/utils/misc.ts
export function messageToOpenAIRole(message: BaseMessage): string {
  // HumanMessage -> "user"
  // AIMessage -> "assistant"
  // SystemMessage -> "system"
  // ToolMessage -> "tool"
  // FunctionMessage -> "function"
}

18.3.3 多模态内容

HumanMessage.content 是数组时(包含文本和图片),会被转换为 OpenAI 的 content parts 格式:

// LangChain 统一格式
new HumanMessage({
  content: [
    { type: "text", text: "这张图片是什么?" },
    { type: "image_url", image_url: { url: "data:image/png;base64,..." } },
  ],
});

// 转换后的 OpenAI 格式
{
  role: "user",
  content: [
    { type: "text", text: "这张图片是什么?" },
    { type: "image_url", image_url: { url: "data:image/png;base64,..." } },
  ],
}

18.4 bindTools -- 工具绑定

源码位置: libs/providers/langchain-openai/src/chat_models/base.ts:662

override bindTools(
  tools: ChatOpenAIToolType[],
  kwargs?: Partial<CallOptions>
): Runnable<BaseLanguageModelInput, AIMessageChunk, CallOptions> {
  let strict: boolean | undefined;
  if (kwargs?.strict !== undefined) {
    strict = kwargs.strict;
  } else if (this.supportsStrictToolCalling !== undefined) {
    strict = this.supportsStrictToolCalling;
  }
  return this.withConfig({
    tools: tools.map((tool) => {
      if (isBuiltInTool(tool) || isCustomTool(tool)) {
        return tool;                              // 内置工具直接透传
      }
      if (hasProviderToolDefinition(tool)) {
        return tool.extras.providerToolDefinition; // Provider 专属定义
      }
      return this._convertChatOpenAIToolToCompletionsTool(tool, { strict });
    }),
    ...kwargs,
  } as Partial<CallOptions>);
}

设计要点

  • bindTools 返回一个新的 RunnableBinding,不修改原始模型实例(不可变性)
  • 工具转换分三种路径:内置工具、Provider 专属工具、标准 LangChain 工具
  • strict 模式确保模型输出严格匹配 JSON Schema

18.4.1 工具格式转换

LangChain 的 StructuredTool(含 Zod schema)会被转换为 OpenAI 的 function calling 格式:

// LangChain StructuredTool
{
  name: "get_weather",
  description: "获取天气",
  schema: z.object({ city: z.string() })
}

// 转换后的 OpenAI 格式
{
  type: "function",
  function: {
    name: "get_weather",
    description: "获取天气",
    parameters: {
      type: "object",
      properties: { city: { type: "string" } },
      required: ["city"]
    }
  }
}

18.5 _generate -- 核心生成方法

BaseChatOpenAI_generate 在继承链中由 BaseChatModel 的模板方法驱动。实际的 API 调用通过 OpenAI SDK 的 client.chat.completions.create() 完成。

18.5.1 调用流程

invoke(messages)
  → BaseChatModel._generate(messages, options)
    → 构造 OpenAI API 请求参数
    → client.chat.completions.create(params)
    → 将 OpenAI 响应转换为 ChatResult

18.5.2 响应映射

OpenAI API 返回的 ChatCompletion 被映射为 LangChain 的 ChatResult

// OpenAI 响应
{
  choices: [{
    message: {
      role: "assistant",
      content: "Hello!",
      tool_calls: [{ id: "tc_1", function: { name: "get_weather", arguments: '{"city":"北京"}' } }]
    }
  }],
  usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
}

// 映射为 LangChain ChatResult
{
  generations: [{
    message: AIMessage {
      content: "Hello!",
      tool_calls: [{ name: "get_weather", args: { city: "北京" }, id: "tc_1" }]
    },
    text: "Hello!"
  }],
  llmOutput: { tokenUsage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 } }
}

18.6 _streamResponseChunks -- 流式实现

流式调用使用 OpenAI 的 Server-Sent Events (SSE) 协议:

18.6.1 流式工作原理

client.chat.completions.create({ stream: true })
  → 返回 AsyncIterable<ChatCompletionChunk>
    → 每个 chunk 转换为 ChatGenerationChunk
      → yield 给上层

18.6.2 关键处理

  • 首个 chunk:可能包含 role 信息但不含 content
  • tool_calls 流式:函数名和参数分多个 chunk 逐步到达,需要累积拼接
  • usage 信息:当 streamUsage: true 时,最后一个 chunk 携带 token 统计
// 流式 chunk 示例
{ choices: [{ delta: { role: "assistant" } }] }
{ choices: [{ delta: { content: "Hello" } }] }
{ choices: [{ delta: { content: " World" } }] }
{ choices: [{ delta: {} }], usage: { prompt_tokens: 5, completion_tokens: 2 } }

每个 delta 被包装为 AIMessageChunk,由 BaseChatModel._generateUncached() 自动合并。


18.7 Token 统计

_combineLLMOutput(...llmOutputs: OpenAILLMOutput[]): OpenAILLMOutput {
  return llmOutputs.reduce(
    (acc, llmOutput) => {
      if (llmOutput?.tokenUsage) {
        acc.tokenUsage.completionTokens += llmOutput.tokenUsage.completionTokens ?? 0;
        acc.tokenUsage.promptTokens += llmOutput.tokenUsage.promptTokens ?? 0;
        acc.tokenUsage.totalTokens += llmOutput.tokenUsage.totalTokens ?? 0;
      }
      return acc;
    },
    { tokenUsage: { completionTokens: 0, promptTokens: 0, totalTokens: 0 } }
  );
}

_combineLLMOutput 在 batch 调用时合并多次调用的 token 统计。


18.8 推理模型支持

OpenAI 的推理模型(如 o1、o3 系列)有特殊要求:

// CallOptions 中的推理选项
reasoning?: OpenAIClient.Reasoning;  // { effort: "low" | "medium" | "high" }

// 推理模型检测
import { isReasoningModel } from "../utils/misc.js";

推理模型可能不支持 temperaturetop_p 等采样参数,Provider 层会自动处理这些兼容性问题。


18.9 错误处理

import { wrapOpenAIClientError } from "../utils/client.js";

Provider 层会捕获 OpenAI SDK 抛出的底层错误,包装为更有意义的 LangChain 错误(如 ContextOverflowError、认证错误等),让上层代码能够统一处理。


18.10 实战练习

练习 1:阅读 _generate 源码

打开 libs/providers/langchain-openai/src/chat_models/base.ts,找到 _generate() 方法,跟踪以下流程:

  1. BaseMessage 数组如何转为 OpenAI API 的 messages 参数
  2. 工具定义如何转为 tools 参数
  3. API 返回值如何映射为 ChatResult

练习 2:对比有无工具的调用

import { FakeChatModel } from "@langchain/core/utils/testing";
import { tool } from "@langchain/core/tools";
import { z } from "zod/v3";

const weatherTool = tool(
  async ({ city }) => `${city}: 25°C`,
  { name: "weather", description: "Get weather", schema: z.object({ city: z.string() }) }
);

const model = new FakeChatModel({});

// 无工具调用
const result1 = await model.invoke("Hello");
console.log(result1.tool_calls); // []

// 有工具绑定(观察 bindTools 返回的类型)
const modelWithTools = model.bindTools([weatherTool]);
console.log(modelWithTools.constructor.name); // RunnableBinding

练习 3:流式输出观察

const stream = await model.stream("Tell me a story");
for await (const chunk of stream) {
  console.log("chunk type:", chunk.constructor.name);
  console.log("chunk content:", chunk.content);
}

18.11 源码精读路线

优先级文件关注点
P0langchain-openai/src/chat_models/base.ts:244-400BaseChatOpenAI 类定义、配置字段
P0langchain-openai/src/chat_models/base.ts:662-694bindTools() 实现
P1langchain-openai/src/utils/tools.ts_convertToOpenAITool()、工具格式转换
P1langchain-openai/src/utils/misc.tsmessageToOpenAIRole()、推理模型检测
P2langchain-openai/src/utils/output.ts结构化输出、withStructuredOutput()
P2langchain-openai/src/chat_models/profiles.ts模型 Profile(能力声明)

本课收获总结

级别你应该掌握的
🟢 基础理解 Provider 包的结构:继承 BaseChatModel,实现 _generate()
🔵 中阶掌握消息格式转换的完整流程:LangChain Message -> OpenAI API 格式 -> ChatResult
🟡 高阶理解流式实现:SSE chunk 的逐步到达和 AIMessageChunk 的累积合并
🟠 资深分析 bindTools() 的不可变设计;理解工具格式转换的三种路径
🔴 架构评估 Provider 层的职责边界:纯适配 vs 增强(token 统计、推理模型兼容)

下一课预告

第 19 课对比 Anthropic Provider 的实现,看同一抽象在不同 API 设计下的差异化处理。