数据截至 (上游 commit a51131d3fc7a)
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:30
(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:1208),
而计费仍能按 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 才享受这层保护。这是一处"安全默认关闭、显式开启"的谨慎设计。
4. 基类 BaseLLM —— 所有 provider 都要遵 守的契约
本节讲 application/llm/base.py:36(BaseLLM)定义的"最小公约数":上层只依赖这几个方法,provider 想接入就得实现它们。
4.1 gen / gen_stream:对外的两个入口
上层只调这两个方法:gen(一次性)和 gen_stream(流式)。它们本身不发请求,而是做三件公共事,再委托给子类的 _raw_*:
- 发一条 start 日志(
_emit_stream_start_log,给计费面板按 provider 分组)。 - 挂上装饰器:
stream_cache(缓存)+stream_token_usage(计费)。 - 交给
_execute_with_fallback执行(见 §5)。
真实实现 application/llm/base.py:781-799(gen_stream):
def gen_stream(self, model, messages, stream=True, tools=None, *args, **kwargs):
has_attachments = bool(kwargs.get("_usage_attachments") or kwargs.get("attachments"))
self._emit_stream_start_log(model, messages, tools, has_attachments)
decorators = [stream_cache, stream_token_usage]
return self._execute_with_fallback(
"_raw_gen_stream", decorators, model=model, messages=messages,
stream=stream, tools=tools, *args, **kwargs,
)
子类真正干活的是两个抽象方法,不实现就没法实例化(base.py:404-410):
_raw_gen(self, model, messages, stream, tools, ...)_raw_gen_stream(self, model, messages, stream, ...)
注意一处不寻常的签名。 子类的
_raw_gen_stream(self, baseself, model, ...)第二个参数叫baseself——因为装饰器把方法当普通函数调用时会再传一次实例(base.py:172method(self, ...))。看 provider 代码时 别把baseself当成笔误。
4.2 一次调用的完整数据流
把装饰器和抽象方法串起来:
gen_stream(model, messages, tools)
│ 发 start 日志
▼
_execute_with_fallback("_raw_gen_stream", [stream_cache, stream_token_usage])
│ 给 _raw_gen_stream 套上缓存 + 计费装饰器
▼
_raw_gen_stream(baseself, model, messages, ...) ← 子类实现,真正发 SDK 请求
│ 逐块 yield:纯文本 str / {"type":"thought"} / 原始 choice(带 tool_calls)
▼
(回到第 2 章 LLMHandler.handle_streaming 消费这些 chunk)
子类 yield 的东西有三种形态,是上下层之间的非正式约定:
str—— 普通正文增量。{"type": "thought", "thought": "..."}—— 思考/reasoning 增量(DeepSeek、Gemini thinking)。- 一个"choice/part 对象" —— 里面带
tool_calls,交给 handler 解析。
4.3 能力协商:这个模型到底支不支持 X
不是每个模型都支持工具调用、结构化输出、或图片附件。BaseLLM 用三个方法做"能力协商",让上层在派发前先问一句:
| 方法 | 问什么 | 默认 | 代码 |
|---|---|---|---|
_supports_tools() | 支持 function calling 吗 | 子类实现;OpenAI/Google 返回 True | base.py:412-418 |
_supports_structured_output() | 支持 JSON schema 强约束吗 | 基类默 认 False | base.py:420-427 |
get_supported_attachment_types() | 能吃哪些 MIME 附件 | 基类默认 [] | base.py:434-441 |
关键设计:注册表能力覆盖硬编码默认。 内置 provider 类里这几个方法常常硬编码返回 True,但 BYOM 用户
可能接了一个不支持工具的端点。所以工厂把注册表记录里的 capabilities(application/core/model_settings.py:29,
ModelCapabilities)一路 forward 进构造参数(llm_creator.py:65,123),provider 在派发时优先读它。
以 OpenAI 为例(application/llm/openai.py:1654-1661,_supports_tools):
def _supports_tools(self):
if self.capabilities is not None: # 注册表带来的 per-model 能力
return bool(self.capabilities.supports_tools)
return True # 否则 OpenAI 默认支持
这条能力还会在 _raw_gen 里做纵深防御:即使上层传了 tools,若能力标志说不支持,就地丢弃
(openai.py:589-592)——防止把不被端点接受的字段发出去导致 400。
5. 跨 provider 回退 —— 主模型挂了,悄悄换一家
这是本层最巧妙的机制:主模型失败时,在同一次 gen_stream 调用内部切换到备份模型,上层几乎无感。
5.1 fallback_llm:懒加载的备胎
application/llm/base.py:89(fallback_llm,property)在第一次被读时才构造备份 LLM,顺序是:
- 先试每个 agent 的 backup_models(用户为这个 agent 配的备份列表)。
- 都失败,再退回全局
FALLBACK_*设置(部署级兜底)。
构造备胎时它递归调用 LLMCreator.create_llm(base.py:80、114),并把 model_user_id
(BYOM 解析范围)一路传下去——这样共享 agent 的备份也在属主的层里解析(base.py:68、121)。
备胎还被打上两个标记:_token_usage_source = "fallback"(计费面板据此归到 fallback 来源)和继承父级的
_request_id(同一次用户请求即使跑了回退,仍归在同一个 id 下)——见 base.py:93-96。