LLM 抽象层:provider 无关、BYOM、跨模型回退与结构化输出
30 秒导读: DocsGPT 的 agent(见 01)和工具循环(见 02) 从头到尾不知道自己在跟 OpenAI、Anthropic 还是 Google 说话。中间隔着一层
BaseLLM:统一的gen/gen_stream契约在上,十几个 provider 子类在下。本章讲清这层怎么把"provider 差异"藏起来—— 包括用户自带模型(BYOM)怎么解析、主模型挂了怎么在同一次调用里换一家、以及不同 provider 的流式 chunk 怎么被归一成同一个LLMResponse。
1. 这是什么(零基础也能懂)
一句话定义: LLM 抽象层是一层"翻译 + 适配"代码,让上层业务只用一套 API 就能调用任意大模型 provider。
它要解决的问题。 每家大模型的 SDK 都不一样:
| provider | Python SDK | 调用方式 | 流式 chunk 长什么样 |
|---|---|---|---|
| OpenAI | openai | client.chat.completions.create 或 client.responses.create | choice.delta.content |
| Anthropic | anthropic | anthropic.completions.create | completion.completion |
google.genai | client.models.generate_content_stream | candidate.content.parts[].text |
如果 agent 直接写死其中一家,换 provider 就要重写一遍。抽象层的价值就是:agent 只调 llm.gen_stream(...),
底下是谁、怎么拼参数、怎么解流,它一概不管。
给谁用 / 典型场景:
- 部署方想把默认模型从 GPT 换成 Gemini —— 改一个配置,agent 代码不动。
- 终端用户想用自己的 API key 接一个自建的 OpenAI 兼容端点(BYOM,Bring Your Own Model)—— 注册一条记录即可。
- 主模型被限流(429)—— 系统在同一次请求内自动换到备份模型,用户几乎无感。
一句话直觉: 把 BaseLLM 想成电源插座标准。你的电器(agent)只认插座的形状;背后是水电、火电还是风电
(OpenAI / Anthropic / Google),电器不需要知道。BYOM 就是允许你自己接一根电线进来,只要插头符合标准。
2. 顶层全景(它大概怎么转)
这一层由四类角色组成,职责分明:
| 角色 | 干什么 | 在哪 |
|---|---|---|
工厂 LLMCreator | 按 provider 名选实现类;为 BYOM 把注册表 UUID 解析成真实端点 | application/llm/llm_creator.py:LLMCreator |
基类 BaseLLM | 定义 gen/gen_stream 契约、能力协商、跨 provider 回退 | application/llm/base.py:BaseLLM |
| provider 子类 | 每家一个,实现 _raw_gen/_raw_gen_stream,做 SDK 适配 | application/llm/openai.py、anthropic.py、google_ai.py … |
| handler | 把不同 provider 的流式 chunk 归一成 LLMResponse/ToolCall | application/llm/handlers/ |
主线走一遍(高层,不进代码):
agent._llm_gen(messages) (第 2 章:agent 侧)
│ 拼 model / tools / 结构化输出格式
▼
llm.gen_stream(...) ── BaseLLM:发日志、挂装饰器(计费/缓存)
│
▼
_execute_with_fallback ───── 主模型失败?→ 悄悄切到 fallback_llm
│ 并标记 _responding_provider
▼
_raw_gen_stream(...) ── provider 子类:拼这家 SDK 的参数、发请求、吐 chunk
│
▼
handler.parse_response(chunk) ── 按"真正响应的 provider"选 handler
│ 归一成 LLMResponse(content, tool_calls, ...)
▼
工具循环 / 文本流 (第 2 章:LLMHandler 编排)
怎么读这张图: 从上到下是一次生成调用的路径 。左边是"上层不变的接口",越往下越贴近某家 provider 的真实
SDK;_execute_with_fallback 是那道"provider 可以中途被换掉"的暗门,本章 §5 专门讲它。
工厂在创建时决定用哪家;回退在调用时可能再换一家。记住这两个时刻,后面就不会绕晕。
3. 工厂:LLMCreator.create_llm —— 谁来接这次请求
本节讲清"给一个 llm_name 和一个 model_id,怎么造出正确的 LLM 实例"。
3.1 按名选实现类
第一步很朴素:拿 provider 名去插件注册表里查对应的实现类。
真实实现 application/llm/llm_creator.py:39-41(create_llm):
plugin = PROVIDERS_BY_NAME.get(type.lower())
if plugin is None or plugin.llm_class is None:
raise ValueError(f"No LLM class found for type {type}")
PROVIDERS_BY_NAME 是 {provider 名: Provider 插件} 的字典,由 application/llm/providers/__init__.py:32
(ALL_PROVIDERS)按固定顺序构建。每个插件(application/llm/providers/base.py:13,Provider)声明两样东西:
自己的 name 和要实例化的 llm_class。
内置 provider 与它们的实现类:
| provider 名 | 实现类 | 说明 |
|---|---|---|
openai | OpenAILLM | 云端 OpenAI |
openai_compatible | OpenAILLM | Mistral / Together / Ollama / LM Studio 等 OpenAI 兼容端点 |
anthropic | AnthropicLLM | Claude |
google | GoogleLLM | Gemini |
groq / novita / openrouter / premai / sagemaker / llama_cpp | 各自子类 | 其余 provider,插件同构,不逐一展开 |
huggingface | llm_class = None | 出现在目录里但不可派发(工厂会直接报错) |
注意 openai 和 openai_compatible 共用同一个 OpenAILLM 类——差异不在代码,而在"端点配置从哪来",
这正是下一节 BYOM 要解决的。
3.2 BYOM:把注册表 UUID 解析成真实端点
它要 解决的小问题。 内置模型的 model_id 就是上游 API 认得的名字(如 gpt-4o)。但用户自带模型时,
DocsGPT 给这条记录发的是一个内部 UUID——上游 API 根本不认。所以工厂必须把 UUID 翻译回三样东西:
真实的 upstream model 名、base_url、api_key。
思路。 去模型注册表按 model_id 查出那条 AvailableModel 记录,记录上带着这三样,谁有值谁就覆盖调用方传入的默认值。
真实实现 application/llm/llm_creator.py:61-84(create_llm):
model = ModelRegistry.get_instance().get_model(model_id, user_id=user_id)
if model is not None:
capabilities = getattr(model, "capabilities", None) # 见 §4.3
...
if model.api_key:
api_key = model.api_key
if model.base_url:
base_url = model.base_url
# BYOM 的 registry id 是 UUID;上游 API 需要用户填的真实模型名
if model.upstream_model_id:
upstream_model_id = model.upstream_model_id
这里有几个关键点,拆开讲:
(a) 按 user_id 分层查找。 BYOM 模型属于某个用户,查找必须带上 user_id 才能命中"该用户的私有模型层";
内置模型不带 user_id 也能查到,所以保持了向后兼容。get_model 的分层逻辑见
application/core/model_registry.py:357-364:先查用户层,miss 再查全局层。
(b) model_user_id —— 替谁解析。 user_id 默认取 decoded_token['sub'](调用者),但共享 agent
场景里,agent 存的 default_model_id 是属主的 BYOM UUID,而 decoded_token 代表的是调用者。这时要显式
传 model_user_id 指定"用属主的层去解析"(create_llm docstring,llm_creator.py:23-31)。
(c) 这就是 base.py 里 self.upstream_model_id 的来历。 工厂把解析出的真实名字塞进构造参数
model_id=upstream_model_id(llm_creator.py:118);同时把原始 UUID 单独盖在 _canonical_model_id 上给计费用:
llm._canonical_model_id = model_id # llm_creator.py:129,UUID 归 UUID,给 token_usage
于是 llm.model_id 永远是上游认得的名字,agent 侧 _llm_gen 用它发请求(agents/base.py:707),
而计费仍能按 canonical UUID 归账。
3.3 派发前的安全闸(BYOM 特有)
用户能自填 base_url 就意味着"服务端可能被诱导去访问内网地址"(SSRF)。工厂在派发前加了两道闸:
| 闸 | 做什么 | 代码 |
|---|---|---|
| 拒绝无 key 的用户模型 | user-source 记录若没有自己的 api_key,直接报错——否则会把服务端 settings.API_KEY 泄漏给用户的 base_url | llm_creator.py:68-76 |
| 重新校验 base_url + 钉住 IP | 对 user-source 的 base_url 再跑一次 validate_user_base_url,并给 SDK 注入一个"解析一次、绑定已验证 IP"的 pinned_httpx_client,关掉 DNS-rebinding 的 TOCTOU 窗口 | llm_creator.py:90-110 |
第二道闸目前只对 openai_compatible 生效(llm_creator.py:102):未来的 BYOM provider 必须显式接入
http_client 才享受这层保护。这是一处"安全默认关闭、显式开启"的谨慎设计。