ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

litellm统一AI网关实践:多模型接入、路由调度与成本追踪

litellm统一AI网关实践:多模型接入、路由调度与成本追踪

前阵子做一个多模型接入的小项目,被各家大模型API的格式差异折腾得够呛。厂商A的请求体长这样,厂商B的请求体又换了一套字段,好不容易把两家的都跑通,产品经理又说要接第三家,而且第三家的错误码结构跟前两家完全不一样。那段时间我一半的精力都花在写适配层上,真正该做的业务反而没什么进展。后来我接触到litellm这个开源项目,它把这件事彻底简化了:所有模型服务商统一成同一种调用格式,我只需要维护一套代码,想换哪家换哪家。这篇文章就把我实际使用litellm的完整经验整理一遍,从安装、配置、路由、成本追踪到各种坑,尽量讲清楚每个选择背后的原因,适合正在做多模型接入、或者想把模型调度能力收拢到一个统一层里的开发者参考。

1. 一个BaseURL接所有模型,litellm到底在解决什么

1.1 从一次三方接入事故说起

事情是这样的。我当时要在一个应用里同时接三家模型服务商,一家做文本生成,一家做向量化,还有一家做长上下文理解。每个厂商都有自己的SDK,请求参数、认证方式、返回字段、错误码全都不一样。

我最初的做法是每个厂商封装一个模块,然后在上层做工厂方法。看起来挺干净,但实际情况是:厂商A的响应里token用量字段叫usage.total_tokens,厂商B叫usage.completion_tokens加prompt_tokens,厂商C则是把计量信息放在meta里。我做一层适配还行,做到第三层的时候已经想骂人了。

这还不算完。某天厂商B悄悄更新了某个字段的格式,我们线上服务直接解析报错;厂商A的限流策略改了,之前设置的retry逻辑全部失效。我意识到,靠业务代码去追每个上游的变化,这条路是走不远的。

1.2 litellm做的事:把SDK差异吞掉的一层网关

litellm的定位就是一个AI网关,或者叫统一接入层。它把你对各个模型服务商的所有调用都收口到一个入口,你的业务代码不直接对接任何一家厂商,而是对接litellm。

它在内部做了几个关键的事:

  • 请求格式统一:不管上游需要什么样的消息结构、系统提示词格式、采样参数命名,litellm都帮你转成目标服务商要求的形态。
  • 响应格式统一:所有返回结果都整理成同一套结构,包括内容、角色、token用量、结束原因等。
  • 错误码统一:不同厂商的限流、鉴权失败、模型不存在等错误,litellm会翻译成统一的异常类型,业务侧只需要处理一套异常。
  • 模型名映射:你可以给每个模型起一个内部的逻辑名,比如fast-llm、big-llm,实际请求转发到谁,由网关配置决定。

换句话说,litellm对我来说就是一个模型流量的交换机。应用不需要知道下一刻流量被转给谁,只要把请求交给交换机就行。后续要加新模型,改配置、加一个上游条目就完事,业务代码一行不动。

1.3 和编排框架的区别:它更靠近传输层

很多人会把litellm和那些AI编排框架混淆。我个人的理解是,编排框架解决的是“如何把模型调用组合成一条链”,比如先做意图识别、再调检索、最后生成回答;而litellm解决的是“如何把一次模型调用稳定地发出去、发出去之后账怎么算、出错了怎么兜底”。

编排框架可以集成litellm,litellm也可以单独使用。我在项目里的做法是:业务层直接用litellm发请求,不引入太重的东西,等确实需要复杂编排再考虑上层框架。先把通路和账本管好,比什么都实在。

2. 十分钟把litellm跑起来:安装、最小配置和第一次转发

2.1 安装与环境准备

litellm是Python写的,安装非常简单:

pip install litellm

建议Python版本在3.8以上,我用的环境是3.10,运行很稳定。安装完之后,你用python -c "import litellm; print(litellm.__version__)"确认一下导入没问题。如果是在公司内网环境,可能需要配置内部pip源,这个取决于各自的基础设施。

