LLM 层:双路径、多provider与容错
30 秒导读: 这一层只干一件事——把一次「调用大模型」的请求,可靠地打到某个模型上,再把文本、token、成本收回来。它的核心取舍是「快 vs 通用」:默认用原生 OpenAI 客户端(导入快、延迟低),一旦你要用别的 provider(模型名带
/、传 dict 配置、或设了base_url)就切到 LiteLLM(一个库统一 100+ provider)。两条路径外面,再裹上 provider 侦测、错误分类、限速与重试、跨账号失败切换、token 与成本核算这几圈「保命」逻辑。
本章讲这一层内部怎么把请求打稳。至于 Agent 在什么时机、用什么消息来调这一层,属于 01-agent-chat-loop;工具怎么定义和执行属于 02-tools-and-mcp。
1. 这是什么(零基础也能懂)
一句话定义: LLM 层是 PraisonAI 里「模型调用的统一网关」——上层只说「拿这段 prompt 去问模型」,这层负责选哪条路径、怎么重试、怎么算钱。
它解决什么问题。 假设你写了个 agent,今天用 gpt-4o-mini,明天老板说换成 claude,后天要接自建的 Ollama 本地模型,大后天 OpenAI 限流了想自动切到备用 key。如果每处都手写 if provider == ...,代码会烂成一团。这层把这些差异全收进去,让上层永远只写一句「问模型」。
它能做什么(功能清单):
- 两套后端并存:原生 OpenAI SDK(快)+ LiteLLM(通用)。
- 从模型名自动认出 provider(openai / anthropic / gemini / ollama…),并套上 provider 专属默认值。
- 把 API 报错分类(限流 / 上下文超长 / 认证失败 / 服务过载…),据此决定「重试还是直接抛」。
- 令牌桶限速 + 带抖动的指数退避,避免多 agent 同时把 provider 打爆。
- 一个 provider 挂了,自动换到下一个 auth profile(跨 key / 跨 provider 切换)。
- 数每次调用的 token 用量与美元成本。
用起来什么样。 上层几乎感觉不到这一层的存在——你只是在建 Agent 时给个 llm 参数:
# 示意,非源码
from praisonaiagents import Agent
# 默认路径:走原生 OpenAI 客户端,导入快、延迟低
a1 = Agent(instructions="你是助手", llm="gpt-4o-mini")
# 带 "/" → 切到 LiteLLM,支持任意 provider
a2 = Agent(instructions="你是助手", llm="anthropic/claude-3-5-sonnet")
# 传 dict / 设 base_url → 也走 LiteLLM
a3 = Agent(instructions="你是助手", llm={"model": "gpt-4o-mini", "temperature": 0.2})
一句话直觉。 把这层想成机场的双跑道 + 塔台:绝大多数航班(OpenAI)走主跑道,起降最快;国际航班(其它 provider)走副跑道,程序多但哪都能飞。塔台(侦测 / 限速 / 重试 / 切换)保证不管哪条跑道,飞机都能安全落地。
2. 顶层全景(它大概怎么转)
2.1 一张图:一次调用从入口到落地
先说怎么读这张图:从上往下是一次请求的生命周期;中间那个菱形是全章最重要的岔路口——「选哪条路径」。
上层 Agent.chat(属第 01 章)
│ prompt / tools / system
▼
┌───────────────────────────────────────┐
│ 路径选择(在 Agent 构造时就定死) │
│ llm 是 dict?含 "/"?设了 base_url? │
└───────────────┬───────────────┬─────────┘
否(纯模型名) │ │ 是
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ 原生 OpenAI 路径 │ │ LiteLLM 路径 │
│ OpenAIClient │ │ class LLM │
│ (openai_client) │ │ get_response(_async) │
│ 快、默认 │ │ 多 provider、通用 │
└────────┬─────────┘ └───────────┬──────────┘
│ │
│ 两条路径共用的「保命圈」 │
▼ ▼
┌─────────────────────────────────────────────┐
│ ① provider 侦测 + 默认值(adapter) │
│ ② 令牌桶限速(RateLimiter,可选) │
│ ③ 调 API │
│ ④ 出错→分类→重试/退避/换账号(_call_with_retry)│
│ ⑤ 收 token 用量 + 算成本 │
└───────────────────┬─────────────────────────┘
▼
文本(+ 可选 TokenUsage)
注意:④中「保命圈」里,限速/重试的完整实现只长在 LiteLLM 路径(
class LLM)内;原生 OpenAI 路径有自己一套更薄的循环。侦测、成本核算则两边都用。下文 §3 会点明每个机制归哪条路径。
2.2 部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
OpenAIClient | 原生 OpenAI SDK 封装,快路径主体 | llm/openai_client.py:280 |
class LLM | LiteLLM 路径主入口,多 provider 的大脑 | llm/llm.py:133 |
get_litellm() | 全包只此一处 lazy import litellm,import 失败缓存为 None | llm/_litellm_loader.py:16 |
| provider adapter | 按 provider 给专属默认值/行为(Ollama/Anthropic/Gemini) | llm/adapters/__init__.py:17 |
RateLimiter | 令牌桶限速(按请求数或 token 数) | llm/rate_limiter.py:34 |
| 错误分类 | 把异常归成 rate_limit/auth/context_overflow… 决定重试 | llm/llm.py:701、llm/error_classifier.py:283 |
jittered_backoff | 带抖动的指数退避,防「惊群」 | llm/retry_utils.py:10 |
FailoverManager | 多个 auth profile 之间轮换 | llm/failover.py:195 |
ModelRouter | 按任务复杂度/成本自动挑模型(可选,独立工具) | llm/model_router.py:37 |
| 成本核算 | 数 token、按 litellm 价目表折算美元 | llm/_cost.py:54、llm/llm.py:106 |
UnifiedLLMDispatcher | 把两条路径包成同一个 async 协议(收敛用) | llm/unified_adapters.py:271 |
2.3 主线走一遍(高层,不进代码)
- Agent 构造时就根据
llm参数定死走哪条路径,并置_using_custom_llm标志(agent/agent.py:1676-1710)。 - 真正调用时:LiteLLM 路径进
LLM.get_response(llm/llm.py:2075),原生路径进OpenAIClient的方法。 - 无论哪条,先经 provider 侦测决定小动作,再(可选)过限速闸门。
- 打 API。成功→抽 token、算成本、返回;失败→分类、决定退避多久或换哪个 profile,再重试。
- 把纯文本(
return_token_usage=True时连同TokenUsage)交回上层。
3. 核心原理(逐个机制,由浅入深)
3.1 双路径:为什么有两套后端,何时走哪条
它要解决的小问题。 「快」和「通用」天生矛盾:LiteLLM 一个库统一所有 provider,但它 import 一次就要 2-3 秒;而 90% 的用户只用 OpenAI,凭什么让他们吃这 2-3 秒?
思路。 那就默认走原生 OpenAI SDK(import 只要约 100ms),只有当用户明确要多 provider 时,才付 LiteLLM 的代价。这个取舍在 warmup() 的 docstring 里讲得最白(praisonaiagents/__init__.py:753):
默认 OpenAI 用法(
llm="gpt-4o-mini")不需要 warmup,因为走的是快的原生 SDK;只有触发 LiteLLM 时才值得预热。
触发 LiteLLM 的三个条件(其一即可,判定在 Agent 构造期):
| 触发条件 | 例子 | 依据 |
|---|---|---|
模型名带 / | llm="openai/gpt-4o-mini" | agent/agent.py:1690 |
| 传 dict 配置 | llm={"model": "gpt-4o-mini"} | agent/agent.py:1678 |
设了 base_url | Agent(..., base_url="http://…") | agent/agent.py:1649 |
| 以上都不满足 | llm="gpt-4o-mini" | 落到 else,走原生 OpenAI(agent/agent.py:1710) |
两条路径不是「重复调 API」,是「两条独立代码路径」。 这点作者专门在源码里写了注释澄清,别误以为每次请求打了两次(llm/llm.py:86 附近):
# NOTE: The custom-LLM path (Agent.chat → get_response) and OpenAI path
# (Agent.chat → _chat_completion) are separate code paths, not duplicate
# API calls per request. —— llm/llm.py:86
懒加载是这套设计的关键细节。 litellm 从不在模块顶层 import,全包统一走 get_litellm()(llm/_litellm_loader.py:16):import 只尝试一次,成功缓存模块、失败缓存 None,之后再问直接返回缓存。这样「没装 litellm、只用 OpenAI」的用户完全不受影响。
原生路径这边,OpenAIClient 的 sync_client / async_client 也是属性级懒加载,真正用到时才 new(llm/openai_client.py:334)。
收敛尝试(了解即可)。 unified_adapters.py 把两条路径各包一个 adapter(LiteLLMAdapter:20、OpenAIAdapter:182),再统一成一个 UnifiedLLMDispatcher(:271)暴露同一套 async 接口。这是把「双路径」收敛成「单协议」的架子,方便未来统一调用点。
3.2 provider 侦测与默认值:一个名字认出一个厂商
它要解决的小问题。 用户只给一个模型名字符串,这层得自己认出「这是哪家的模型」,才能套对默认行为——比如 Ollama 这种弱模型需要更强的工具调用兜底。
思路:先看显式前缀,再看模型名模式,最后看 base_url。 集中在 _detect_provider(llm/llm.py:507),返回 "ollama"/"anthropic"/"gemini"/"openai" 之一。判定顺序(命中即停):
model = "anthropic/claude-3-5-sonnet"
│
▼ ① 切出 "/" 前缀
前缀是 ollama / anthropic|claude / gemini|google ?──命中──▶ 定厂商
│ 否
▼ ② _is_ollama_provider():前缀、base_url、:11434 端口都算
│ 否
▼ ③ 模型名 startswith claude / gemini ?
│ 否
▼ ④ base_url 或环境变量里有 "ollama" / ":11434" ?
│ 否
▼ ⑤ 兜底 → "openai"
Ollama 侦测单独抽成 _is_ollama_provider(llm/llm.py:577),因为它太杂:ollama/ 前缀、base_url 含 ollama、或 OPENAI_BASE_URL/OPENAI_API_BASE 指到 :11434 端口都算。Qwen 也有专门的 _is_qwen_provider(:601),用来决定是否走 XML 工具格式。
认出厂商后,用 adapter 套默认值。 构造时 _initialize_provider_adapter(:552)按侦测结果从注册表取一个 adapter,再由 _apply_provider_defaults(:563)把 adapter 的默认值只在用户没显式设过时填进去。各 adapter 的差异:
| Adapter | 关键差异 | 依据 |
|---|---|---|
DefaultAdapter | 兜底:支持流式、无特殊格式 | adapters/__init__.py:17 |
OllamaAdapter | 关流式带工具、迭代 1 次后就催总结、工具结果用自然语言、给 max_tool_repairs=2 等默认 | adapters/__init__.py:88 |
AnthropicAdapter | 支持 prompt caching / 结构化输出;async 下关流式(litellm 兼容坑) | adapters/__init__.py:178 |
GeminiAdapter | 有内部工具需特殊格式、带工具时跳过流式 | adapters/__init__.py:200 |
get_provider_adapter(adapters/__init__.py:266)先精确匹配名字,匹配不到再按子串(含 ollama/claude/gemini)兜底,最后回落 DefaultAdapter。新 provider 可用 add_provider_adapter 注册,不用改核心代码。
能力探测另走一路:直接问 litellm。 「这个模型支不支持结构化输出 / 联网搜索 / prompt 缓存」不自己维护清单,而是转调 litellm 的 helper(llm/model_capabilities.py:25 的 supports_structured_outputs 等),理由写在文件头:litellm 由社区维护,更准更新。只有 litellm 还没覆盖的(如 web fetch)才自维护一张静态表(model_capabilities.py:205)。