大模型推理服务优化:从token到TTFT的全面指南
2026/9/9 4:57:09 网站建设 项目流程

前阵子一个做 ToB 产品的朋友跟我吐槽:他们换了一个更强的底座模型,跑分和主观评测都明显变好,结果上线一周,用户不满率反而涨了。排查到最后,问题根本不在模型本身,而在于“上菜”环节——不是模型生成的 token 变差了,而是推理服务(inference serving)没跟上,登录态里的 token 管理也一路踩坑。这让我想起那句很妙的类比:Great token requires great inference to be served well,就像米其林餐厅的体验,绝不只靠后厨那道菜的配方,端菜速度、摆盘、倒水节奏、餐桌空间,任何一环掉链子,整顿饭的感觉就全垮了。这篇就从“好 token 怎么服务好”这个角度展开,聊聊推理引擎的选型、TTFT/TPOT 这些延迟指标的实测方法,再到上下文截断、credits 和 token 的换算,最后把登录态里 token exchange failed 这类高频报错的排查链路完整过一遍。

1. 把 token 当菜品:为什么“好模型”不等于“好体验”

1.1 后厨和前厅的分工:模型产出 token,serving 负责端上桌

在自然语言处理里,模型输出的最小单位是 token,也就是把文本切成的词元或子词块。一个 7B 模型好不好,看的是它生成 token 的质量——选词是否精准、语义是否连贯、逻辑是否自洽。这就是“菜”本身。但用户感知到的,从来不是“这 500 个 token 写得真好”,而是“我发出请求之后,多久能看到第一个字”“字是一个一个蹦出来还是刷一下就出完了”“我聊到第 8 轮的时候它是不是把我前面的内容忘了”。

这些体验全部由 serving 层决定。serving 层要干的事包括:接收请求、把 prompt 喂给模型、调度 GPU 显存、按顺序生成 token、管理并发、处理超时和异常。换句话说,后厨决定了菜的上限,前厅决定了客人最终记住的下限。很多团队只盯着“换更好的模型”,却忽略了 serving 配置不合理、并发一上来就崩、上下文管理粗糙,最后把好模型活活做成了差体验。

token 还有另一个隐含特性让 serving 更加关键:它是自回归生成的。也就是说,输出 token 是一个接一个产生的,后一个 token 依赖前一个 token 和全部历史上下文。这意味着你没法像处理传统请求那样把一整段答案并行算出来。这就像一场严格按顺序上菜的宴席,第一道菜没上,第二道菜不可能提前出现。所以 serving 的调度节奏,本质上决定了整个交互的时序体验。

1.2 上菜的节奏:prefill 和 decode 是两种完全不同的工作

一次推理请求,内部其实有两个阶段,理解这两者的区别,你才看得懂后面所有调优指标。

第一个阶段叫 prefill(预填充),也叫 prompt processing。模型把用户输入的整个 prompt 一次性并行处理,生成每个位置的 Key 和 Value,存在 KV cache 里。这个阶段是高度并行的,GPU 利用率高,速度快。你可以把它理解成后厨在你入座之前就把菜单和预约信息看完了,提前备料、提前热锅。

第二个阶段叫 decode(解码),模型逐个生成输出 token。每生成一个新 token,都要把新 token 的 Key/Value 追加到 KV cache,然后重新计算整个序列的注意力。这个阶段是串行的,GPU 算力需求高但并行度低,通常会成为瓶颈。对应到餐厅,就是每一道菜都得按顺序做,做完端上去,下一道才能开始。

举个例子:假设一个 2K token 的 prompt,在消费级显卡上 prefill 可能只需要 0.3 秒,但输出 500 个 token,按 25 token/s 的解码速度算,要 20 秒。所以用户体验的主要等待时间,几乎都花在 decode 上。理解这一点,你就能明白为什么“首字延迟”和“稳定输出速度”是两个完全不同的优化维度。

1.3 真正构成“就餐体验”的三个数字:TTFT、TPOT、吞吐

