OpenAI-Compatible LLM Provider
OpenAI-Compatible LLM Provider 是 LLM Provider Abstraction 的一种具体适配器模式:复用 OpenAI 风格的协议表面,通过 base_url、模型能力映射与规范化层接入多个兼容服务。
[!info] related notes
- 上位抽象: LLM Provider Abstraction
- 前置概念: Function Calling
- 流式细节: Function Calling 流式累积
- 上层应用: 咨询 Agent 工作流
OpenAI-Compatible LLM Provider
核心问题
LLM Provider Abstraction 定义“上层只依赖统一 Provider 接口”的架构边界;本笔记只讨论其中一种具体实现:
当多个服务暴露 OpenAI 风格 API 时,怎样用一个兼容适配器承接协议差异,而不是为每个服务重新实现完整 Provider?
因此本笔记不是“LLM Provider 抽象”的同义入口。即使系统已经有抽象层,也仍需要判断某个后端到底是真正兼容、部分兼容,还是必须使用独立 adapter。
设计目标
- 一处配置,全局生效 — 环境变量切换模型,代码零修改
- 上层不感知模型细节 — 统一的 chat 和 chat_stream 接口
- 流式累积封装在内部 — 上层不用关心 tool_call 的 chunk 拼接
- 可测试 — 通过 Protocol 接口注入 mock
接口设计
class LLMProvider(Protocol):
async def chat(
self,
messages: list[ChatMessage],
tools: list[ToolDefinition] | None = None,
temperature: float = 0.7,
max_tokens: int = 2048,
) -> ChatMessage: ...
async def chat_stream(
self,
messages: list[ChatMessage],
tools: list[ToolDefinition] | None = None,
temperature: float = 0.7,
max_tokens: int = 2048,
) -> AsyncIterator[StreamChunk]: ...
用 Protocol 而不是 ABC:不需要显式 implements,只要方法签名匹配就行。
统一数据结构
@dataclass
class ChatMessage:
role: str # "system" | "user" | "assistant" | "tool"
content: str | None
tool_calls: list[ToolCall] | None
tool_call_id: str | None # tool 结果的关联 ID
@dataclass
class StreamChunk:
delta: str = "" # 文本增量
tool_calls: list[ToolCall] | None = None # 流结束时才填充完整 tool_calls
finished: bool = False
关键设计:StreamChunk 把 tool_calls 的累积逻辑封装在 Provider 内部。上层只需要在 finished=True 时拿到完整的 tool_calls,不需要自己拼 chunk。
为什么选 OpenAI 兼容 API
几乎所有主流模型都兼容 OpenAI API 格式:
| 服务商 | base_url | 说明 |
|---|---|---|
| OpenAI | 默认 | 原生 |
| DeepSeek | https://api.deepseek.com/v1 | 完全兼容 |
| 阿里通义 | https://dashscope.aliyuncs.com/compatible-mode/v1 | 兼容 |
| OpenRouter | https://openrouter.ai/api/v1 | 聚合多模型 |
只需要一个 OpenAICompatibleProvider,通过 base_url 切换。
环境变量配置
LLM_MODEL=gpt-4o-mini
LLM_API_KEY=sk-xxx
LLM_BASE_URL=https://api.deepseek.com/v1 # 切换模型只改这一行
单例 vs 依赖注入
# 全局单例 — 生产环境
_default_provider: OpenAICompatibleProvider | None = None
def get_llm_provider() -> OpenAICompatibleProvider:
global _default_provider
if _default_provider is None:
_default_provider = OpenAICompatibleProvider()
return _default_provider
单例的好处是避免重复创建 HTTP 连接。测试时可以通过依赖注入传 mock。
与 LangChain 的区别
| 维度 | 自建 Provider | LangChain |
|---|---|---|
| 复杂度 | ~200 行 | 框架级 |
| 依赖 | 只有 openai SDK | 大量传递依赖 |
| 灵活性 | 完全可控 | 受框架约束 |
| 适用 | 接口简单、模型少 | 需要 chain/agent/memory |
如果只需要”发消息、收回复、调工具”,自建就够了。如果需要复杂的 chain 编排、memory 管理、多 agent 协作,考虑 LangChain 或 LangGraph。
常见错误
硬编码模型名
# ❌ 每个调用点写死模型
response = await client.chat.completions.create(model="gpt-4o", ...)
# ✅ 通过 Provider 统一管理
provider = get_llm_provider()
response = await provider.chat(messages, tools=tools)
API Key 泄露
永远不要把 API Key 硬编码在代码里。用环境变量或 secret manager。
不处理模型不支持 function calling
不是所有模型都支持 function calling。Provider 应该在 tools 不为 None 时检查模型能力,不支持时降级为纯文本提示。
边界与兼容性风险
“OpenAI-compatible”通常只意味着请求/响应形状大体相似,不等于所有语义都一致。需要显式验证:
- tool calling schema 与增量流式 chunk 是否完全兼容;
- usage、finish reason、reasoning 字段是否需要归一化;
- JSON / structured output 是否支持同一套参数;
- 模型名、能力发现、错误码和重试语义是否一致;
- provider-specific header、rate limit 和鉴权方式是否仍需单独配置。
一旦这些差异开始影响上层契约,应把差异收敛在 adapter 内,而不是把 provider 分支泄漏到业务调用点。
学习检查
- LLM Provider Abstraction 与 OpenAI-Compatible Provider 为什么是“抽象边界”和“具体适配器”的关系?
- 一个接口能成功返回文本,为什么仍不能证明它对 tool calling 或 streaming 真正兼容?
- 当两个兼容服务的错误码、usage 字段不同,差异应该放在哪一层归一化?
- 什么情况下应该放弃单一 OpenAI-compatible adapter,改为独立 Provider 实现?