wth2jhl.online

Back

深入解析 BerriAI/litellm:用一张 dispatch 字典撬动 100+ LLM 的工业级 AI Gateway#

一、写在前面:为什么我要拆 litellm#

6 万 star、Y Combinator W23、被 Stripe / Google ADK / OpenAI Agents SDK / Netflix 摆在官网首页的”OSS Adopters”上——这是 BerriAI/litellm 的牌面。

但我更想搞清楚的,是它为什么是这个样子。

我之前拆过的项目(HyperFrames、God’s Eye View、MathModelAgent、pi agent harness)各有各的爽点:HyperFrames 的”原子截帧”很黑客、God’s Eye View 的”产品决策日志”很工程师、pi 的”TypeScript 扩展取代 MCP”很激进、MathModelAgent 的”SKILL 教材”很轻巧。

litellm 不一样。它做的是一件很脏、很重、很容易写崩的事:把 100+ 个 LLM provider 的请求/响应格式、流式行为、auth 模式、cost 公式、报错码、streaming 协议统一成一个 OpenAI 兼容的入口。

你随便想想:OpenAI 用 /v1/chat/completions + SSE 增量;Anthropic 用 /v1/messages + 自己的 event types;Bedrock 有 Converse 和 Invoke 两条路(后者还得塞 base64);Vertex 走 Gemini 和 Claude 两种 partner model;Gemini 自己又分 vertex 和 ai studio;Azure 的 O-series 要单独走 O1Config;Ollama 是本地;vLLM 走 OpenAI 兼容但 base 不一样;HuggingFace 走 inference endpoint 还是 text-generation-inference 又是两套;AWS SageMaker 是 hosted endpoint;Replicate 是按秒计费的异步 poll——

光把这些 provider 接到一个 Python 函数上,代码量就已经是个天文数字。这还没算 router 策略、cost 追踪、proxy auth、caching、guardrails、admin dashboard、mcp 集成这些”上面搭的层”。

所以我这次拆解的核心问题不是”它有多少行代码”(很多,光 litellm/main.py 就 9522 行),而是:

  1. 它怎么把”加 provider”的成本压到 ~50 行? 这是工程上最难的问题
  2. Rust 重写到底换了什么? 不是性能噱头吗?
  3. Router 凭什么支持 15 种策略? 是堆功能还是有内在架构?
  4. Cost 记账为什么是它的护城河? 这个最反直觉

下面我分 5 个模块讲,每节都先放我从代码里抠出来的真实链路图,再展开讲。

本文所有图都是原创,路径和文件来自我下载的 2026-10-10 拉取的 BerriAI/litellm@main(commit 信息见文末)。

二、Python SDK 入口:acompletion() 怎么把 100+ provider 收口到一个函数#

我第一次打开 litellm/main.py 的时候是有点懵的——一个 9522 行的文件,入口函数 acompletion 的签名竟然有 90+ 个参数。但读下去才发现,这 90+ 个参数背后是一套非常工整的”三段 dispatch”设计。

下图是我从代码里抠出来的真实链路:

SDK 请求链路

2.1 入口层:90+ 参数的真相#

acompletion() 这个函数,本质是 4 个步骤的串联:

  1. prompt management hook:async_get_chat_completion_prompt 把用户传的 prompt_id / prompt_label / prompt_version 解析成实际的 messages
  2. AnthropicCacheControlHook.maybe_seed_default_injection_points:自动给 Anthropic 调用加上 cache_control 注入点(这个钩子特别细节——它会扫描 messages 找最长的 system 块加 ephemeral 标记)
  3. mock_completion / mock_timeout:测试和调试用,不走真 provider
  4. completion_with_retries:最后真正的派发,带 fallback 链

90+ 个参数里有 50+ 都是 **kwargs 透传,最终被 _dispatch_metadata / _dispatch_client_http 这种辅助函数按字段提取。这种”宽进宽出”的 API 看似丑陋,但实际是让上层 LangChain / LlamaIndex / DSPy 能零成本接入——它们随便传什么 litellm 都接住。

2.2 Provider 解析:4 段逻辑的”链式解析”#

get_llm_provider() 在 litellm_core_utils/get_llm_provider_logic.py 文件里,不在 utils.py——这点是 v1.x 重构后的结果,原因是这个函数调用频次太高,要单独做 lazy import 避免循环依赖。