评价 serving 层,我一般只看三个数字:

  • TTFT(Time To First Token):从请求发起到返回第一个 token 的时间。它对应“客人坐下后第一道菜多久能上”。TTFT 主要由 prefill 耗时、请求排队时间、网络延迟决定。聊天场景下,TTFT 控制在 0.5 到 1 秒内才算及格,超过 2 秒用户就会觉得卡。
  • TPOT(Time Per Output Token),也叫每 token 延迟或 ITL(inter-token latency):相邻两个输出 token 之间的间隔时间,也就是“菜与菜之间的上菜节奏”。对流式体验来说,TPOT 在 30 到 60 毫秒(约等于每秒 16 到 33 个 token)是舒适的区间,低于 100 毫秒都能接受,超过 200 毫秒就会明显感受到“一顿一顿”。
  • Throughput(吞吐):整个服务在单位时间内产出的 token 总数,通常以 tokens/s 计量。它对应的是“餐厅翻台率”,决定你能同时服务多少人。

这三者的关系值得注意:追求极低的 TTFT 和 TPOT 时,往往要牺牲吞吐;反过来,高吞吐的批量处理会让单用户感觉变慢。所以大流量生产环境的优化目标,不是某一项数字最漂亮,而是三者在你的用户规模下平衡。

2. 先学会测量:llama-server 的实测方法与 HTTP 500 排查

2.1 一条 curl 命令摸清自家“上菜速度”

无论你用哪种 serving 框架,测量手段基本一致。以 llama-server 为例,启动一个 OpenAI 兼容接口:

llama-server -m ./models/qwen2.5-7b-instruct-q4_k_m.gguf \ --host 127.0.0.1 --port 8080 \ -c 8192 --parallel 4

-c 8192是上下文窗口长度,--parallel 4是并发槽位数,对应餐厅里最多同时招待的桌数。启动后用 curl 请求,记得加-N关闭缓冲,这样流式返回的 token 才会实时显示:

curl -N http://127.0.0.1:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "qwen2.5-7b", "messages": [{"role": "user", "content": "写一段 300 字的产品介绍"}], "stream": true, "max_tokens": 512 }'

要得到精确的 TTFT 和 token 吞吐,我习惯写一个几十行的小脚本,核心逻辑是记录请求发出时间、第一个 SSE chunk 到达时间,以及累计收到的 chunk 数量。llama-server 的流式返回里,每个data:事件通常对应一个 token,所以数 chunk 数基本就等于数 token 数:

import requests import time import json url = "http://127.0.0.1:8080/v1/chat/completions" payload = { "model": "qwen2.5-7b", "messages": [{"role": "user", "content": "写一段 300 字的产品介绍"}], "stream": True, "max_tokens": 512, } t0 = time.time() ttft = None token_count = 0 with requests.post(url, json=payload, stream=True) as resp: for line in resp.iter_lines(): if not line or not line.startswith(b"data:"): continue if b"[DONE]" in line: break if ttft is None: ttft = time.time() - t0 token_count += 1 elapsed = time.time() - t0 print(f"TTFT: {ttft * 1000:.0f} ms") print(f"吞吐: {token_count / elapsed:.2f} token/s")

我在实际跑的时候发现,同一台机器上,7B Q4 量化模型单请求大概能到 20 到 30 token/s,早于预期的话先检查 CPU 线程数和 GPU 层数设置,不要想当然地认为“显卡越好就一定越快”,显存带宽才是 decode 阶段的主要制约因素。

2.2 当 500 上桌:llama-server inference returned http 500 的排查链路

最近在社区里和身边同事那儿,最常看到的报错之一就是llama-server inference returned http 500 (internal server error)。这类 500 不是模型能力问题,而是 serving 层处理请求时碰到了无法正常返回结果的情况。我踩过的坑集中在这几类,排查顺序建议按照“复现 → 看日志 → 精简请求 → 定位根因”来走。

第一步,先用最小请求复现,不要上来就套生产环境里那一大坨历史消息和特殊参数。最小请求能稳定复现,才能快速缩小范围。

第二步,读服务端日志。llama-server 启动时加--verbose会输出更详细的信息,我遇到的多数 500 在日志里都能直接看到关键词。我把常见组合整理成了下面的表:

