DAY 15 / PHASE 2 · 应用与系统

Latency Engineering

TTFT vs TPOT · Streaming · Prompt Caching · Tail Latency

2026-05-29 · BigCat

用户感知的从来不是总时延,是首 token 出现的那一刻。

// WHY THIS MATTERS

延迟是 LLM 产品里最容易优化错地方的指标。大多数人盯着「端到端总耗时」一个数字,砍输出长度、换更小的模型——结果瓶颈在 prefill,白忙一场。真正的 latency engineering 有两层完全不同的功夫:一层是真实时间(TTFT、TPOT 各自由什么决定,prompt caching 怎么把 prefill 砍掉),另一层是用户感知时间(streaming 一个 token 都没省却能让等待「感觉」减半,p99 而非平均数才是真实体验)。这两层用不同的杠杆。这一期讲四件事:把延迟拆成可优化的分量、用 streaming 优化感知、用 prompt caching 砍 TTFT 的单一最大杠杆、以及为什么 fan-out 会把尾延迟放大成常态——并给你一套能直接跑的测量与缓解代码。

// 01

拆解延迟:TTFT vs TPOT,先量再优化

论断:端到端延迟 = TTFT + N × TPOT;不先分解就动手优化,十有八九砍错地方。

背景与原理

一次生成的耗时由两段构成,机制不同、优化手段也不同:

关键推论:长输入伤 TTFT,长输出伤总时长(通过 N × TPOT),两者是不同的病。「我的 agent 慢」如果不分解,你根本不知道该治哪个。交互式应用(chat、coding)对 TTFT 和 TPOT 都敏感——首字要快,之后要顺。

请求 ───────────────────────────────────────────▶ 时间 │◀─ TTFT ─▶│◀──────── N × TPOT ──────────▶│ │ (prefill) │ (decode,逐 token 吐) │ 非流式: [════════ 全程盯 spinner ════════]→ 一次性看到全部 流式: [等 TTFT]→ 首 token 出现 → 逐字滚动 → 完成 ▲ 用户「感知延迟」≈ TTFT,而非总时长

实战示例

任何优化前,先用 streaming 把两个分量测出来——这是 latency 工作的第一步:

import anthropic, time
client = anthropic.Anthropic()

t0 = time.perf_counter(); ttft = None; n = 0
with client.messages.stream(model="claude-sonnet-4-6", max_tokens=1024,
        messages=[{"role":"user","content": prompt}]) as s:
    for text in s.text_stream:
        if ttft is None: ttft = time.perf_counter() - t0   # 首 token 时间
        n += len(text)
total = time.perf_counter() - t0
tpot = (total - ttft) / max(n, 1)
print(f"TTFT={ttft*1000:.0f}ms  TPOT≈{tpot*1000:.1f}ms/char  total={total*1000:.0f}ms")

把这段跑在你的真实 prompt 上。如果 TTFT 占大头 → 病在输入(prompt 太长 / 没缓存),见 §3;如果 total 大头是 N×TPOT → 病在输出(让模型少说、或换更快的 decode)。

失败模式:用「平均 token 数 × 单价」估延迟。延迟和 token 数不是线性同构——同样 2000 token,全在输入(伤 TTFT)和全在输出(伤总时长)体验天差地别。还有人靠砍 max_tokens 来「提速」,但瓶颈在 prefill 时这毫无用处。
进阶资源 · vLLM Metrics(TTFT / TPOT / ITL 的标准定义), docs.vllm.ai/.../metrics · Day 14《Inference Optimization》— 为何 decode 是 memory-bound。
// 02

Streaming:优化的是「感知延迟」,不是真实延迟

论断:流式一个 token 都没省,却能把用户的等待从「总时长」压到「TTFT」——这是最便宜的体验优化。

背景与原理

Streaming 不改变任何真实耗时:总时间、总 token、总成本都一样。它改变的是用户什么时候开始看到内容。非流式下用户盯着 spinner 等完整个 N×TPOT;流式下等到 TTFT 就开始逐字滚动,感知等待 ≈ TTFT。一个 8 秒生成的回答,非流式是「8 秒空白」,流式是「0.5 秒出字、边读边出」——同样的 8 秒,体验是两个量级。

技术上 Claude API 用 SSE(Server-Sent Events):设 stream:true,服务端单向推一串事件——content_block_delta 携带增量文本,最后 message_stop 收尾。官方 SDK 的 messages.stream() 帮你把原始事件封装成 text_stream 迭代器,不用手解析。

实战示例

