跳到主要内容

数据截至 (上游 commit 3f15dc32871c)

横切层:@client 装饰器里的日志、缓存、成本与异常

30 秒导读: LiteLLM 的每一个公开函数(completion / acompletion / embedding / responses …)都被同一个装饰器 client 包住。装饰器负责的事和"调哪家模型"毫无关系:生成调用 ID、装上日志回调、查预算、查缓存、算成本、发回调、把各家五花八门的错误收敛成同一组异常。本章讲的就是这层外壳,不讲壳里面的翻译和 HTTP(那是 0203)。


1. 这是什么(零基础也能懂)

一句话定义: 横切层是一段被复用 177 次的函数包装代码,它在"真实调用"前后做所有与 provider 无关的事。

它解决什么问题。 LiteLLM 要支持 100+ 家 LLM API。如果不抽这一层,就会出现这种局面:

  • OpenAI 的调用路径里写一遍"记日志、算钱、查缓存"
  • Anthropic 的调用路径里再写一遍
  • Bedrock 的调用路径里再写一遍……

于是同一个 bug 要修 100 次,同一个功能(比如新增一个 Langfuse 回调)要接 100 次。

它的解法: 把这些事全部提到函数外面,用一个装饰器统一做。真源码里就是一行:

# litellm/main.py:4899-4901,真实源码
@tracer.wrap()
@client
def completion(

整个包里一共 177 处 @client、分布在 23 个文件(grep -rn "^@client" litellm --include="*.py"):litellm/main.py 占 11 处(acompletion:388、completion:4901、embeddingtext_completionimage_generationtranscription…),其余分布在 responses/main.pyrerank_api/main.pybatches/main.pyfiles/main.pygoogle_genai/main.py 等各能力子包的 main.py 里。

用起来什么样。 用户什么都不用做——他调的是普通函数,横切层是隐形的:

# 示意,非源码
import litellm

litellm.success_callback = ["langfuse"] # 装一个回调
litellm.cache = litellm.Cache(type="redis") # 开缓存
litellm.max_budget = 10.0 # 设预算上限

resp = litellm.completion(model="gpt-4o", messages=[{"role": "user", "content": "hi"}])
print(resp._hidden_params["response_cost"]) # 这次花了多少钱,装饰器算好塞进来的

上面三行全局配置,作用点全部在装饰器里,completion 函数体本身对它们一无所知。

建立直觉的一句话: 装饰器是一次调用的"检票口 + 记账台"——进场前查票(预算、缓存、重试上限),出场后记账(成本、日志、回调),中间那段旅程(真正调模型)它不碰。


2. 顶层全景(一次调用穿过装饰器的七道关)

怎么读这张图: 从上往下是时间顺序;左侧竖线是装饰器管的事,中间那格是被装饰的原函数(真正调模型的地方)。任何一步抛异常都跳到最右侧的失败路径。

用户调用 litellm.completion(...)


┌──────────────────────────────────────┐
│ ① 早退判定 │ _is_async_request / _is_streaming_request
│ 异步入口的透传 / 流式请求不做后处理 │
├──────────────────────────────────────┤
│ ② function_setup │ 建 litellm_call_id、装 callbacks、
│ 建"记录仪"Logging 对象 │ 造 Logging 对象
├──────────────────────────────────────┤
│ ③ 预算闸 + 每请求重试上限闸 │ 超了直接 BudgetExceededError
├──────────────────────────────────────┤
│ ④ 查缓存 │ 命中 → 直接 return,下面全部跳过
└──────────────────────────────────────┘
│ 未命中

╔══════════════════════════════════════╗
║ 原函数:翻译 + HTTP + 流式拼装 ║ ← 见 02 / 03 章
╚══════════════════════════════════════╝
│ 成功 │ 失败
▼ ▼
┌──────────────────────────────────────┐ ┌───────────────────────┐
│ ⑤ post-call rules + JSON schema 校验 │ │ ⑧ 异常映射 │
├──────────────────────────────────────┤ │ exception_type() │
│ ⑥ 写缓存 │ │ → 统一异常类 │
├──────────────────────────────────────┤ ├───────────────────────┤
│ ⑦ 算成本 + 发成功回调(不阻塞主路径) │ │ ⑨ failure_handler │
└──────────────────────────────────────┘ └───────────────────────┘
│ │
▼ ▼
返回 response raise 统一异常
(_hidden_params 带 response_cost) (Router 据此决定回退/冷却)

各关卡的落点:

关卡干什么主要符号文件
① 早退判定判断是异步入口的透传调用、还是流式请求_is_async_request / _is_streaming_requestlitellm/utils.py:1978 / :2020
② setup生成调用 ID、归位回调、造 Loggingfunction_setuplitellm/utils.py:786
③ 预算闸累计花费超 litellm.max_budget 就拒BudgetExceededErrorlitellm/exceptions.py:960
④ 查缓存按请求参数哈希查两级缓存LLMCachingHandler._sync_get_cachelitellm/caching/caching_handler.py:284
⑤ 后置校验跑用户自定义 rule + JSON schema 校验post_call_processinglitellm/utils.py:1255
⑥ 写缓存结果回填缓存sync_set_cache / async_set_cachelitellm/caching/caching_handler.py:1013 / :946
⑦ 算成本 + 回调算钱写进 _hidden_params,异步派发回调Logging.success_handlerlitellm/litellm_core_utils/litellm_logging.py:2185
⑧ 异常映射各家错误 → 同一组异常类exception_typelitellm/litellm_core_utils/exception_mapping_utils.py:2164
⑨ 失败日志发失败回调(同步、不放线程Logging.failure_handlerlitellm/litellm_core_utils/litellm_logging.py:3037

3. 一个装饰器、两个 wrapper

3.1 它要解决的小问题

litellm.completion 是同步函数,litellm.acompletion 是协程函数。同一份横切逻辑要同时服务这两种,但两者写法完全不同(一个用线程池派发回调,一个用 asyncio.create_task)。

3.2 思路

client 里定义两个闭包,最后按被装饰函数是不是协程挑一个返回:

# litellm/utils.py:1969-1975,真实源码(略去注释)
is_coroutine = get_coroutine_checker().is_async_callable(original_function)
if is_coroutine:
return wrapper_async
else:
return wrapper

即:wrapper(utils.py:1347) 给同步函数,wrapper_async(utils.py:1651) 给协程函数。

3.3 关键细节:为什么同步 wrapper 里还要判"这是不是异步请求"

因为 acompletion 内部其实又调了同步的 completion

用户 → acompletion() [被 wrapper_async 包住]
│ completion_kwargs["acompletion"] = True (main.py:593)

loop.run_in_executor(None, partial(completion, ...)) (main.py:631-635)


completion() [被 wrapper 包住 —— 这里会二次横切!]

如果不管,一次异步调用会被记两次日志、查两次缓存、算两次钱。所以同步 wrapper 的第一行逻辑就是这个短路:

# litellm/utils.py:1348-1351,真实源码
# DO NOT MOVE THIS. It always needs to run first
call_type = original_function.__name__
if _is_async_request(kwargs):
...

_is_async_request(utils.py:1978) 只是检查 kwargs 里有没有 acompletion / aembedding / atranscription 等一堆 a* 标志位为 True。命中就走精简分支:只查一下 num_retries_per_request,然后直接调原函数并返回(utils.py:1353-1374),setup、预算、缓存、成本、回调全部跳过——因为外层的 wrapper_async 已经做过了。

3.4 关键细节:流式请求为什么要提前 return

流式响应返回的是 CustomStreamWrapper,此刻内容还没生成完,算不了成本、也没法跑内容校验。所以两个 wrapper 都在真实调用之后立刻判定流式并早退:

# litellm/utils.py:1496-1504(同步)/ :1763-1766(异步),真实源码结构
if _is_streaming_request(kwargs=kwargs, call_type=call_type):
if "complete_response" in kwargs and kwargs["complete_response"] is True:
chunks = []
...

_is_streaming_request(utils.py:2020) 的判定只有两条:kwargs 里 stream=True,或 call_type 属于 _STREAMING_CALL_TYPES(utils.py:2010,Google GenAI 的 generate_content_stream 系列)。

早退的代价: 流式调用的成功日志和成本核算不在装饰器里发生,而是延后到流结束时由 CustomStreamWrapper 触发(utils.py:1815-1816 的注释明确写了 "streaming requests return early (before this point) via CustomStreamWrapper")。除非用户传了 complete_response=True,那样装饰器会把所有 chunk 收齐再用 stream_chunk_builder 拼成完整响应返回。


4. function_setup:给这次调用装上记录仪

4.1 它要解决的小问题

后面所有横切动作(日志、成本、回调、缓存命中标记)都需要一个共享的"这次调用的上下文对象"。function_setup(utils.py:786) 就是造这个对象的地方,一次调用只跑一次

4.2 它做的三件事

第一件:给这次调用发身份证。

# litellm/utils.py:1383-1384,真实源码
if "litellm_call_id" not in kwargs:
kwargs["litellm_call_id"] = str(uuid.uuid4())

注意这行在 wrapper 里、在 function_setup 之前执行。这个 litellm_call_id 会一路带到 _hidden_paramsllm_response_utils/response_metadata.py:41)和所有回调的 payload 里,是串起分布式追踪的那根线。

第二件:把回调归位。 用户可以在三个地方声明回调,function_setup 负责把它们摊平并按"同步/异步"分家:

声明位置变量处理位置
全局litellm.success_callback / litellm.failure_callback / litellm.callbacksutils.py:818-905
单次请求kwargs["callbacks"]utils.py:818 get_dynamic_callbacks
单次请求kwargs["success_callback"] / kwargs["failure_callback"]utils.py:883-905

分家的判据是 coroutine_checker.is_async_callable(callback)utils.py:855)——协程回调进 dynamic_async_success_callbacks,普通函数留在 dynamic_success_callbacks。这样后面派发时不用再判一次。