解析的顺序是这样(这块我反复读了三遍才确认顺序):

  1. JSONProviderRegistry.exists(prefix):先看 JSONProviderRegistry 里有没有用户自定义的 provider(这是个 JSON 配置文件)
  2. model.split("/")[0] in litellm.provider_list:标准的”前缀匹配”模式,比如 openai/gpt-4o → 拆成 openai + gpt-4o
  3. 子路由重定向:handle_cohere_chat_model_custom_llm_provider 这种特殊处理,比如用户传 cohere/command-r-plus 但实际要走 cohere_chat 而不是 cohere
  4. 返回 4-tuple:(model, custom_llm_provider, dynamic_api_key, api_base)

第 3 步特别容易被忽略——它是 litellm 的”历史包袱吸收层”。比如 azure/command-r-plus 这种 Azure AI Studio 上的 Cohere 模型,要重定向到 cohere_chat;anthropic/claude-3 走 anthropic 而 anthropic_text/claude-2 走 text completion 协议。

2.3 三层 dispatch:教科书级的”加 provider”成本#

这一节是整篇文章我最想讲的部分。

litellm 的 dispatch 用了三层抽象,对应三个文件:

层在哪干什么
字典litellm/utils.py 的 ProviderConfigManagerLlmProviders → (factory, needs_model) 的 O(1) 查表
上下文litellm/types/completion.py 的 CompletionDispatchContext14 字段统一签名
函数litellm/main.py 的 _complete_xxx(ctx)每个 provider 一个独立函数

关键设计:150+ provider 全部走同一个 dict 注册,新增 provider 的成本是”加 50 行 _complete_xxx 函数 + 一行 dict 记录”。这是教科书级的”开放-封闭原则”实现——对扩展开放(加 provider),对修改封闭(不改 dispatch 核心)。

我特意去翻了下 ProviderConfigManager._build_provider_config_map() 这个方法,它返回一个 dict[LlmProviders, tuple[Callable, bool]],第二个 bool 字段是”是否需要 model 参数”——因为 Azure AI Studio、Bedrock、Vertex 这种”一个 provider 内有多种 model 路由”的情况需要拿 model 名做二次分派,而 OpenAI 这种”一种协议走到底”的不需要。

CompletionDispatchContext 是个 pydantic model,把 model/messages/api_key/api_base/optional_params/litellm_params/... 这 14 个字段从函数签名里赶出去,让 _complete_xxx 函数可以纯靠 ctx 做参数传递,方便单测和 mock。

反面教材:我看 pi agent harness 的设计里 pi-ai 的 provider 适配器是”每个 provider 一个 class 继承 base class”——那种方式更 OO,但加 provider 要写更多模板代码。litellm 的 dict 模式更”脚本化”,加 provider 速度更快,但牺牲了一点 type safety(dict 里的 Callable 没法在 IDE 里跳转)。

2.4 HTTP 收口:所有 standard provider 走同一个 handler#

到第 4 层,代码收敛到 litellm/llms/custom_httpx/llm_http_handler.py 的 BaseLLMHTTPHandler.completion()。

这个文件有 13900 行,比 main.py 还大——但它做的事非常机械:拿到 ctx → 调 ProviderConfig.transform_request() → 用 HTTPHandler 发请求 → 调 ProviderConfig.transform_response() → 流式的话走 streaming_handler.py。

60+ 个 provider 的 transformation 都继承 BaseConfig(在 litellm/llms/base_llm/chat/transformation.py),要实现的接口就 2 个:transform_request 和 transform_response。这是 litellm 能 hold 住 100+ provider 的根本原因——接口极薄,扩展极容易。

BaseLLMAIOHTTPHandler 是 async 版本,区别就是用 AsyncHTTPHandler(基于 httpx.AsyncClient)。两套代码是镜像的,逻辑完全一致,只是 await/await/await。

2.5 出网 + 算账:两个独立的”出口”#

到第 5 层,代码分叉成两个独立的支线:

  • 出网:HTTPHandler 把请求发到 provider 的真 API,这块没什么好说的
  • 算账:Logging.update_response_metadata 从 raw response 抽 usage → _response_cost_calculator 调对应 provider 的 cost_per_token() → 写到 response._hidden_params["response_cost"]

async_success_handler 之后派发到所有 CustomLogger 子类(Langfuse、Datadog、S3 等等),这是个观察者模式的典型实现。