流式真正的工程坑在结构化输出:如果下游要完整 JSON 才能用,流式收益归零——你得等最后一个 } 才能 parse,中间的 partial JSON 用不了。两个解法:

# 坑:流式 JSON,但 UI 要等完整对象才能渲染 → 等于没流式

# 解法 A:把"给人看的散文"和"给机器的结构"拆成两次调用
#   散文那次流式(用户立刻看到),结构那次不流式(后台解析)

# 解法 B:流式但增量解析——边收边喂 partial JSON parser
with client.messages.stream(model="claude-sonnet-4-6", max_tokens=2048,
        tools=[summary_tool],
        messages=msgs) as s:
    for ev in s:
        if ev.type == "input_json":           # 工具入参的增量 JSON
            render_partial(ev.snapshot)        # 用累积快照,可渲染"打字中"

原则:面向人的输出尽量流式,面向机器的结构尽量别在关键路径上流式。需要两者兼顾时,把它们拆成不同调用,让流式那条单独承载「感知速度」。

失败模式:(1)流式 + 强制完整 JSON 才渲染——流式白做,用户还是等到底。(2)工具调用的入参也是流式 input_json_delta,但工具必须等参数完整才能执行;误以为能边流边调用会拿到半截参数报错。(3)网络层 buffer 或反代(nginx)开了缓冲,把 SSE 攒成一坨再发,前端「流式」其实一次性到达。
进阶资源 · Anthropic Streaming Messages 文档(SSE 事件类型、SDK 流式), docs.claude.com/.../streaming
// 03

Prompt Caching:砍 TTFT 的单一最大杠杆

论断:TTFT 主要花在 prefill;缓存命中 = 跳过重算 prefix = TTFT 断崖式下降,还顺带省钱。

背景与原理

§1 说了 TTFT 大头是 prefill——把整段输入过一遍算 KV cache。但很多请求的输入前缀是重复的:同一套 system prompt、同一组 tool schema、同一批 few-shot、多轮对话里不变的历史。Prompt caching 让服务端把这段 prefix 的 KV 算一次存住,下次命中就直接跳过 prefill,TTFT 从「随全长增长」降到「只算未命中的尾巴」。这是 TTFT 优化里杠杆最大的一招,对长 system prompt / 大 few-shot / RAG 长上下文的场景收益最猛。

三个必须记住的工程约束:(1) 缓存是 prefix-match——从头逐字节匹配,前缀任何一字节变了,整段失效。(2) 命中读取只花基础输入价的 ~10%,但写入要 ~1.25×(首次建缓存更贵)。(3) 默认 TTL 5 分钟、每次命中刷新;可加 "ttl":"1h" 买 1 小时(更贵)。还有最小门槛:Sonnet ~1024 token、Opus 4.5+ ~4096 token 才可缓存,最多 4 个 breakpoint。

prefix-match:稳定的放前,易变的放后 ┌───────────────────────────────────────────────┐ │ [system prompt] ←稳定 ┐ │ │ [tool schemas] ←稳定 ├ cache_control 命中→ │ 跳过 prefill │ [few-shot 示例] ←稳定 ┘ (breakpoint) │ TTFT 暴跌 │ ───────────────────────────────────────────── │ │ [对话历史] ←半稳定 cache_control │ 增量缓存 │ ───────────────────────────────────────────── │ │ [本轮 user 输入] ←每次变 (不缓存,放最后) │ └───────────────────────────────────────────────┘ 把易变内容放前面 = 每次 cache miss = 杠杆直接归零

实战示例

resp = client.messages.create(
    model="claude-sonnet-4-6", max_tokens=1024,
    system=[
        {"type":"text", "text": LONG_STABLE_INSTRUCTIONS},   # 稳定
        {"type":"text", "text": FEWSHOT_EXAMPLES,
         "cache_control": {"type":"ephemeral"}},        # ← 缓存到此处
    ],
    messages=[{"role":"user", "content": user_query}],       # 每次变,放最后
)
print(resp.usage)  # cache_creation_input_tokens / cache_read_input_tokens

上线后必须盯 usage 里的两个字段cache_read 高 = 命中良好;cache_creation 持续偏高 = 你的 prefix 不稳定,缓存形同虚设。这是判断缓存到底有没有生效的唯一硬证据,别靠感觉。