字符串形式的回调名("langfuse""lago""s3"…)由 _init_custom_logger_compatible_class(utils.py:824) 实例化成真正的 logger 类。全局回调列表的增删则统一走 litellm.logging_callback_managerlitellm/litellm_core_utils/logging_callback_manager.py:20 LoggingCallbackManager),它做的关键一件事是去重_add_custom_logger_to_list(:289) 按 _get_custom_logger_key(:314) 判重,避免同一个 logger 被注册两次导致每条日志发两遍。

第三件:造 Logging 对象。

# litellm/utils.py:1083-1098,真实源码(节选)
logging_obj = get_litellm_logging_class()( # Victim for object pool
model=model,
messages=messages,
stream=stream,
litellm_call_id=kwargs["litellm_call_id"],
...
dynamic_success_callbacks=dynamic_success_callbacks,
...
)

这个 Logging 类定义在 litellm/litellm_core_utils/litellm_logging.py:375,构造函数在 :391。造好后立刻 update_environment_variables(:669) 把 litellm_params / metadata 灌进去,然后返回给 wrapper,wrapper 再写回 kwargs["litellm_logging_obj"]utils.py:1399),让下游的翻译层和 HTTP 层也能拿到同一个对象去打 pre_call / post_call

一个容易忽略的入口:kwargs 里已经带了 litellm_logging_obj(比如 Router 或 Proxy 在上层已经造好了),wrapper 会直接复用、跳过 function_setuputils.py:1389-1390)。这就是"一次逻辑调用只有一条日志"的实现方式。


