1. 为什么是TGI:被线上故障逼出来的选型
先说个背景。今年年初我们团队接了一个内部AI助手项目,要在一个13B的开源对话模型上做推理服务,QPS要求不高,但必须稳定、能水平扩展,还得兼容多轮对话的上下文处理。第一版图省事,直接用FastAPI套Transformer写了个异步推理服务,模型用FP16加载到单卡A100上。功能倒是很快跑通了,但压测一上来就暴露问题:并发请求超过8个,GPU利用率不到40%,响应时间却开始剧烈抖动,有的请求甚至要等前面一个长序列生成完才轮到计算,用户体验非常差。
那段时间我们试过各种“民间优化”,比如动态batch、显存池化、请求队列改优先级,代码越写越复杂,效果却始终不理想。后来才意识到:LLM推理这种场景,模型调用只是小事,真正的难点在于如何管理KV Cache、如何做连续批处理、如何在多并发下把GPU算力吃满。自己从零造轮子根本不划算,不如直接用社区成熟的推理引擎。
TGI(Text Generation Inference)就是当时进入我们视野的候选之一。它是一个专门为大模型文本生成场景设计的推理运行时,不是简单的模型容器,而是直接内置了连续批处理、分页注意力(Paged Attention)、Flash Attention、量化加载、张量并行、流式输出这些推理侧最关键的机制。也就是说,它把“怎么让GPU跑得更快、让高并发更稳”这件事,提前帮你做完了。
当时我们还对比了另一个流行的方案vLLM。vLLM的性能表现也很强,但TGI有几个点更贴合我们的情况:一是它原生提供HTTP和gRPC接口,不需要自己包一层服务;二是支持Token流式输出,对前端打字机效果很友好;三是它在多模型切换、量化兼容性上做得比较全,尤其对GPTQ和AWQ这类量化格式支持稳定。当然,TGI也不是没有缺点,比如调度策略偏保守,极端高吞吐场景不如某些自研方案激进,但对大多数企业级落地来说,它足够成熟了。
一句话总结我们的选型逻辑:如果你需要一个开箱即用、能直接对接生产流量的LLM推理服务,TGI是当前成本最低的路线之一。这篇指南就是基于我们这半年多踩坑、调优和实践沉淀下来的经验,从Docker部署讲到GPU优化,再到企业级稳定性设计,希望对做同类项目的朋友有帮助。
2. Docker部署前的准备:镜像、依赖和驱动检查
2.1 硬件与驱动基线
TGI是CUDA深度绑定的,所以在部署之前,先把底层的GPU环境理清楚。
- GPU型号:TGI的官方镜像基本支持Turing架构及以上的NVIDIA显卡,消费级卡(如RTX 3090/4090)和企业级卡(如A100、H100、L40S)都能跑,区别主要在显存和算力上限。
- 驱动版本:宿主机NVIDIA驱动建议不低于525系列,因为要用到较新的CUDA runtime;容器内不需要安装驱动,只需确保nvidia-smi能在宿主机上正常工作。
- CUDA版本:TGI官方镜像自带CUDA运行时,你不用手动装CUDA,只要宿主机的GPU驱动能匹配镜像内的CUDA版本即可。建议用较新的镜像tag,老镜像里的CUDA组合可能存在兼容性问题。
2.2 镜像选择原则
TGI的镜像主要在ghcr.io仓库下发布,tag非常细。一开始我们图省事直接拉latest,结果有次更新把默认模型加载逻辑变了,导致推理结果异常,排查了好一阵。后来我们确定了一个原则:生产环境必须锁定精确版本的tag,比如ghcr.io/某镜像仓库/text-generation-inference:2.1.0这种具体版本号,绝不追latest。确认镜像版本对应的依赖和配置项变更后,再决定是否升级。
对于国内网络环境,拉取这些海外镜像仓库可能较慢,建议提前配好镜像加速器,或者在有条件的机器上先拉镜像再导出导入。我们当时统一走的是构建机的镜像中转,避免每台GPU服务器都直接到外网拉。
2.3 Docker运行的基础参数
下面是我们在生产环境中使用的Docker启动命令,参数都标注了用途:
docker run --name tgi-server \ --runtime=nvidia \ --gpus all \ --shm-size 16g \ -p 8080:80 \ -v /data/models:/data/models \ -e MODEL_ID=/data/models/xxx-13b-chat \ -e NUM_SHARD=1 \ -e MAX_BATCH_TOTAL_TOKENS=8192 \ -e MAX_INPUT_LENGTH=4096 \ -e MAX_TOTAL_TOKENS=8192 \ ghcr.io/某镜像仓库/text-generation-inference:2.1.0几个容易踩坑的参数:
--shm-size:默认Docker共享内存只有64MB,TGI的数据加载和多进程通信很容易撞上这个限制,表现为启动时卡死、Tensor Parallel的进程间通信报错。建议直接设到16g,不用担心浪费,大模型推理服务本身就需要不少共享内存。NUM_SHARD:单卡部署时设为1即可;如果是多卡张量并行,需要设为GPU卡数。我们一开始在4卡机器上忘了设置这个参数,结果TGI只用了单卡,服务倒是正常,性能差到没法看。- 端口映射:容器内默认是80端口,宿主机可以映射到任意端口。这里建议统一规划端口段,比如8080给TGI、9090给监控、8888给管理端口,方便后续接网关。
2.4 网络和服务发现
TGI本身无状态,多个实例之间不需要互相通信,这给部署带来很大便利。你只需要在网关层做负载均衡,将请求分摊到多个TGI实例即可。我们在Kubernetes里就是用一个普通Deployment加Service管理,不需要StatefulSet,因为TGI不存储任何持久化状态,模型文件挂载的是共享存储。
这里有一个建议:TGI实例不要和模型下载逻辑放在同一个Pod里。模型下载是重IO操作,推理是重算力操作,两者混跑会导致启动阶段CPU打满,影响已有流量。我们最终的方案是单独用一个初始化任务把模型文件提前拉取到共享存储上,TGI实例只负责加载本地路径。
3. 模型加载与容器内路径规划
3.1 本地模型目录的组织方式
许多初学者使用TGI时直接传模型在Hugging Face上的仓库ID,让TGI自动下载。这在开发环境没问题,但生产环境会很被动:一是模型文件动辄几十GB,每次冷启动重新下载都在挑战网络带宽;二是如果模型更新或分支调整,你很难控制线上服务加载的是哪个版本。
我们的做法是建立一套“模型版本目录”规范:
/data/models/ ├── chat-13b-v1/ │ ├── config.json │ ├── model.safetensors.index.json │ ├── model-00001-of-00003.safetensors │ ├── ... │ └── tokenizer.json ├── chat-13b-v2/ │ └── ... └── embedding-model-v1/ └── ...每个模型目录就是一个独立的版本,更新时直接切换MODEL_ID环境变量,秒级生效,不需要重新构建镜像。这样既方便灰度回滚,也方便多模型共存。
3.2 safetensors格式是硬要求
TGI加载模型时强烈依赖safetensors格式的权重文件。我们第一次部署时图方便,直接用了PyTorch原生的.bin权重,结果TGI启动就开始做格式转换,耗时很长,而且偶发内存溢出。后来我们统一用官方转换脚本把权重转成safetensors格式,启动速度提升了好几个量级,内存占用也稳定很多。
如果你的模型权重目前是.bin格式,可以这样转换:
import torch from safetensors.torch import save_file # 以PyTorch权重为例 checkpoint = torch.load("pytorch_model.bin", map_location="cpu") save_file(checkpoint, "model.safetensors")注意转换前确认模型词典、配置和分词器都在同一目录下,并把config.json里的model_type、max_position_embeddings等字段核对一遍,否则TGI加载时可能报参数不匹配。
3.3 分词器和配置的一致性
还有一个非常容易被忽略的问题:模型的config.json里的eos_token_id、bos_token_id,以及分词器的pad_token,必须和模型实际的生成逻辑匹配。例如,有些对话模型在微调时自定义了特殊的“结束符”token,如果config里没有正确标识,TGI在生成时无法自动停止,会一直输出到max_total_tokens上限。
我们在一个内部模型上就踩过这个坑:模型生成的结尾始终多一段重复文本,排查后发现是微调时用了自定义的结束token,但导出模型时config里没同步更新。建议加载模型后先用一段固定输入测一次生成,确认结束行为正常,再接入网关流量。
4. 关键环境变量背后的优化逻辑
TGI很多性能调优不是靠改代码,而是通过环境变量控制的。这些参数直接影响显存分配、批处理策略和生成上限,理解它们才算真正掌握TGI。
4.1 MAX_BATCH_TOTAL_TOKENS:控制“积压”的总水位
这个参数可能是TGI里最需要花心思调的一个。它表示TGI在单个batch中最多允许处理的token总数(包括输入和输出)。它不是“同时请求数”,而是所有并发请求的token累加值。
举一个例子:如果业务场景是短对话为主,平均每个请求输入300 token、输出200 token,那么一个请求大约占500 token。当MAX_BATCH_TOTAL_TOKENS设置为8192时,理论上可以支持大约16个请求同时参与batch。但如果某个请求是个4500 token的长文档摘要任务,一个请求就吃掉一半预算,此时并发能力会明显下降。
调参策略上,我们根据业务场景做了区分:
- 客服问答场景(输入短、输出短):
MAX_BATCH_TOTAL_TOKENS可以调高,用足显存余量。 - 长文档摘要场景(输入长、输出长):该参数需要谨慎设置,避免单个请求拖垮其他请求。
- 混合场景:建议通过网关把长短请求分流到不同TGI实例,不要让它们争抢同一个batching池。
实际调参时,可以一边压测一边观察nvidia-smi里的显存占用和GPU利用率。如果显存还有剩余但GPU利用率上不去,往往就是batch预算太小;如果服务直接OOM,就该降低该值。
4.2 MAX_INPUT_LENGTH与MAX_TOTAL_TOKENS
这两个参数定义了模型单次请求的上限。
MAX_INPUT_LENGTH:限制输入文本长度,防止用户传入超长文本击穿显存预分配。MAX_TOTAL_TOKENS:输入加上输出的总长度上限,用于控制生成长度的天花板。
生产环境一定要显式设置它们,别用默认的“几乎无限”值。默认值是基于大显存服务器设计的,如果部署在80GB显存以下的机器上,一旦有用户传入几万字文本,轻则响应极慢,重则直接把显存耗尽,影响同实例的所有请求。
我们线上服务设置的是输入4096、总长8192,也就是单轮最多生成4096个token。这个配置对大多数内部问答足够用了,同时也能有效防止滥用。如果业务确实需要超长上下文,比如代码库分析、论文翻译,建议单独部署一个长上下文实例,用更大显存承载,而不是在通用实例里放开限制。
4.3 NUM_SHARD与张量并行的取舍
NUM_SHARD控制TGI使用多少张卡对模型做张量并行切分。对于13B模型,单张A100 80GB完全可以放下,所以NUM_SHARD=1是合理的;对于70B甚至更大模型,就需要设置NUM_SHARD=4或8。
这里有一个很多人没意识到的问题:张量并行会引入卡间通信开销,并不是“卡越多越快”。我们在一个70B模型场景下对比过,4卡张量并行和8卡张量并行,同样QPS下延迟差距并不大,但8卡版本的功耗和故障面高出不少。所以选型时优先考虑“单卡能放下就单卡跑”,只有单卡显存确实不够时才做张量并行。
另外,多卡张量并行时,两个重要的硬件配置不能省:
- 卡间通信要走NVLink或者InfiniBand,不要走PCIe。PCIe的带宽在张量并行下会变成瓶颈。
- 共享内存必须足够大,建议按卡数乘以4GB起步。否则启动时TP通信初始化会失败或报错。
4.4 QUANTIZE参数:显存不够时的第一选择
TGI支持多种量化加载方式,包括bitsandbytes、gptq、awq等。量化可以显著降低显存占用,让模型能在消费级显卡上跑起来,但代价是生成质量可能下降,以及部分算子在量化后速度反而变慢。
我们在实际部署中的取舍逻辑是:
- 如果显存刚好比模型权重多20%-30%,优先用
awq或gptq,这类量化是预先训练好的,推理时延影响相对可控。 - 如果是临时实验或者显存严重不足,才用
bitsandbytes动态量化。它的优点是模型还是原始的,不需要预先量化,但推理速度通常不如固定量化。 - 量化不是银弹。对于70B级别的大模型,4-bit量化后显存确实能降到40GB左右,但延迟可能比FP16还差。项目上线前一定要用真实业务数据做对比测试,别只盯着显存数字。
5. 压测调优:从能用到好用
5.1 我们的压测工具和指标口径
没有压测数据,所有优化都是感觉。我们自己搭了一套压测脚本,基于Locust工具改造,请求模式完全模拟线上:混合了短对话、多轮续写和少量长文档输入。
关键指标我建议盯这几个,而不是只盯QPS:
- TTFT(Time To First Token):用户感知“是否卡了”的第一直观指标。
- TPOT(Time Per Output Token):生成过程中每个token的间隔时间,影响打字机流畅度。
- 端到端延迟:完整请求从发起到收尾的时间。
- 错误率:包括超时、拒绝连接、HTTP 5xx等。
压测中我们发现一个典型现象:QPS看似不高,但TTFT已经飙升到15秒以上。这往往不是算力不够,而是batch调度策略把太多新请求放进了同一个batch,导致新请求要等batch里老的生成任务跑完一部分才能排上。TGI的调度器会做权衡,不适合的空间预算会暂时挂起请求。此时需要调整MAX_BATCH_TOTAL_TOKENS的上限,或者降低并发数,让调度器更“激进”地抢占。
5.2 高并发下的超时与排队策略
TGI底层使用的是异步任务队列,默认队列容量不算大。高峰时段一旦请求速率超过处理能力,多余请求会在队列里等待,最终导致网关层超时。这个问题我们从两个方向解决:
一是控制入口并发。我们在网关层给TGI实例设置了最大并发数,超过的请求直接返回429(Too Many Requests)而不是无限排队。这个改动看起来简单,但对用户体验的提升很显著:与其让用户等10秒再失败,不如立刻告知“系统繁忙”。用户能感知到服务是有明确边界的。
二是优化队列参数。TGI内部可以通过环境变量调整排队逻辑,但更实际的做法是在网关层添加限流规则,按Token量做前置预估:估计每个请求的输入token数,超过阈值直接拒绝或转人工,避免长文本请求挤占全部batch预算。
5.3 预热:刚启动的服务为什么慢
TGI启动后第一次请求通常会比后续慢很多,原因是CUDA kernel需要懒编译,部分显存分配也是首次请求时才触发。我们的解决方案很简单:每次服务启动后,自动发送一组预热请求,覆盖典型的输入输出长度,等预热完成后再把流量切进来。在Kubernetes的探针里,我们就把“预热完成”作为就绪条件的一部分。
预热还有一个额外好处:它能提前暴露加载错误。比如权重文件缺失、配置不匹配、量化格式不对,这些问题如果等到真用户流量触发,就变成线上事故了。
5.4 CPU绑核与GPU推理实例分离
我们在优化后期发现,GPU利用率已经很高,但整体延迟还是有点飘。排查后发现宿主机上还有其他容器在抢占CPU资源,导致GPU的kernel launch、数据传输环节出现CPU抖动的连锁反应。
针对这个问题,我们做了两件事:
- 用
taskset或Kubernetes的CPU管理策略,将TGI容器绑定到固定的CPU核心,避免CPU调度切换。 - 将推理实例和模型下载、日志采集、监控采集等辅助进程完全分离到不同的节点或实例上。推理节点保持干净,只跑TGI。
这个优化对于小模型、高并发场景特别明显。GPU算模型的时间很短,反而CPU上的数据搬运和调度成为瓶颈,绑核能减少几个毫秒的抖动,对TTFT影响很大。
6. GPU优化的实际收益:一次升级带来的量化对比
为了直观看到GPU优化的价值,我整理了一次对比实验的数据。场景是同一个13B对话模型,输入平均512 token,输出平均256 token,并发请求量恒定20,压力持续10分钟。四个配置的差异如下:
| 配置项 | 基线(默认参数) | 优化后(调显存/批处理) | 再优化(绑核+预热) | 进一步(量化AWQ) |
|---|---|---|---|---|
| 单卡显存占用 | 约28GB | 约32GB | 约32GB | 约18GB |
| 平均TTFT | 890ms | 620ms | 540ms | 610ms |
| 平均TOT | 约42 token/s | 约55 token/s | 约62 token/s | 约58 token/s |
| GPU利用率 | 约65% | 约86% | 约92% | 约88% |
| 错误率 | 1.2% | 0.4% | 0.1% | 0.2% |
从数据能看到两个结论:
- 第一,显存和批处理参数的调整是性价比最高的,直接提升了GPU利用率和吞吐,几乎没有副作用。
- 第二,量化主要解决显存不够的问题,在显存充裕时性能不一定比FP16更好。AWQ量化后虽然显存大幅下降,但单Token生成速度略有回退,需要结合具体业务权衡。
我们还测试过混合部署:70B模型4-bit量化后放进2张A100上跑,对比8张A100跑FP16版本,最终在120并发下,量化版的吞吐只有FP16版的一半左右。这说明一个很现实的问题:当模型规模上到70B,量化省下的显存并不能直接弥补极端并发下的算力缺口。如果业务就是重流量场景,预算允许时优先上足够多的GPU;显存紧张且并发不高时,量化才值得考虑。
7. 从单实例到企业级:稳定性与可观测性
除了性能优化,TGI要真正落地到生产环境,稳定性设计和观测体系不能省。
7.1 多实例与流量治理
单实例TGI性能再强,也存在单点故障风险。我们在生产环境至少保持两个TGI实例,后面挂一层负载均衡。模型文件放在共享存储上,新的TGI实例启动后可以直接从共享存储加载,不需要额外下载。
升级时我们采用滚动更新策略:先启动新版本实例,预热完成后把流量切过去一部分,观察指标稳定后再全部切换。如果出现问题,直接切回旧实例即可。这个过程的体验很像普通Web服务升级,前提是你的网关层支持实例级别的灰度权重切换。
7.2 监控指标与告警阈值
TGI自身暴露了一些Prometheus格式的指标,比如请求数、批处理大小、显存使用等。我们额外设置了自定义指标采集,重点监控:
- 推理请求延迟(按分位数统计)。
- GPU的利用率、显存剩余、温度、功耗。
- 队列积压数量:如果积压持续增长,说明服务容量不足。
- token生成速率:这个指标能反映实际业务吞吐量。
- 输入token长度分布:如果业务发生变化,比如用户开始大量输入长文本,需要及时调整实例规格。
告警阈值没有统一标准,我建议先定基线:观察正常运行一周的数据,取P95的1.5倍左右作为告警阈值,然后边运行边调整,避免噪声刷屏。
7.3 几个必须提前规划的问题
- 模型更新不是换文件那么简单。新模型如果词表变了、能力边界变了,下游调用方可能要跟着调整参数。建议模型更新和业务发版绑定,而不是后台悄悄换。
- TGI不同版本的API有细微差异,特别是某些参数的命名和默认行为。如果你有多套环境,尽量保持版本一致,避免在测试环境用新版本、生产环境用旧版本。
- 流式输出的日志处理和普通请求不同。对WebSocket或者SSE连接,需要监控的是“连接保持时长”和“断流率”,而不是简单的HTTP状态码。这两个指标我们一开始漏了,后来排查用户反馈“打字机突然停住”时才补上。
8. 落地TGI后的几点个人体会
这半年多下来,TGI已经稳定运行在多个内部项目里,线上推理业务几乎没有再出过严重的性能事故。回看整个迁移和调优过程,我最深的体会是:企业级LLM推理部署,瓶颈往往不是模型本身,而是工程化的细节。
具体来说有三点最值得分享:
- 不要怀疑官方默认配置就完事。TGI的默认参数是面向通用场景的,和你具体的业务载荷完全不匹配,必须经过压测和调整。我们最大的性能飞跃就来自调MAX_BATCH_TOTAL_TOKENS和预热。
- 观察指标比猜测更重要。有时候你以为瓶颈在显存,实际可能出在CPU调度,有时候你以为量化会加速,实际反而减速。没有监控数据支撑,很容易做白费功夫的优化。
- 稳定压倒一切。单一实例的性能再好看,如果扩展性差、版本管理乱、监控缺失,最终还是会出事故。建议先把部署流程自动化,再做花哨的优化。
最后再分享一个小技巧:如果同一个模型有多个业务场景调用,尽量在网关层按场景拆分成不同的TGI实例,而不是让所有业务共用同一个。我们最初为了省卡,让问答和摘要共用一个实例,结果两类请求互相拖累,各自体验都很差。拆开后每个实例用各自的参数配置调优,整体资源占用其实是下降的,因为不再需要为最坏场景统一放大参数了。这个拆分思路,比在参数上反复试探要高效得多。