失败模式:(1)把时间戳、随机 ID、用户名拼进 system prompt 开头——前缀每次都变,缓存永远 miss。(2)稀疏流量:请求间隔 > 5 分钟,缓存到下一次已过期,白付了 1.25× 的写入费。(3)prefix 没到最小 token 门槛,cache_control 被静默忽略,你以为缓存了其实没有。
进阶资源 · Anthropic Prompt Caching 文档(cache_control / TTL / 最小门槛 / breakpoint), docs.claude.com/.../prompt-caching
// 04

并行化与尾延迟:p99 才是用户的真实体验

论断:平均延迟是给老板看的营销数字,p99 才是体验;fan-out 会把尾部放大成常态。

背景与原理

两件常被忽视的事。其一,独立子任务该并行:N 个互不依赖的调用,串行耗时是 Σ,并行(asyncio.gather)耗时是 max。把「逐个问」改成「一起问」往往是最直接的提速。

其二,也是更深的坑:尾延迟在 fan-out 下会被放大。这是 Google 的 Dean & Barroso 在《The Tail at Scale》(CACM 2013)点出的经典现象——当一个用户请求扇出成 K 个并行子调用,整体延迟取决于最慢的那一个(max),于是单个调用的 p99「偶发慢」会变成聚合请求的常态。算一下就触目惊心:单调用 1% 概率慢,K=20 时至少一个慢的概率 ≈ 1 − 0.99²⁰ ≈ 18%;K=100 时 ≈ 63%。所以一个服务的 p99 会变成它上游聚合请求的 p50。优化平均数毫无意义,要治的是尾巴。

实战示例

import asyncio, anthropic
client = anthropic.AsyncAnthropic()
sem = asyncio.Semaphore(8)   # 有界并发,别打爆 rate limit

async def ask(q):
    async with sem:
        r = await client.messages.create(model="claude-sonnet-4-6",
            max_tokens=512, messages=[{"role":"user","content": q}])
    return r.content[0].text

# 并行:整体 ≈ max(latency),而非 Σ(latency)
results = await asyncio.gather(*(ask(q) for q in questions))

# 治尾巴:hedged request——过了 p95 还没回,补发一份,取先到的
async def hedged(q, p95):
    a = asyncio.create_task(ask(q))
    done, _ = await asyncio.wait({a}, timeout=p95)
    if done: return a.result()
    b = asyncio.create_task(ask(q))                       # 补发第二份
    done, pend = await asyncio.wait({a, b}, return_when=asyncio.FIRST_COMPLETED)
    for t in pend: t.cancel()
    return done.pop().result()

Hedging 用约 5% 的额外调用量,换 p99 的大幅下降——这是《The Tail at Scale》给的经典处方。但它和「有界并发」要配合:无限并发只会触发 429、重试、雪崩,越优化越慢。

失败模式:(1)用平均延迟做 SLO——平均 800ms 很漂亮,但 p99 是 12 秒,那 1% 用户每天都在等到放弃。(2)无脑 gather 几百个并发,撞 rate limit 触发 429 重试,整体反而更慢。(3)对有依赖的步骤强行并行——下一步要上一步结果,并行只会拿到半成品。
进阶资源 · Dean & Barroso《The Tail at Scale》, CACM 2013, cacm.acm.org/research/the-tail-at-scale · Anthropic AsyncAnthropic / rate limit 文档(并发与 429 处理)。

// 综合实战 · 给你的 LLM app 做一次 latency 体检

把四点串成一次半小时的体检,顺序就是优化的优先级:

  1. 先量再说:用 §1 的代码在真实 prompt 上测出 TTFT / TPOT / total 三个数。没有这三个数,后面全是瞎猜。
  2. TTFT 大 → 先上缓存:检查输入前缀是否稳定,把 system + tools + few-shot 提到最前并打 cache_control,盯 usage 确认 cache_read 起来了。这是性价比最高的一步。
  3. 感知层 → 全程 streaming:面向人的输出一律流式;要结构化的拆成单独调用,别让 JSON 卡住感知速度。
  4. 多调用 → 并行 + 测 p99:独立子任务 gather(带 Semaphore),然后跑 100 次记录 p50/p95/p99——你几乎一定会发现 p99 远超想象。
  5. 尾巴痛 → 上 hedging:对延迟敏感的关键路径加 hedged request,用 ~5% 额外量压平 p99。

做完这套,你对「慢」的归因会从「模型不行」升级到「TTFT 还是 TPOT?真实还是感知?p50 还是 p99?」——这才是 latency engineering 的思维方式。

// ENGLISH GLOSSARY