5. 三道闸:预算、每请求重试上限、缓存

三道闸都在真实调用之前,任何一道拦下就不会产生 API 花费。

5.1 预算闸

# litellm/utils.py:1409-1414,真实源码
if litellm.max_budget:
if litellm._current_cost > litellm.max_budget:
raise BudgetExceededError(
current_cost=litellm._current_cost,
max_budget=litellm.max_budget,
)

异步 wrapper 里有一份完全相同的(utils.py:1694-1699)。

_current_cost 是进程内的一个全局浮点数(litellm/__init__.py:404),累加发生在成功日志的准备函数里(litellm_logging.py:2051 litellm._current_cost += litellm.completion_cost(...))。

这里有个真实的局限(读代码才看得出):累加那段的判定条件是 litellm.max_budgetself.stream is Falseisinstance(result, dict)"content" in resultlitellm_logging.py:2042-2047),而正常 completion 返回的是 ModelResponse 对象、不是 dict。也就是说 SDK 层这个 max_budget 闸在常规路径上几乎不会真正累加。要做真正的预算控制,应该用 Proxy 的 key/team 预算(见 06-proxy-gateway),而不是这个进程内变量。

BudgetExceededError(exceptions.py:960) 本身有个设计细节:它不继承 RateLimitError(怕破坏用户已有的 except BudgetExceededError:),但把 status_code = 429categoryrate_limit_type 三个字段照着限流错误的样子填上(exceptions.py:972-980),这样下游按"限流类错误"消费的回调仍能正确归类。

5.2 每请求重试上限闸

# litellm/utils.py:1417-1424,真实源码
if litellm.num_retries_per_request is not None:
previous_models = (kwargs.get("metadata") or {}).get("previous_models", None)
if previous_models is not None:
if litellm.num_retries_per_request <= len(previous_models):
raise Exception("Max retries per request hit!")

判据是 metadata["previous_models"] 的长度——这个列表由 Router 在每次重试/回退时追加。所以这道闸的本质是给 Router 的重试链条设一个全局硬顶,防止"回退到下一个模型 → 又失败 → 又回退"无限展开。注意它只在同步 wrapper 里出现(两处::1353 的异步透传分支和 :1417 的主路径),异步 wrapper 里没有对应的前置检查。

5.3 缓存闸

同步侧的入口:

# litellm/utils.py:1447-1455,真实源码
caching_handler_response: "CachingHandlerResponse" = _llm_caching_handler._sync_get_cache(
model=model or "",
original_function=original_function,
logging_obj=logging_obj,
start_time=start_time,
call_type=call_type,
kwargs=kwargs,
args=args,
)

命中就立刻 return caching_handler_response.cached_resultutils.py:1457-1459),后面的真实调用、成本、写缓存全部不发生。

进入这个分支的门槛写在 utils.py:1429-1445 的那个长条件里,读法是三段:

  1. 开关: kwargs["caching"] is True,或者用户设了 litellm.cache 且没显式传 caching=False
  2. 旁路: kwargs["cache"]["no-cache"] 不为 True
  3. 排除: call_type 不是 aembedding / acompletion / atranscription 等一串 a* 异步标志——这些交给异步 wrapper 处理。

异步侧简单得多:wrapper_async 无条件调 _async_get_cache(utils.py:1706,实现在 caching_handler.py:145),由 handler 内部决定要不要真查。它还多一条同步侧没有的返回路径:embedding 的部分命中——一批 input 里命中一部分,没命中的那部分照常发请求,最后由 _combine_cached_embedding_response_with_api_result(caching_handler.py:588) 把两半拼起来(utils.py:1854-1857)。


6. 缓存:两级存储 + 一张后端清单

6.1 键是怎么算出来的

Cache.get_cache_key(litellm/caching/caching.py:320) 的做法是:把 kwargs 里属于"LLM API 参数"的字段(ModelParamHelper._get_all_llm_api_params())按 "参数名: 值" 拼成一个长字符串,再哈希(_get_hashed_cache_key)。

