跳到主要内容

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 LLMLiteLLM 路径主入口,多 provider 的大脑llm/llm.py:133
get_litellm()全包只此一处 lazy import litellm,import 失败缓存为 Nonellm/_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:701llm/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:54llm/llm.py:106
UnifiedLLMDispatcher把两条路径包成同一个 async 协议(收敛用)llm/unified_adapters.py:271

2.3 主线走一遍(高层,不进代码)

  1. Agent 构造时就根据 llm 参数定死走哪条路径,并置 _using_custom_llm 标志(agent/agent.py:1676-1710)。
  2. 真正调用时:LiteLLM 路径进 LLM.get_responsellm/llm.py:2075),原生路径进 OpenAIClient 的方法。
  3. 无论哪条,先经 provider 侦测决定小动作,再(可选)过限速闸门。
  4. 打 API。成功→抽 token、算成本、返回;失败→分类、决定退避多久或换哪个 profile,再重试。
  5. 把纯文本(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_urlAgent(..., 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」的用户完全不受影响。

原生路径这边,OpenAIClientsync_client / async_client 也是属性级懒加载,真正用到时才 new(llm/openai_client.py:334)。

收敛尝试(了解即可)。 unified_adapters.py 把两条路径各包一个 adapter(LiteLLMAdapter:20OpenAIAdapter:182),再统一成一个 UnifiedLLMDispatcher:271)暴露同一套 async 接口。这是把「双路径」收敛成「单协议」的架子,方便未来统一调用点。

3.2 provider 侦测与默认值:一个名字认出一个厂商

它要解决的小问题。 用户只给一个模型名字符串,这层得自己认出「这是哪家的模型」,才能套对默认行为——比如 Ollama 这种弱模型需要更强的工具调用兜底。

思路:先看显式前缀,再看模型名模式,最后看 base_url。 集中在 _detect_providerllm/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_providerllm/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_adapteradapters/__init__.py:266)先精确匹配名字,匹配不到再按子串(含 ollama/claude/gemini)兜底,最后回落 DefaultAdapter。新 provider 可用 add_provider_adapter 注册,不用改核心代码。

能力探测另走一路:直接问 litellm。 「这个模型支不支持结构化输出 / 联网搜索 / prompt 缓存」不自己维护清单,而是转调 litellm 的 helper(llm/model_capabilities.py:25supports_structured_outputs 等),理由写在文件头:litellm 由社区维护,更准更新。只有 litellm 还没覆盖的(如 web fetch)才自维护一张静态表(model_capabilities.py:205)。

3.3 错误分类与重试/限速:出错了到底该不该再打一次

这是整层「保命」的核心,主要长在 LiteLLM 路径(class LLM)里。分三块:分类 → 决策 → 退避/限速

3.3.1 分类:把五花八门的报错归成有限几类

它要解决的小问题。 provider 的报错是一堆自由文本,得先归类才能决定策略——限流该等一会再试,认证错误再试也没用。

现役分类器是 classify_error_kindllm/llm.py:701),用关键词把异常映射成一个类型化AgentErrorKind 字符串。顺序有讲究——先查更严重/更专的:

归类触发关键词(节选)可重试?
auth_permanentinvalid api key、authentication_error
authunauthorized、authentication failed可(尝试刷新/换 profile)
billinginsufficient quota、payment required否(排在 429 之前,别把欠费当限流)
rate_limitrate limit、429、resource_exhausted
context_overflowmaximum context length、prompt too long否(需先压缩上下文)
model_not_foundmodel not found、invalid model
overloaded503/502/500、service unavailable
idle_timeouttimeout、deadline exceeded是(但过熔断器)

注意有两套分类器,别混。 上面这套 classify_error_kind现役、喂给失败切换决策的。另有一个独立模块 error_classifier.pyErrorCategory:29classify_error:283classify_llm_error:124),提供更结构化的恢复提示(should_compress_context / should_fallback_model 等),是能力更全的平行分类器。而 _classify_error_and_should_retry_legacyllm/llm.py:677)已标注 deprecated,内部转调新决策。