TTFT (Time To First Token)
发请求到吐出首 token 的时间 = 排队 + prefill + 网络。决定用户「感知」的等待。
TPOT / ITL
Time Per Output Token / Inter-Token Latency,逐 token 解码时相邻两 token 的间隔,近似恒定。
Prefill
把整段输入 prompt 过一遍、算 KV cache 的阶段,计算密集,随输入长度增长——TTFT 的主因。
Decode
逐 token 自回归生成阶段,访存密集,决定 TPOT。
Streaming / SSE
用 Server-Sent Events 把输出增量推给客户端,优化感知延迟而非真实延迟。
Prompt Caching
缓存稳定 prefix 的 KV,命中即跳过 prefill。prefix-match,读 ~10%、写 ~1.25×,默认 5 分钟 TTL。
Cache Breakpoint
用 cache_control 标记的缓存边界,最多 4 个,控制哪几段可独立缓存。
p50 / p95 / p99
延迟分位数。p99 = 最慢 1% 的延迟,比平均数更能反映真实体验。
Tail Amplification
fan-out 下整体延迟取 max,单调用的 p99 会变成聚合请求的 p50(《The Tail at Scale》)。
Hedged Request
过了 p95 还没回就补发一份重复请求,取先到的,用少量额外量压平尾延迟。

// 深入思考

Streaming 不省一毫秒真实时间,却被当成"提速"。这种"感知优化"还能用在 LLM 产品的哪些地方?边界在哪?
同源思路:先回流式的"思考中/检索中"占位文本(让 TTFT 感觉更短)、把长任务拆成可见的中间产物(plan → 草稿 → 终稿)、骨架屏。本质都是「用首个可见信号换掉空白等待」。边界:当用户需要的是结果的正确性而非过程感时(如一次性 API 返回、批处理、机器消费),感知优化无意义甚至有害——流式 JSON 反而拖慢下游。感知优化只对「有人在屏幕前等」的交互路径有效。
Prompt caching 把"稳定内容放前面"。这和 Day 2 Context Engineering 的"重要内容放两头(lost-in-the-middle)"是否冲突?
不冲突,因为约束的是不同维度。Caching 约束的是跨请求的字节稳定性(前缀别变),lost-in-the-middle 约束的是单次请求内信息的注意力位置。稳定的 system/tools/few-shot 放最前正好两全:既是 cache 友好的前缀,又占据高注意力的开头。真正要避免的是把「当前任务最关键的指令」埋进中段——那既不在注意力高地,又因为常变而破坏缓存。两个原则指向同一个布局:稳定+重要的骨架在前,易变的查询在尾。
为什么 prefill 是计算密集而 decode 是访存密集?这个差异如何决定了"长输入"和"长输出"要用完全不同的优化手段?
Prefill 一次性并行处理全部输入 token,算力被喂满 → compute-bound,瓶颈是 FLOPs,所以缓存(跳过计算)是杠杆。Decode 每步只生成 1 个 token,却要把整个模型权重 + KV cache 从显存搬一遍,算力闲着等内存 → memory-bound,瓶颈是带宽,所以 batching、量化、speculative decoding(Day 14)是杠杆。结论:治 TTFT(长输入)靠"少算"(缓存),治 TPOT(长输出)靠"少搬"(批/量化/投机)——手段不可互换。
Hedged request 用 ~5% 额外调用换 p99 下降。这个 trade-off 在什么场景下会反过来变成净亏损?
三种情况翻车:(1) 系统已接近容量上限——补发的 5% 推高负载,反而制造更多 straggler,正反馈雪崩;(2) 调用本身昂贵(长输出 / 大模型)——5% 的额外 token 成本可能超过 p99 改善的业务价值;(3) 非幂等的副作用调用(写库、发消息、扣费)——补发会重复执行,必须先保证幂等或加去重。Hedging 的前提是「便宜、幂等、系统有余量」,否则 Dean&Barroso 自己也建议改用 tied request 或干脆不 hedge。
如果给你的 LLM 产品只能定一个 latency SLO 指标,你选 TTFT 还是 p99 端到端?为什么单选很危险?
交互式产品多半选「p99 TTFT」——它同时编码了"用户感知的起点"和"尾部体验"。但单选危险:只盯 TTFT,模型可能首 token 飞快、之后慢吞吞(TPOT 烂),总时长仍糟;只盯 p99 端到端,又会牺牲首字响应去优化总时长。成熟做法是分位数 × 分量的矩阵:至少同时设 p99 TTFT(感知)和 p99 total(完成)两个门,再按产品类型加权——chat 偏 TTFT,批量摘要偏 total。

// 延伸阅读