日志关键词根因修复方向
exceeds n_ctxprompt too longprompt 和 max_tokens 之和超过了-c设定的上下文长度调大-c,或截断历史消息,或调低max_tokens
failed to allocate/out of memoryKV cache 或批次显存分配失败调小-c--batch,降低--parallel,或换更小的 KV cache 量化
invalid chat template/formatmessages 结构或角色名不符合该模型的模板要求检查 role 是否只用了 system/user/assistant,content 是否是合法字符串或结构化对象
model load failed/ tensor 不匹配GGUF 文件损坏,或下载的模型与配置不一致校验文件 hash,重新下载,先用 llama-cli 跑一句简单对话确认模型本身可用
slot busy相关所有并发槽位都被占满,新请求挤不进来调大--parallel,或在服务前面加排队/限流

第三步,用 llama-cli 直接把模型跑一遍,和 HTTP 接口做对照。如果命令行能正常输出,而 HTTP 接口返回 500,那问题基本锁定在请求参数、模板或并发配置上;如果命令行同样报错,那大概率是模型文件本身的问题。

我特别想提醒一个容易忽略的坑:当你通过 API 网关转发到 llama-server 时,网关层自己也会造 500。这时候要先去网关日志看它转发的上游响应到底是什么。我有一次排查半天,最后发现是网关注入了不合法的Content-Type头,导致 llama-server 拒绝解析请求体。所以 500 不等于模型服务崩了,先确认这个 500 是谁产生的。

2.3 翻台率才是餐厅利润:并发上量后的吞吐变化

单请求测出来的 token/s 只是“单人餐桌的服务速度”。真实生产环境里,决定服务能力的是并发场景下的总吞吐。这时候就轮到 continuous batching(连续批处理)发挥价值了。传统的批处理要等一批请求全部完成后才统一释放显存,而 continuous batching 会动态地把不同请求的 decode 步骤编排到同一个计算批次里,谁生成了多少 token 都独立调度。打个比方:传统餐厅是一桌客人全吃完才收拾桌子,continuous batching 是每桌吃完一道菜就立刻上下一道、翻台不用等整批结束。

在 vLLM 里,这块的核心实现叫 PagedAttention,思路是把 KV cache 切成固定大小的块,按需分配,避免内存碎片,从而让显存利用率大幅提升。llama-server 也支持--parallel多槽位和上下文移位(context shifting)来平滑处理并发。

我自己做个一个很简单的对比实验:单请求约 25 token/s,但同时开 8 个并发请求时,服务总吞吐能跑到 150 到 200 token/s。也就是说,单用户感受变慢了一点(每个请求分到的算力少了),但整体单位时间产出的 token 数翻了 6 到 8 倍。生产环境里,你要根据在线用户数来权衡“每个人都快”还是“更多人能用”,这是一个明确的容量规划问题,不是玄学。

3. 后厨选型:llama-server、vLLM 还是 TGI?

3.1 从单人食堂到宴会厅:三类 serving 框架对照

serving 框架的选择决定了你后厨的上限。我这些年用下来,给这三类主流选择做个直接对比:

框架部署成本吞吐上限内存效率核心特性适合场景
llama.cpp / llama-server最低,单机甚至 CPU 都能跑中低GGUF 量化、跨平台、内置 HTTP 服务本地开发、小团队内网、边缘部署
vLLM中,需 GPU 集群规模起步PagedAttention、continuous batching、prefix caching 完善生产环境大量并发、追求吞吐
Hugging Face TGI与 HF 生态深度集成、支持消息队列、监控完善已经深度依赖 HF 生态的团队
SGLang结构化输出和复杂调用的加速、RadixAttention大量使用 JSON 输出、function call 的复杂应用

选择逻辑其实很直白:如果你只是本地验证模型效果,起一个 llama-server 就够了,连显卡都不用非要独显;如果你的产品已经要服务几十个并发用户,vLLM 是更省心的选择;如果你们团队已经在 HF 生态里沉淀了大量 pipeline 脚本,TGI 的集成收益会更高。不要一上来就追求最重的框架,后厨越大,管理成本越高。

3.2 备料间面积:KV cache 为什么比模型权重更吃显存

