用户感知的从来不是总时延,是首 token 出现的那一刻。
延迟是 LLM 产品里最容易优化错地方的指标。大多数人盯着「端到端总耗时」一个数字,砍输出长度、换更小的模型——结果瓶颈在 prefill,白忙一场。真正的 latency engineering 有两层完全不同的功夫:一层是真实时间(TTFT、TPOT 各自由什么决定,prompt caching 怎么把 prefill 砍掉),另一层是用户感知时间(streaming 一个 token 都没省却能让等待「感觉」减半,p99 而非平均数才是真实体验)。这两层用不同的杠杆。这一期讲四件事:把延迟拆成可优化的分量、用 streaming 优化感知、用 prompt caching 砍 TTFT 的单一最大杠杆、以及为什么 fan-out 会把尾延迟放大成常态——并给你一套能直接跑的测量与缓解代码。
一次生成的耗时由两段构成,机制不同、优化手段也不同:
关键推论:长输入伤 TTFT,长输出伤总时长(通过 N × TPOT),两者是不同的病。「我的 agent 慢」如果不分解,你根本不知道该治哪个。交互式应用(chat、coding)对 TTFT 和 TPOT 都敏感——首字要快,之后要顺。
任何优化前,先用 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)。
max_tokens 来「提速」,但瓶颈在 prefill 时这毫无用处。
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) # 用累积快照,可渲染"打字中"
原则:面向人的输出尽量流式,面向机器的结构尽量别在关键路径上流式。需要两者兼顾时,把它们拆成不同调用,让流式那条单独承载「感知速度」。
input_json_delta,但工具必须等参数完整才能执行;误以为能边流边调用会拿到半截参数报错。(3)网络层 buffer 或反代(nginx)开了缓冲,把 SSE 攒成一坨再发,前端「流式」其实一次性到达。
§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。
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 不稳定,缓存形同虚设。这是判断缓存到底有没有生效的唯一硬证据,别靠感觉。
cache_control 被静默忽略,你以为缓存了其实没有。
两件常被忽视的事。其一,独立子任务该并行: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、重试、雪崩,越优化越慢。
gather 几百个并发,撞 rate limit 触发 429 重试,整体反而更慢。(3)对有依赖的步骤强行并行——下一步要上一步结果,并行只会拿到半成品。
把四点串成一次半小时的体检,顺序就是优化的优先级:
cache_control,盯 usage 确认 cache_read 起来了。这是性价比最高的一步。gather(带 Semaphore),然后跑 100 次记录 p50/p95/p99——你几乎一定会发现 p99 远超想象。做完这套,你对「慢」的归因会从「模型不行」升级到「TTFT 还是 TPOT?真实还是感知?p50 还是 p99?」——这才是 latency engineering 的思维方式。