算账链路我后面单独开一节讲,这里先卖个关子——它有 3 个我没想到的工程细节。

三、Rust 重写到底换了什么 —— 不是性能噱头#

我本来以为 litellm-rust 是”为了性能把 Python 关键路径用 Rust 重写”,打开看 Cargo.toml 我就发现这理解不对。

工作空间是这么组织的:

Rust workspace

60+ 个 crate,按”可独立替换的能力”切分成 8 组:

  • Auth + Secrets(10):auth 决定”我是谁”,secrets 决定”怎么拿 token”,是两件事但要协同
  • Cache(10):memory / disk / redis / s3 / gcs / azure-blob / qdrant-semantic / redis-semantic / valkey-semantic / 抽象 trait
  • Inference(11):核心推理编排,每个 API 协议(chat / messages / responses / ocr / transcription)一个子 crate
  • Gateway(8):装配层,把上面所有 crate 绑成 Axum Router
  • LLM Providers(10+):anthropic / openai / bedrock / vertex_ai / cohere / deepseek / mistral / azure_ai / openai_like / reducto / aws_textract / base_llm
  • Router + Types(4):deployment 选择 + 类型 + config 解析 + tracing
  • Host + Coroutine(6):抽象 + http + native + python (PyO3) + tokio ↔ Python event loop
  • Cost + DB + Tokens + HTTP(8):cost 算账 / db 抽象 / token counter 多种 / http client / framer (SSE)

3.1 设计哲学:跟 Python 端完全不同的拆分思路#

Python 端是”按功能切”(main.py / utils.py / router.py / cost_calculator.py),Rust 端是”按可独立替换的能力切”。

为什么这么切?两个原因:

第一,编译时间。把 cache 后端塞进一个 crate,每加一个后端就触发整个 crate 重编译,对一个 60+ crate 的工作空间来说编译时间会爆炸。拆开后改一个 cache 后端只重编那个 crate。

第二,能力边界。auth-aws 和 secrets-aws 看起来都是 AWS 相关,但前者处理 SigV4 签名,后者处理 Secrets Manager 拉 API key。它们可以独立替换——你可以用 auth-gcp + secrets-aws 组合,授权走 GCP 凭据,密钥从 AWS Secrets Manager 拿。这种”正交拆分”是 Rust 端设计的精髓。

3.2 Inference 是中心:CallOptions 2 字段的精妙#

crates/inference/src/lib.rs 文件只有 34 行,但定义了整套工作空间的灵魂类型:

#[derive(Clone, Default)]
pub struct CallOptions {
    pub cache: Option<litellm_cache_response::CachePolicy>,
    pub observers: Option<litellm_host::observation::ObservationSender>,
}
rust

2 个字段。cache 控制是否走缓存、什么 policy;observers 是 OTel span 的发送端点。

但真正精妙的是 From 实现的两个:

impl From<Option<ObservationSender>> for CallOptions { ... }
impl From<CachePolicy> for CallOptions { ... }
rust

这两个 From 让所有调用点能写一行:

some_fn(cache_policy.into())
some_fn(observer.into())
rust

inference crate 内部按职责拆 6 个子模块:caching / context / diagnostic / outbound / provider / resources。每个子模块独立演进,互不污染。

inference-chat / inference-messages / inference-responses / inference-ocr / inference-transcription 这 5 个 inference 子 crate 分别对应 OpenAI chat / Anthropic messages / OpenAI Responses / OCR / 语音转录,每种 API 协议一个 crate。这种拆分方式跟 litellm 整体的”每种 API 一个 proxy endpoint”是同构的——/v1/chat/completions / /v1/messages / /v1/responses / /v1/audio/transcriptions 各自有自己的 route 树。

3.3 Gateway 是装配:build_inference 一行函数#

crates/gateway/src/lib.rs 的 build_inference() 是整套工作空间的入口函数,简化后是这样:

pub fn build_inference(config: &Config) -> Result<Arc<Gateway>, Error> {
    let pool = Arc::new(HttpClientPool::new(Arc::new(PublicDnsResolver)));
    let environment = secrets::environment_values(&config.environment_variables)?;
    let http = Resolution::from(&http_settings).config;
    let client = pool.client(&http, ClientVariant::Provider)?;
    let secrets = Arc::new(secrets::ConfigSecrets::new(environment, ...));
    let resources = CoreResources::new(pool);
    Ok(Arc::new(Gateway::new(
        resources, http, secrets,
        ModelRouter::from_model_list(&config.model_list)?,
    )?))
}
rust