我装好之后习惯先把默认的debug日志开起来,方便观察请求从进入到返回的完整链路:

export LITELLM_LOG=DEBUG

第一次跑建议开着DEBUG,能清楚看到litellm把请求重写成了什么样子、实际发给了哪个上游地址,这对理解它的工作方式非常有帮助。

2.2 最小配置文件:model_list是核心

使用litellm的网关模式,核心是一个YAML配置文件。我习惯先建一个config.yaml,内容长这样:

model_list: - model_name: fast-llm litellm_params: model: vendor_a/gpt-4o-mini api_key: os.environ/A_API_KEY api_base: https://api.vendor-a.example.com/v1 - model_name: big-llm litellm_params: model: vendor_b/claude-3.5-sonnet api_key: os.environ/B_API_KEY api_base: https://api.vendor-b.example.com/v1

这里有几个关键点:

  • model_name是你自己定义的名字,业务侧只认这个名字。
  • litellm_params里的model字段,前半部分是提供商标识,后半部分是实际模型名。
  • api_key可以用os.environ/环境变量名这种写法,litellm启动时会自动去环境变量里取,避免密钥写死在配置文件里。
  • api_base显式指定上游地址,防止它自动推断到你不想访问的默认地址。

为什么强调model_name要自定义?因为这样做业务代码和上游解耦。今天fast-llm指向厂商A的模型,明天想换成厂商B的,改配置就行,业务完全无感。我后面的多模型路由,也是基于这一层别名的。

2.3 启动网关,三种方式调用

配置文件准备好后,一条命令启动:

litellm --config config.yaml --port 8000

看到Uvicorn running on的日志就说明网关起来了。默认端口是8000,如果被占用,改个端口即可。

启动之后我一般会先验证三条路:

第一条:curl直连,最直观的探活方式

curl http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "fast-llm", "messages": [{"role": "user", "content": "你好,做个自我介绍"}] }'

只要返回里带choices[0].message.content,就说明网关转发链路完全打通。这一步的核心价值是:先证明通路没问题,之后再加参数、加策略,排错范围就小很多。

第二条:从Python代码里直接调用

from litellm import completion response = completion( model="fast-llm", messages=[{"role": "user", "content": "你好,做个自我介绍"}], api_base="http://localhost:8000", ) print(response.choices[0].message.content)

注意这里我传了api_base="http://localhost:8000",意思是不走litellm的直连模式,而是把请求发给自己起的这个本地网关。这种做法适合在业务服务里使用,业务服务只依赖一个固定的网关地址。

第三条:用官方SDK兼容格式接进来

这是最有价值的一点。litellm启动后暴露的HTTP接口风格,和不少团队已经用过的那个主流接口格式高度一致。也就是说,你把原来的base_url从真实厂商地址改成http://localhost:8000,其他代码一行都不用动。这对存量项目的迁移极其友好,不需要动业务代码,只需要改环境变量里的地址。

2.4 为什么端口和入口设计得这么“常规”

一些人可能觉得litellm的接口长得太“普通”了。恰恰是这个“普通”让它好用。它在设计上没有发明新协议,而是选择兼容大家已经写烂了的客户端代码,让迁移成本降到最低。很多人在生产环境里纠结“要不要换网关”,本质上纠结的是“我现有代码要不要大改”,litellm把这个问题的答案变成了“不用”。

我实测下来,从直连上游改成走litellm,一个核心服务只改了配置文件里的base_url,剩下的测试全部通过。这份兼容性带来的迁移红利,比任何炫酷的新功能都值钱。

3. 把多个模型变成一个池子:Router的路由、负载均衡与故障转移

3.1 为什么需要Router:单点接入不够,要的是调度能力

如果litellm只是把请求转发给一个固定的模型,那叫“统一网关”,但称不上“调度”。生产环境真正需要的是:同一个模型配置多个上游Key,甚至配置多家厂商的同级别模型,当其中一个不可用、限流或者变慢时,系统能自动把流量导到另一个。