3.3.2 决策:分类之后做什么

分类归分类,「做什么」单独抽在 resolve_failover_decisionllm/llm.py:790),输出一个 FailoverDecision(动作 + 退避毫秒 + 是否可重试)。作者刻意把「归类」和「动作」拆开,好让策略单独测试、单独覆盖。几条关键规则:

  • rate_limit:先从错误文本里抠出 provider 建议的延迟(_parse_retry_delayllm/llm.py:620,能识别 retryDelay: 58sRetry-After: 等多种写法并夹到上限);抠不到就退指数退避 2^(attempt-1),封顶 60s。
  • auth 且配了 failover:动作 rotate_profile,换个账号再试。
  • overloaded/idle_timeout:指数退避 2s→4s→8s… 封顶 30s;idle_timeout 还要先过熔断器 IdleTimeoutBreaker,连续超时够多次直接放弃。
  • context_overflow / billing / model_not_foundsurface_error,不重试。

3.3.3 退避与限速:别把 provider 打爆,也别惊群

限速用令牌桶。 RateLimiterllm/rate_limiter.py:34)实现标准 token bucket:令牌按固定速率(rpm/60 每秒)补充,每次请求消耗一个,没令牌就等(acquire:183)。同时支持按请求数按 API token 数两种额度(acquire_tokens:201,给 Gemini 那种按 tokens/min 计费的场景)。没配 limiter 时零开销,用单调时钟保证精度,sleep/时钟可注入便于测试。

退避加抖动,防「惊群」。 多个 agent 同时撞限流,如果都等一样的时间,会同一秒又一起重试、再一起被拒。jittered_backoffllm/retry_utils.py:10)在指数退避上叠 ±50% 随机抖动,把重试时间打散:

# 示意,非源码:指数退避 + ±50% 抖动
exponential = min(base * 2 ** (attempt - 1), cap) # 逐次翻倍、封顶
jitter = random.uniform(-0.5 * exponential, 0.5 * exponential)
return max(0.0, exponential + jitter) # 重点:随机化,避免同步重试

calculate_backoff_with_retry_afterretry_utils.py:47)在此之上取「server 建议延迟」和「本地退避」的较大者——既尊重 provider 的 Retry-After,又能应对反复失败。

3.3.4 把三块串起来:一个重试循环

上面三块由 _call_with_retryllm/llm.py:1076)串成一个循环,异步版是 _call_with_retry_async:1152),两者共用不睡觉的决策函数 _handle_retry_exception:961):

for attempt in 0..max_retries:
(可选) 限速闸门 acquire()
试着调 func(...) ── 成功 ─▶ 标记 profile 成功、重置熔断器、返回
│失败

_handle_retry_exception:resolve_failover_decision 出决策

├─ should_raise → 直接抛
├─ rotate_profile → 换 auth profile,改 kwargs 里的 key/url/model
└─ retry → 按 retry_delay 睡(有 limiter 走 limiter 的 wait_for_retry)

litellm.completion / acompletion 就是被这个循环包起来调的(_completion_with_retry:1234)。成功时若配了 failover 会 mark_success 让受损 profile 恢复,并重置 idle 熔断器。

3.4 跨 provider 失败切换:一个 key 挂了自动换下一个

它要解决的小问题。 生产里常备多把 key 或多个 provider,主的限流/挂了要能秒切备用,而不是整个请求失败。

核心数据结构是 AuthProfilellm/failover.py:38):一条「用哪个 provider、哪把 key、哪个 base_url、优先级多少」的记录,还带状态(available / rate_limited / error / disabled)和冷却时间。