这段代码做 4 件事:

  1. 建 HTTP client pool(连接复用)
  2. 解环境变量 + 注入 SSL 配置
  3. 建 secrets 源(环境变量 + Python 兼容 + AWS/Azure/GCP/HashiCorp/CyberArk 5 套 backend)
  4. 装 ModelRouter

注意它不直接构建 Axum Router——Axum::Router 是在 gateway-inference crate 里拼的,gateway crate 只负责”装核心对象”。这种”core + 装配分离”是面向长期演进的:

  • 哪天想换 actix-web / hyper-only / warp 都不用动 core
  • 哪天想加新 auth 机制也不用动 core
  • 哪天想做 native binary(不是 HTTP server)也不影响

3.4 Router 在 Rust 端仍然薄#

crates/router/src/deployment.rs 文件只有 15 行。这是因为复杂选 deployment 的逻辑(15 个 strategy、Redis pipeline 批量读、TTFT 滑动窗口)全在 Python 端的 litellm/router.py(15325 行)。

Rust 端的 router crate 只做一件事:接住 Python 通过 PyO3 传过来的 deployment 列表,做最朴素的转发。这不是”偷懒”,是务实的选择——把已经在 Python 端验证过的算法迁移到 Rust 是巨大工程量,先把网关 / 缓存 / secrets 这些纯 IO 路径 Rust 化能拿到 80% 的收益(启动速度、内存、并发),剩下 20% 的 router 算法可以慢慢迁移。

host-python crate(PyO3 暴露)让 Python 代码可以 import litellm_rust 然后用 Rust 版的 cache / auth / secrets,不用等整个 SDK 迁移完。这是渐进式迁移的经典路径。

四、Router 选 deployment:15 个 strategy 的”什么算最优”#

这块我一开始也以为是堆功能,但读 litellm/router.py 的 _async_get_available_deployment(行号 13790)发现核心流程只有 5 步:

Router 5 步链路

4.1 第 1 步:pre-routing hook 是被低估的扩展点#

async_pre_routing_hook(model, request_kwargs, messages, input) 这个钩子我一开始以为是”预留接口”,看了 enterprise/ 下的实现才发现它被用得很重。

它能改 model、messages、litellm_params。典型场景:

  • model 改写:根据 metadata 里的 user role 把 gpt-4o 改写成 gpt-4o-mini
  • messages 改写:自动加 system prompt
  • model_group 替换:把 gpt-4o 替换成 prod-east-gpt4o 这种带 routing group 的名字

_pin_tier_params_onto_request 是紧接着的一步:把 tier 强制设到 request 顶层。这是为了避免”model 选错 deployment 后才发现自己无权用”——tier 检测在 router 之前,强行覆盖可避免浪费一次 deployment 健康检查。

4.2 第 2 步:healthy deployments 是个”动态白名单”#

async_get_healthy_deployments 读的不是静态配置,是个动态白名单:

  • _run_background_health_check 这个隐藏 job 持续 ping 所有 deployment
  • 失败的 deployment 标 cooldown,30s 后首次恢复,60s 后二次恢复
  • routing group 过滤:tag-based routing 把 model_name 转成 [{deployment1}, {deployment2}] 的候选 list

我之前以为 health check 是同步的,结果发现是完全异步的后台 job 维护。这意味着 router 在选 deployment 时不做任何网络请求——所有健康状态都是内存里现成的。

4.3 第 3 步:5 个 strategy 的”什么算最优”#

15 个 strategy 不是平等的——按 Python 端实现的成熟度,5 个有”async 优化路径”,剩下 10 个走老路 get_available_deployment(同步 + 简单选择):

lowest_latency.py:

  • 滑动窗口存最近 10 次 P50/P95 latency
  • TTFT (Time To First Token) 单独算,streaming 必须用
  • RoutingArgs(ttl=3600, max_latency_list_size=10, ttft_percentile=0.5) 三个可调
  • _percentile_latency 走 ceil(len(values) * percentile) - 1 这种简单公式

lowest_cost.py + lowest_tpm_rpm.py:

  • 从 model_prices_and_context_window.json 拉每 token 单价(60K 行 JSON)
  • 实时从 Redis 读 TPM/RPM (Token Per Minute / Request Per Minute)
  • 优先选”还有余量 + 单价低”的 deployment