litellm的Router组件就是干这个的。它负责在三层做事情:

  • 同模型多Key负载均衡:把请求均匀或按权重分发到配置的多个上游Key上,降低单Key被限流的概率。
  • 不同厂商的故障转移:上游A挂了,能自动切到上游B。
  • 请求级别的重试与冷却:某个上游连续失败,会暂时把它踢出调度池,等冷却期过了再放回来。

3.2 model_list的配置手法:同一个别名挂多个真实目标

Router的配置方式和之前的config.yaml基本一致,区别在于同一个model_name可以对应多个litellm_params条目:

model_list: - model_name: fast-llm litellm_params: model: vendor_a/gpt-4o-mini api_key: os.environ/A_API_KEY_1 api_base: https://api.vendor-a.example.com/v1 - model_name: fast-llm litellm_params: model: vendor_a/gpt-4o-mini api_key: os.environ/A_API_KEY_2 api_base: https://api.vendor-a.example.com/v1 - model_name: fast-llm litellm_params: model: vendor_b/claude-3-5-haiku api_key: os.environ/B_API_KEY_1 api_base: https://api.vendor-b.example.com/v1

配置里三个条目共用一个别名fast-llm,而实际上游分布在两个厂商。从业务侧看来,它仍然只知道“我调用fast-llm”,但流量实际被Router按策略分发。

在代码里这样用:

from litellm import Router router = Router(model_list=[ # 这里放和上面yaml等价的模型列表 ]) response = router.completion( model="fast-llm", messages=[{"role": "user", "content": "你好"}], ) print(response.choices[0].message.content)

Router内部会自动记录每个上游的成功率、延迟、限流状态,然后决定下一次请求发给谁。你不需要自己维护“可用清单”,它替你做。

3.3 权重、冷却时间和Fallback的配合

Router的调度不是随机抢单,它支持几套可调参数。我常用的几个:

参数作用我的建议
routing_strategy选路由策略,如simple-shuffle、least-busy、usage-based-routing一般用默认;多Key场景可以考虑least-busy
cooldown_time上游失败后冷却多少秒我习惯设60秒,太短会导致病节点反复被请求
max_fallbacks单个请求最多尝试多少个备用上游设2就够了,尝试太多会放大整体延迟
allowed_fails连续失败多少次才触发冷却默认3次比较合适

举个例子,cooldown_time: 60的意思是:某个上游连续失败达到阈值后,Router会在60秒内不再把请求发给它,而不是每次都顺手试一下。这个机制很像电路里的熔断器,防止病节点把整个池子的成功率拖垮。

Fallback则是另一个层面。比如配置了两个厂商,vendor_a片全部不可用,Router会直接把请求转给vendor_b,业务侧只感觉到响应慢了一点点,不会收到“上游挂了”的报错。对于追求稳定的线上服务,这个是保命功能。

3.4 实测体验:一次人为故障演练

我曾经做过一次测试:把厂商A的两个Key全部停掉,然后持续向Router发请求,观察行为。

第一波请求回来时,Router返回了厂商A的限流错误。设置好allowed_fails和cooldown_time之后,几秒内Router识别出该厂商的所有目标都不可达,开始把流量全部切到厂商B。切换过程中我没有看到业务侧出现明显的5xx比例上升,只有个别请求延迟从几百毫秒涨到一两秒。

那次测试给我的启发是:真正决定故障恢复速度的,是冷却时间和失败阈值的配合。阈值设得太小,偶发抖动就会触发切换,反而影响稳定性;设得太大,故障期间用户会一直打到坏节点上。这个参数没有绝对标准,建议按线上真实流量逐步调整。

4. 生产环境要有一本账:虚拟Key、配额和成本追踪

4.1 虚拟Key体系:把上游Key藏起来,给团队发“分Key”

直接让每个开发人员拿着厂商的上游Key开发,是件危险的事。Key的权限边界很难控制,离职人员带走的Key也可能没有及时回收。litellm提供了虚拟Key的机制,它相当于在网关层再签发一拨“分Key”,这些Key只对litellm生效,不直接对应上游任何账号。

你可以在启动网关后调用管理接口生成一个虚拟Key:

curl -X POST "http://localhost:8000/key/generate" \ -H "Authorization: Bearer sk-1234" \ -H "Content-Type: application/json" \ -d '{ "max_budget": 20, "max_parallel_requests": 10, "models": ["fast-llm", "big-llm"] }'

这里sk-1234是管理员的master key,在启动网关时配好的。返回的JSON里会有一个新的key字段,把这个key分发给团队里的人使用。每个人用自己的虚拟Key访问网关,上游的真实Key始终握在管理员手里。

4.2 预算与配额:超了不让用,而不是事后吓人

虚拟Key支持max_budget和max_parallel_requests这样的限制。max_budget是总预算上限,以美元计,超过之后这个Key会被自动停用;max_parallel_requests则是并发上限,防止某个项目把整个网关流量占满。

我通常这样分配:

  • 测试环境的Key,预算给5到10美元,够用又不至于失控。
  • 线上某个业务线的Key,按预估月消耗设上限,并且配上并发限制。
  • 给外部合作方开Key时,只开放特定模型,限制预算,避免被拿去做无关调用。

这套做法的好处是,控制成本的动作前置到了网关层,而不是等月账单出来之后再去追查是哪个项目超了。实际效果非常明显,我们接入多模型之后,再也没出现过“某个月某项目突然跑了几百美元”的惊吓。

4.3 成本与Token追踪:一次请求花了多少钱,明明白白

除了预算,还要知道钱具体花在哪。litellm支持在请求完成之后触发回调,把用量和费用信息吐给你自己的系统。我习惯在服务启动时挂一个回调函数:

import litellm def track_cost(kwargs): response = kwargs.get("response") if not response: return usage = response.get("usage", {}) cost = kwargs.get("response_cost", 0) model = kwargs.get("model", "") print(f"model={model}, tokens={usage}, cost=${cost:.4f}") litellm.success_callback = [track_cost]

挂上这个回调之后,每完成一次请求,我都能看到模型名、token消耗、估算成本。长期累积下来,我就有了一个按模型、按应用维度的成本明细。这个数据对后续做容量规划、模型选型极其有用——比如把某类任务从大模型切换到小模型到底能省多少钱,不用拍脑袋,看几天的调用统计就明白了。

4.4 账本之外:日志和可观测性的延伸

litellm本身也支持把日志输出到各种可观测性平台,但在内网环境里,我更倾向于用回调自建一套轻量记录,把关键字段落到日志系统或数据库里。

我做成本追踪时的经验是:不要只采集成功请求,失败请求也要留记录。很多成本异常来自重试——一次请求失败,上层自动重试三次,账单上就记了四次调用。如果只看成功率,永远发现不了这个问题。把失败和重试都计入成本统计,才能看到真实消耗。

5. 实测踩坑记录:几个让我加班到深夜的问题

5.1 坑一:环境变量写法不对,Key一直读不到

litellm在配置里支持os.environ/A_API_KEY这种语法,它启动时会从环境变量里读取。但我第一次写配置时,把api_key的值直接写成了os.environ/A_API_KEY,并且用双引号包住。结果litellm没去解析这个特殊语法,而是把它当成一个普通的字符串Key发出去了,上游直接返回401鉴权失败。

排查过程我印象很深:先检查了环境变量本身,确认在shell里能打印出来;又检查了配置文件路径,确认启动时加载的是同一个文件;最后对比了官方文档里的示例,才发现写法上少了“裸写为YAML的标量”这个前提。正确的姿势是不加引号:

litellm_params: model: vendor_a/gpt-4o-mini api_key: os.environ/A_API_KEY

这个坑的本质是YAML里引号改变了类型解析。之后我的规则是:配置文件里的特殊语法一律不戴引号,普通字符串才用引号。

5.2 坑二:流式输出时响应迟迟不结束

某个功能需要流式返回,我在调用里加了stream=True,奇怪的是,网关这边日志显示已经向上游拿全了数据,但应用侧就是迟迟拿不到完整内容,或者打印到一半就断了。

后来抓包看,litellm转发流式响应时用的是SSE格式,也就是一段一段以data:前缀开头的数据流。我的应用代码用普通JSON解析方式去读,读到一半就报错,或者因为HTTP连接没有正常关闭而一直等。

解决方式是在应用侧实现标准的SSE读取逻辑:

import requests resp = requests.post( "http://localhost:8000/v1/chat/completions", json={ "model": "fast-llm", "messages": [{"role": "user", "content": "讲个故事"}], "stream": True, }, stream=True, ) for line in resp.iter_lines(): if not line: continue text = line.decode("utf-8").strip() if not text.startswith("data:"): continue data = text[5:].strip() if data == "[DONE]": break # 解析JSON,逐段处理内容

实际做流式接入,建议直接用支持SSE的HTTP客户端库,或者自己封装上面这套解析。遇到流式响应先不要怀疑litellm,先确认你的解析逻辑是按SSE规范写的。

5.3 坑三:model_name和上游模型名混在一起用

我一开始配置Router时,图省事,model_name直接写成上游的真实模型名,比如(fast-llm别名为vendor_a/gpt-4o-mini,然后我在业务代码里把model参数传成了vendor_a/gpt-4o-mini,甚至加了api_base,绕过别名直接用上游参数。

这种写法在单模型场景下跑得通,但治不好多模型调度。model_name和litellm_params.model是两回事:前者是给业务看的别名,后者是给上游看的真实值。业务代码应该只认前者。

打比方说,别名是“客服组电话”,真实模型是“具体某个客服工号”。业务部门只需要拨客服组电话,由总机决定今天谁接;如果业务直接拨工号,那总机的调度、备份、负载均衡全都白配了。

5.4 坑四:默认超时在某些场景下不够用

litellm默认的请求超时设置,对于大多数场景是够的,但遇到长上下文模型或者高峰期排队,就会出现read timeout。而且超时并不是“只影响那一个请求”那么简单——客户端超时后可能重试,重试又挤占了上游的排队资源,形成恶性循环。

我给慢模型单独设了更宽松的超时参数:

litellm_params: model: vendor_b/long-context-model api_key: os.environ/B_API_KEY timeout: 600 request_timeout: 600

同时提醒一点:不同的litellm版本里,超时参数名可能有微小差异,我用的这个版本是认timeout和request_timeout,建议升级版本之后顺手看一眼更新文档。超时设得太小省不了成本,反而制造重复请求;设得太大,又会占用更多连接资源,需要按模型特性分别对待。

5.5 坑五:网关模式和直连模式混用,回调不生效

有段时间我在同一个服务里既用litellm的网关地址调模型,又直接用litellm的Python包直连上游。我以为success_callback能同时覆盖这两种调用,结果只有直连模式触发了回调,走网关的请求完全没有成本统计。

原因是:走网关时,真正执行请求的是那个独立跑着的网关进程,它跟你当前这个Python进程是完全隔离的,你在业务进程里挂的success_callback当然管不到它。网关自己只按它加载的配置工作,而成本统计回调需要写在网关进程那边。

这个认知很重要。用网关模式就是放权给网关进程,所有策略配置、日志回调都要在网关的配置文件里声明,不能指望业务进程的局部变量对它生效。理顺这一点,很多“为什么我的回调没执行”的疑问都会消失。

我在实际使用中慢慢形成的固定做法是:网关层管统一入口、路由和账户体系,业务层只管传参数、收结果。所有跨模型的策略尽量下沉到网关层,业务代码保持简单。这样既能让不同团队共用一套模型出口,也能把成本、权限、稳定性这些横切关注点集中在一处治理。如果你也在为多模型接入头疼,不妨先起一个本地网关,把现有的一个模型服务切过去试一两天,亲身感受一下“业务代码完全不用改”的迁移过程,再决定要不要全面铺开。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进