含义很直接:模型、messages、temperature 任何一个不同,就是不同的 key。这是精确匹配,不是"意思差不多"。想要"意思差不多也算命中",得上 §6.3 的语义缓存。

6.2 两级:内存 + Redis

DualCache(litellm/caching/dual_cache.py:51) 是"本地内存 + Redis"的组合。读路径:

get_cache(key)


┌───────────────┐ 命中 ┌──────────┐
│ InMemoryCache │ ───────► │ 返回值 │
└───────┬───────┘ └──────────┘
│ 未命中

┌───────────────┐ 命中 ┌──────────────────────┐
│ RedisCache │ ───────► │ 回填 InMemoryCache │──► 返回值
└───────┬───────┘ └──────────────────────┘
│ 未命中

None

对应源码 dual_cache.py:153-182(同步 get_cache)与 :217-247async_get_cache),两者结构完全一致,回填那行是 self.in_memory_cache.set_cache(key, redis_result, **self._backfill_kwargs(kwargs)):175)。

内存那一级是有界的:InMemoryCache(in_memory_cache.py:28) 默认 max_size_in_memory=200default_ttl=600 秒(:31-40),超出时 evict_cache(:101) 按最早过期时间淘汰。

6.3 后端清单

后端文件匹配方式特点
内存InMemoryCachecaching/in_memory_cache.py:28精确默认 200 条 / TTL 600s,进程内
RedisRedisCachecaching/redis_cache.py:267精确跨进程共享,Proxy 多副本的默认选择
Redis 集群RedisClusterCachecaching/redis_cluster_cache.py精确redis_startup_nodes
磁盘DiskCachecaching/disk_cache.py:14精确基于 diskcache 库,默认目录 .litellm_cache
S3S3Cachecaching/s3_cache.py:23精确boto3 直写对象;不支持批量写,embedding 走单条路径(caching_handler.py:995
GCS / Azure BlobGCSCache / AzureBlobCachecaching/gcs_cache.pycaching/azure_blob_cache.py精确同上,云对象存储
Redis 语义RedisSemanticCachecaching/redis_semantic_cache.py:38向量相似必须传 similarity_threshold,内部转成余弦距离阈值 1 - threshold:99
Valkey 语义ValkeySemanticCachecaching/valkey_semantic_cache.py向量相似Redis 语义缓存的 Valkey 版
Qdrant 语义QdrantSemanticCachecaching/qdrant_semantic_cache.py:37向量相似支持 binary / scalar / product 三种量化(:100-118),默认二值量化

选型由 Cache.__init__(caching/caching.py:56) 的 type 参数决定;哪些 call_type 允许缓存由 supported_call_types(caching.py:69-82) 控制,默认包含 completion / embedding / transcription / rerank / responses 五类的同步与异步版本。

6.4 语义缓存的读法

精确缓存问"这条请求我见过吗";语义缓存问"我见过意思差不多的请求吗"。实现上是把 prompt 过一遍 embedding 模型(redis_semantic_cache.py:330 _get_embedding),在向量库里做近邻检索,把返回的余弦距离换算回相似度再和阈值比:

# litellm/caching/redis_semantic_cache.py:471-476,真实源码
vector_distance = float(cache_hit["vector_distance"])
# Convert vector distance back to similarity score
# For cosine distance: 0 = most similar, 2 = least similar
similarity = 1 - vector_distance

代价也很直白:每次查缓存都要多打一次 embedding API。语义缓存的 key 里还会剔掉一批参数(_SEMANTIC_CACHE_SCOPE_EXCLUDED_PARAMScaching.py:288),否则连 temperature 改一下都会分桶,语义匹配就失去意义了。

6.5 写回

  • 同步:sync_set_cache(caching_handler.py:1013) 直接 litellm.cache.add_cache(...),阻塞。
  • 异步:async_set_cache(caching_handler.py:946) 一律 asyncio.create_task(...):997:1003:1011)——发射后不管,写缓存的耗时不进用户的延迟。

两者都先过 _should_store_result_in_cache(caching_handler.py:1038) 这道判断。


7. 成本:从 usage 到 _hidden_params["response_cost"]

7.1 它要解决的小问题

用户想知道"这次调用花了多少钱"。但每家 provider 的计价单位不一样:OpenAI 按 token,Vertex 部分模型按字符,Replicate 按秒,Rerank 按 query 数,转录按音频时长。横切层要把这些统一成一个美元数字。

7.2 一条计算链

响应对象 result


Logging._response_cost_calculator litellm_logging.py:1489
│ ├─ cache_hit → 直接返回 0.0
│ └─ _hidden_params 里已有 response_cost → 直接复用

response_cost_calculator cost_calculator.py:1715


completion_cost cost_calculator.py:1112
│ ├─ _select_model_name_for_cost_calc :729 决定"按哪个模型名查价"
│ └─ _get_usage_object :869 把各家 usage 统一成 Usage

cost_per_token cost_calculator.py:300
│ 查 litellm.model_cost[模型名] 里的单价

(prompt_cost, completion_cost) → 相加 → float 美元

_response_cost_calculator 的前两个短路很值得注意:

