1. 多租户网关里,Token 成本归因到底难在哪
LiteLLM 网关做统一入口之后,最容易被忽略的一件事就是:调用量上来了,钱却说不清是谁花的。LiteLLM 是一个把多家模型 API 收敛成 OpenAI 兼容接口的代理网关,支持虚拟 Key、预算限额、消费日志落库,适合团队内部做多租户的模型调用管理。它本身自带LiteLLM_SpendLogs表和一套 UI 账单,但很多人第一次对账就会发现数字对不上——不是网关算错了,而是读账本的姿势不对。
我这边网关跑了一段时间后,需求很朴素:每笔 API 调用都要能回答三个问题——谁在调、调的什么模型、花了多少钱。听起来像 UI 里点两下的事,实际做下来要处理请求头注入、日志落库、按 Key 和用户维度聚合,还要把 endpoint 统一到一个稳定的 API 通道上,才能让归因链路复用。这篇就把这套链路拆开讲,从配置到回调钩子到验证动作,一步步给可复制的片段。
先说清楚归因的核心机制,不然后面全是坑。LiteLLM 的计费归属看的是 Key 所属的user_id,不是key_alias。key_alias只是个给人看的标签,写什么都行,不参与计费。很多团队是管理员统一代建 Key,key_alias按人名写,但user_id全是管理员本人,于是系统眼里全公司的消费都挂在管理员头上。这是第一个必须纠正的认知。
第二个机制是 Key 的存储方式。LiteLLM 对 Key token 做 SHA-256 hash 存储,LiteLLM_VerificationToken.token字段存的是 64 位 hex 的 hash 值,不是明文。你拿配置里的sk-xxx去等值匹配,永远查不到。SpendLogs.api_key存的也是 hash 前缀,大约 12 位,UI 上显示的 Key ID 就是这个前缀。所以查账要用前缀匹配,不是明文等值。
第三个机制是删除行为。VerificationToken没有软删除字段,删 Key 是硬删除,归属信息一并蒸发,但SpendLogs里的调用记录还在,只是 JOIN 不上 Key 表了。这意味着离职人员的 Key 一删,历史账就成了孤儿记录,得靠LEFT JOIN反查。
把这三个机制记住,后面的配置和 SQL 才有意义。归因链路的目标就是:请求进来时带上用户标识,落库时把标识写进日志,聚合时按 Key 前缀和用户维度还原账单。下面从环境准备开始。
2. 前置准备:TaoToken 统一 Key 与 LiteLLM 接入
在讲归因配置之前,先把上游通道定下来。归因链路要复用,前提是上游 endpoint 稳定、Key 统一管理。我这边把 LiteLLM 的上游 endpoint 改到 TaoToken 的统一 Key/API 通道,这样所有下游应用(RAG 知识库、Claude Code、内部 agent 平台)都走同一个出口,归因只需要在 LiteLLM 这一层做,不用每个应用各写一套。
TaoToken 的官网入口是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 基址是 https://taotoken.net/api 。注意 API 地址不带 UTM 参数,配置里写干净的基址就行。控制台和 Key 管理在 https://taotoken.net/console ,API Keys 页面在 https://taotoken.net/api-keys ,接入文档在 https://taotoken.net/doc 。如果你要验证模型是否可用,可以用模型对话页面 https://taotoken.net/models 先跑一条请求;如果是长期编码或 Agent 场景,Coding Plan 页面在 https://taotoken.net/coding-plan 。
前置准备分三步。第一步,在 TaoToken 控制台创建一把统一 Key,记下 Key 值。第二步,确认 LiteLLM 的数据库后端是 Postgres,因为归因依赖SpendLogs和VerificationToken两张表,SQLite 也能跑但生产环境建议 Postgres。第三步,确认 LiteLLM 版本,v1.86.0+存在 UI 日期筛选翻倍的已知问题,对账时要注意,后面排障章节会细讲。
环境变量建议这样组织,把上游 Key 和数据库连接分开:
export TAOTOKEN_API_KEY="sk-你的统一Key" export DATABASE_URL="postgresql://litellm:password@127.0.0.1:5432/litellm" export LITELLM_MASTER_KEY="sk-你的master-key" export LITELLM_SALT_KEY="sk-你的salt-key"LITELLM_SALT_KEY很关键,它决定 Key 的 hash 方式。如果你中途换了 salt,历史 Key 的 hash 前缀就对不上了,归因会断档。所以这个值一旦定下来就别改。
接下来是 LiteLLM 的config.yaml。归因相关的配置集中在general_settings、litellm_settings和model_list三块。model_list里把上游指向 TaoToken 的 API 基址,general_settings里开启数据库和日志,litellm_settings里挂回调钩子。下面给一份可复制的完整配置。
3. 可复制配置:config.yaml 归因链路
这份config.yaml是归因链路的核心,路径按你实际部署位置调整,我这边放在/etc/litellm/config.yaml。配置里包含上游模型定义、数据库落库、回调钩子和请求头透传四部分。
model_list: - model_name: claude-sonnet-4-6 litellm_params: model: anthropic/claude-sonnet-4-6 api_base: https://taotoken.net/api api_key: os.environ/TAOTOKEN_API_KEY - model_name: gpt-4o-mini litellm_params: model: openai/gpt-4o-mini api_base: https://taotoken.net/api api_key: os.environ/TAOTOKEN_API_KEY - model_name: deepseek-chat litellm_params: model: deepseek/deepseek-chat api_base: https://taotoken.net/api api_key: os.environ/TAOTOKEN_API_KEY general_settings: master_key: os.environ/LITELLM_MASTER_KEY database_url: os.environ/DATABASE_URL salt_key: os.environ/LITELLM_SALT_KEY store_model_in_db: true store_prompts_in_spend_logs: true forward_client_headers_to_llm_api: true litellm_settings: drop_params: true set_verbose: false success_callback: ["custom_attribution"] failure_callback: ["custom_attribution"] callbacks: ["custom_attribution"] callback_settings: custom_attribution: module_path: "callbacks.attribution" function_name: "log_attribution"几个参数要解释清楚。store_prompts_in_spend_logs: true会把 prompt 和 completion 的 token 数写进SpendLogs.metadata,归因时能按 token 维度拆。forward_client_headers_to_llm_api: true让客户端请求头透传到上游,但归因真正依赖的是 LiteLLM 自己记录的user字段和metadata。success_callback和failure_callback都挂上自定义回调,成功和失败的调用都要落库,否则失败请求的 token 消耗会漏账。
callback_settings里的module_path指向你的回调模块。这个模块要放在 LiteLLM 能 import 到的路径下,我这边放在项目根目录的callbacks/attribution.py。下面给回调钩子的代码。
# callbacks/attribution.py import json import logging from typing import Any, Dict, Optional logger = logging.getLogger("litellm.attribution") def log_attribution( kwargs: Dict[str, Any], response_obj: Optional[Any], start_time: Any, end_time: Any, ) -> None: """ LiteLLM 自定义回调:把用户标识、Key 前缀、模型、token 数写入日志。 成功和失败都会触发,失败时 response_obj 为 None。 """ try: metadata = kwargs.get("litellm_params", {}).get("metadata", {}) or {} user_id = metadata.get("user_api_key_user_id") or kwargs.get("user") or "unknown" key_alias = metadata.get("user_api_key_alias") or "no-alias" key_hash = metadata.get("user_api_key_hash") or "" key_prefix = key_hash[:12] if key_hash else "no-key" model = kwargs.get("model") or "unknown" call_type = kwargs.get("call_type") or "completion" usage = {} if response_obj is not None: usage = getattr(response_obj, "usage", None) or {} if not isinstance(usage, dict): usage = usage.model_dump() if hasattr(usage, "model_dump") else {} record = { "user_id": user_id, "key_alias": key_alias, "key_prefix": key_prefix, "model": model, "call_type": call_type, "prompt_tokens": usage.get("prompt_tokens", 0), "completion_tokens": usage.get("completion_tokens", 0), "total_tokens": usage.get("total_tokens", 0), } logger.info("attribution_record=%s", json.dumps(record, ensure_ascii=False)) except Exception as exc: logger.warning("attribution callback failed: %s", exc)这个回调的作用是把归因需要的字段从metadata里抽出来,打成一条结构化日志。user_api_key_user_id是 LiteLLM 在鉴权时注入的,user_api_key_alias是 Key 的别名,user_api_key_hash是 Key 的 hash,取前 12 位就是 UI 上显示的 Key ID。注意metadata的字段名在不同版本可能略有差异,如果取不到值,先打印整个metadata看实际结构。
配置和回调都就位后,启动 LiteLLM:
litellm --config /etc/litellm/config.yaml --port 4000 --detailed_debug启动日志里如果看到custom_attribution注册成功,说明回调挂上了。接下来要验证归因链路是否真的把用户标识写进了SpendLogs。
4. 验证请求:多用户调用后核对账单归属
验证分两步:先发起多用户调用,再查库核对归属。这一步是整个归因链路的验收动作,不能跳过。
先创建两个用户和两把 Key,模拟多租户场景。用 master key 调管理接口:
# 创建用户 alice curl -X POST http://127.0.0.1:4000/user/new \ -H "Authorization: Bearer $LITELLM_MASTER_KEY" \ -H "Content-Type: application/json" \ -d '{"user_email": "alice@example.com", "user_alias": "alice"}' # 创建用户 bob curl -X POST http://127.0.0.1:4000/user/new \ -H "Authorization: Bearer $LITELLM_MASTER_KEY" \ -H "Content-Type: application/json" \ -d '{"user_email": "bob@example.com", "user_alias": "bob"}'拿到两个user_id后,分别给 alice 和 bob 生成 Key,注意user_id要填实际使用者,不要都填管理员:
# 给 alice 生成 Key curl -X POST http://127.0.0.1:4000/key/generate \ -H "Authorization: Bearer $LITELLM_MASTER_KEY" \ -H "Content-Type: application/json" \ -d '{"user_id": "alice-user-id", "key_alias": "alice-key", "models": ["claude-sonnet-4-6"]}' # 给 bob 生成 Key curl -X POST http://127.0.0.1:4000/key/generate \ -H "Authorization: Bearer $LITELLM_MASTER_KEY" \ -H "Content-Type: application/json" \ -d '{"user_id": "bob-user-id", "key_alias": "bob-key", "models": ["gpt-4o-mini"]}'然后用各自的 Key 发起调用。alice 调 claude,bob 调 gpt-4o-mini:
# alice 调用 curl -X POST http://127.0.0.1:4000/v1/chat/completions \ -H "Authorization: Bearer sk-alice-key" \ -H "Content-Type: application/json" \ -d '{"model": "claude-sonnet-4-6", "messages": [{"role": "user", "content": "用一句话解释什么是网关"}]}' # bob 调用 curl -X POST http://127.0.0.1:4000/v1/chat/completions \ -H "Authorization: Bearer sk-bob-key" \ -H "Content-Type: application/json" \ -d '{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "用一句话解释什么是成本归因"}]}'调用成功后,查SpendLogs核对归属。这条 SQL 按 Key 前缀和用户维度聚合,是归因的核心查询:
SELECT LEFT(api_key, 12) AS key_prefix, "user" AS user_id, model, COUNT(*) AS calls, ROUND(SUM(spend)::numeric, 4) AS spend, SUM((metadata::json->>'prompt_tokens')::int) AS prompt_tokens, SUM((metadata::json->>'completion_tokens')::int) AS completion_tokens FROM "LiteLLM_SpendLogs" WHERE "startTime" >= NOW() - INTERVAL '1 hour' GROUP BY LEFT(api_key, 12), "user", model ORDER BY spend DESC;预期结果是 alice 的 Key 前缀对应 claude 模型,bob 的 Key 前缀对应 gpt-4o-mini,user_id分别是 alice 和 bob 的user_id。如果user字段是空的或者全是管理员,说明 Key 生成时user_id填错了,回到第 1 节的机制:计费归属看user_id,不看key_alias。
再验证一下回调日志有没有落。看 LiteLLM 进程日志里有没有attribution_record=开头的行:
grep "attribution_record=" /var/log/litellm/litellm.log | tail -5如果日志里有记录但SpendLogs里user字段为空,说明回调拿到了数据但 LiteLLM 自己的落库没写user,这时候要检查store_prompts_in_spend_logs和forward_client_headers_to_llm_api是否都开了。两个都开了还不行,就升级 LiteLLM 版本,早期版本对user字段的写入有差异。
验证通过后,归因链路就算跑通了。接下来是排障,这部分是实战里最容易卡住的地方。
5. 常见报错排查:401、local proxy failed 与 OAuth
归因链路跑起来之后,报错主要集中在鉴权、代理和 OAuth 三类。下面按真实报错对照排查。
第一类,401 Unauthorized。这个报错在归因场景下通常不是 Key 错了,而是 Key 的user_id和请求里的user字段冲突。LiteLLM 鉴权时会用 Key 绑定的user_id覆盖请求体里的user,如果你在请求里手动传了user字段,可能被忽略。排查动作:查VerificationToken表确认 Key 绑定的user_id:
SELECT token, user_id, key_alias, models, spend FROM "LiteLLM_VerificationToken" WHERE LEFT(token, 12) = '你的key前缀';如果user_id是管理员,说明 Key 生成时填错了,重新生成一把绑定实际使用者的 Key。注意token字段是 hash,用前缀匹配,别拿明文查。
第二类,local proxy failed或APIConnectionError。这个报错说明 LiteLLM 到上游的请求失败了。归因场景下常见原因是api_base配错,比如把 TaoToken 的 API 基址写成了带路径的地址。正确写法是https://taotoken.net/api,不要带/v1后缀,LiteLLM 会自己拼。排查动作:用 curl 直接测上游连通性:
curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "ping"}]}'如果这条通,说明上游没问题,问题在 LiteLLM 配置;如果不通,检查 Key 和网络。注意local proxy failed有时是 LiteLLM 内部代理超时,调大request_timeout参数。
第三类,OAuth相关报错,比如OAuth token expired或invalid_client。这类报错出现在用 OAuth 方式接入上游时。如果你走的是 TaoToken 的统一 Key,一般不会遇到 OAuth,但如果下游应用(比如 Claude Code)用 OAuth 方式连 LiteLLM,就要检查 LiteLLM 的 OAuth 配置。排查动作:确认 LiteLLM 的general_settings里有没有开oauth2_config,没开的话下游 OAuth 请求会被拒。归因场景下建议统一用 Key 鉴权,OAuth 的 token 刷新会干扰归因字段的注入。
第四类,reading choices报错,完整信息类似Error reading choices from response。这个报错说明上游返回的结构和 LiteLLM 预期的不一致。常见于上游返回了错误 JSON 但 HTTP 状态是 200。排查动作:开--detailed_debug看原始响应,确认上游返回体里有没有choices字段。如果是 TaoToken 通道,正常返回是 OpenAI 兼容格式,有choices;如果没有,检查model名是否拼错,比如把anthropic/claude-sonnet-4-6写成了claude-sonnet-4-6而没在model_list里注册。
第五类,UI 日期筛选数字翻倍。这是 LiteLLMv1.86.0+的已知问题,根因是DailyUserSpend聚合表按 UTC 整日分桶,前端把浏览器时区偏移传给后端,东八区查询起始日期往前扩了一天,查一天变成查两个整日桶。排查动作:用 SQL 直查SpendLogs对比 UI 数字:
SELECT '本地日' AS scope, COUNT(*) AS calls, ROUND(SUM(spend)::numeric, 2) AS spend FROM "LiteLLM_SpendLogs" WHERE "startTime" >= '2026-08-20 00:00:00' AND "startTime" < '2026-08-21 00:00:00' UNION ALL SELECT 'UTC日', COUNT(*), ROUND(SUM(spend)::numeric, 2) FROM "LiteLLM_SpendLogs" WHERE "startTime" >= '2026-08-20 08:00:00' AND "startTime" < '2026-08-21 08:00:00';处理建议是对账一律 SQL 直查SpendLogs,别信 UI 日期筛选。长期方案是升级到含修复的版本,但每日分桶仍是 UTC 口径,东八区看日报仍有 8 小时错位。不建议改容器 TZ 硬扛,分桶逻辑写死了 UTC,改了反而让新旧数据口径断档。
第六类,孤儿记录查不到归属。Key 删了之后SpendLogs里的记录 JOIN 不上VerificationToken,用LEFT JOIN反查:
SELECT LEFT(sl.api_key, 12) AS key_prefix, MAX(sl."user") AS user_id, COUNT(*) AS call_count, ROUND(SUM(sl.spend)::numeric, 2) AS total_spend, MIN(sl."startTime") AS first_call, MAX(sl."startTime") AS last_call FROM "LiteLLM_SpendLogs" sl LEFT JOIN "LiteLLM_VerificationToken" vt ON sl.api_key = vt.token WHERE vt.token IS NULL GROUP BY sl.api_key ORDER BY total_spend DESC;vt.token IS NULL的就是已删除 Key 的历史消费,sl."user"字段里还留着调用时提交的用户标识,多数情况能救回来。血泪教训是删 Key 之前先把 Key 前缀、key_alias、归属人记档,一张内部表格的事,别等对账时抓瞎。
排障做完,归因链路基本稳定了。最后把 CTA 分流说清楚,方便你按场景选入口。
6. 归因链路复用与入口选择
归因链路跑通之后,复用是关键。我这边把 LiteLLM 的上游 endpoint 统一到 TaoToken 的 API 通道,所有下游应用走同一个出口,归因只需要在 LiteLLM 这一层做。这样新增应用时不用改归因代码,只要在 LiteLLM 里生成一把绑定实际使用者的 Key 就行。
如果你在排障或接入阶段卡住了,先看 API Keys 页面 https://taotoken.net/api-keys 确认 Key 状态,再看接入文档 https://taotoken.net/doc 对照配置。文档里有完整的config.yaml示例和回调写法,比对着改能省不少时间。
如果你要验证某个模型是否可用,或者想先跑一条请求确认通道没问题,用模型对话页面 https://taotoken.net/models ,直接发一条消息看返回,不用先配 LiteLLM。这一步能快速排除上游问题。
如果你是长期编码或 Agent 场景,调用量大、需要预算控制,看 Coding Plan 页面 https://taotoken.net/coding-plan ,里面有按量或包月的方案说明。归因链路和 Coding Plan 不冲突,LiteLLM 这一层的归因照做,上游走哪个通道不影响SpendLogs的落库。
最后沉淀三条团队规范,供参考。Key 谁用谁建,管理员代建的必须把user_id改成实际使用者,key_alias该写还得写但别拿它对账。删 Key 前先导出该 Key 的消费明细和前缀存档,避免孤儿记录。对账和日报一律以SpendLogs直查为准,不依赖 UI 筛选,时间字段是"startTime",大写 S 带双引号,小写会报列不存在。
把这几件事做到位,每笔 API 调用都能查到谁花的钱。归因链路本身不复杂,复杂的是那些机制细节——key_alias不参与计费、token 是 hash 存的、删 Key 是硬删除、代理后面的 IP 不可信、UI 日期筛选有 bug。这些坑踩过一遍,账本就读明白了。