ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

大模型推理服务优化:从token到TTFT的全面指南

大模型推理服务优化:从token到TTFT的全面指南 前阵子一个做 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 后厨和前厅的分工模型产出 tokenserving 负责端上桌在自然语言处理里模型输出的最小单位是 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 层我一般只看三个数字TTFTTime To First Token从请求发起到返回第一个 token 的时间。它对应“客人坐下后第一道菜多久能上”。TTFT 主要由 prefill 耗时、请求排队时间、网络延迟决定。聊天场景下TTFT 控制在 0.5 到 1 秒内才算及格超过 2 秒用户就会觉得卡。TPOTTime Per Output Token也叫每 token 延迟或 ITLinter-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, jsonpayload, streamTrue) as resp: for line in resp.iter_lines(): if not line or not line.startswith(bdata:): continue if b[DONE] in line: break if ttft is None: ttft time.time() - t0 token_count 1 elapsed time.time() - t0 print(fTTFT: {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_ctx或prompt too longprompt 和 max_tokens 之和超过了-c设定的上下文长度调大-c或截断历史消息或调低max_tokensfailed to allocate/out of memoryKV cache 或批次显存分配失败调小-c、--batch降低--parallel或换更小的 KV cache 量化invalid chat template/formatmessages 结构或角色名不符合该模型的模板要求检查 role 是否只用了 system/user/assistantcontent 是否是合法字符串或结构化对象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 还是 TGI3.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 大小 2K 和 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 KB8192 个 token 加起来就是 1 GB。如果是老式 MHA 结构、32 个 KV head这个数字直接翻到 4 GB。也就是说一个 7B 模型权重可能只占 4 到 5 GBQ4 量化后但 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”本来就不是一个固定数字。计费项假设价格每百万 token2500 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. 登录态里的 tokenexchange failed 系列的完整排查5.1 这不是模型 tokenJWT、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 tokenaccess 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_typerefresh_tokenrefresh_tokenYOUR_TOKENclient_idYOUR_CLIENTclient_secretYOUR_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 里的iat和exp都是绝对时间戳如果你的服务器时间不同步校验时就会出现“token 还没生效”或“token 已过期”的假报错。凡是分布式环境第一件事就是确认所有节点都开了 NTP 时间同步。5.3 让登录态不容易崩的三层防御经历过几次线上“大批用户突然掉登录”的事故后我总结出三层防御按优先级从高到低第一层是提前续期。不要等 access token 真的过期了再刷新按照我常用的策略在 token 生命周期到 70% 到 80% 时就用 refresh token 申请新 token。这样用户无感也避免在高并发刷新的瞬间把认证服务打爆。第二层是校验端的容错。JWT 校验时给exp和iat留一定的 leeway比如 30 秒可以抵消时钟抖动带来的误判。同时对所有校验节点做 NTP 同步这是成本最低但收益最高的操作。第三层是失败分级和自动恢复。把错误分成两类一类是网络抖动、上游 5xx这类可以退避重试另一类是invalid_grant、invalid_client这种明确的状态错误这类重试也没用应该立刻清理本地会话、引导用户重新登录同时把完整的 error 详情记录到日志方便向认证服务商报障。我在生产环境里还会额外做一个动作把 token exchange 的失败率做成监控指标。登录失败率突然上涨往往比模型服务变慢更能说明系统出了问题。这相当于给前厅装了一个预警铃后厨还没乱你已经先知道了。最后再分享一点个人的实际体会做完了模型 serving 的调优又处理了登录态 token exchange 的排查我对“好 token 需要好 serving”这句话的体会更深了一层。在真实项目里最影响用户口碑的往往不是模型本身而是三个容易忽视的小问题上下文被截断时处理不丝滑、并发高峰期吞吐骤降、登录态 token 刷新逻辑没做好导致用户被强制登出。这三件事看起来不“高端”但每一个都能让再好的模型效果直接归零。我现在的习惯是凡是新接一个模型项目第一件事不是急着调 prompt而是先把 serving 的三个指标TTFT、TPOT、吞吐拉出来测一遍再确认账户体系和 token 刷新策略是否稳健。建议你也这样在服务端和客户端各准备一份“token 体检清单”把指标和报错处理都变成例行检查。这样好模型才能真正被好好地端上桌。
返回列表