← 返回资讯

降低聚合层延迟:从 TTFT 到 P99 的优化实践

2026-07-27

聚合层引入了额外的网络跳数,但好的聚合方案增加的延迟通常不超过 20ms——这与模型推理本身的 500ms+ 相比微乎其微。真正影响用户体验的延迟来自上游模型、网络路径和你自己代码中的串行等待。本文梳理各层延迟来源与对应的优化手段。

延迟的构成分析

一次完整的聚合层调用延迟 = 客户端→网关(网络)+ 网关处理 + 网关→上游(网络)+ 模型推理 + 响应回传

延迟段典型值优化空间
客户端→网关5–50ms选就近节点
网关内部处理1–10ms连接复用、避免同步 I/O
网关→上游(同区域)5–20ms选同区域部署
网关→上游(跨境)80–300ms专线/就近出口
模型推理(TTFT)200–2000ms选快速模型、预热
Token 生成速度20–80 token/s取决于模型和服务商负载

关键结论:跨境网络延迟(80–300ms)和模型推理延迟是大头,网关自身处理通常不是瓶颈。

这里多说一句原理,很多人调优调了半天却没抓到重点,就是没搞懂 TTFT 到底是怎么产生的。模型推理内部分成两个阶段:**Prefill(预填充)**和 Decode(解码)。Prefill 是把你输入的整段 prompt 一次性喂给模型做前向计算,生成第一个 token 之前,模型要把 prompt 里每个 token 的注意力都算一遍——所以 TTFT 几乎完全由 Prefill 阶段决定,而 Prefill 的耗时基本跟 输入长度 成正比。这就是为什么同样的模型,一个 200 字的短 prompt 和一个塞满 8K 上下文的长 prompt,TTFT 能差出 3-5 倍。Decode 阶段则是逐 token 生成,每生成一个 token 只需要看前面已经生成的部分,耗时相对稳定,这就是 Token 生成速度(token/s)为什么能保持相对恒定的原因。

搞清楚这个之后,你会发现一个反直觉的结论:减少 TTFT 最直接的手段往往不是换模型,而是精简你的 prompt。如果你的系统提示词(system prompt)里塞了几千字的规则说明,或者每次都把完整的历史对话原样传回去,Prefill 阶段的耗时会随之线性增长。实践中我见过一个客服场景,把系统提示词从 3000 字精简到 800 字(只保留必要规则,把长尾说明改成按需检索),TTFT 从 1.4s 降到 600ms 左右,比换一个”更快”的模型效果还明显。

优化手段详解

1. 启用流式输出(Streaming)

流式输出是最有效的用户体感优化——用户看到第一个字的时间(TTFT,Time to First Token)比等待完整响应更重要:

# 启用流式输出
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[...],
    stream=True
)

for chunk in response:
    if chunk.choices[0].delta.content:
        print(chunk.choices[0].delta.content, end="", flush=True)

流式输出让 TTFT 从”等待全部生成完”降低到”等待第一个 token”,在输出较长时体验提升尤为明显。

这里有个坑很多人踩过:流式输出用的是 SSE(Server-Sent Events),如果你在网关和客户端之间套了一层反向代理(比如 Nginx),默认配置会把响应先缓冲再转发,导致流式输出变成”假流式”——客户端还是要等全部生成完才能一次性收到。排查方法很简单:如果你直连上游没问题,套了代理之后 TTFT 明显变长且跟总耗时接近,基本可以确定是代理缓冲的问题。Nginx 下需要显式关闭代理缓冲:

location /v1/ {
    proxy_pass http://upstream;
    proxy_buffering off;
    proxy_cache off;
    chunked_transfer_encoding on;
}

另外流式响应在客户端也要注意及时消费,如果你把 chunk 攒起来批量处理(比如攒够 100 个字符再渲染一次),相当于自己在客户端又实现了一层”伪缓冲”,白白抵消了流式输出带来的体感提升。

2. HTTP 连接复用

避免每次请求都重新建立 TCP + TLS 连接,使用持久连接:

import httpx

# 使用 httpx 的 AsyncClient 复用连接
async with httpx.AsyncClient(
    limits=httpx.Limits(max_keepalive_connections=20, max_connections=100)
) as client:
    response = await client.post(
        "https://api.your-gateway.com/v1/chat/completions",
        headers={"Authorization": f"Bearer {api_key}"},
        json={...}
    )

OpenAI Python SDK 默认已启用连接复用,不需要额外配置。对于高并发场景,确保连接池大小足够。

为什么连接复用能省下这么多时间?一次新建 HTTPS 连接要走完整的 TCP 三次握手加上 TLS 握手(TLS 1.2 是两次往返,TLS 1.3 优化到一次往返,但如果没有会话恢复缓存还是要算完整流程),跨境场景下一次往返都是 100ms+ 起步,握手加起来轻松吃掉 200-400ms,这个开销在短请求里占比相当可观。连接复用把这部分开销摊薄到只在建连接时发生一次,后续请求直接复用已经建好的连接,省下的就是这部分握手时间。

