数据截至 (上游 commit 610bac85d242)
第 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_url、api_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",
}
没有一行方法。aliases(llm_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_schema(llm_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_block(llm_provider.py:71-85)统一判断「这块是 bytes 就当图片,否则当文本」,具体图片对象再由子类的 create_image_block 生成:
| 家族 | 图片块格式 | 代码 |
|---|---|---|
| OpenAI 系 | image_url + data:image/png;base64,... | llm_provider.py:122-136 |
| Anthropic | source.type=base64 + media_type | llm_provider.py:191-199 |
OpenAI 分支还用 Pillow 探测真实图片类型(llm_provider.py:124-129),探测失败默认 png。
差异三:system 消息位置
Anthropic 的 call(llm_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 解析;并且同时认 parameters 和 arguments 两种键名(llm_provider.py:161),因为不同模型叫法不一。这段就是「支持一大堆参差不齐的开源模型」的实战胶水。
4.7 Mistral 的特例
MistralBaseProvider(llm_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.py | LLMProvider、LLMProvider.completion、transform_message |
| 工具 schema 遍历 | os_computer_use/llm_provider.py | LLMProvider.create_function_schema |
| OpenAI 家族 | os_computer_use/llm_provider.py | OpenAIBaseProvider、create_image_block、call |
| Anthropic 家族 | os_computer_use/llm_provider.py | AnthropicBaseProvider.call |
| Mistral 特例 | os_computer_use/llm_provider.py | MistralBaseProvider.call |
| 乱 JSON 容错 | os_computer_use/llm_provider.py | OpenAIBaseProvider.call(re.search 分支) |
| 具体供应商声明 | os_computer_use/providers.py | GroqProvider、OpenRouterProvider、AnthropicProvider、MistralProvider … |
| 别名解析 | os_computer_use/llm_provider.py | LLMProvider.__init__(aliases) |