← 返回博客
AI Gateway

跟着一条请求读 LiteLLM:我目前对这个项目的理解

·14 分钟阅读

我最近开始看 LiteLLM 源码。

一开始我以为它就是个 Python SDK:统一调 OpenAI、Anthropic、Gemini、Kimi 这些模型,省得每家都接一遍。

实际跑起来、打了几轮断点后,我发现这理解有点太浅了。

LiteLLM 的 Proxy 更像一个 LLM Gateway。它不只是“转发请求”,而是在模型调用前后塞进了很多生产环境才会出现的东西:

鉴权
模型路由
Provider 协议转换
Retry / Fallback
成本统计
日志和回调
缓存、限流、预算这些治理能力

这个仓库很大,litellm/ 下两千多个 Python 文件,接近 60 万行源码。直接从目录开始翻肯定会迷路。

所以我这次没试图理解整个项目,而是只干了一件事:

启动 LiteLLM Proxy
→ 发一个 POST /v1/chat/completions
→ 从断点一路跟到模型调用
→ 再看响应怎么回来

请求长这样:

{
  "model": "kimi-k2.5",
  "messages": [
    {
      "role": "user",
      "content": "只回复 OK"
    }
  ]
}

下面是我目前整理出的调用链。

1. HTTP 请求从哪里进来

入口在:

litellm/proxy/proxy_server.py

对应函数:

async def chat_completion(...)

这个函数挂的是:

POST /v1/chat/completions

这里本身不做什么高深的模型逻辑,主要是:

拿 HTTP Request
→ 做 API Key 鉴权
→ 读 body
→ 组装内部 data
→ 交给后面的请求处理器

从职责上看,它和 Spring Boot 里的 Controller 很像。

真正开始“干活”的是:

litellm/proxy/common_request_processing.py

里面的:

ProxyBaseLLMRequestProcessing.base_process_llm_request()

我目前把它理解成一次 LLM 请求的总编排器。

pre-call
→ 路由
→ 调模型
→ 等模型结果
→ 日志/成本/回调
→ 返回 HTTP response

2. 为什么 LiteLLM 要先做一堆 Pre-call

一开始我看到请求刚进来就被各种清理、补 metadata,觉得有点绕。

后来想明白了:HTTP body 里的字段不能直接信。

比如客户端自己传:

{
  "metadata": {
    "user_api_key_user_id": "admin"
  }
}

如果 Gateway 后面真的拿这个字段做预算、审计或者权限判断,那就等于把权限交给客户端自己写。

所以 LiteLLM 的做法是:

先清理客户端伪造的内部字段
↓
从 API Key 鉴权结果里拿真实 user/team 等信息
↓
再把可信 metadata 放回请求上下文

这层不是模型调用本身,但它是 Gateway 和“业务代码直接调 SDK”的一个重要区别。

直接调 SDK 时,业务代码通常默认“我自己就是可信调用方”。

但 Gateway 要面对很多调用方,所以必须把“谁在调”“能调什么”“花谁的钱”变成一等公民。

3. data 不是普通请求体,而是请求上下文

在调试器里我看到 data 大概长这样:

{
    "model": "kimi-k2.5",
    "messages": [...],
    "metadata": {
        "user_api_key_user_id": "...",
        ...
    }
}

我后来发现它不只是 HTTP body。

它更像一个会沿调用链往下传的 RequestContext:

请求参数
+ 鉴权结果
+ 调用 ID
+ 路由信息
+ 日志信息
+ 成本归属信息

这个设计一开始看会觉得“怎么这么多东西都往 dict 里塞”。

但换个角度看,这些信息要经过 Router、Provider Adapter、日志 Hook、预算、缓存等很多层。如果每一层都重新依赖 FastAPI Request 或认证对象,耦合会更重。

所以 LiteLLM 最终选择把必要上下文带着走。

4. Proxy 和 Router 不是一回事

后面请求会进入:

litellm/proxy/route_llm_request.py

函数:

route_request(...)

这层做了一个很关键的分流:

有 llm_router
→ 调 Router

没有 llm_router
→ 直接调 litellm.acompletion()

所以 LiteLLM 不一定非得通过 Router。

简单场景可以:

业务代码
→ LiteLLM
→ 一个 Provider

复杂场景则是:

Client
→ Proxy
→ Router
→ 多个 deployment
→ Provider

Router 的重点不是“怎么发 HTTP 请求”,而是“这次请求应该落到哪里”。

比如:

客户端请求 smart-chat
↓
Router 发现 smart-chat 有多个 deployment
↓
根据策略选一个
↓
失败时 retry 或 fallback

这就是为什么 Router 是整个 Gateway 的核心之一。

5. Model、Deployment、Provider 这几个词别混了

这几个概念刚开始特别容易混。

我现在是这样区分的:

客户端 model
→ 客户端传进来的名字,比如 smart-chat

Model Group
→ Router 眼里的一个逻辑模型组

Deployment
→ 一条可实际调用的配置

Provider
→ moonshot / openai / anthropic / gemini

实际模型
→ kimi-k2.5 / claude-sonnet-... / gpt-...

例如:

Client: smart-chat
↓
Router: 选中 deployment A
↓
deployment A: moonshot/kimi-k2.5
↓
Provider: moonshot
↓
actual model: kimi-k2.5

Router 解决的是:

选哪个 deployment

Provider Resolver 解决的是:

这个 deployment 最终按哪套协议调出去

