跳到主要内容

第 4 章 · Provider 抽象:一套接口接住 10+ 家 LLM

这章讲「怎么让换模型只改一行」背后的适配层。读完你能说清各家 API 的三大差异(工具 schema、图片格式、system 消息)是怎么被抹平的,以及有个模型不肯好好返回 JSON 时是怎么被硬掰回来的。


4.1 它要解决的小问题

项目要支持 Fireworks、Groq、OpenAI、Gemini、Anthropic、Mistral、Moonshot、OS-Atlas、ShowUI…… 十几家。它们的 API 长得都不一样

  • 工具(function)定义的 JSON 结构不同;
  • 图片怎么塞进消息不同(有的要 image_url + data URI,有的要 source.base64);
  • system 消息的位置不同(OpenAI 放在 messages 里,Anthropic 要单独一个 system 参数);
  • 有的模型甚至不肯用规范的 tool_calls 字段,把工具调用混在普通文本里。

目标:让上层的 agent.run 完全不知道下面是谁,只管 model.call(messages, tools)


4.2 思路 / 直觉:一个基类 + 按 API 家族分子类

所有 provider 实现同一个方法 call(messages, functions=None)。差异被收进两层:

LLMProvider(基类:通用流程)
├── completion() 统一发请求
├── transform_message() 统一包装图片/文本
└── create_function_schema() 统一遍历工具
│(抽象方法,子类填格式差异)
┌────────────────┼───────────────────────┐
▼ ▼ ▼
OpenAIBaseProvider AnthropicBaseProvider MistralBaseProvider
(image_url/ (source.base64/ (继承 OpenAI,
tools 列表/ system 参数/ 额外处理 prefix)
choices[0]) content blocks)

具体家: Groq / Fireworks / OpenRouter / OpenAI / Gemini / Moonshot / DeepSeek
(只声明 base_url + api_key + aliases)

关键观察:绝大多数 provider 都是 OpenAI 兼容的——它们只需在 providers.py 里声明三样东西(base_urlapi_key、模型别名 aliases),逻辑全从 OpenAIBaseProvider 继承。真正写了新逻辑的只有 Anthropic 和 Mistral 两个特例。


4.3 具体 provider 有多薄

看一个具体 provider 就知道有多省:

# 真实源码 providers.py:65-71
class GroqProvider(OpenAIBaseProvider):
base_url = "https://api.groq.com/openai/v1"
api_key = os.getenv("GROQ_API_KEY")
aliases = {
"llama-3.2": "llama-3.2-90b-vision-preview",
"llama-3.3": "llama-3.3-70b-versatile",
}

没有一行方法。aliasesllm_provider.py:37__init__self.aliases.get(model, model)llm_provider.py:41)让你在 config 里写好记的短名 "llama-3.3",运行时自动换成供应商的全名。OpenRouter、Fireworks、OpenAI、Gemini、DeepSeek、Moonshot 全是这个套路(providers.py:17-89)。


4.4 三大差异是怎么抹平的

差异一:工具 schema 结构

基类 create_function_schemallm_provider.py:46-60)负责通用遍历,把每个工具的参数都声明成 type: string;具体格式交给抽象方法 create_function_def,两个家族各写各的:

  • OpenAI 系(llm_provider.py:108-120):外层 {"type":"function","function":{..."parameters":{...}}}
  • Anthropic(llm_provider.py:180-189):扁平 {"name","description","input_schema":{...}}

差异二:图片编码

transform_message / wrap_blockllm_provider.py:71-85)统一判断「这块是 bytes 就当图片,否则当文本」,具体图片对象再由子类的 create_image_block 生成:

家族图片块格式代码
OpenAI 系image_url + data:image/png;base64,...llm_provider.py:122-136
Anthropicsource.type=base64 + media_typellm_provider.py:191-199

OpenAI 分支还用 Pillow 探测真实图片类型(llm_provider.py:124-129),探测失败默认 png。

差异三:system 消息位置

Anthropic 的 callllm_provider.py:201-213)会先把所有 role=="system" 的消息抽出来拼成一个字符串,作为独立的 system= 参数传入,再从 messages 里剔除——因为 Anthropic API 不接受 messages 里的 system 角色。OpenAI 系不用管,直接留在 messages 里。


4.5 带不带 tools,返回不一样

统一约定:传了 functions 就返回 (文本, 工具调用列表),没传就只返回文本。这对应第 2 章里 vision(不传 tools,只要描述)和 action(传 tools,要动作)两种用法。

  • OpenAI 系(llm_provider.py:138-172):有 tools 时读 message.tool_calls;无 tools 时只回 message.content
  • Anthropic(llm_provider.py:216-227):从 completion.content 里挑 type=="tool_use" 的块。

4.6 巧妙之处:模型不肯好好返回 JSON 时硬掰

这是最实用的一段容错。有些便宜/开源模型不填标准的 tool_calls 字段,而是把工具调用当成普通文本吐出来(比如直接回一段 {"name": "click", "parameters": {...}})。OpenAI 分支专门兜这种情况:

# 真实源码摘录 llm_provider.py:155-166
if message.content and not tool_calls:
tool_call_matches = re.search(r"\{.*\}", message.content)
if tool_call_matches:
tool_call = parse_json(tool_call_matches.group(0))
# Some models use "arguments" as the key instead of "parameters"
parameters = tool_call.get("parameters", tool_call.get("arguments"))
if tool_call.get("name") and parameters:
combined_tool_calls.append(...)

两个细节:用正则从自由文本里抠出第一段 {...} 当 JSON 解析;并且同时认 parametersarguments 两种键名llm_provider.py:161),因为不同模型叫法不一。这段就是「支持一大堆参差不齐的开源模型」的实战胶水。


4.7 Mistral 的特例

MistralBaseProviderllm_provider.py:230-246)继承 OpenAI 系,但多处理一个「前缀」问题:如果消息列表最后一条是 assistant 角色(本项目的 prompt 常以一句 assistant 引导语结尾),Mistral 不接受结尾是 assistant,于是它把这条内容并进上一条 user 消息或另起一条 user(llm_provider.py:238-246)。这是被具体 API 约束逼出来的适配。


4.8 边界与局限

  • 所有工具参数都被声明成 string create_function_schema 里写死 "type": "string"llm_provider.py:54),没有数字/布尔/枚举类型。对本项目够用(工具参数就是 query/command/text),但不通用。
  • 正则抠 JSON 很粗。 re.search(r"\{.*\}", ...) 贪婪匹配,遇到文本里有多个花括号或嵌套复杂的情况可能抠错。
  • grounding provider 不在这套体系内。 OS-Atlas / ShowUI 走 Gradio,不继承 LLMProvider,只是碰巧也叫 .call()(鸭子类型),所以能被 agent 一视同仁地调用。

4.9 本章代码地图

主题文件符号
基类:通用流程os_computer_use/llm_provider.pyLLMProviderLLMProvider.completiontransform_message
工具 schema 遍历os_computer_use/llm_provider.pyLLMProvider.create_function_schema
OpenAI 家族os_computer_use/llm_provider.pyOpenAIBaseProvidercreate_image_blockcall
Anthropic 家族os_computer_use/llm_provider.pyAnthropicBaseProvider.call
Mistral 特例os_computer_use/llm_provider.pyMistralBaseProvider.call
乱 JSON 容错os_computer_use/llm_provider.pyOpenAIBaseProvider.callre.search 分支)
具体供应商声明os_computer_use/providers.pyGroqProviderOpenRouterProviderAnthropicProviderMistralProvider
别名解析os_computer_use/llm_provider.pyLLMProvider.__init__aliases