实践中我见过一个真实的坑:有团队每次请求都用 with httpx.Client() as client: 这种写法(在函数内部创建又立刻销毁客户端),表面上代码整洁,但实际上每次请求都在重新建连接,完全没有享受到连接池的好处。正确的做法是把 client 实例做成全局单例或者用依赖注入的方式在应用生命周期内复用,只在应用启动时创建一次、关闭时统一释放。判断你的连接池有没有生效很简单:用 httpx.Client() 加个事件钩子打印每次请求是否复用了连接,或者直接抓包看 TCP 连接数是否随请求数量线性增长——如果是,说明没复用上。

3. 延迟感知路由

聚合层实时监测各上游的 TTFT,自动将请求路由到当前响应最快的上游:

# LiteLLM 配置
router_settings:
  routing_strategy: latency-based-routing
  routing_strategy_args:
    ttft_deadline: 2.0        # TTFT 超过 2s 触发重路由
    lowest_latency_buffer: 0  # 0 = 始终选最快的

在多个上游的延迟差异较大时(如一个在亚太、一个在美东),这个策略效果显著。

但延迟感知路由不是配上就万事大吉,有两个取舍要想清楚。第一是”抖动”问题:如果只看最近一次请求的延迟就切换上游,遇到网络瞬时抖动会导致请求在多个上游之间来回跳,反而增加平均延迟——实践中一般用滑动窗口(比如最近 20 次请求的 P50)而不是单次采样来做路由决策。第二是延迟和质量的取舍:最快的上游不一定是最该选的上游,如果某个上游延迟低是因为它偷偷降级了模型版本或者截断了上下文,你需要在路由策略里加质量兜底(比如结合错误率、内容长度异常等指标),单纯看延迟排序容易被带偏。

4. 超时与快速失败

设置合理的超时,避免慢上游拖垮整体响应:

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[...],
    timeout=30.0   # 30 秒超时,超时后抛出异常
)

搭配 Fallback 机制:主上游超时后立即切换备用上游,而不是无限等待。

超时时间怎么定是个技术活,设太短容易误杀正常的慢请求,设太长又会让用户干等。这里给一个实用的经验法则:先统计你调用的模型的 TTFT 和总耗时的 P99(不是平均值,平均值会被少数快请求拉低,掩盖真实的尾部延迟),连接超时设为正常 P99 的 1.5-2 倍,读超时(流式场景下应该是”两个 chunk 之间的最大间隔”而不是整个响应的总时长)设为单个 chunk 间隔 P99 的 3 倍左右,避免模型长时间”卡壳”时你还在傻等。

实际踩过的报错和排查方式列一下,遇到时直接对号入座:

报错现象根因排查/修法
httpx.ConnectTimeout网络不通或握手阶段被墙/被限速先用 curl -v 测试是否能连通目标地址,排除网络问题再排查代码
httpx.ReadTimeout / openai.APITimeoutError上游推理耗时超过设置的 timeout,常见于长 prompt 或上游过载区分是 TTFT 慢还是总耗时慢:只有 TTFT 慢通常是上游排队,适合切换上游;TTFT 正常但总耗时超时可能是 token 生成速度慢,适合缩短 max_tokens 或换模型
大量并发下偶发 Connection pool timeout连接池 max_connections 设置过小,请求排队等连接调大 httpx.Limits 里的 max_connections,同时检查是不是有连接没有正确释放(比如异常分支忘了关闭响应流)
流式响应中途断流,日志显示 IncompleteRead反向代理或网关的空闲连接超时时间比模型生成总时长短,连接被中途掐断检查 Nginx/网关的 proxy_read_timeout,流式长输出场景要设置到覆盖预期最长生成时间(比如 120s 起)

搭配 Fallback 时还有个细节:不要在触发超时之后立刻原样重试同一个上游,那大概率还是超时,白白浪费一次超时等待的时间。正确的顺序是超时后立刻切到备用上游(或者降级到更快的小模型),把重试放在备用上游也失败之后再考虑,并且重试要带退避(下面会展开)。

5. 预热与并发控制

避免冷启动:对于有会话的场景,提前发送空请求或轻量请求预热连接。

控制并发:单个客户端对同一上游的并发请求过多会触发限流,反而降低吞吐量。合理设置并发上限:

import asyncio

semaphore = asyncio.Semaphore(20)  # 最多 20 个并发请求

async def call_api(message):
    async with semaphore:
        return await client.chat.completions.create(...)

