数据截至 (上游 commit e839e559ac61)
多供应商 LLM 抽象:统一 chat 接口、消息/工具翻译与重试
30 秒导读: Trae Agent 的 agent 循环(第 1 章)只知道一件事——"给我一份消息列表和一组工具,还我一个带
content和tool_calls的回复"。至于对面是 Anthropic、OpenAI 还是 Ollama,循环完全不在乎。这层"不在乎"就是本章讲的东西:trae_agent/utils/llm_clients/里一套中立数据模型 + 每家一个客户端子类,把 7 家供应商的消息格式差异、工具 schema 差异、tool-call 拆解差异,全部关进各自chat()的内部。
本章不讲 agent 怎么循环(那是第 1 章),也不讲工具本身怎么执行(那是第 2 章)。本章只讲一件事:agent 如何用一套代码对接 7 家供应商。
1. 这是什么(零基础也能懂)
一句话定义
这是一个**"翻译中间层":上层 agent 用一种统一的中立格式**说话,这层负责把它翻译成每家供应商各自的方言,发出去,再把各家五花八门的回复翻译回同一种中立格式交还给上层。
它解决什么问题
不同 LLM 供应商的 API 长得完全不一样,尤其在两件事上:
- 消息格式——同样是"工具执行结果",Anthropic 要你塞进一个
role="user"消息里的tool_resultcontent block;OpenAI 系要一条独立的role="tool"消息。 - 工具描述(tool schema)——同样是"我有一个 bash 工具",Anthropic 对某些内置工具有原生类型(
bash_20250124),OpenAI 要包成type="function"的 JSON Schema,而且 strict 模式下所有参数都得进required。
如果不做抽象,agent 循环里就会撒满 if provider == "anthropic": ... elif provider == "openai": ...,加一家新供应商就要改一圈。Trae 的做法是:把这些 if 全部收进 llm_clients/ 里,循环只面对一个干净的 LLMClient.chat()。
它能做什么
- 一套代码对接 7 家:OpenAI、Anthropic、Azure、Ollama、OpenRouter、Doubao(豆包)、Google。
- 懒加载:构造时只
import你实际选的那一家的客户端,其余不碰。 - 统一的 usage 统计、统一的重试退避、统一的轨迹记录(第 6 章)。
用起来什么样
上层拿到 LLMClient 后,一次调用就是这样(agent 循环里的真实用法):
# 示意,非源码:agent 侧只见这一个统一接口
client = LLMClient(model_config) # 按 provider 懒加载具体子类
client.set_trajectory_recorder(recorder) # 挂上轨迹记录
response = client.chat( # 中立入参:LLMMessage 列表 + Tool 列表
messages=[LLMMessage(role="user", content="修一下这个 bug")],
model_config=model_config,
tools=[bash_tool, edit_tool],
reuse_history=True, # 复用累积的对话历史
)
# 中立出参:content 是文本,tool_calls 是要执行的工具调用
for call in response.tool_calls or []:
... # 交给第 2 章的 ToolExecutor 去跑
注意:整段代码里没有一处出现供应商名字。 这就是抽象的目标。
一句话直觉
把它当成一个"多国语言同声传译"。 会议桌上层(agent)只讲一种"公司内部语"(LLMMessage),传译员(具体 client)负责把它翻成对面客人的母语(Anthropic / OpenAI 格式),再把对面的回答翻回内部语(LLMResponse)。换个客人只是换个传译员,会议流程一个字不用改。
2. 顶层全景(它大概怎么转)
一张图:一次 chat 从中立格式到某家 API 再回来
怎么读:从上到下是一次 chat() 的数据流。左边是中立世界(agent 只认这些类型),中间竖线是翻译边界,右边 是某家供应商的方言。
中立世界(agent 只见这层) │ 翻译边界(具体 client 内部) │ 供应商方言
│ │
list[LLMMessage] ──────────────────────┼──► parse_messages() │
(role/content/tool_call/tool_result) │ 按 role 和字段分派翻译 ───────┼──► MessageParam[] (Anthropic)
│ │ 或 ChatCompletionMessageParam[]
list[Tool] ─────────────────────────────┼──► tool_schemas 生成 │
(第 2 章的工具对象) │ 内置工具→原生类型 / 其余→通用 ─┼──► ToolUnionParam[] / ToolParam[]
│ │
│ retry_with(...) 包一层重试 ───┼──► client.messages.create(...)
│ │ │
│ 拆 response.content ◄──────────┼─────────┘
LLMResponse ◄──────────────────────────┼── text→content / tool_use→ToolCall│
(content / tool_calls / usage) │ 记 usage + 写 trajectory │
部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
LLMProvider 枚举 | 列出 7 家供应商的字符串标识 | llm_client.py:15 |
LLMClient | 门面:按 provider match 懒加载具体子类,再把调用转发过去 | llm_client.py:27 |
BaseLLMClient | 抽象基类:定死 chat() / set_chat_history() 契约 | base_client.py:13 |
LLMMessage / LLMResponse / LLMUsage | 三个中立数据模型,抽象的"公司内部语" | llm_basics.py:11,21,45 |
AnthropicClient | Anthropic 方言的完整翻译实现(本章主线) | anthropic_client.py:19 |
OpenAICompatibleClient | Azure / OpenRouter / Doubao 共用的 OpenAI 兼容翻译层 | openai_compatible_base.py:65 |
retry_with | 给任何 API 调用套上"失败随机退避重试" | retry_utils.py:13 |
Tool.get_input_schema() | 按 model_provider 分支,生成各家能吃的参数 schema | tools/base.py:127 |
主线走一遍(高层,不进代码)
- agent 拿
model_config造一个LLMClient→ 里面按 provider 懒加载出真正干活的子类(如AnthropicClient)。 - agent 调
client.chat(messages, ..., tools)。 - 子类内部:先
parse_messages()把中立消息翻成方言消息;再把tools翻成方言的 tool schema。 - 用
retry_with包一层,真正调供应商 API。 - 拿回原始响应,拆开 content block:文本拼进
content,工具调用拆成ToolCall。 - 统计 usage、写轨迹,返回一个中立的
LLMResponse。
3. 核心机制一:门面 + 懒加载(LLMClient + LLMProvider)
它要解决的小问题
7 家供应商各有一个客户端类,每个都 import 一堆重量级 SDK(anthropic、openai、google-genai…)。如果构造 LLMClient 时把 7 个全 import 进来,既慢又可能因为没装某个 SDK 而报错——而用户其实只用一家。
思路
把 import 语句写进 match 的每个分支里。 Python 的 import 是执行到才生效的语句,所以只有命中的那个分支的 SDK 才被真正加载。
真实实现
LLMProvider 就是一个字符串枚举,把配置里的 provider 字段收敛成有限集合:
# llm_client.py:15
class LLMProvider(Enum):
OPENAI = "openai"
ANTHROPIC = "anthropic"
AZURE = "azure"
OLLAMA = "ollama"
OPENROUTER = "openrouter"
DOUBAO = "doubao"
GOOGLE = "google"
构造时按枚举 match,import 写在分支内,命中谁才加载谁:
# llm_client.py:34
match self.provider:
case LLMProvider.OPENAI:
from .openai_client import OpenAIClient
self.client: BaseLLMClient = OpenAIClient(model_config)
case LLMProvider.ANTHROPIC:
from .anthropic_client import AnthropicClient
self.client = AnthropicClient(model_config)
# ... 其余 5 家同构
注意 self.client 的类型标注是 BaseLLMClient——门面只依赖抽象基类,不依赖任何具体子类。这正是抽象的落点:LLMClient.chat()(llm_client.py:72)只是一句 return self.client.chat(...),把调用透明转发给多态的子类。
关键细节
LLMClient 本身几乎没有逻辑,它是一个薄门面(facade):chat、set_chat_history、set_trajectory_recorder、supports_tool_calling 全是转发。真正的多态发生在 self.client 上。supports_tool_calling(llm_client.py:82)还多做了一层 hasattr 防御,子类没实现也不会炸。