# litellm/litellm_core_utils/litellm_logging.py:1524-1536,真实源码(节选)
if cache_hit is True:
return 0.0
...
if isinstance(result, BaseModel) and hasattr(result, "_hidden_params"):
hidden_params = getattr(result, "_hidden_params", {})
if ("response_cost" in hidden_params and hidden_params["response_cost"] is not None):
return hidden_params["response_cost"] # use cost if already calculated

第一条保证缓存命中不重复计费;第二条保证一次响应只算一次钱(Router 场景下同一个响应可能被多层看到)。

7.3 模型名的选择比想象中麻烦

_select_model_name_for_cost_calc(cost_calculator.py:729) 的 docstring 直接写了优先级:

  1. 用户开了自定义定价 → 用传进来的 model 名
  2. 设了 base_model(Azure 常见:部署名 ≠ 模型名)→ 用 base_model
  3. 响应对象里的 model 字段
  4. 兜底用调用时传的 model

第 2 条是 Azure 用户最容易踩的坑:Azure 的 deployment 名可以随便起(比如 my-gpt4-prod),价格表里根本没有这个名字,必须靠 base_model 告诉 LiteLLM"它其实是 gpt-4"。

7.4 价格数据从哪来

来源位置何时用
远程价格表model_cost_map_urllitellm/__init__.py:412,默认指向 GitHub raw 上的 model_prices_and_context_window.json默认,import litellm 时拉一次
仓库内价格表model_prices_and_context_window.json(仓库根)远程表的源文件
打包备份litellm/model_prices_and_context_window_backup.json拉取失败、或校验不过时兜底
用户自定义litellm.register_model(...)utils.py:2831私有模型 / 议价后的单价

加载逻辑在 get_model_cost_map(litellm/litellm_core_utils/get_model_cost_map.py:426)。它有两个防呆设计:

  • 环境变量 LITELLM_LOCAL_MODEL_COST_MAP=true → 完全离线,只读本地备份(:277-282)。适合不想让库在 import 时联网的生产环境。
  • 缩水校验:远程表拉回来后要跟本地备份的条目数比(GetModelCostMap.validate_model_cost_map,常量 MODEL_COST_MAP_MAX_SHRINK_RATIO / MODEL_COST_MAP_MIN_MODEL_COUNT)。远程文件如果被截断成半个,会被判定为不可信并回落本地备份——避免"上游一次坏发布让全世界的成本统计归零"。

最终产物是 litellm.model_cost 这个全局 dict(litellm/__init__.py:530)。

7.5 算完的钱去哪了

答案是 _hidden_params。链路是:wrapper 在返回前调 update_response_metadata(utils.py:1563-1571),它构造 ResponseMetadata 并调 set_hidden_params

# litellm/litellm_core_utils/llm_response_utils/response_metadata.py:40-48,真实源码(节选)
new_params = {
"litellm_call_id": getattr(logging_obj, "litellm_call_id", None),
"api_base": get_api_base(model=model or "", optional_params=kwargs),
"model_id": model_id,
"response_cost": logging_obj._response_cost_calculator(
result=self.result, litellm_model_name=model, router_model_id=model_id
),
...
}

所以用户拿到的 resp._hidden_params["response_cost"] 就是这里写进去的。同一个数字还会走另一条路进日志:_process_hidden_params_and_response_cost(litellm_logging.py:1903) 把它写进 model_call_details["response_cost"],再打包进 standard_logging_object——这就是所有回调(Langfuse、Prometheus、Proxy 的账单表)看到的那份成本。

细项拆分(输入/输出/缓存读/缓存写/推理 token 分别多少钱)由 Logging.set_cost_breakdown(litellm_logging.py:1416) 存成 CostBreakdown


8. 日志与回调:四个钩子 + 一个后台队列

8.1 四个钩子分别在什么时候打

钩子时机谁来调位置
pre_call请求即将发出(已知 api_base / headers / body)翻译层与 HTTP 层litellm_logging.py:1064
post_call原始响应刚回来(还没解析成对象)HTTP 层litellm_logging.py:1269
success_handler成功且拿到结构化响应装饰器(线程池)litellm_logging.py:2185
async_success_handler同上,异步路径后台 logging workerlitellm_logging.py:2619
failure_handler抛异常装饰器(当前线程litellm_logging.py:3037
async_failure_handler同上,异步路径装饰器(awaitlitellm_logging.py:3231

pre_call 里做的一件重要的事是把请求还原成 curl 命令存进 metadata(litellm_logging.py:1090-1097_get_request_curl_command),Langfuse 之类的平台可以直接展示"这条请求原样长什么样"——但 headers 会先过 _get_masked_headers(:1261)。

8.2 成功回调怎么做到不拖慢主路径

同步路径用线程池,并且显式复制 contextvars,否则 OpenTelemetry 的 span 上下文会在跨线程时丢失:

# litellm/utils.py:1552-1560,真实源码
ctx = contextvars.copy_context()
executor = getattr(sys.modules[__name__], "executor")
executor.submit(
ctx.run,
logging_obj.success_handler,
result,
start_time,
end_time,
)

异步路径进后台队列。_client_async_logging_helper(utils.py:1136) 把 async_success_handler 这个协程扔给全局 worker:

# litellm/utils.py:1152-1156,真实源码
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER

GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(
async_coroutine=logging_obj.async_success_handler(result=result, start_time=start_time, end_time=end_time)
)

LoggingWorker(litellm/litellm_core_utils/logging_worker.py:35) 是个有界队列 + 信号量并发控制的消费者:

主路径 ──enqueue()──► asyncio.Queue (上限 50_000)
(从不阻塞) │

_worker_loop ── 先 acquire 信号量(并发 100) 再取任务


_process_log_task (单任务超时 20s)

常量在 litellm/constants.py:453-455:并发 100、队列 50000、单协程 20 秒超时。队列满了不阻塞主路径,而是走 _handle_queue_full(:187) 主动丢弃/退避重试——类注释里把这个取舍写得很清楚:这是 best-effort,换来 "+200 RPS"(logging_worker.py:37-42)。

进程退出时 atexit 注册的 _flush_on_exit(:448) 会尽量把队列排空,减少"最后几条日志丢了"的情况。

8.3 失败回调为什么偏偏不放线程

两个 wrapper 的异常分支里,failure_handler 都带着同一句大写注释:

# litellm/utils.py:1643-1647,真实源码
logging_obj.failure_handler(
e, traceback_exception, start_time, end_time
) # DO NOT MAKE THREADED - router retry fallback relies on this!

原因是 Router 的重试和回退依赖失败已经被完整记录(冷却计数要用到它)。如果丢进线程池异步执行,Router 可能在日志落下来之前就已经切到下一个 deployment 了,冷却统计会失真。

8.4 敏感信息不落日志

两层机制,作用对象不同:

机制遮什么入口开关
消息脱敏messages / prompt / 响应内容redact_message_input_output_from_logging(redact_messages.py:347)litellm.turn_off_message_logging,或请求头
字段掩码参数里像密钥的字段(值变成 sk-1***********ef12SensitiveDataMasker(sensitive_data_masker.py:9)默认按 key 名匹配

消息脱敏的开关判定有明确优先级,写在 should_redact_message_logging(redact_messages.py:295) 的 docstring 里:动态参数 > 请求头(litellm-disable-message-redaction / x-litellm-enable-message-redaction)> 全局 litellm.turn_off_message_logging

字段掩码靠 key 名里的敏感词表(password / secret / key / token / credentials / certificate …,sensitive_data_masker.py:19-34)。有个很小但很实用的补丁在 non_sensitive_overrides:39):input_cost_per_token 里含 token 却是价格字段,靠 cost 这个反向词把它排除,否则价格会被打成星号。


9. 异常统一:Router 能回退的前提

9.1 它要解决的小问题

同样是"上下文超长",各家给的信号完全不同:

  • OpenAI:"This model's maximum context length is ..."
  • Anthropic:"prompt is too long" / "prompt: length"
  • llama.cpp:"exceeds the available context size"
  • Gemini:"exceeds the maximum number of tokens allowed"
  • Cerebras:"Current length is X while limit is Y"

这些文案被集中列在一个共用判定里(ExceptionCheckers.is_error_str_context_window_exceededexception_mapping_utils.py:74-103,目前 9 条已知子串 + 1 条 Cerebras 模式)。

上层(Router、Proxy、用户代码)不可能为每家写一套 if。所以横切层要把这些收敛成同一组异常类

9.2 异常体系:全部继承 openai.*

litellm/exceptions.py 的做法很干脆:所有异常都继承 openai SDK 的对应异常。这样已经在用 openai SDK 的代码,except openai.RateLimitError: 原样能接住 LiteLLM 抛的错。

LiteLLM 异常继承自定义典型触发
AuthenticationErroropenai.AuthenticationErrorexceptions.py:129401,key 错
NotFoundErroropenai.NotFoundError:173404,模型不存在
BadRequestErroropenai.BadRequestError:216400
Timeoutopenai.APITimeoutError:330408 / 读超时
RateLimitErroropenai.RateLimitError:413429
InternalServerErroropenai.InternalServerError:729500
ServiceUnavailableErroropenai.APIStatusError:633503
BadGatewayErroropenai.APIStatusError:681502
ContextWindowExceededErrorBadRequestError:504上下文超长(LiteLLM 特有细分)
ContentPolicyViolationErrorBadRequestError:588内容被安全策略拦(LiteLLM 特有细分)
BudgetExceededErrorException:960预算闸拦下(不走 openai 体系)
MidStreamFallbackErrorServiceUnavailableError:1073流已经开始吐字后才失败,需要中途换模型

加粗那四个是 LiteLLM 自己加的。前两个之所以要从 BadRequestError 里再细分出来,就是为了 §9.4 的 Router 回退。

MidStreamFallbackError 值得单说:它带一个 generated_content 字段(exceptions.py:1084)保存"已经吐出去的那部分文本",还有 is_pre_first_chunk 标记是否连第一个 chunk 都没到。有了这两个信息,上层才能判断中途换模型时要不要把已生成内容拼回去。

9.3 映射函数:一个总闸 + 每家一个

总入口是 exception_type(exception_mapping_utils.py:2164),它的第一行就是幂等保护:

# litellm/litellm_core_utils/exception_mapping_utils.py:2172-2173,真实源码
if any(isinstance(original_exception, exc_type) for exc_type in litellm.LITELLM_EXCEPTION_TYPES):
return original_exception

已经是 LiteLLM 异常就原样返回——防止多层调用把同一个错误反复包装。

然后按 custom_llm_provider 分派给每家的映射函数:

映射函数位置
_map_openai_exceptionexception_mapping_utils.py:257
_map_anthropic_exception:501
_map_replicate_exception:607
_map_openai_like_exception:699
_map_bedrock_exception:814
_map_vertex_exception:1074
_map_cohere_exception:1323
_map_azure_exception:1857
_map_openrouter_exception:2070
(另有 ai21 / huggingface / together / ollama / vllm / sagemaker / nlp_cloud / cloudflare / aleph_alpha 各一个):1493

每个映射函数的结构是一样的两段:先按错误串猜,再按 status_code 兜底。Anthropic 那一支是很典型的例子:

# litellm/litellm_core_utils/exception_mapping_utils.py:512-521,真实源码
if (
"prompt is too long" in error_str
or "prompt: length" in error_str
or ExceptionCheckers.is_error_str_context_window_exceeded(error_str)
):
raise ContextWindowExceededError(
message="AnthropicError - {}".format(error_str),
model=model,
llm_provider="anthropic",
)

字符串匹配看起来土,但这是没办法的事:Anthropic 的 400 和"参数写错"的 400 是同一个 status_code,只能看文案区分。共用的判据被抽成 ExceptionCheckers(exception_mapping_utils.py:37/74/101) 的三个静态方法(判限流、判上下文超长、判 Azure 内容策略),各家复用。

调用点在 main.py 各入口的 except 里,比如 main.py:697:5787:7058

9.4 为什么这是 Router 能做回退的前提

Router 的回退逻辑是纯粹的 isinstance 判断:

# litellm/router.py:6414,真实源码
if isinstance(e, litellm.ContextWindowExceededError):
if context_window_fallbacks is not None:
...

冷却逻辑同理,_is_cooldown_required(router_utils/cooldown_handlers.py:205) 看的是 exception_status 这个已经归一化的状态码——429 冷却、4xx 一般不冷却。

把这两件事串起来看:

Anthropic 返回 "prompt is too long" Bedrock 返回另一段文本
│ │
└──────► exception_type() ◄───────────┘


litellm.ContextWindowExceededError ← 同一个类

┌───────────────┴───────────────┐
▼ ▼
Router: 走 context_window_fallbacks 冷却判定: 400 → 不冷却这个 deployment
(router.py:6414) (cooldown_handlers.py:234)

如果没有这层归一,Router 就得自己去认 100 家的错误文案——那等于把横切层的活儿抄一遍到路由层。异常映射是"翻译层统一入参、横切层统一出错"这套设计里的后一半;正因为出错也被统一了,05-router 才能用几十行代码写完冷却与回退。

9.5 装饰器自己的重试

在把异常交给上层之前,wrapper 还会做一次本地重试(仅当不是 Router 发起的调用时):

# litellm/utils.py:1589-1591,真实源码(同步 completion 分支)
_is_litellm_router_call = "model_group" in (
kwargs.get("metadata") or {}
) # check if call from litellm.router/proxy

判据是 metadata 里有没有 model_group。有就说明 Router 在管重试,装饰器不插手(utils.py:1593-1603);没有才走 litellm.completion_with_retries / acompletion_with_retries。异步侧还会按异常类型挑退避策略:RateLimitErrorexponential_backoff_retry,一般 APIErrorconstant_retryutils.py:1902-1906)。

另有一条独立的兜底:ContextWindowExceededError + 用户配了 context_window_fallback_dict 时,直接换个模型名重调一次(utils.py:1604-1612)。

最后,异常在被 raise 出去之前会挂上两个属性给 Router 用:

# litellm/utils.py:1947-1950,真实源码
setattr(e, "num_retries", num_retries) ## IMPORTANT: returns the deployment's num_retries to the router
timeout = _get_wrapper_timeout(kwargs=kwargs, exception=e)
setattr(e, "timeout", timeout)

10. 巧妙之处(可以借鉴的)

① 用"是不是协程"选 wrapper,而不是运行时判断。 client 在装饰的那一刻(import 时)就用 get_coroutine_checker().is_async_callable 决定返回哪个闭包(utils.py:1969-1975),运行时零开销。

② 用一个标志位切断重入。 acompletion 委托给 completion 时带上 acompletion=Truemain.py:593),同步 wrapper 第一行就据此透传(utils.py:1351)。一个 bool 解决了"同一层横切逻辑被套两遍"的问题,比引入 threadlocal 或 contextvar 简单得多。