simple_shuffle.py:

  • 最朴素:hash(model + secret) % N
  • 同 model 落到同 deployment,缓存友好
  • 没有 redis 读、没有 latency 采样,最快

least_busy.py:

  • 读”正在处理的请求数”,选最闲的
  • 适合 batch 场景,避免一个 deployment 拥塞

lowest_tpm_rpm_v2.py + adaptive_router:

  • RoutingReadBatch.scoped 一次性读所有 deployment 的指标
  • PrefetchedUsage.scoped 缓存到 ctx,strategy 之间共享
  • adaptive_router 加 ML:根据 prompt 复杂度路由到不同 deployment

4.4 15 个 strategy 都依赖的 3 个底层机制#

我读完所有 strategy 后总结出 3 个共性:

① DualCache(litellm/caching/dual_cache.py): 内存 cache + Redis cache 两层,O(1) 读 deployment 的 RPM/TPM/cooldown 状态。这个抽象让 strategy 写起来很”无脑”——所有读状态都走 dual_cache.get(),不用关心是内存还是 Redis。

② RedisPipelineIncrementOperation: 每次成功/失败不直接写 Redis,推到本地 queue,后台 task 批量 flush(DEFAULT_REDIS_SYNC_INTERVAL=1s)。这是异步刷新的精髓——主路径不 await Redis,吞吐拉满。

③ with_service_target(“router_usage”): contextvar 标记,Otel span 知道这次调用属于 router_usage 服务,跟 cache_usage / inference 区分开。这种 tagging 是 OTel 实践里最容易被忽略的——你不打 tag,trace 出来是一坨;打了 tag,trace 出 7 层嵌套结构,每层是独立服务。

易踩坑:我读 router 的时候发现一个反直觉的设计——is_batch_retrieve_call_type 这个判断让 batch 检索请求不更新 latency 统计。原因:batch retrieve 拿的是之前的结果,不算新调用,混进去会污染 P50。这是 production-grade 的细节,自己写容易忘。

五、Cost 记账是隐形护城河:3 个反直觉的工程细节#

litellm/cost_calculator.py 3183 行,每个 provider 一个 cost_per_token(),这点我读之前以为是”功能堆砌”。读完才发现这是 litellm 真正护城河——多团队共用一个 AI Gateway 不爆 DB 的关键。

下图是完整的 cost 链路:

Cost 记账双路

5.1 同步算账:从 raw response 到 hidden_params#

Logging.update_response_metadata(litellm_core_utils/llm_response_utils/response_metadata.py)从 raw response 抽 usage,然后 _response_cost_calculator 调对应 provider 的 cost_per_token()。

这块的关键设计是结果写到 response._hidden_params["response_cost"] 上——这是个 pydantic model 的 dynamic field,不破坏 OpenAI 兼容的 response schema。客户端拿到的还是标准 OpenAI response,但能通过 .hidden_params 拿到自己的真实成本。

proxy/common_request_processing.py 接着把 hidden_params["response_cost"] 提取出来,写到 x-litellm-response-cost response header 上——这样客户端能拿到自己这次调用的真实成本,不用解析 response body。

5.2 异步写库:DBSpendUpdateWriter 的 4 步流水线#

同步算账完成后,cost 还要进 PG 给管理员看。这块走完全异步的 4 步流水线:

A. async_success_handler (fire-and-forget) _ProxyDBLogger.async_log_success_event() 在主调用返回后被触发,不 await、不阻塞客户端。它走 asyncio.create_task 后台跑,主调用已经返回给客户端了,cost 还在飞。这种”返回先到、cost 后到”的设计让 P99 latency 不被 cost 写库拖慢。

B. Redis 队列 (攒批) 队列命令是 lpush spend_queue {team, key, model, cost, ts}。多个 proxy instance 并发写也不冲突——Redis lpush 是原子的。异常兜底:log_redis_failure 走 in-memory 兜底,内存 set 满了丢最早的,不丢成本计算(只是延迟入账)。

C. APScheduler 60s 批量 flush proxy 启动时 ProxyStartupEvent.initialize_scheduled_background_jobs() 把 9 个后台 job 一次性挂上去,其中 update_spend 每 60s 跑一次,从 Redis lpop 一次 100 条,用 prisma_client.db.tx() 事务写 PG。这一步把”1000 req/s 的写入”压成”17 req/s 的事务写”。