这两层拆开以后,很多事情才好做。

比如 smart-chat 以后可以从 Kimi 切到 Claude,客户端不需要改代码。

6. Provider 是怎么识别的

Provider 识别的核心代码在:

litellm/litellm_core_utils/get_llm_provider_logic.py

函数:

get_llm_provider(...)

它最后返回的东西大概是:

(
    model,
    custom_llm_provider,
    api_key,
    api_base
)

我一开始以为它是:

if model.startswith("claude"):
    return "anthropic"

其实没这么粗暴。

它大概按这样的优先级处理:

1. deployment 里有没有明确配置 Provider
2. model 有没有 provider/model 前缀
3. 有没有 custom_llm_provider
4. api_base 是不是某个已知 endpoint
5. model 是否命中内置模型表
6. 最后才是一些泛化规则

最稳妥的写法还是显式指定:

anthropic/claude-...
moonshot/kimi-k2.5
gemini/gemini-...

因为“模型名”和“调用渠道”不是一一对应的。

比如 Claude 既可能走 Anthropic 官方,也可能走 Bedrock 或 Vertex。模型看起来一样,但认证、URL、签名和计费都不一样。

7. LiteLLM 怎么统一不同 Provider

这个是我觉得 LiteLLM 最核心的设计。

它不是把所有 Provider 的原始 JSON 统一成一个大 JSON。

它统一的是两头:

上游统一调用语义
↓
model / messages / tools / stream / temperature / max_tokens

下游统一响应对象
↓
ModelResponse / Usage / Exception

中间通过 Provider Adapter 做翻译。

抽象类在:

litellm/llms/base_llm/chat/transformation.py

两个核心方法:

transform_request()
transform_response()

这两个方法基本就把 Adapter 的职责说完了:

transform_request
统一参数 → Provider 专属请求体

transform_response
Provider 原始响应 → LiteLLM 统一响应

以 Anthropic 为例。

OpenAI 风格请求里,system message 通常在 messages 数组里:

{
  "messages": [
    {"role": "system", "content": "你是助手"}
  ]
}

Anthropic 不是这么组织的,它有单独的 system 字段,普通消息和工具调用也有自己的 content block 结构。

所以 Anthropic 的 Adapter 要做很多转换:

把 system 从 messages 里拆出来
转换 messages
转换 tool call / tool result
处理 thinking 规则
处理 Anthropic 特有 headers

而 Kimi/Moonshot 相对 OpenAI-compatible,所以普通场景下几乎可以复用 OpenAI 的转换逻辑。

这点也让我意识到,Adapter 不代表“每家都要重写一大堆代码”。

协议接近
→ 复用

协议差异大
→ 局部重写转换

8. Provider 返回后,LiteLLM 得到了什么

模型调用成功后,我在断点里看到的是:

ModelResponse(...)

不是 Kimi 原始响应,也不是某个 Provider SDK 的对象。

里面有这些统一字段:

id
model
choices
usage

比如:

response.choices[0].message.content
response.usage.prompt_tokens
response.usage.completion_tokens

这样 Router、日志、成本统计就不用管底层到底是 Kimi、Claude 还是 Gemini。

我这次的结果是 OK,但 usage 里还能看到 reasoning token。

这件事挺有意思:用户最终只看到几个字,但成本计算必须以模型真实返回的 usage 为准,而不是看最终文本有多短。

9. 为什么成本和日志在模型返回后做

一开始我以为请求进来时就可以开始算成本。

后来发现不行。

因为请求刚到 Gateway 时,你只知道用户“想调哪个模型”,但不知道最终:

Router 选中了哪个 deployment
实际调用了哪个 Provider
是否发生 retry
是否 fallback
实际用了多少 input/output/reasoning token
有没有缓存命中

这些都要等模型真正返回后才能确定。

所以 LiteLLM 后面还有一段 post-call:

拿到 ModelResponse
→ 取 usage、cost、deployment、api_base 等信息
→ 更新调用状态
→ 调 success hook / callback
→ 补 response headers
→ 返回给客户端

这个位置才是成本、日志、监控真正发生的地方。

10. 我现在对 LiteLLM 的理解

如果让我现在用一句话说 LiteLLM,我会说:

LiteLLM 是一个把不同模型 Provider 隔离在 Adapter 后面,并把模型路由、重试、fallback、成本、日志、鉴权这些事情集中起来的 LLM Gateway。

它不只是帮你少写几段 OpenAI SDK 代码。

它解决的是业务服务一多以后,模型调用逻辑开始到处复制的问题:

每个服务都存一份 Provider Key
每个服务都自己做重试
每个服务都自己算 token 和成本
每个服务都自己处理 OpenAI / Claude / Gemini 差异

短期看直接调 SDK 最快。

长期看,模型调用一旦变成基础设施,还是需要一个统一的入口和治理层。

接下来准备看什么

今天这一轮我主要把主调用链走通了。

但下面这些我还只是知道它们“在架构上应该存在”,还没真正沿源码吃透:

Router 怎么选 deployment
负载均衡策略具体怎么写
Rate Limit 在哪一层做
Cooldown 怎么避免持续打失败 deployment
Cache / Redis 怎么在多实例下协作
Streaming 怎么统一成 OpenAI SSE

下一步我准备先看 Router 的 deployment 选择。

因为这一块能把 Retry、Fallback、Load Balancing、Cooldown 这些概念真正串起来。

查看全部文章 →