恒定200-token负载测试只测量曲线上一个点,Prompt长度非线性影响prefill时间和成本;均匀长度还会掩盖prefix caching收益和batch调度差异。
一次负载测试如果每次都发送同一个 200 token 的提示词五万次,它实际上只测量了曲线上一个点,然后把这个点当作整条曲线来报告。提示词长度驱动 prefill 时间,prefill 时间驱动首 token 时间,而首 token 时间占了 p95 的大部分——它还直接决定了账单上输入侧的费用。如果测试的长度分布和线上生产的不一样,那么得到的任何一个数字都无法迁移到真实场景。
当提示词长度固定时,三种不同的误差会叠加在一起。
延迟在对的地方呈非线性增长。Prefill 开销随序列长度增长快于线性,因为 attention 是二次方的。在均值长度上测试然后做乘法并不能还原尾部;那些产生最差请求的长提示词,恰恰是均值长度测试永远不会发出的。
Prompt Caching 会搭便车。相同的提示词完美共享前缀。提供 prefix 缓存的服务商会从温缓存中服务后续请求,因此固定提示词测试报告的首 token 时间是真实用户永远看不到的。这个值得专门提出来,因为它会让测试结果看起来很好看。
批处理行为不同。服务器在批处理统一请求时能整齐地填充批次。混合长度会带来填充和调度效应,这些只在混合负载下才会显现。
解决办法不是去猜一个更好的固定长度,而是从你自己的流量中采集分布样本,让测试以和生产环境相同的比例发出 80 token 和 9000 token 的请求。
提示词长度是一个有下限、正值、大量集中在中间、右侧有长尾的量:大量短轮次、适量中等长度、少量巨大的请求(携带粘贴的文档或完整的对话历史)。这种形状正是对数正态分布的用武之地,它通常是一个足够好的拟合,既有用又不假装是某种精确模型。
你不需要做解析拟合,对大多数团队来说更好的选择也是不做拟合。如果你有带 token 数量的请求日志,经验分布就是分布:导出这些数量,存进一个文件,然后从文件里采样。这样同时去掉了所有建模假设。只有在没有日志的时候——新端点、新功能——才用拟合的对数正态,并在测试中说明你这样做了。
# From your own request logs, one integer per line.
# psql -Atc "select input_tokens from requests
# where created_at > now() - interval '7 days'" > prompt-tokens.txt
import statistics
lengths = [int(line) for line in open("prompt-tokens.txt")]
lengths.sort()
def pct(p):
return lengths[int(len(lengths) * p) - 1]
print("n ", len(lengths))
print("median ", statistics.median(lengths))
print("p90 ", pct(0.90))
print("p99 ", pct(0.99))
在继续之前先看 median 和 p99 的比值。如果只有两倍,说明提示词长度不是你的问题,用固定提示词也够了。如果有三十倍——这对任何允许用户粘贴文本的场景来说都很常见——那说明单长度测试一直在给你讲一个关于错误端点的故事。
Locust 文档化的 user 类是 HttpUser,任务用 @task 装饰器声明,节奏由 wait_time 控制。在 LLM 端点上需要小心的是时间计算:流式响应有两个延迟——首 token 时间和总时间——而 HTTP client 每次请求只能报告一个。Locust 的文档化出口是自己触发 request 事件,传入参数 request_type、name、response_time、response_length、response、context 和 exception。用两个不同的 name 触发两次,就能得到两个独立的百分位表。
# locustfile.py
import random, time
from locust import HttpUser, task, between
# Sampled from real logs. Loaded once per worker process.
CORPUS = open("corpus.txt").read().split()
LENGTHS = [int(l) for l in open("prompt-tokens.txt")]
def sample_prompt():
# ~0.75 words per token is a rough English ratio; if you need the
# real count, encode with the tokenizer your provider documents.
target_tokens = random.choice(LENGTHS)
words = max(1, int(target_tokens * 0.75))
body = " ".join(random.choice(CORPUS) for _ in range(words))
return target_tokens, "Summarise the following in one sentence.\n\n" + body
class ChatUser(HttpUser):
wait_time = between(1, 3)
@task
def chat(self):
target_tokens, prompt = sample_prompt()
bucket = "small" if target_tokens < 1000 else "large"
start = time.perf_counter()
first = None
chars = 0
try:
with self.client.post(
"/v1/chat/completions",
json={
"model": "your-model",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 256,
"stream": True,
},
headers={"Authorization": "Bearer " + self.environment.parsed_options.api_key},
stream=True,
name="/v1/chat/completions [" + bucket + "]",
catch_response=True,
) as response:
for line in response.iter_lines():
if not line:
continue
if first is None:
first = time.perf_counter()
chars += len(line)
if first is None:
response.failure("no tokens received")
else:
response.success()
except Exception as exc:
self.environment.events.request.fire(
request_type="POST", name="ttft [" + bucket + "]",
response_time=(time.perf_counter() - start) * 1000,
response_length=0, response=None, context={}, exception=exc,
)
return
self.environment.events.request.fire(
request_type="POST", name="ttft [" + bucket + "]",
response_time=(first - start) * 1000,
response_length=chars, response=None, context={}, exception=None,
)
其中有两个细节是承重的。name 参数把请求分到不同的统计行里,这样你能得到短提示词和长提示词各自的百分位,而不是一个把长尾完全隐藏掉的混合表。corpus 是打乱顺序的词而不是一个重复的段落,因为重复段落会重新制造出整个做法要避免的 prompt-cache 问题。
Locust 的事件和 client API 记录在 docs.locust.io 上。request 事件的参数在各大版本间有变化,复制前要对照你锁定的版本核实一下。
对输入采样然后把 max_tokens 固定为一个常数,只解决了一半问题,还悄悄地偏置了另一半。输出 token 是一个一个生成的,所以它们主导着总时间,而且通常是账单上更贵的部分。如果每个响应恰好都是 256 token,测试报告的总时间 p95 完全没有 spread。
也从你记录下来的 completion 长度中采样 max_tokens,并且要注意这是上限而不是目标:模型在该停的时候停。要得到真实分布的实际输出长度,你需要用问法差异足够大的提示词——这是另一个要用真实请求(去掉敏感部分)来构建语料库而不是用 lorem-ipsum 生成器的理由。如果你把真实请求拉到 fixture 里,要把它当作数据处理决策而非单纯的测试决策:在语料库提交之前先脱敏,在内容本身对测量不重要的地方优先使用合成文本。
报告每个 bucket 的百分位,不要报告均值。均值在一个重尾分布上会被拉到几乎所有实际发生的请求之上,而在这个工作负载上尾部才是整个重点。
从同一次运行中可以计算出两个有价值的派生数字。第一个是每千请求成本:用采样输入长度的均值乘以输入价格,加上观察到的输出长度的均值乘以输出价格,再乘以一千。在结果旁边同时说明两个价格和读取日期,因为它们会变动。第二个是每个 bucket 中首 token 时间与总时间的比值,这能告诉你 prefill 还是 decode 是你的瓶颈,从而决定缩短提示词还是缩短回答才是杠杆。在大提示词 bucket 上这两个方向往往相反,混合报告会完全掩盖这一点。
最后,把采样的语料库和长度文件与 locustfile 一起放进版本控制。一次输入分布在两次运行之间悄无声息地改变的负载测试,是一个无法和自己比较的基准,而做比较才是你跑两次的原因。