D. PostgreSQL · LiteLLM_SpendLogs 写完后同时触发 2 个并行 budget 检查:

  • user 余额:SELECT current_spend FROM LiteLLM_UserTable
  • team 余额 + key 余额

超 budget → SlackAlerting.send_weekly_spend_report / send_monthly_spend_report 告警。

5.3 3 个我没想到的工程细节#

① prompt cache 自动按 cache_read 单价: Anthropic Claude 的 cache 命中是 input 单价 1/10(5x 写 1x 读),OpenAI GPT-4o 的 cache 是 input 单价 1/2。model_prices_and_context_window.json 60K 行分开定价(cache_creation_input_cost_per_token / cache_read_input_cost_per_token / input_cost_per_token),cost_per_token() 函数自动按 prompt_tokens_details.cached_tokens 字段挑对应单价。如果混在一起定价,cache 那部分成本会被错算。

② 自定义 pricing 不污染同 model 兄弟 deployment: _register_custom_pricing_for_request 这个函数处理一个微妙的 case:router 选出来的 deployment 可能有自己单独的 pricing override(比如某个 wildcard deployment 0 价测试用),但同 model 名的”兄弟 deployment”还要按正常单价计费。

代码的做法是:

  • deployment_id 单独注册带 pricing 的 entry
  • 共享 key {provider}/{model} 拿到的是 stripped pricing(CustomPricingLiteLLMParams.strip_custom_pricing_fields 抹掉 pricing 字段)

这样 0 价 wildcard 不会污染其他走同 model 的兄弟 deployment。我读到这里停下来想了 5 分钟——这个细节完全是我之前没考虑过的 production 场景。

③ 失败回退完整到 4 层:

  • Redis 挂了 → 内存 queue 兜底
  • 60s flush 挂了 → 下次再 flush(不会丢数据)
  • PG 挂了 → SlackAlerting 告警,数据还在 Redis 队列
  • 内存也满了 → 丢最早的(按 timestamp 排序),不丢成本计算只延迟

这种”4 层兜底”是 production-grade 跟 hobby project 的本质区别。自己写 gateway 容易在第 1 层就崩——Redis 一挂整个记账停摆。litellm 的设计是”任何一层挂掉都降级不挂”。

六、Proxy 启动 + 9 个后台 Job:把”网关”做成产品#

litellm/proxy/proxy_server.py 是 proxy 入口,但真正能体现 litellm 把它做成产品的,是 ProxyStartupEvent.initialize_scheduled_background_jobs() 启动的 9 个 APScheduler job:

Proxy 启动 + 9 个 job

6.1 启动 4 阶段#

Phase 1 · CLI 启动: litellm --config config.yaml --port 4000 触发 ProxyStartupEvent 异步事件,加载 router_config、build PrismaClient(PG 连接池)。

Phase 2 · Cache + Auth 初始化:

  • DualCache(RedisCache + InMemoryCache):内存 200MB LRU + Redis
  • warm_key_cache() 预热:5000 keys 一次 batch load

Phase 3 · 后台 Scheduler 启动:

  • 9 个 job 一次性挂上去
  • 每个 job 独立 try/except
  • 失败重试 N 次 + 告警

Phase 4 · First Request:

  • POST /v1/chat/completions
  • auth (cache hit) → router → deployment → SDK call → cost write async

6.2 9 个 job 拆解#

Job 名称间隔干什么关键文件
update_spend60s从 Redis 批量 lpop spend_logs,事务写 PGproxy/db/db_spend_update_writer.py::DBSpendUpdateWriter
reset_budget10-12min重置 key/user/team 的 budget(按时间窗)proxy/common_utils/reset_budget_job.py
add_deployment10s从 DB 同步新 model deployment 到运行中 routerproxy/proxy_server.py::ProxyConfig
cleanup_old_spend_logscron/interval删 N 天前的 spend_logs,防 PG 膨胀proxy/db/db_transaction_queue/spend_log_cleanup.py
check_batch_cost30min算 /v1/batches 异步 batch job 的 costenterprise/.../check_batch_cost.py
check_responses_cost30min算 OpenAI Responses API async costenterprise/.../check_responses_cost.py
process_rotations1hr自动 rotate API key(Secret 泄露应急)proxy/common_utils/key_rotation_manager.py
_run_background_health_checkcontinuous持续 ping 所有 deployment,更新 cooldownproxy/proxy_server.py::ProxyStartupEvent
send_weekly_spend_reportweekly每周 Slack 推送 spend 报告proxy/utils.py::SlackAlerting
send_monthly_spend_reportmonthly每月 Slack 推送,含 team 排名 + 异常 keyproxy/utils.py::SlackAlerting

