把整个服务从“能用”做到“扛造”,我花在算力优化上的时间,比写业务代码还要多。
最早接手那个AI问答系统的时候,一张GPU跑着一个7B模型,线上反馈“有时候很快、有时候卡死”。后来我把链路从头到尾看了一遍,才发现问题根本不在模型本身。上游会话查询有个慢SQL,平均300ms;下游Token校验走了远程JWKS请求,高峰期还要排队;真正跑模型的GPU,利用率只有四成,显存却被KV Cache占得七七八八。说白了,整个系统就像一条流水线,工位之间有大量等待和浪费,而最贵的那个工位(GPU)反而在摸鱼。
“Token工厂”这个概念,就是那时候在我脑子里成型的。无论是大模型吐出来的语言Token,还是系统里签发的认证Token,背后都在消耗算力、电力和存储。前者优化的是每瓦电换多少个Token,后者优化的是每次校验花多少毫秒。这篇东西,我把这两条线串起来聊,从硬件选型、推理引擎、KV Cache、量化、调度,说到JWT续签、token exchange failed这类线上高频报错的排查思路。适合正在做AI推理服务优化、准备搭私有算力集群、或者天天被Token报错折磨的朋友。
1. 全局设计:从电力到Token,先建立一张完整的地图
1.1 为什么要拿“每瓦Token”当核心指标
做算力优化,如果只盯GPU利用率,很容易走进死胡同。利用率是个过程指标,不是结果指标。一张H100跑满算力,如果吐出来的Token有一半是无效的、重复的、被下游丢弃的,那这个“跑满”就没有意义。
我更习惯用“每瓦Token”这个口径,也就是每消耗一度电,系统最终交付多少个有效Token。这个指标的好处是它能穿透所有技术层次,直接反映业务价值。数据中心层面,PUE决定了多少电浪费在制冷和供电上;服务器层面,电源转换效率决定了多少交流电变成直流电供GPU使用;再到模型层面,推理引擎的批处理策略、KV Cache利用率、量化精度,最终都汇入同一个分母——电费。
我算过一笔账。一块450W的显卡,一年365天满载跑,光显卡本身的电费就将近2000块。如果是数据中心托管,乘以PUE(通常1.3到1.5),成本还要再上浮三成。一个几十卡的小集群,一年电费几十万是很正常的。在这个前提下,哪怕把每瓦Token的产出提高10%,省下的钱都够再买一张卡。
1.2 Token工厂的全链路漏斗
我们把整个系统理解成一个漏斗,每一层都在消耗资源,每一层也都在“漏掉”一部分潜在产出。
- 电力层:市电进入数据中心,经过变压器、UPS、PDU,再到服务器电源,每一级都有损耗。电力层优化的核心是提高PUE、减少空转设备。
- 硬件层:GPU、内存、SSD、网卡。GPU的算力、显存带宽、显存容量决定了模型能不能跑、跑多快。硬件层优化要解决“买什么卡、配多少显存、什么拓扑”的问题。
- 系统层:调度、排队、批处理、缓存、网络。系统层优化的目标是把请求尽量打成大Batch,减少GPU空转,避免频繁的上下文切换。
- 模型层:模型结构、参数量、上下文长度、KV Cache、量化方式。模型层优化决定的是“同样的硬件,一次前向能算出多少有用信息”。
- 协议与业务层:认证Token签发、续签、权限校验、缓存命中率、数据库查询。这一层看似不直接消耗GPU算力,但它决定了上游请求能否快速进入推理队列,也决定了用户的体验。
每一层之间都有耦合。比如模型层用了FP8量化,显存占用降低,系统层就能同时跑更多并发,协议层如果校验慢,再多的并发也会堵在入口。所以做优化不能只盯着某一层,而要先建立全局视角,找出当前最明显的瓶颈。
1.3 做优化前,先把这几个指标打出来晒一晒
在没有数据的情况下谈优化,等于闭着眼睛调参。我在做任何一次Token工厂优化之前,都会先建立一套基础指标,至少包括下面几项:
| 指标 | 含义 | 关注原因 |
|---|---|---|
| TTFT | 首Token延迟,从请求发出到收到第一个Token的时间 | 直接影响用户“有没有响应”的感知 |
| TPOT | 每个输出Token的生成时间 | 决定整体出字速度,流式输出时尤其关键 |
| Token/s | 单请求或系统整体每秒生成的Token数 | 最直观的吞吐指标 |
| GPU利用率 | GPU计算单元在一段时间内的忙碌程度 | 判断算力是否被有效使用 |
| 显存占用率 | 显存被权重、KV Cache、激活值占用的比例 | 判断是否能塞下更大Batch或更长上下文 |
| 平均排队时间 | 请求进入推理队列到实际开始处理的时间 | 定位调度瓶颈 |
| PUE | 数据中心总能耗与IT设备能耗的比值 | 衡量电力利用效率 |
| 单Token成本 | 单位Token对应的电费与硬件折旧之和 | 最终业务指标 |
这些指标不是一次性采集,而是要在压测和线上持续观察。我习惯先记录一个“优化前基线”,再做改动,改完再测,只改一个变量,效果才判断得准。
2. 一个Token的诞生:算力和显存都花在了哪里
2.1 Prefill与Decode:同一个Token,两个世界
大模型生成Token的过程,分两个完全不同的阶段。
Prefill阶段处理用户的输入Prompt。这个阶段是并行的,所有输入Token一次性进入模型,计算密集,GPU利用率往往很高。它的瓶颈通常在于算力(FLOPS),而不是显存带宽。
Decode阶段逐个生成输出Token。每生成一个Token,都要把模型权重重新读一遍,串行执行。这个阶段的瓶颈是显存带宽,因为计算量相对小,但要从HBM里搬权重和KV Cache。很多人不理解为什么模型跑起来“算力明明很高,出字却慢”,其实就是卡在带宽上。
打个不严谨的比方:Prefill像一次批量盖章,一叠文件摊开一起盖,效率取决于手速;Decode像流水线写贺卡,一张一张写,写完才能写下一张,效率取决于纸和笔的传递速度。
理解这两个阶段的差异,对调优至关重要。如果你追求首Token快,要优化Prefill的并行度和输入长度;如果你追求吞吐高、出字快,要优化Decode阶段的Batch大小和KV Cache管理。
2.2 KV Cache:隐藏在显存里的隐形大户
在推理过程中,模型需要记住已经生成的Token之间的注意力关系,于是把历史Token的Key和Value缓存下来,这就是KV Cache。它不存在模型参数里,而是随着生成过程动态增长的。
举个例子计算一下。假设一个7B参数的模型,32层Transformer,隐藏层维度4096,使用FP16精度。每生成一个Token,每一层需要缓存一份Key和一份Value,大小是2×4096×2字节=16KB。乘以32层,每个Token的KV Cache就是512KB。如果上下文长度是2048,那么单条请求的KV Cache占用就是1GB。
如果同时处理32个并发请求,意味着接近32GB显存被KV Cache吃掉。这时候你再看看手里的24GB消费级显卡,模型权重14GB,KV Cache再占掉剩余空间,能塞进去的并发屈指可数。
这也是为什么vLLM这类推理引擎会使用PagedAttention。它借鉴操作系统虚拟内存的分页思想,把KV Cache切成固定大小的块,按需分配,避免预分配造成的显存浪费。实测下来,同样的显存容量,PagedAttention能把并发数提高不少。
2.3 并发与Batch:吞吐和延迟之间的钢丝
Batch size是推理服务最重要、也最容易被忽视的参数。Batch越大,GPU利用率越高,单位Token的算力成本越低,但单个请求的延迟会变长。这个权衡没有标准答案,取决于业务场景。
在线问答场景,用户无法忍受动辄几十秒的首Token延迟,这时候Batch不宜开得太大,要优先保证TTFT和TPOT稳定。离线批量生成场景,比如用大模型批量生成摘要、标签、报告,延迟无所谓,Batch越大越好,把GPU榨干。
传统推理框架用静态Batch,一个请求结束要等整个Batch处理完才能释放显存。vLLM、TensorRT-LLM等框架支持Continuous Batching,也叫连续批处理,每解码一个Token后就检查是否有请求完成并插入新请求。这个机制让GPU一直处于忙碌状态,吞吐提升非常可观。
我自己的经验是:先以“显存能装下”为上限跑压测,把吞吐曲线画出来,找到吞吐增长速度明显放缓的拐点,再往回退一档作为生产配置。不要直接抄网上的参数,因为你的模型大小、上下文长度、显存容量都不一样。
2.4 精度取舍:FP16、FP8与INT4怎么选
模型精度直接决定显存占用和推理速度。FP16是基准精度,7B模型权重大约14GB。FP8能把权重减半到7GB,INT4进一步压到3.5GB左右。显存少了,意味着同一个批量可以塞下更多请求,或者可以加载更大的模型、更长的上下文。
但量化不是免费的午餐。INT4量化后的模型质量多少会下降,尤其在数学推理、代码生成这类需要精确计算的任务上,降智可能很明显。FP8相对温和,很多生产环境已经能接受。我的建议是:先跑量化前后的评测集,对比关键指标下降幅度,再决定是否采用。
另外,KV Cache也可以量化。把KV Cache从FP16压到FP8,可以显著降低长上下文场景的显存压力,但对注意力计算的影响需要实测。如果显存卡得难受,优先量化KV Cache,比量化权重更“划算”。
3. 实操优化:把瓦特变成Token的六个关键动作
3.1 硬件选型:显存带宽比单纯堆算力更划算
很多人选显卡只看FP16算力,这是误区。对于大模型推理,尤其是Decode阶段,显存带宽往往才是真正的天花板。因为每生成一个Token,都要把模型权重从头到尾读一遍,带宽越高,出字越快。
看一组主流显卡的规格:
| 显卡 | 显存 | 显存带宽 | 典型功耗 | 适合场景 |
|---|---|---|---|---|
| NVIDIA H100 | 80GB HBM3 | 3.35TB/s | 700W | 大规模生产推理、训练 |
| NVIDIA A100 | 80GB HBM2e | 2.0TB/s | 400W | 中大规模推理 |
| NVIDIA L40S | 48GB GDDR6 | 864GB/s | 350W | 中等并发、图形与推理混用 |
| RTX 4090 | 24GB GDDR6X | 1.0TB/s | 450W | 开发调试、轻量推理 |
H100的带宽是4090的三倍多,所以同样是跑7B模型,H100的Decode速度可能比4090快出两倍。而两者的价格差距更大,所以算力成本需要结合“每GB带宽价格”来判断,而不是单纯看算力峰值。
至于显存容量,决定了你到底能不能跑起来。24GB只能勉强跑7B模型的中短上下文,如果要做RAG或者长文档,建议直接上48GB以上的卡。在预算有限的情况下,宁可买带宽高、显存够用的上一代卡,也不要买算力强但显存小的卡。
3.2 推理引擎选型:vLLM、TensorRT-LLM、SGLang怎么选
跑大模型推理,直接用原生PyTorch写肯定不行,推理引擎已经把这个领域优化到了很成熟的程度。我这里只聊四个常用方案。
vLLM,上手最快,兼容OpenAI接口,文档全,社区活跃。它内置了PagedAttention和Continuous Batching,对大多数团队来说是首选。启动一个服务很直接:
python -m vllm.entrypoints.openai.api_server \ --model meta-llama/Llama-2-7b-chat-hf \ --max-model-len 4096 \ --gpu-memory-utilization 0.9 \ --dtype float16TensorRT-LLM,英伟达家的东西,性能上限最高,但需要额外的模型编译和转换步骤,调试成本也高。适合有专门的推理优化人力、且对性能极度敏感的团队。
SGLang的调度设计更灵活,在多轮对话、复杂采样策略上表现不错。LMDeploy对国产显卡兼容性好,如果你用的是非N卡,可以优先考虑。
我的建议是:没有特殊需求就上vLLM,先用最简单的方式拿到稳定的吞吐,再根据瓶颈决定要不要换引擎。
3.3 集群调度:让每块GPU都别闲着
有集群和没有集群是两个世界,但调度的方法论是通用的。
单卡场景,把注意力放在Batch size、并发设计和PagedAttention的显存分配上。如果有两张卡,可以用模型并行或者服务分片,把不同请求分发到不同卡上。多卡场景,需要引入队列和优先级,不能让某个请求阻塞整个队头。
如果只是自己搭小型私有算力,不必一开始就上K8s。可以先写一个简单的调度脚本,把客户的请求按队列分发到空闲GPU上。等请求规模上来了,再迁移到K8s + GPU Operator方案。调度的核心目标只有一个:让每块GPU都保持合理负载,避免“一块卡忙死,旁边一块闲死”。
另外,无论是用云GPU还是自己的算力集群,都要设置好超时和重试机制。推理服务偶发变慢很正常,但如果客户端没有超时控制,请求会越积越多,最终把整个服务拖垮。
3.4 业务与数据层的“隐性算力浪费”
这一层不直接吃GPU,但往往是最容易被忽视的瓶颈。
我之前遇到过一个案例:AI问答接口的P95延迟一直很高,排查到最后,发现是每次对话前都要查一次用户的历史会话列表,而这张表已经有几千万条数据,索引还不合理,查询平均要300ms。用户的请求从开始到进入推理队列,光数据库这一下就消耗了三分之一的时间。
类似的问题还有:每次请求都重新拉取模型配置;知识库检索没有做缓存;Token校验每次都要远程拉取公钥;日志把整个请求体打出来,导致I/O和存储暴涨。
这些问题的共同点是“算力没用在模型上,而是消耗在等待和处理冗余数据上”。优化方式也很直接:数据库加索引、热点数据进Redis、配置本地化、日志只记录关键字段。把这些毛刺清掉,GPU的利用率反而会提高,因为上游不会再把请求堵在门口了。
再强调一下流式输出。Chat类应用一定要开SSE流式输出,用户看到Token一个个蹦出来,感知延迟会大幅下降。而且流式输出还能提前释放请求线程,系统的并发能力上限会更高。
3.5 压测与监控:别拍脑袋调参
优化的每一步都需要数据支撑。我压测推理服务时,习惯用这样一个流程:先压单请求,拿到TTFT和Token/s的基线;再逐步增加并发,观察GPU利用率和显存;最后把吞吐和P95延迟画成曲线,找到拐点。
压测工具可以用hey、wrk这类轻量工具,也可以用Locust或k6写脚本。监控方面,prometheus加dcgm-exporter是GPU监控的标配,实时看显存、温度、功耗、利用率。日志里要带上请求ID、模型名、Token数、耗时,方便定位单个慢请求。
我踩过最深的坑是只看平均延迟,不看P95。平均延迟好看,但P95可能已经翻倍。做推理优化,P99才是用户体验的真相。
4. Token协议层的工程实战:续签、失效与排查
4.1 JWT续签与Token失效的常规解法
前面聊的是语言Token,现在说认证Token。这个领域最高频的痛点就是“Token过期”。JWT自带过期时间,一旦过期,访问接口就返回401。处理不好,用户就会被频繁踢下线。
业界最常用的方案是双Token机制:Access Token短期有效(比如15分钟),Refresh Token长期有效(比如7天)。Access Token失效后,客户端用Refresh Token去换新的Access Token,用户无感续签。
服务端要做几件事:
- Refresh Token要存服务端(比如Redis),设置过期时间,并且支持滑动续期。
- Access Token校验可以无状态,直接在本地验签。
- 并发刷新要加锁,防止多个线程同时用同一个Refresh Token去换新Token,导致前面的刷新失败。
- Refresh Token丢失或被盗,要有吊销机制,做黑名单。
下面是一个Spring Boot拦截器处理Token失效并自动续签的简化思路:
@Component public class TokenRefreshInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { try { // 正常调用业务接口,校验Access Token return true; } catch (UnauthorizedException ex) { // Access Token过期,尝试用Refresh Token换取新Token boolean refreshed = tokenService.refreshAccessToken(); if (refreshed) { // 刷新成功,重新发起业务请求 return true; } // 刷新失败,返回401让用户重新登录 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } } }代码不复杂,但要注意:拦截器只处理“Access Token过期”这一种情况,如果Refresh Token也失效,一定要让用户重新登录,不能陷入死循环。
4.2 一起token exchange failed的完整排查实录
“sign-in could not be completed token exchange failed: token endpoint returned status 403 forbidden”,这行报错我在线上看过很多次。它看起来像是在“交换Token”这一步被身份提供方拒绝了。最常见的原因有几个:
| 可能原因 | 表现 | 排查方式 |
|---|---|---|
| Client ID或Secret配置错误 | 每次都403,换环境也一样 | 核对配置中心的凭据 |
| Scope缺少关键项 | 有时成功有时失败 | 检查scope是否包含openid、offline_access等 |
| 服务器时钟偏移 | 偶尔成功偶尔失败,间隔不定 | 检查服务器NTP时间同步 |
| 重定向URI不匹配 | 只有特定入口触发 | 检查IdP侧的回调地址白名单 |
| 地区或网络策略拦截 | 特定网络环境必现 | 检查IdP合规策略 |
| 授权码已被使用或过期 | 重复点击登录时出现 | 确认授权码是一次性的 |
我遇到的一个典型场景是这样的:测试环境一切正常,生产环境登录必现403。查了一圈,最后发现是生产环境的Client Secret还在用之前的旧版本,而代码已经切到新Client。配置中心和代码版本不一致,导致每次token exchange都被拒。
还有一起典型的时钟问题。签发JWT时会校验iat(签发时间)、exp(过期时间),如果签发服务器和校验服务器的时间差超过容忍范围,合法的Token也会被判为失效。那次排查到最后是用NTP同步了所有服务器时间,问题立刻消失。
遇到token exchange相关报错,我的排查顺序是这样的:先看服务端日志的原始响应体,定位是参数错误、路由错误还是证书错误;再检查服务器时间和Client凭据;最后看scope和回调地址配置。大部分问题出在配置,而不是代码。
4.3 多环境下的Token生命周期管理
开发、测试、生产环境应该各自使用独立的Client和Secret,这是基本要求。很多人为了省事,所有环境共用一套凭据,结果测试环境的异常流量污染了生产环境的Token颁发策略,线上用户被莫名其妙踢下线。
代码里千万不要硬编码Secret和Token,要用配置中心或环境变量统一管理。Secret要定期轮换,轮换时要保证新旧Secret有一段并存期,避免服务重启瞬间全部请求失败。
还有一个很容易踩的坑是“刷新Token的并发刷新”。多个线程同时发现Access Token过期,同时用同一个Refresh Token去换新的,结果只有一个线程能成功,其他线程全部报错“could not be refreshed”。解决方式是在刷新操作上加分布式锁,或者做一个短暂的本地互斥,让失败的线程等待刷新完成后重试一次。
4.4 认证服务的算力账单:别小看验签开销
JWT验签不是免费的。每次请求都要从HTTP头里取出Token,解Base64,验签名,查Redis。如果用的是RSA签名,每次验签都是一次公钥运算,高频接口的JWT验签消耗的CPU并不少。
优化方式有几种:把远程JWKS公钥缓存到本地,定期刷新;把校验结果按Token做短时间缓存,同样一段Token在缓存有效期内不需要重复验签;对纯内部服务之间的调用,可以考虑用更轻量的共享签名机制,减少非对称加密的开销。
从宏观来看,认证服务和推理服务一样,都有自己的算力漏斗。我们优化认证Token的每一步,都是在提高整个系统“单位算力能支撑的有效请求数”。当一个Token工厂的推理侧和认证侧都做到高效,整个系统的吞吐和成本才会真正达到平衡。
我个人在实际排查中最大的体会是:不要一看到Token相关报错就改代码。先抓原始请求和响应,看着报错字面背后的HTTP状态码、响应体、服务器时间,再动手。十次里面至少有六次是配置、时钟或者凭据过期的问题,改代码只会让系统更乱。把每一瓦电、每一次验签、每一个Token都当成有成本的资源来管理,优化才真正开始。