1. 流式输出第一个字为什么这么慢:TTFT 首字延迟的链路拆解
先说结论:LLM 流式输出里,用户感知的“快”几乎全押在第一个字上。你打开一个 AI 对话产品,点发送之后盯着屏幕,如果 3 秒还没动静,手指就开始往返回键挪了;只要第一个字蹦出来,哪怕后面一个字一个字往外挤,你也会耐心读完。这个“从点发送到看见第一个字”的时间,就是 TTFT(Time To First Token,首字延迟)。
TTFT 是什么、能做什么、适合谁:它是衡量流式产品体验的核心指标,决定了用户愿不愿意等;它适合所有做 LLM 应用、Agent、RAG 问答、代码补全的开发者去测量和优化。很多人简历上写“精通 SSE 流式输出”,但一问 TTFT 由什么决定就卡壳。这篇就把第一个字背后的工程链路拆开,并给出一套可复制的 TaoToken 统一 API 通道配置,让你能自己量、自己压。
先建立一个关键认知:流式输出并没有让模型变快。同样一段回答,非流式要等全部生成完一次性返回,流式是边生成边推。模型算的总时间几乎没变,分块和传输甚至让端到端总时间略微增加。流式真正改变的是用户的等待锚点——从 E2E(端到端总时间)挪到了 TTFT。第一个字出来,用户就觉得“开始了”。
所以优化流式体验,本质是优化两段:
| 指标 | 全称 | 含义 | 决定阶段 |
|---|---|---|---|
| TTFT | Time To First Token | 首字延迟,点发送到看见第一个字 | Prefill |
| ITL | Inter-Token Latency | 字间延迟,字与字之间的间隔 | Decode |
| E2E | End-to-End | 端到端总时间 | 全流程 |
TTFT 决定用户愿不愿意等,ITL 决定读起来顺不顺。中文阅读速度大约每秒 5 到 10 个字,ITL 只要快过阅读速度,用户就感觉不到卡顿,再快也读不过来。所以 TTFT 要尽量压低,ITL 压到略快于阅读速度就够了,多出来的算力留给吞吐更划算。
那 TTFT 到底由什么决定?答案是:几乎只由输入长度决定,和你让模型生成多长输出基本无关。你把 max_tokens 从 200 调到 4000,第一个字到达的时间几乎不动。原因在于推理被拆成两个阶段:
Prefill(预填充)阶段,模型在吐第一个字之前,必须把你的整个 prompt 读一遍,一次前向并行算出所有输入 token 的 Key/Value,建好 KV cache。这一步高度并行、吃算力(compute bound),计算量随输入长度近似二次方增长——输入翻倍,计算量翻四倍。Prefill 跑完,第一个 token 才出生,所以 TTFT 反映的是 prefill 耗时。
Decode(解码)阶段,从第二个字开始逐 token 自回归生成,每个字都要把模型权重从显存搬一遍,吃显存带宽(memory bound),串行执行,决定的是 ITL,和首字无关。
两个阶段撞的是两堵不同的墙:Prefill 吃算力,Decode 吃带宽。这也是为什么有些团队把两阶段拆到不同硬件分开伺候(Disaggregated Serving),让 prefill 吃算力、decode 吃带宽,各自吃饱。
对 RAG 产品来说,这个问题尤其致命。你为了答得准,往 prompt 里塞十几段检索结果,每多塞一段,用户的首字就多等一截。更隐蔽的是,RAG 的 TTFT 不只有 prefill:用户请求进来后,还要先把 query 转成 embedding、去向量库检索、可能再 rerank、最后拼装 context——这一长串都发生在模型看到 prompt 之前。有一组 RAG 延迟拆解显示,检索加长 context 占了 45% 到 47%,剩下的才是 prefill 计算。你以为慢在模型,其实有一截慢在你自己喂进去的那堆 context 和取它的过程。
理解了链路,接下来要解决的是:怎么在一个稳定的通道上把这些指标量出来。这就需要一个统一的 API 入口,避免今天换一个供应商、明天改一次 base_url,测量口径全乱。
2. TaoToken 统一 API 通道:把测量口径固定下来
做 TTFT 优化,最怕的不是慢,而是测不准。你今天用 A 家的接口测出 800ms,明天换 B 家测出 1.2s,到底是模型变了、网络变了还是代码变了?说不清。所以第一步不是优化,而是把请求通道固定成一个统一入口,让每次测量的变量可控。
TaoToken 在这里扮演的角色就是一个统一 API 通道:它提供兼容 OpenAI 协议的接口,你原来的openaiSDK 代码几乎不用改,只换 base_url 和 key 就能跑。官网入口是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api (这个不加 UTM,直接用于代码里)。
为什么统一通道对 TTFT 优化这么重要?因为 TTFT 的组成里有一大块是网络传输 + 排队等待。如果你每次请求打到的节点不一样、连接没复用、DNS 每次重解析,那你的测量里就混进了一堆和模型无关的噪声。统一通道能让你:
第一,固定 base_url,所有测量在同一入口下进行,横向对比不同模型、不同 prompt 长度的 TTFT 才有意义。
第二,复用连接。HTTP 长连接(keep-alive)能省掉每次请求的 TCP 握手和 TLS 协商,这部分在首字延迟里能占到几十到上百毫秒,尤其是跨地域请求。用统一 SDK 客户端实例,连接池自动复用。
第三,统一鉴权。一个 key 走天下,不用在多个供应商之间切换配置,减少出错面。
我试过在同一个客户端实例上连续发 20 次请求,第一次 TTFT 明显偏高(包含建连开销),后面稳定下来。如果你每次请求都新建 client,那测出来的永远是“冷启动”数字,优化方向就偏了。
这里要强调一个概念:TaoToken 是统一 API 通道,不是让你绕过什么,而是把多模型、多协议的调用收敛到一个兼容 OpenAI 的入口,方便你做工程测量和切换。它的价值在于可观测性和一致性,而不是玄学加速。
拿到 key 之后,你需要记住三件套,后面所有配置都围绕它们:
- Base URL:
https://taotoken.net/api - API Key:在控制台创建,形如
sk-... - Model ID:你要测的模型标识,比如
gpt-4o、claude-3-5-sonnet之类,以控制台实际列表为准
这三件套在后面的 JSON、TOML、环境变量里会反复出现。任何一处写错,你测出来的 TTFT 都是假的——要么直接报错,要么打到了别的模型上。
关于 key 的获取,进入控制台后创建 API Key 即可,地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,创建完复制保存,页面关掉就看不到了。如果你还没决定用哪个模型,可以先去模型对话页面 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 手动聊两句,确认模型可用、响应正常,再回到代码里做自动化测量。
把通道固定下来之后,下一步才是真正可复制的配置。很多人卡在“我知道要测 TTFT,但代码怎么写、环境变量怎么配”这一步。下面直接给可复制的片段。
3. 可复制配置:环境变量、JSON 与 SDK 初始化
这一节给的是能直接抄的配置。核心原则:key 不进代码,走环境变量;base_url 写死统一入口;model 单独抽出来方便切换对比。
先配环境变量。Linux/macOS 下写到~/.bashrc或~/.zshrc:
export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api"Windows PowerShell:
$env:TAOTOKEN_API_KEY="sk-你的key" $env:TAOTOKEN_BASE_URL="https://taotoken.net/api"如果你用配置文件管理,可以写一个config.json,路径放在项目根目录,注意别提交到 git:
{ "base_url": "https://taotoken.net/api", "api_key_env": "TAOTOKEN_API_KEY", "default_model": "gpt-4o", "timeout_seconds": 60, "max_retries": 2 }如果你用 TOML(比如某些 CLI 工具或自建脚本),等价写法:
[taotoken] base_url = "https://taotoken.net/api" api_key_env = "TAOTOKEN_API_KEY" default_model = "gpt-4o" timeout_seconds = 60 max_retries = 2Python 侧初始化,关键是复用同一个 client 实例,别在循环里 new:
import os from openai import OpenAI client = OpenAI( api_key=os.environ["TAOTOKEN_API_KEY"], base_url=os.environ["TAOTOKEN_BASE_URL"], timeout=60.0, max_retries=2, ) MODEL_ID = "gpt-4o" # 换成控制台里实际的 Model IDNode.js 侧等价写法:
import OpenAI from "openai"; const client = new OpenAI({ apiKey: process.env.TAOTOKEN_API_KEY, baseURL: process.env.TAOTOKEN_BASE_URL, timeout: 60000, maxRetries: 2, }); const MODEL_ID = "gpt-4o";如果你用 Claude Code 这类工具,配置通常落在~/.claude/settings.json或项目级 settings 里,把 base_url 和 key 指到统一通道即可。以 settings 片段为例:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "sk-你的key" } }注意:不同工具的环境变量名不一样,Claude Code 用ANTHROPIC_BASE_URL,OpenAI SDK 用base_url,Codex 的auth.json里则是另一套字段。三件套必须齐全:Base URL 指向https://taotoken.net/api,Key 用你创建的那把,Model ID 填控制台里真实存在的标识。少一个都会报错,后面排障章节会逐个对照。
如果你用 Cline 或带 MCP 的编辑器插件,配置里同样要写全三件套。以 Cline 的 provider 配置为例,选择 OpenAI Compatible,Base URL 填https://taotoken.net/api,API Key 填你的 key,Model ID 填模型标识。MCP 场景下注意别把生产库直连进去,MCP 只做工具调用通道,数据源要隔离。
配置写完,先别急着优化,先跑通一次请求确认通道没问题。下一节给测量脚本和成功结果的样子。
4. 验证请求:TTFT 测量脚本与成功结果判读
配置对不对,跑一次就知道。这一节给一个完整的 TTFT 测量脚本,能直接复制运行,并告诉你什么样的输出算成功。
import os import time import statistics from openai import OpenAI client = OpenAI( api_key=os.environ["TAOTOKEN_API_KEY"], base_url=os.environ["TAOTOKEN_BASE_URL"], timeout=60.0, ) MODEL_ID = "gpt-4o" PROMPT = "用三句话解释什么是注意力机制" def measure_ttft(prompt: str, runs: int = 5): ttfts = [] for i in range(runs): t0 = time.time() stream = client.chat.completions.create( model=MODEL_ID, messages=[{"role": "user", "content": prompt}], stream=True, ) first_token_time = None for chunk in stream: if chunk.choices and chunk.choices[0].delta.content: first_token_time = time.time() - t0 break if first_token_time is not None: ttfts.append(first_token_time) print(f"run {i+1}: TTFT = {first_token_time:.3f}s") # 主动关闭剩余流,避免占用连接 stream.close() if ttfts: print(f"\n中位数 TTFT = {statistics.median(ttfts):.3f}s") print(f"最小值 = {min(ttfts):.3f}s 最大值 = {max(ttfts):.3f}s") return ttfts if __name__ == "__main__": measure_ttft(PROMPT, runs=5)几个关键点。第一,stream=True打开流式。第二,遍历 chunk 时判断delta.content非空,第一个非空 chunk 到达的时间就是 TTFT。第三,测完立刻break并stream.close(),否则后面的 token 还在推,连接被占着,影响下一次测量。第四,跑 5 次取中位数,别只看单次——网络抖动会让单次数字失真。
成功的结果长这样:
run 1: TTFT = 0.842s run 2: TTFT = 0.615s run 3: TTFT = 0.598s run 4: TTFT = 0.631s run 5: TTFT = 0.607s 中位数 TTFT = 0.615s 最小值 = 0.598s 最大值 = 0.842s第一次偏高是正常的,包含建连开销。后面稳定在 0.6s 左右,说明通道通了、连接复用了、模型正常响应。如果五次都在 0.6s 上下小幅波动,这就是你的基线,后面所有优化都跟这个基线比。
接下来做两个对照实验,验证前面讲的原理。
实验一:固定输出长度,拉长输入。把 PROMPT 从一句话换成一段 5000 字的长文本,再测。你会看到 TTFT 明显上涨,可能从 0.6s 涨到 2s 以上。这验证了 TTFT 由输入长度决定。
实验二:固定输入,拉长输出。把max_tokens从 200 调到 4000,PROMPT 不变,再测。TTFT 基本纹丝不动。这验证了 TTFT 和输出长度无关。
一来一回两个对照,胜过背十遍定义。做完这两个实验,你对 TTFT 的直觉就建立起来了。
如果你要测的是 reasoning 模型,注意它的“第一个 token”很可能是思考链的开头,不是答案的开头。如果你的产品不展示思考过程,用户感知的首字延迟要等到思考链 decode 完才出现,可能是几秒到几十秒。这时候测量脚本要额外记录“首个答案 token 延迟”,别被传统 TTFT 骗了。
通道验证通过、基线建立之后,就可以进入排障环节了。下面把最常见的几类报错逐个对照。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
这一节按真实报错来。你跑上面的脚本,大概率会撞到下面几类,逐个对照解决。
401 Unauthorized / invalid api key
最常见。原因通常是 key 没读到、key 写错、或者环境变量没生效。排查顺序:先在终端echo $TAOTOKEN_API_KEY看有没有值;再看代码里是不是os.environ["TAOTOKEN_API_KEY"]拼错了;最后确认 key 没有多余空格或换行。如果你把 key 写进了config.json又提交到了 git,赶紧去控制台吊销重建。401 的本质是鉴权失败,和模型、网络都无关,先把 key 这条链路捋直。
local proxy failed / connection refused
这个报错通常出现在你本地配了某个代理,但代理没起来或者端口不对。注意:这里说的是你本地开发环境的网络配置问题,不是让你去搞什么特殊通道。排查:检查环境变量里有没有HTTP_PROXY、HTTPS_PROXY指向一个不存在的端口;检查你的 base_url 是不是写成了http://而不是https://;确认https://taotoken.net/api能正常访问。如果是公司内网,确认出口策略允许访问该域名。把代理相关环境变量临时清掉再试:
unset HTTP_PROXY HTTPS_PROXY ALL_PROXYreading choices / 'NoneType' object has no attribute 'choices'
这个报错说明你拿到的 chunk 结构和你预期的不一样。常见原因:第一,你用的不是流式,却按流式解析;第二,某些 chunk 的choices是空数组,你直接chunk.choices[0]就炸了。正确写法是先判断:
for chunk in stream: if not chunk.choices: continue delta = chunk.choices[0].delta if delta and delta.content: # 处理内容 ...还有一种情况是模型返回了错误信息而不是正常 chunk,这时候要打印原始 chunk 看看到底返回了什么。别硬解析,先看数据。
OAuth / authentication failed(Claude Code 等工具场景)
如果你在 Claude Code 或类似工具里配了统一通道,却报 OAuth 相关错误,通常是环境变量名不对。Claude Code 认的是ANTHROPIC_BASE_URL和ANTHROPIC_API_KEY,你写成OPENAI_BASE_URL它不认。对照你用的工具文档,把变量名改对。另外,某些工具会缓存旧的鉴权信息,改完配置要重启工具或清缓存。
Codex auth.json 场景
Codex 类工具的鉴权信息落在auth.json里,字段名和 OpenAI SDK 不一样。如果你在这里配,要确认三件套齐全:base_url 指向https://taotoken.net/api,key 填对,model 填控制台里真实存在的 ID。改完auth.json记得重启,别让旧进程读着旧配置。
模型不存在 / model not found
Model ID 写错了。去控制台看实际可用的模型列表,复制准确的标识。别凭记忆写,大小写、连字符都可能不一样。
超时 / timeout
请求发出去了但迟迟没响应。先确认是不是 prompt 太长导致 prefill 时间过长,把 prompt 缩短再试。如果短 prompt 也超时,检查网络和 base_url。timeout 设 60 秒是合理的,别设太短,长 prompt 的 prefill 本身就要几百毫秒到几秒。
排障的核心思路:先分清是鉴权问题、网络问题还是数据解析问题。401 是鉴权,connection refused 是网络,reading choices 是解析,OAuth 是配置字段。分清了,解决就快。
通道跑通、报错清零之后,如果你要长期做编码或 Agent 类应用,可以考虑用 Coding Plan 把额度固定下来,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API Keys 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。
6. 把 TTFT 压下去:从测量到优化的落地顺序
前面把链路、通道、配置、测量、排障都走了一遍,最后落到优化动作上。记住一个原则:先量后调,先分清排队还是 prefill,再决定上什么手段。
一个请求的首字延迟大致等于:网络传输 + 排队等待 + prefill 计算。8 秒的锅是排队还是 prefill,决定了完全不同的打法。
排查第一步,看是不是排队。高并发下最隐蔽的杀手是队头阻塞:一个超长 prompt 进来,它的 prefill 占满 GPU 这一拍,排在后面的请求只能干等。结果 TTFT 呈双峰分布——p50 看着正常,p95/p99 比 p50 差 5 到 10 倍。所以排查第一步是看 p95/p99 而不是平均值,看到双峰基本就是排队问题。
排队问题的解法是 Chunked Prefill:把一个长 prompt 的 prefill 切成固定大小的块,块与块之间插进其他请求的 decode 步,让长 prefill 不再独占一整拍。这套思路源自 Sarathi-Serve,vLLM 新架构已经默认打开。它真正改善的是尾部,p95/p99 的 TTFT 会明显被压下来,代价是 p50 可能略微变高。它治的是双峰里那条长尾,平均首字未必更快。
排查第二步,看 prefill 本身能不能省。最有效的一招是前缀缓存(Prefix Caching):把已经算过的 prompt 前缀的 KV cache 存下来,下次来的请求只要前缀一样,直接复用,跳过这部分 prefill。命中缓存时 TTFT 降幅非常夸张,实测有从 4.3 秒降到 0.6 秒、降幅 86% 的案例,生产 Agent 流量也有 480ms 降到 110ms、降幅 77% 的数据。
但前缀缓存有两个工程陷阱。陷阱一:缓存失效是二元的,按前缀逐块匹配,Position 0 改一个 token,整条前缀的缓存全废,没有模糊匹配。最佳实践是把稳定的内容(system prompt、工具定义)放最前面当固定前缀,把用户输入、时间戳这类每次都变的东西放最后。陷阱二:缓存默认是单节点的,4 个节点 round-robin 负载均衡,同一个 prompt 有 3/4 的请求会打到没预热这条前缀的节点。多副本部署要配合按前缀路由,否则缓存命中率全被负载均衡稀释掉。
落地顺序建议:先看 p95 分清排队还是 prefill,再决定上 chunked prefill 还是前缀缓存,最后才考虑缩输入和扩容。监控盯三个数:TTFT p95、缓存命中率、每副本缓存利用率。命中率一掉就是流量变了或 prompt 模板被改了。
还有一个容易被忽略的点:reasoning 模型时代,传统 TTFT 指标被改写了。reasoning 模型先想后答,会先生成几百到几千个思考 token。如果产品不展示思考过程,用户要等到整段思考链 decode 完、答案的第一个字才冒出来,这时用户真正感知的首字延迟可能是几秒到几十秒。所以 reasoning 产品该把“首个答案 token 延迟”单独拉出来当一级指标,传统 TTFT 退居二线。
最后给一个我踩过的坑:一开始我盯着平均值优化,把 p50 从 0.8s 压到 0.5s,结果用户投诉没减少。后来看 p99 才发现尾部一直在 6 秒以上,是排队问题,跟平均值没关系。换成看 p95/p99 之后,方向才对。所以别被平均值骗了,尾部才是用户体验的真实写照。
把这段时间拆开、量出来、压下去,往往比换一个贵一倍的模型实在得多。流式把用户的注意力从总时长偷偷换成了首字,这是个聪明的设计。可一旦第一个字本身要等一整段思考,这个设计就穿帮了——到那时候,要么把思考摊开给用户看,要么换一个不用逐字排队的生成范式。能把流式讲顺的人不少,但能说清第一个字之前到底发生了什么的人不多。后者优化延迟时,手里有的是刀,而不是只有钱包。