6.3 一些有意思的细节#

① job 启动顺序写死在代码里: 不是用”优先级”配置,是字面意义上 initialize_scheduled_background_jobs() 里的代码顺序:update_spend 先起(没有它 PG 会爆)→ reset_budget → add_deployment → 其他。这种 hard-code 顺序在 production 里很常见——配置化启动顺序的代码比 hard-code 复杂 5 倍但收益不抵成本。

② job 失败隔离: 每个 job 独立 try/except + asyncio.create_task,一个挂掉不影响其他。我读 check_batch_cost 的时候发现它甚至单独 try/except 自己内部的每次循环——某个 batch job 算 cost 失败不影响同批次其他 job。

③ Proxy 重启无状态: SSE streaming 中途重启 → 客户端 connection reset → router 重启后从 DB 重新 load 状态,Redis 状态保留。这意味着 proxy 是个”无状态 server”,可以水平扩展,唯一的”状态”是 DB + Redis。

④ 一个隐藏的 job:_run_background_health_check 不在文档的 9 个 job 列表里,但持续跑。它把失败的 deployment 标 cooldown,30s 后首次恢复,60s 后二次恢复。我之前以为 cooldown 是 router 调用时同步检查的,结果是后台维护的——这跟前面说的”router 不做网络请求”是一致的。

6.4 真正把”网关”做成产品的几个细节#

  • SlackAlerting 双轨:weekly + monthly 报告自动推送,team 排名 + 异常 key 提示
  • process_rotations 自动 rotate:Secret 泄露应急不用人盯,1hr 自动轮换
  • spend_log_cleanup 定时清理:默认保留 N 天(可配),防止 PG 无限膨胀
  • check_batch_cost + check_responses_cost 单独存在:异步 batch job 的 cost 算账跟实时 cost 算账走不同路径,因为 batch job 的 cost 是事后算(job 跑完才知道用了多少 token)

这 9 个 job 加起来才是 litellm 真正区别于 LangChain / LlamaIndex / raw SDK 的地方。其他 LLM 库都是”帮你调 LLM”,litellm 是”帮你管 LLM”——前者是 library,后者是 platform。

七、对比与反思:litellm 教会我的 3 件事#

拆完 litellm 之后我最大的感受是:它不是一个”AI Gateway”项目,是一个”如何管理 100+ provider 的工程化样本”。

7.1 对比:我之前拆过的 AI 项目#

项目关注点跟 litellm 的差异
HyperFrames (HeyGen)渲染引擎 + Agent Skills 教材解决”如何确定性渲染 HTML→视频”,跟 LLM 无关
God’s Eye View (Bilawal Sidhu)浏览器上帝视角 + WebRTC解决”如何把 15 路公开数据塞进一个 3D 地球”,跟 LLM 无关
pi agent harness (Mario Zechner)终端编码 Agent + TS 扩展解决”如何做一个 1MB TS 写成的 coding agent”,单 provider 假设
MathModelAgent数学建模 SKILL 包解决”如何把 Codex + 教材串成 pipeline”,单 provider 假设
litellm (本篇)AI Gateway + 100+ provider 抽象解决”如何把 N 个互不兼容的 API 收口到一个 OpenAI 兼容入口”

对比一下就很明显——前 4 个都是”单 provider 假设”,它们假设你只用 OpenAI 或者只用 Anthropic,所以不需要管”多 provider 怎么协同”。litellm 不一样,它把”多 provider”做成了一等公民,每多一个 provider 就多一种可能性:

  • 同 prompt 可以同时发给 3 个 provider 做 ensemble
  • 主 provider 挂了自动 fallback 到备用
  • 复杂 query 路由到 GPT-4,简单的路由到 GPT-4o-mini
  • 一个 team 限额用完了自动切到另一个 team 的 key

7.2 我从 litellm 学到的 3 件事#

① “加 provider 成本压到 ~50 行”是 open/closed 原则的极致实现