③ 成功日志异步、失败日志同步。 前者用线程池/后台队列换吞吐(utils.py:1554logging_worker.py:137),后者必须同步因为 Router 的冷却统计依赖它(utils.py:1647 的全大写注释)。同一类"日志"按下游消费者的时序要求分开处理。

④ 跨线程派发前复制 contextvars。 ctx = contextvars.copy_context() 后用 ctx.run 提交(utils.py:1552-1555),OpenTelemetry 的 span 上下文才不会在线程边界断掉。LoggingWorker.enqueue(logging_worker.py:137) 也做了同样的事(:144)。

⑤ 价格表的缩水校验。 远程价格表条目数比本地备份少太多就判为不可信、回落备份(get_model_cost_map.pyvalidate_model_cost_map + MODEL_COST_MAP_MAX_SHRINK_RATIO)。这是"外部数据源可能坏掉"的一种低成本防线。

⑥ 敏感词的反向豁免表。 non_sensitive_overrides = {"cost"}sensitive_data_masker.py:39)让 input_cost_per_token 不会因为含 token 被打码。加一条反向规则,比把敏感词表改精细省事得多。

⑦ 后台日志队列满了就丢,不阻塞。 LoggingWorker 明确定位为 best-effort(logging_worker.py:37-42)。可观测性数据的价值配不上"拖慢用户请求"的代价,这个取舍写进了类注释。