FailoverManagerllm/failover.py:195)管一堆 profile:

  • add_profilepriority 升序插入(数字小 = 优先级高)。
  • get_next_profile:283)先把冷却到期的 profile 复位,再按优先级返回第一个可用的;全不可用就返回「剩余冷却最短」的那个。
  • mark_failure:316):限流的进较长冷却(默认 60s),其它错误进较短冷却(默认 30s),并触发 failover 回调。
  • mark_success:把非 available 的 profile 复位(视为恢复)。

FailoverConfig:161)配重试次数、退避、各类冷却时长。切换动作最终由 §3.3.4 的循环触发:决策为 rotate_profile 时调 _switch_to_profilellm/llm.py:882)把当前实例的 api_key/base_url/model 换成新 profile 的值,下一次重试就打到新账号。

failover.py 文件头明说自己「轻量、协议驱动,重实现放在 praisonai wrapper 里」——所以这里给的是机制骨架,不是全部策略。

顺带一提 ModelRouter(可选,不在主链路)。 model_router.py:37 是另一套东西:按任务复杂度(关键词启发式,analyze_task_complexity:166)在一批候选模型里挑性价比最高的(select_model:222),可按成本或能力排序、按 provider 偏好优选。它是「选哪个模型」的工具,和「某模型挂了切下一个」的 failover 是两回事。

3.5 Token 与成本核算:这次调用花了多少钱

它要解决的小问题。 observability 和预算控制都要知道每次调用吃了多少 token、折多少钱,但不该为这个在热路径里硬吃 litellm 的 import

token 用量结构化成 TokenUsagellm/llm.py:106):prompt/completion/total 之外还细分 cached、reasoning、audio input/output。get_response(..., return_token_usage=True) 时由 _extract_token_usage:4912)从响应里抽出来,随文本一起返回(get_response:2075_prepare_return_value 闭包)。

成本核算是「按需、可关」的。 集中在 _cost.py

  • is_cost_tracking_enabled_cost.py:35):只有设了 PRAISONAI_TRACK_COST / PRAISONAI_SAVE_OUTPUT(或 Agent 传 metrics=True)才开。
  • calculate_cost_cost.py:54):没开且没 force 就直接返回 None——热路径零成本;真要算时才 lazy import litellm,用 litellm.completion_cost 按其内置价目表(1000+ 模型)折算美元。
  • 也能从 token 数反算 calculate_cost_from_tokens:109)。

Agent 侧的 _calculate_llm_costagent/tool_execution.py:1051)转调 utils.cost_utils.calculate_llm_cost:litellm 在就用它,不在就回落内置价目表——保证没装 litellm 也有个粗估。


4. 巧妙之处(可借鉴的技术)

  • 「默认快、按需慢」的双后端。 用「模型名有没有 /」这种零成本信号,把 90% 用户挡在 2-3 秒的 litellm import 之外(agent/agent.py:1690__init__.py:753)。
  • 懒加载收进单一入口。 全包只有 get_litellm() 一处 import litellm,成败都缓存,杜绝重复 import 开销与散落的 try/except(_litellm_loader.py:16)。
  • 分类与动作解耦。 classify_error_kind(判断「这是什么错」)和 resolve_failover_decision(判断「那怎么办」)分开,策略能单测、能覆盖(llm/llm.py:701 vs :790)。
  • 退避加抖动防惊群。 多 agent 场景下,±50% 抖动把重试时间打散,避免「一起被拒→一起重试」的雪崩(retry_utils.py:10)。
  • 成本核算默认短路。 不开追踪时 calculate_cost 立即返回 None,热路径完全不碰 litellm(_cost.py:74)。
  • 能力探测外包给 litellm。 「模型支不支持某功能」直接问 litellm 而非自维护清单,跟着社区更新走(model_capabilities.py)。