很多人刚接触 serving 时有个误区:以为显存只要装得下模型权重就够了。实际上,运行时 KV cache 才是大头。KV cache 是 prefill 阶段计算出来的 Key 和 Value 缓存,decode 阶段每生成一个 token 都要往里追加。它的计算公式是:

KV cache 大小 = 2(K 和 V 两份)× 层数 × KV head 数 × head_dim × 序列长度 × 每个元素字节数

拿一个 7B 参数模型举例:假设 32 层、GQA 结构下 8 个 KV head、head_dim 128、fp16 精度(2 字节)、上下文 8192,那么每个 token 占用的 KV cache 约等于:

2 × 32 × 8 × 128 × 2 = 131072 字节 ≈ 128 KB

8192 个 token 加起来就是 1 GB。如果是老式 MHA 结构、32 个 KV head,这个数字直接翻到 4 GB。也就是说,一个 7B 模型权重可能只占 4 到 5 GB(Q4 量化后),但 KV cache 轻轻松松再吃几 GB 显存。这也是为什么现在新模型普遍用 GQA(分组查询注意力)——主要目的之一就是把 KV cache 做小,从而支持更长的上下文。

所以选型时不光要看模型参数量,还要算清楚:我的上下文窗口打算开多大?并发几个请求?每多一个并发,就要多备一份 KV cache 的空间。KV cache 的量化(q8_0、q4_0)也能显著压缩显存占用,但同样需要实测质量变化。

3.3 三道快菜做法:量化、投机解码、前缀缓存

后厨想提速,有几道“快菜”是我实测下来最有效的:

第一道是权重量化。把模型从 fp16 压到 Q4_K_M 这类 4-bit 量化,显存占用直接缩到三分之一左右,decode 阶段因为受显存带宽限制,量化后往往反而更快。代价是生成质量略有下降,具体下降多少要拿你自己的评测集去跑,不要拍脑袋。

第二道是投机解码(speculative decoding)。思路是让一个小模型先快速生成 N 个候选 token,再让大模型一次性并行验证这 N 个 token,对的就收下。相当于副厨先把菜备好,主厨一次性验收,避免了主厨每一步都从头开始。在 vLLM 这类支持投机解码的框架里,特定条件下生成速度能提升 1.5 到 3 倍,但小模型和大模型的匹配度会影响收益,需要实测。

第三道是前缀缓存(prefix caching)。如果所有请求都共享同一个系统提示词,服务端可以把这段的 KV cache 缓存下来,新请求命中后直接复用,不用重新 prefill。这和后面要讲的“省 token”是同一个原理:保持系统提示词前缀稳定,把易变的部分放到后面,让缓存命中率最大化。

4. token 账本:上下文截断、credits 换算与服务端计费

4.1 上菜到一半没了:“已达到输出 token 上限”到底发生了什么

如果你用过各家大模型产品,大概率见过这句话:“已达到输出 token 上限,回答被截断,已有输出保留在对话中。发送‘继续’可让模型接”。这背后的机制其实很简单:每次请求都会设置一个max_tokens参数,模型生成到上限后,API 返回的 finish_reason 是length,而不是stop。服务端不会主动帮你续,客户端拿到这段不完整的内容后,要做的是把这段内容作为 assistant 消息加入历史,然后追加一条“继续”的用户消息,发一个新的请求。

用 OpenAI 兼容接口表示,大致是这样:

{ "messages": [ {"role": "system", "content": "你是一个严谨的助手"}, {"role": "user", "content": "写一篇关于推理服务的文章"}, {"role": "assistant", "content": "上文已生成但被截断的内容……"}, {"role": "user", "content": "继续"} ] }

这里有一个容易被忽略的细节:直接把截断处的半句话作为上下文再让模型“继续”,模型是有可能重复开头或者接错位置的。我习惯在“继续”指令里加上一点约束,比如“从刚才最后一句的语义接着往后写,不要重复已输出的内容”。另外,客户端要保存好被截断的 assistant 内容,不然新请求里的模型看不到它前面写过什么,接续就是无源之水。

如果你是自己调用 API,想减少这种截断,最简单的办法是合理预估输出长度,不要让max_tokens设得过于保守。长文生成场景,可以分段请求,而不是一口气要求模型输出几千 token。流式输出也能让你更早发现截断迹象,而不是等结果返回了才发现内容不完整。