我之前看 OO 教材说”对扩展开放、对修改封闭”,以为是说”加新 class 继承 base class”。litellm 给我上了一课:

  • 对扩展开放:加 provider 只需要 dict 加一条记录 + 写一个 _complete_xxx 函数
  • 对修改封闭:dispatch 核心、HTTP handler、cost 算账所有这些都不动

字典 + 函数指针的”脚本式”设计牺牲了 type safety,但换来了”加 provider 不需要碰 5 个文件”。这是工程上”实际成本”的胜利。

② “可独立替换的能力”是 Rust 端 crate 拆分的核心

Python 端按”功能”切(main/utils/router/cost),Rust 端按”能力”切(auth / secrets / cache / inference / gateway / host)。这种差异不是语言特性驱动的,是演进阶段驱动的:

  • Python 端 1.x 时只有 OpenAI 1 个 provider,按”功能”切够了
  • Rust 端从 0 开始设计,按”能力”切才能 hold 住 60+ crate

拆 crate 的判断标准只有一个:“改这个文件需要重编多少代码”。改 cache 后端只需要重编 cache-* crate,那就值得拆出来;改 HTTP client 只需要重编 http crate,那也值得拆。

③ Cost 记账是 production-grade 的真正护城河

我看 pi agent harness、MathModelAgent 这种”小而美”的项目,最大的缺陷就是没有 cost 记账。他们假设你用单 provider、单 key、单 team——这种假设在 YC 演示时成立,在真实团队里立即崩。

litellm 的 4 层兜底(同步算 / Redis 队列 / 60s flush / PG 事务)+ 3 个反直觉细节(cache 分价 / deployment 隔离 / 失败降级),是只有当你的客户是 100+ 企业的工程团队时才会被逼出来设计。Hobby project 不会有这种细节。

7.3 我不会照搬的部分#

公平讲,litellm 也有我觉得”过度工程化”的地方:

  • litellm/main.py 9522 行 + 90+ 参数的 acompletion 签名,对调用方心智负担大
  • _complete_xxx 函数族每个都长得很像,重复模板代码很多(100+ 函数 = 100+ 段 if-elif 链)
  • 9 个 APScheduler job 的启动顺序 hard-code,加新 job 要小心
  • ProviderConfigManager 字典的 Callable 类型在 IDE 里没法跳转,加 provider 时容易漏

但这些”瑕疵”在 100+ provider 的体量下,都比”重新设计一个能 hold 住的架构”便宜。这就是为什么我说它是”工程化样本”——它不是在追求”代码漂亮”,是在追求”加 provider 成本最低”。

八、结语#

litellm 这个项目给我最大的冲击不是”100+ provider 怎么接”,而是**“100+ provider 怎么管”**。

main.py 9522 行不是问题——加了 50 个新 provider 它就该长这样。router.py 15325 行也不是问题——15 个 strategy 加每种 1000+ 行实现就该这么大。真正的问题是:怎么在 100+ provider 上做 cost 记账、rate limit、auth、cache、health check 而不爆 DB、Redis 队列、APScheduler、Prisma 事务。

这套答案藏在我前面画的 5 张图里:

  1. dispatch 字典 = 把”加 provider”压成”加 50 行 + 一条 dict”
  2. Rust 工作空间 = 把”能力边界”按”可独立替换”切,60+ crate 互不污染
  3. router 5 步 = pre-hook → tier pin → healthy → strategy → event,把”什么算最优”的定义权下放
  4. cost 双路 = 同步算 + 异步写,4 层兜底,3 个反直觉细节
  5. proxy 9 job = 60s update_spend + 30min check_batch_cost + weekly report = 把”网关”做成”平台”

这 5 张图 + 5 节展开,是我对 litellm 这个项目的诚实拆解。


附录 · 真实信息#

  • 仓库:BerriAI/litellm(GitHub)
  • 版本:2026-10-10 拉取的 main 分支(commit hash 没记,但 archive size 251MB / 文件数 7122 / 60+ Rust crate 都在)
  • star:60,694(截至 2026-10-10)
  • Y Combinator:W23 批次
  • LICENSE:MIT
  • 公司归属:BerriAI(YC 投资的 AI Infra 公司,Ishaan Jaffer + Krrish 创办)

关键文件清单(我读过的)#

深入解析 BerriAI/litellm:dispatch 字典撬动 100+ LLM 的工业级 AI Gateway
https://wth2jhl.online/blog/litellm-rust-gateway-deep-dive/
Author 小吴同学
Published at October 10, 2026