5. 边界与局限(诚实说)

  • 两套错误分类器并存。 classify_error_kind(现役)、error_classifier.py(结构化平行版)、_classify_error_and_should_retry_legacy(已弃用)三者共存,读代码要认清哪个在链路上——现役重试走的是前者经 resolve_failover_decision
  • 分类靠关键词匹配,会漏会误。 归类基于错误文本的正则/子串(llm/llm.py:713 起、error_classifier.py:57)。provider 改文案、或非英文报错,可能落进 unknown 或错类。
  • 失败切换是骨架,不是全策略。 failover.py 自陈重实现放在 praisonai wrapper 里;这层只给 profile 轮换与冷却机制。
  • 限速/重试的完整实现偏在 LiteLLM 路径。 _call_with_retryRateLimiter 接入主要在 class LLM;原生 OpenAI 路径有自己更薄的一套,两边不完全对称。
  • MODEL_WINDOWS 是手维护的静态表。 上下文窗口大小硬编码(llm/llm.py:149),新模型或改了窗口的模型可能过时。
  • ModelRouter 的复杂度判断是关键词启发式。 analyze_task_complexitymodel_router.py:166)自陈「简单启发式,生产可换 ML 分类器」,边界情况容易判偏。
  • llm.py 单文件 6000+ 行。 双路径、工具循环、流式、反思等都塞在一个 class LLM 里,get_response 系列方法很长,定位具体分支成本高。

6. 代码地图(导航索引)

主题文件路径关键符号
LiteLLM 路径主入口src/praisonai-agents/praisonaiagents/llm/llm.py:133LLM
同步/异步/流式入口llm/llm.py:2075 / :3952 / :3625get_response / get_response_async / get_response_stream
原生 OpenAI 客户端llm/openai_client.py:280OpenAIClientget_openai_client:2258
路径选择(在 Agent 侧)src/praisonai-agents/praisonaiagents/agent/agent.py:1649-1710_using_custom_llm
双路径澄清注释llm/llm.py:86(NOTE 注释)
何时走哪条(docstring)praisonaiagents/__init__.py:753warmup
litellm 单点懒加载llm/_litellm_loader.py:16get_litellm
provider 侦测llm/llm.py:507 / :577 / :601_detect_provider / _is_ollama_provider / _is_qwen_provider
provider 默认值/adapterllm/llm.py:552 / :563llm/adapters/__init__.py:266_initialize_provider_adapter / _apply_provider_defaults / get_provider_adapter
provider adapter 实现llm/adapters/__init__.py:17/88/178/200DefaultAdapter / OllamaAdapter / AnthropicAdapter / GeminiAdapter
能力探测(问 litellm)llm/model_capabilities.py:25 / :127supports_structured_outputs / supports_web_search
错误分类(现役)llm/llm.py:701classify_error_kind
失败决策llm/llm.py:790resolve_failover_decision
错误分类(平行/结构化)llm/error_classifier.py:283 / :124 / :29classify_error / classify_llm_error / ErrorCategory
限流判定 / 抠延迟llm/llm.py:653 / :620_is_rate_limit_error / _parse_retry_delay
重试循环llm/llm.py:1076 / :1152 / :961_call_with_retry / _call_with_retry_async / _handle_retry_exception
令牌桶限速llm/rate_limiter.py:34RateLimiteracquire:183acquire_tokens:201
带抖动退避llm/retry_utils.py:10 / :47jittered_backoff / calculate_backoff_with_retry_after
跨账号失败切换llm/failover.py:195 / :38 / :161FailoverManager / AuthProfile / FailoverConfig
模型自动路由(可选)llm/model_router.py:37 / :222 / :17ModelRouter / select_model / TaskComplexity
Token 用量结构llm/llm.py:106 / :4912TokenUsage / _extract_token_usage
成本核算llm/_cost.py:54 / :35agent/tool_execution.py:1051calculate_cost / is_cost_tracking_enabled / _calculate_llm_cost
双路径统一协议(收敛)llm/unified_adapters.py:271 / :20 / :182UnifiedLLMDispatcher / LiteLLMAdapter / OpenAIAdapter

相关章节: 上层何时调本层见 01-agent-chat-loop;工具定义与执行见 02-tools-and-mcp;多智能体编排见 04-multi-agent-orchestration;总览见 index