4.2 credits 和 token 到底怎么换算

“2500 credits 相当于多少 token”这个问题在社区里被反复问。根源在于大家容易把 credits 和 token 当成同一种东西,其实它们是两层概念:token 是模型计量的工作量单位,credits 是账号体系里的充值余额单位,中间隔着各平台自己的定价表。

换算逻辑一般是这样的:平台会公布每 100 万 token 的价格,比如假设输入 token 每百万 0.5 元、输出 token 每百万 1.5 元、缓存命中的输入 token 每百万 0.1 元。那么 2500 credits 能换算成多少 token,取决于你这 2500 credits 相当于多少金额,以及你实际用的是输入还是输出 token。计算公式是:

可用的 token 数 ≈ credits 换算成的金额 ÷ 每百万 token 价格 × 100 万

假设 1 credit 等于 0.001 元,2500 credits 就是 2.5 元。如果全部用来买输入 token,按 0.5 元/百万算,大约能换 500 万 token;如果全部用来买输出 token,就只剩大约 166 万 token。而且实际使用中通常是输入输出混合,所以“相当于多少 token”本来就不是一个固定数字。

计费项假设价格(每百万 token)2500 credits 约可换取
输入 token0.5 元500 万
输出 token1.5 元166.7 万
缓存命中输入0.1 元2500 万

我一般建议团队在上线前先做一次 token 消耗摸底:统计单个典型会话的输入输出比、缓存命中率,再用这个表格算出真实成本。很多平台对缓存命中的输入 token 有大幅优惠,这也是为什么前面反复强调保持 prompt 前缀稳定——不只是为了变快,还是为了省钱。

4.3 客户端的省钱习惯:省 token 的细节设计

说到省 token,社区里“XX 如何省 token”一直是个高热度话题,尤其在用 Claude Code、Codex 这类编码 agent 的场景里。核心原则其实是通用的:token 账单的绝大部分来自输入侧的上下文膨胀,而不是输出侧那一小段答案。

我在实际项目里养成了这么几个习惯:

  • 系统提示词固定前缀,易变内容放后面。这样配合 prefix caching,每次请求重复计算的输入 token 会少很多。
  • 多轮对话做摘要压缩。当历史超过一定轮数,把旧对话压缩成一段摘要存入变量,而不是把完整历史无限追加。这就像餐厅的餐桌空间有限,吃完的菜要及时撤下去,而不是全堆在桌上。
  • 给 agent 只传 diff 或相关片段,不要把整个仓库文件反复塞进上下文。编码 agent 最容易烧 token 的操作就是“每次提问都把整个文件重新读一遍”。
  • 用结构化输出约束模型。通过 JSON Schema 或 function calling 让模型只输出必要字段,避免它在边界试探的废话上浪费输出 token。
  • 在日志里记录每个请求的 prompt_tokens 和 completion_tokens。不看数据,你永远不知道钱花在哪。

另外,如果你的团队内部有多个系统都要调用模型服务,建议做一个统一的 API 网关来管理密钥、配额和用量,而不是让每个业务方各自持有密钥、各自统计。网关集中管理的好处是:限流、审计、成本归因都能在一个地方看到。

5. 登录态里的 token:exchange failed 系列的完整排查

5.1 这不是模型 token:JWT、refresh token 与 token exchange 是什么

聊到这里必须澄清一个概念混淆:大模型里的 token 是文本词元,而登录报错里的 token 是身份凭证,两者完全是两码事。登录场景里最常见的报错是“sign-in could not be completed, token exchange failed”,这类问题要不是 OAuth 2.0 / OIDC 流程出错,要不就是 JWT 的生成、校验、刷新环节出了问题。

token exchange(令牌交换)在 OAuth 2.0 里是一个标准动作:客户端拿着授权码或某个已有的 token,向认证服务器的 token endpoint 发起请求,换取用于访问业务 API 的 access token。典型流程是:用户在小程序或网页里点击第三方登录,授权服务器返回一个授权码;你的后端拿着这个授权码和 client_id、client_secret 去换 access token;access token 是短期的,过期后还要用 refresh token 再换一次。

