第 5 章 · 多厂商 LLM 抽象层
本章讲框架怎么用一套代码接 20+ 家 LLM 厂商,还能各家用各家的原生结构化输出、各家的思考档位。这是它作为“可用框架”而非 demo 的一大底座。
5.1 一句话:一个工厂 + 两条路
所有 LLM 创建都过 create_llm_client(llm_clients/factory.py:5)。它按 provider 名分两条路:
create_llm_client(provider, model, base_url, **kwargs)
│
├─ 原生 API(有各自 SDK/wire 格式) → 各自 client 类
│ anthropic → AnthropicClient
│ google → GoogleClient
│ azure → AzureOpenAIClient
│ bedrock → BedrockClient
│
└─ 其余全部 → is_openai_compatible? → OpenAIClient(走注册表)
openai / xai / deepseek / qwen / glm / minimax / openrouter /
mistral / kimi / groq / nvidia / ollama / openai_compatible ...
为什么原生 API 先匹配: 这样它们的字符串判断不会误触发去 import OpenAI client(factory.py:32-33 的注释)。所有 provider 模块都是惰性 import——光 import 这个工厂(比如测试收集时)不会拉起沉重的 LLM SDK,也不会因缺 API key 就炸(factory.py:12-15)。
5.2 OpenAI 兼容注册表:单一真源
十几家“OpenAI 兼容”厂商共用一个 OpenAIClient,差异全收进一张注册表 OPENAI_COMPATIBLE_PROVIDERS(openai_client.py:212)。每行是一个 ProviderSpec(openai_client.py:196),只描述该厂商的差异:
| ProviderSpec 字段 | 描述什么 |
|---|---|
base_url | 默认 endpoint(None = 用 SDK 默认) |
base_url_env | 可覆盖 base_url 的环境变量(如 OLLAMA_BASE_URL) |
chat_class | 该厂商的 wire 格式怪癖放进的子类(如 DeepSeekChatOpenAI) |
key_optional / placeholder_key | 本地无 key 服务器发占位 key |
require_base_url | 通用 endpoint 必须用户提供 URL |
use_responses_api | 用 OpenAI 原生 Responses API |
加一家新的 OpenAI 兼容厂商 = 加一行,不改调用点。双区厂商(qwen/glm/minimax)保留国际/中国两套 endpoint,因为两边账号凭证不能共用(openai_client.py:208-210,issue #758)。
5.3 能力表:驱动结构化输出的方法选择
不同模型支持的结构化输出方式不同(json_schema / function_calling / response_schema / 都不支持),而且有的模型拒收 tool_choice 参数。这些差异集中在能力表 get_capabilities(llm_clients/capabilities.py),由自定义的 NormalizedChatOpenAI.with_structured_output 消费(openai_client.py:38-51):
def with_structured_output(self, schema, *, method=None, **kwargs):
caps = get_capabilities(self.model_name)
if caps.preferred_structured_method == "none":
raise NotImplementedError(...) # 上层据此降级自由文本(见第2章)
method = method or caps.preferred_structured_method
if method == "function_calling" and not caps.supports_tool_choice:
kwargs.setdefault("tool_choice", None) # DeepSeek V4 等拒收 tool_choice:绑 tool 但不发 tool_choice
return super().with_structured_output(schema, method=method, **kwargs)
这条链和第 2 章的 bind_structured 降级配合:能力表说 none 就抛 NotImplementedError,bind_structured 接住、告警 、全程走自由文本。
5.4 内容规范化:抹平 typed-block 差异
它要解决的小问题: OpenAI Responses API、Google Gemini 3 等返回的 content 是一串 typed block([{type:reasoning,...}, {type:text, text:...}]),但下游 agent 都假设 response.content 是纯字符串。
解法: normalize_content(base_client.py:6)抽出 text block、丢掉 reasoning/metadata,join 成字符串。NormalizedChatOpenAI.invoke 每次调用后自动跑一遍(openai_client.py:35-36),于是所有 agent 拿到的永远是干净字符串,不用各自处理 block。
5.5 provider 专属旋钮:思考档位与温度
不同厂商的“思考深度”参数名不一样,_get_provider_kwargs(trading_graph.py:153)按 provider 分发:
| provider | 配置键 | 传给客户端的 kwarg |
|---|---|---|
google_thinking_level | thinking_level | |
| openai | openai_reasoning_effort | reasoning_effort |
| anthropic | anthropic_effort | effort |
| 跨厂商 | temperature | temperature(set 了才传) |
| 跨厂商 | llm_max_retries | max_retries(set 了才传,抗 429,#1091) |
跨厂商参数只在显式设置时才传,否则让各家 SDK 保留自己的默认——这样不会无意覆盖某家的默认重试次数。_coerce_max_retries(trading_graph.py:47)严格校验,负数/布尔/非数字在启动时就大声报错,而不是静默关掉重试。
5.6 全链路配置:环境变量优先
default_config.py 是配置单一真源,且开箱支持 TRADINGAGENTS_* 环境变量覆盖(_ENV_OVERRIDES, default_config.py:10-28)。加一个可覆盖的键 = 加一行映射。类型强制 _coerce(default_config.py:35)按现有默认值的类型转换,拼错的布尔(treu)会在启动时抛错而非静默误配。于是切模型/切 endpoint 纯改 .env 即可,main.py 一行不用动(main.py:4-9)。
5.7 代码地图(本章)
| 主题 | 文件 | 符号 |
|---|---|---|
| 工厂分流 | tradingagents/llm_clients/factory.py | create_llm_client |