11. 边界与局限

  • SDK 层的 litellm.max_budget 基本是摆设。 累加分支要求 result 是含 content 的 dict(litellm_logging.py:2042-2047),正常路径返回的是 ModelResponse。要做真预算控制得用 Proxy 侧的 key/team 预算。
  • 流式调用的成本与成功日志不在装饰器里。 装饰器早退(utils.py:1496:1763),交给 CustomStreamWrapper 在流结束时补。所以"装饰器 = 所有横切逻辑的唯一发生地"这句话对流式不成立。
  • 异常映射大量依赖错误文案字符串匹配。 上游改一句错误提示,某条 ContextWindowExceededError 就可能退化成普通 BadRequestError,Router 的 context-window 回退随之失效。这是这套设计固有的脆弱点。
  • 精确缓存的 key 对参数极度敏感。 temperature 差 0.01 就是另一个 key(caching.py:320)。想要模糊命中只能上语义缓存,代价是每次查询多一次 embedding 调用。
  • num_retries_per_request 只在同步 wrapper 里检查utils.py:1353:1417),异步 wrapper 的主路径没有对应前置闸。
  • 日志队列满了会丢日志。 默认队列 50000(constants.py:454),超出后走丢弃/退避(logging_worker.py:187)。极端流量下可观测性数据不保证完整。

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

主题文件路径符号名
装饰器本体litellm/utils.pyclient
同步 wrapperlitellm/utils.pyclient.wrapper
异步 wrapperlitellm/utils.pyclient.wrapper_async
异步/流式判定litellm/utils.py_is_async_request_is_streaming_request
调用初始化litellm/utils.pyfunction_setup
后置规则与校验litellm/utils.pypost_call_processing
用户自定义规则litellm/litellm_core_utils/rules.pyRules.pre_call_rulesRules.post_call_rules
重试参数解析litellm/utils.py_get_wrapper_num_retries_get_wrapper_timeout
自定义定价注册litellm/utils.pyregister_model
日志对象litellm/litellm_core_utils/litellm_logging.pyLogging
四个钩子litellm/litellm_core_utils/litellm_logging.pypre_callpost_callsuccess_handlerfailure_handler
异步钩子litellm/litellm_core_utils/litellm_logging.pyasync_success_handlerasync_failure_handler
成本写入日志litellm/litellm_core_utils/litellm_logging.py_response_cost_calculator_process_hidden_params_and_response_costset_cost_breakdown
回调注册去重litellm/litellm_core_utils/logging_callback_manager.pyLoggingCallbackManager
后台日志队列litellm/litellm_core_utils/logging_worker.pyLoggingWorkerGLOBAL_LOGGING_WORKER
消息脱敏litellm/litellm_core_utils/redact_messages.pyshould_redact_message_loggingredact_message_input_output_from_logging
字段掩码litellm/litellm_core_utils/sensitive_data_masker.pySensitiveDataMasker
响应元数据注入litellm/litellm_core_utils/llm_response_utils/response_metadata.pyResponseMetadata.set_hidden_paramsupdate_response_metadata
缓存编排litellm/caching/caching_handler.pyLLMCachingHandler_sync_get_cache_async_get_cachesync_set_cacheasync_set_cache
缓存门面与 keylitellm/caching/caching.pyCacheCache.get_cache_key
两级缓存litellm/caching/dual_cache.pyDualCache
内存缓存litellm/caching/in_memory_cache.pyInMemoryCacheevict_cache
语义缓存litellm/caching/redis_semantic_cache.pylitellm/caching/qdrant_semantic_cache.pyRedisSemanticCacheQdrantSemanticCache
成本计算litellm/cost_calculator.pycompletion_costcost_per_tokenresponse_cost_calculator_select_model_name_for_cost_calc_get_usage_object
价格表加载litellm/litellm_core_utils/get_model_cost_map.pyget_model_cost_mapGetModelCostMap
异常类litellm/exceptions.pyContextWindowExceededErrorContentPolicyViolationErrorBudgetExceededErrorMidStreamFallbackError
异常映射litellm/litellm_core_utils/exception_mapping_utils.pyexception_typeExceptionCheckers_map_openai_exception_map_anthropic_exception_map_bedrock_exception

继续读: 这层壳里面发生了什么 → 02-translation-layer(参数怎么翻译)、03-http-and-streaming(请求怎么发、流怎么拼);这层壳外面谁在用 → 05-router(靠统一异常做回退与冷却)、06-proxy-gateway(靠统一成本做多租户计费)。整体主线见 01-request-lifecycle