JWT 是 access token 最常见的格式,分成三段:header、payload、signature。payload 里带着exp(过期时间)、iat(签发时间)、aud(受众)、scope等关键声明。校验方要做的核心事情就是验签名、查exp、查aud是否符合预期。

5.2 403 / 400 / 500 的分别在哪:一次完整的 token 请求排查

假设你遇到了“token exchange failed: token endpoint returned status 403”或者“400 invalid_grant”。第一步永远是复现并抓取完整的请求和响应,不要只盯着错误提示那一行。手动构造一次 token 请求是最快的定位方式:

curl -i -X POST https://idp.example.com/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=refresh_token&refresh_token=YOUR_TOKEN&client_id=YOUR_CLIENT&client_secret=YOUR_SECRET"

然后按响应码分类处理,这是我整理的一张速查表:

响应码常见错误码根因处理方式
400invalid_grantrefresh token 过期、被撤销、或已被使用过清除本地会话,重新走完整登录流程
401invalid_clientclient_id / client_secret 不匹配,或密钥轮换后没同步核对配置文件,检查密钥是否已更新
403token_exchange_failed账户状态异常、设备不在信任列表、IP 白名单或风控策略拦截检查账户状态和风控通知,确认设备和网络环境符合策略
500 或网络错误error sending request for url上游认证服务临时故障、超时、DNS 解析或证书问题检查解析和证书配置,实现带退避的重试

其中token_exchange_failed: error sending request for url这一类,很多人误以为是代码 bug,其实多半是网络链路问题。我见过不少情况是服务端所在环境的 DNS 解析异常,或者出口网络对目标域名访问不稳定,导致 token 请求发不出去。先排除网络层,再查业务逻辑,能省一大半时间。

还有一个特别容易踩的坑是时钟偏差。JWT 里的iatexp都是绝对时间戳,如果你的服务器时间不同步,校验时就会出现“token 还没生效”或“token 已过期”的假报错。凡是分布式环境,第一件事就是确认所有节点都开了 NTP 时间同步。

5.3 让登录态不容易崩的三层防御

经历过几次线上“大批用户突然掉登录”的事故后,我总结出三层防御,按优先级从高到低:

第一层是提前续期。不要等 access token 真的过期了再刷新,按照我常用的策略,在 token 生命周期到 70% 到 80% 时就用 refresh token 申请新 token。这样用户无感,也避免在高并发刷新的瞬间把认证服务打爆。

第二层是校验端的容错。JWT 校验时,给expiat留一定的 leeway(比如 30 秒),可以抵消时钟抖动带来的误判。同时对所有校验节点做 NTP 同步,这是成本最低但收益最高的操作。

第三层是失败分级和自动恢复。把错误分成两类:一类是网络抖动、上游 5xx,这类可以退避重试;另一类是invalid_grantinvalid_client这种明确的状态错误,这类重试也没用,应该立刻清理本地会话、引导用户重新登录,同时把完整的 error 详情记录到日志,方便向认证服务商报障。

我在生产环境里还会额外做一个动作:把 token exchange 的失败率做成监控指标。登录失败率突然上涨,往往比模型服务变慢更能说明系统出了问题。这相当于给前厅装了一个预警铃,后厨还没乱,你已经先知道了。

最后再分享一点个人的实际体会

做完了模型 serving 的调优,又处理了登录态 token exchange 的排查,我对“好 token 需要好 serving”这句话的体会更深了一层。在真实项目里,最影响用户口碑的往往不是模型本身,而是三个容易忽视的小问题:上下文被截断时处理不丝滑、并发高峰期吞吐骤降、登录态 token 刷新逻辑没做好导致用户被强制登出。这三件事看起来不“高端”,但每一个都能让再好的模型效果直接归零。

我现在的习惯是,凡是新接一个模型项目,第一件事不是急着调 prompt,而是先把 serving 的三个指标(TTFT、TPOT、吞吐)拉出来测一遍,再确认账户体系和 token 刷新策略是否稳健。建议你也这样:在服务端和客户端各准备一份“token 体检清单”,把指标和报错处理都变成例行检查。这样,好模型才能真正被好好地端上桌。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询