这个 20 不是随便拍的,得结合你的上游限流额度倒推。假设你的账号在某个模型上的限流是 60 RPM(每分钟 60 次请求),那么稳态下同时在飞的请求数不该超过”限流 RPM ÷ 60 × 平均单次耗时(秒)“,比如平均单次耗时 3 秒,60 RPM 换算成每秒 1 次,同时在飞的合理并发大概是 3 左右,留出安全余量设成 5-8 比较稳妥。如果你把 semaphore 设到 50,超过限流阈值后大概率会看到 429 Too Many Requests,这时候不能靠”重试到成功为止”硬扛,而要做指数退避 + 抖动,直接重试只会让 429 更密集:

import random
import asyncio

async def call_with_backoff(func, max_retries=5):
    for attempt in range(max_retries):
        try:
            return await func()
        except RateLimitError:
            if attempt == max_retries - 1:
                raise
            # 指数退避 + 随机抖动,避免多个请求同时重试造成新一轮拥堵
            delay = min(2 ** attempt + random.uniform(0, 1), 30)
            await asyncio.sleep(delay)

抖动(jitter)这一步容易被忽略,但很关键:如果 10 个并发请求同时因为限流失败,且都按固定的 2 ** attempt 秒后重试,它们会在同一时刻再次一起打过去,相当于把拥堵原样复现了一遍。加上 random.uniform(0, 1) 的随机抖动之后,重试请求会错峰分散开,命中限流的概率明显降低。这套退避逻辑不只对 429 有效,5xx 类的上游临时故障、网络抖动导致的连接重置,用同样的策略处理即可,区别只是判断该不该重试的异常类型不同——4xx 里除了 429,其他大多是参数错误,重试没有意义,直接抛出让上层处理。

6. 选择延迟更低的模型

在质量满足要求的前提下,优先选择响应更快的模型:

场景推荐模型(低延迟)说明
实时对话GPT-4o-mini, Claude 3 Haiku专为低延迟设计
简单分类任何小模型模型越小越快
流式长文本看 token/s 而不是 TTFT生成速度影响总耗时

这张表背后有个容易搞混的取舍点:TTFT 低不等于总耗时短。有些小模型 TTFT 很快(Prefill 快),但 token/s 生成速度反而不如某些大模型(这跟具体推理引擎和服务商的调度策略有关,不是模型参数量越小生成越快这么简单)。如果你的场景是输出很短的分类、打标签、结构化提取,TTFT 基本就等于总耗时,直接按 TTFT 选模型没问题;但如果是长文写作、代码生成这类输出量大的场景,总耗时 ≈ TTFT + 输出 token 数 ÷ token/s,这时候 token/s 的权重要远大于 TTFT,选型前建议拿你的真实业务 prompt 实测一下两个指标,而不是只看官方宣传的”响应快”。

延迟监控

建立延迟监控是持续优化的前提:

  • 记录每次请求的 TTFT、总延迟、上游选择
  • 设置 P95/P99 告警阈值(不只看平均值)
  • 按模型、时间段分析延迟分布

只看平均值会漏掉最影响用户体验的那部分请求。举个例子,100 次请求里 95 次是 800ms,5 次因为上游排队冲到 8s,平均值算出来才 1.2s 左右,看起来很健康,但那 5% 用户的真实体验是”卡住了”。P99 才能暴露这种尾部延迟问题,这也是为什么云服务商的 SLA 基本都用 P99/P999 而不是平均值来定义。

自己动手的话,最简单的落地方式是在网关的请求日志里加三个字段:请求发出时间戳、首字节返回时间戳(算出 TTFT)、响应完全结束时间戳(算出总耗时),再加上命中的上游标识。有了这三个字段,用日志聚合工具(哪怕只是每天跑一次 Python 脚本读日志算分位数)就能画出延迟分布趋势,不需要一上来就上 Prometheus + Grafana 这种重量级方案。等请求量起来之后再考虑接入专门的可观测性工具,把 TTFT、token/s、错误率、上游命中分布都做成看板,具体怎么搭建见聚合层日志与可观测性实践

自查一下你现在的监控是不是够用:如果你只能回答”平均延迟是多少”,答不出”P99 延迟是多少""哪个上游贡献了大部分尾部延迟”,说明监控颗粒度还不够,建议先把这两个指标补上再谈进一步优化,不然优化了半天可能都没优化在真正的瓶颈上。

常见问题

聚合层本身会成为瓶颈吗? 在请求量很大时可能会。自建聚合层(LiteLLM Proxy 等)需要关注其单机 QPS 上限,必要时水平扩展。托管服务通常已经做了横向扩展,不需要用户关心。

跨境延迟有办法优化吗? 在中国大陆访问境外模型(OpenAI、Anthropic)不可避免有跨境延迟。优化手段包括:使用在香港/新加坡有出口的专线;或者选择有境内镜像的聚合服务。力达云聚合 API 对大陆访问做了专线优化,可申请内测评估效果。

流式输出对服务端压力更大吗? 从 token 生成量来看没有区别,只是传输方式不同。流式响应对服务端连接的占用时间更长,高并发场景需要确保网关有足够的连接数支持。


延伸阅读: