Files
liqiang b119135836
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

488 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
本地 LLM 服务性能基准(实验 2-1 配套)
本脚本通过 OpenAI 兼容接口(vLLM 或 Ollama 均可)测量本地部署的小模型在
「服务(serving)」层面的三个核心指标,帮助读者建立对吞吐 / 延迟 / 批处理 /
KV Cache 的直觉:
1. throughput —— 单流解码吞吐(tokens/s)与首 token 延迟(TTFT
2. kv-cache —— 前缀缓存命中 vs 未命中的 TTFT 对比
(对应实验 2-1 第 5 点:系统提示词不变时缓存命中更快,
修改系统提示词开头几个字符导致缓存失效、需重算整个前缀)
3. batching —— 不同并发度下的聚合吞吐,直观展示批处理带来的吞吐提升
所有数字均来自真实服务端的实测,脚本本身不产生任何合成数据。
如果尚未启动服务端,可用 --dry-run 离线查看每个场景将要发出的请求配置。
示例:
# 先启动服务端(二选一)
python server.py # vLLM(需要 NVIDIA GPU
ollama serve && ollama pull qwen3:0.6b # OllamaMac / 无 GPU
# 跑全部场景并保存结果
python benchmark.py --scenario all --output results.json
# 只看 KV Cache 命中 / 未命中的 TTFT 对比
python benchmark.py --scenario kv-cache --backend ollama
# 批处理吞吐扫描
python benchmark.py --scenario batching --concurrency 1,2,4,8
"""
import argparse
import json
import logging
import statistics
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger("benchmark")
# 各后端的默认 OpenAI 兼容地址
BACKEND_DEFAULTS = {
"vllm": {"base_url": "http://localhost:8000/v1", "model": "Qwen3-0.6B"},
"ollama": {"base_url": "http://localhost:11434/v1", "model": "qwen3:0.6b"},
}
# 一段确定性的填充文本,用于把共享前缀撑长,让 KV Cache 的效果更明显
_FILLER_SENTENCE = (
"You are a meticulous assistant that follows the operating manual precisely. "
)
def build_padded_system_prompt(target_tokens: int) -> str:
"""构造一个约含 target_tokens 个 token 的系统提示词(用重复句子填充)。
这里用「4 字符 ≈ 1 token」的粗略估计来控制长度,只需保证前缀足够长、
可复现即可,不追求精确的 token 数。
"""
header = (
"# Operating Manual\n"
"You are a helpful local assistant deployed for the AI Agent book experiment.\n\n"
)
approx_chars = max(0, target_tokens * 4 - len(header))
repeats = approx_chars // len(_FILLER_SENTENCE) + 1
body = _FILLER_SENTENCE * repeats
return header + body
def make_client(base_url: str, api_key: str):
"""创建 OpenAI 兼容客户端。"""
try:
from openai import OpenAI
except ImportError:
logger.error("缺少依赖 openai,请先执行:pip install openai")
sys.exit(1)
return OpenAI(base_url=base_url, api_key=api_key)
def stream_once(
client,
model: str,
messages: List[Dict[str, str]],
max_tokens: int,
temperature: float,
) -> Dict[str, float]:
"""发起一次流式请求,返回 TTFT、总时长、输出 token 数与解码吞吐。
- ttft:从发起请求到收到第一个内容或推理分片的时间(秒)
- total:整个响应的墙钟时间(秒)
- output_tokens:优先取服务端返回的 usage.completion_tokens
否则用收到的内容分片数量作为近似
- decode_tps:解码阶段吞吐 = 输出 token / (总时长 - TTFT)
"""
start = time.perf_counter()
ttft: Optional[float] = None
chunk_count = 0
usage_tokens: Optional[int] = None
stream = client.chat.completions.create(
model=model,
messages=messages,
max_tokens=max_tokens,
temperature=temperature,
stream=True,
stream_options={"include_usage": True},
)
for chunk in stream:
# 最后一个分片可能只携带 usage 而没有 choices
if getattr(chunk, "usage", None) is not None:
try:
usage_tokens = chunk.usage.completion_tokens
except AttributeError:
pass
if not chunk.choices:
continue
delta = chunk.choices[0].delta
text = (
getattr(delta, "content", None)
or getattr(delta, "reasoning_content", None)
or getattr(delta, "reasoning", None)
)
if text:
if ttft is None:
ttft = time.perf_counter() - start
chunk_count += 1
total = time.perf_counter() - start
if ttft is None:
ttft = total
output_tokens = usage_tokens if usage_tokens is not None else chunk_count
decode_time = max(total - ttft, 1e-6)
decode_tps = output_tokens / decode_time if output_tokens else 0.0
return {
"ttft": ttft,
"total": total,
"output_tokens": float(output_tokens),
"decode_tps": decode_tps,
}
# --------------------------------------------------------------------------- #
# 场景实现
# --------------------------------------------------------------------------- #
def scenario_throughput(client, model, args) -> Dict[str, Any]:
"""单流吞吐 + TTFT:连续发起若干次解码密集的请求并汇总统计。"""
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{
"role": "user",
"content": "Write a detailed explanation of how KV Cache works in transformer inference.",
},
]
runs = []
for i in range(args.repeats):
r = stream_once(client, model, messages, args.max_tokens, args.temperature)
runs.append(r)
logger.info(
"throughput 第 %d/%d 次: TTFT=%.3fs, 解码=%.1f tok/s, 输出=%d tok",
i + 1, args.repeats, r["ttft"], r["decode_tps"], int(r["output_tokens"]),
)
return {
"scenario": "throughput",
"repeats": args.repeats,
"ttft_mean_s": statistics.fmean(x["ttft"] for x in runs),
"decode_tps_mean": statistics.fmean(x["decode_tps"] for x in runs),
"output_tokens_mean": statistics.fmean(x["output_tokens"] for x in runs),
"runs": runs,
}
def scenario_kv_cache(client, model, args) -> Dict[str, Any]:
"""KV Cache 命中 vs 未命中的 TTFT 对比(实验 2-1 第 5 点)。
- 命中组:系统提示词逐字节不变,重复发送同一请求,服务端前缀缓存命中,
prefill 几乎可以跳过 → TTFT 明显更低。
- 未命中组:每次只在系统提示词「开头」插入一个不同的计数串,前缀被改写,
缓存全部失效,服务端必须重算整个前缀 → TTFT 明显更高。
两组的提示词长度基本一致,因此差异主要来自前缀缓存是否命中。
"""
base_prompt = build_padded_system_prompt(args.prefix_tokens)
user_msg = {"role": "user", "content": "In one short sentence, say hello."}
# 预热:先发一次把缓存写入(这一次一定是冷启动,不计入统计)
warm_msgs = [{"role": "system", "content": base_prompt}, user_msg]
stream_once(client, model, warm_msgs, args.max_tokens, args.temperature)
hit_ttfts, miss_ttfts = [], []
for i in range(args.repeats):
# 命中:完全相同的前缀
hit = stream_once(client, model, warm_msgs, args.max_tokens, args.temperature)
hit_ttfts.append(hit["ttft"])
# 未命中:在开头插入唯一前缀,使缓存失效
mutated = f"[req-{i}-{time.time_ns()}] " + base_prompt
miss_msgs = [{"role": "system", "content": mutated}, user_msg]
miss = stream_once(client, model, miss_msgs, args.max_tokens, args.temperature)
miss_ttfts.append(miss["ttft"])
logger.info(
"kv-cache 第 %d/%d 次: 命中 TTFT=%.3fs, 未命中 TTFT=%.3fs",
i + 1, args.repeats, hit["ttft"], miss["ttft"],
)
hit_mean = statistics.fmean(hit_ttfts)
miss_mean = statistics.fmean(miss_ttfts)
return {
"scenario": "kv-cache",
"prefix_tokens_approx": args.prefix_tokens,
"repeats": args.repeats,
"ttft_hit_mean_s": hit_mean,
"ttft_miss_mean_s": miss_mean,
"speedup": (miss_mean / hit_mean) if hit_mean > 0 else None,
"ttft_hit_s": hit_ttfts,
"ttft_miss_s": miss_ttfts,
}
def scenario_batching(client, model, args) -> Dict[str, Any]:
"""批处理:在不同并发度下并发发起请求,测量聚合吞吐。
连续批处理(continuous batching)是本地 serving 的核心优化:并发越高,
GPU 利用率越充分,系统聚合吞吐(所有请求合计 tok/s)通常显著上升,
但单个请求的延迟可能上升。此场景把这个权衡直接量化出来。
"""
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Explain what a large language model is."},
]
levels = args.concurrency
rows = []
for level in levels:
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=level) as pool:
futures = [
pool.submit(
stream_once, client, model, messages, args.max_tokens, args.temperature
)
for _ in range(level)
]
results = [f.result() for f in futures]
wall = time.perf_counter() - start
total_tokens = sum(r["output_tokens"] for r in results)
agg_tps = total_tokens / wall if wall > 0 else 0.0
per_req_tps = agg_tps / level if level else 0.0
rows.append(
{
"concurrency": level,
"wall_s": wall,
"total_output_tokens": total_tokens,
"aggregate_tps": agg_tps,
"per_request_tps": per_req_tps,
"ttft_mean_s": statistics.fmean(r["ttft"] for r in results),
}
)
logger.info(
"batching 并发=%d: 聚合吞吐=%.1f tok/s, 单请求=%.1f tok/s, 墙钟=%.2fs",
level, agg_tps, per_req_tps, wall,
)
return {"scenario": "batching", "levels": rows}
# --------------------------------------------------------------------------- #
# 结果表格
# --------------------------------------------------------------------------- #
def print_report(results: List[Dict[str, Any]]) -> None:
print("\n" + "=" * 68)
print("本地 LLM 服务基准结果")
print("=" * 68)
for res in results:
s = res["scenario"]
if s == "throughput":
print("\n[throughput] 单流吞吐 / 首 token 延迟")
print(f" 次数 : {res['repeats']}")
print(f" 平均 TTFT : {res['ttft_mean_s']:.3f} s")
print(f" 平均解码吞吐 : {res['decode_tps_mean']:.1f} tok/s")
print(f" 平均输出长度 : {res['output_tokens_mean']:.0f} tok")
elif s == "kv-cache":
print("\n[kv-cache] 前缀缓存命中 vs 未命中(TTFT")
print(f" 前缀长度(约) : {res['prefix_tokens_approx']} tok")
print(f" 命中平均 TTFT : {res['ttft_hit_mean_s']:.3f} s")
print(f" 未命中平均TTFT : {res['ttft_miss_mean_s']:.3f} s")
if res.get("speedup"):
print(f" 缓存加速比 : {res['speedup']:.2f}x")
elif s == "batching":
print("\n[batching] 并发度对聚合吞吐的影响")
print(f" {'并发':>4} | {'聚合tok/s':>10} | {'单请求tok/s':>12} | {'平均TTFT(s)':>11} | {'墙钟(s)':>8}")
print(f" {'-'*4}-+-{'-'*10}-+-{'-'*12}-+-{'-'*11}-+-{'-'*8}")
for row in res["levels"]:
print(
f" {row['concurrency']:>4} | {row['aggregate_tps']:>10.1f} | "
f"{row['per_request_tps']:>12.1f} | {row['ttft_mean_s']:>11.3f} | {row['wall_s']:>8.2f}"
)
print("\n" + "=" * 68)
def describe_dry_run(args) -> None:
"""离线打印将要执行的场景配置,不访问服务端。"""
print("=" * 68)
print("DRY RUN —— 仅打印计划,不访问服务端")
print("=" * 68)
print(f"后端 : {args.backend}")
print(f"base_url : {args.base_url}")
print(f"模型 : {args.model}")
print(f"重复次数 : {args.repeats}")
print(f"max_tokens : {args.max_tokens}")
print(f"temperature : {args.temperature}")
scenarios = ["throughput", "kv-cache", "batching"] if args.scenario == "all" else [args.scenario]
print(f"待运行场景 : {', '.join(scenarios)}")
if "kv-cache" in scenarios:
prompt = build_padded_system_prompt(args.prefix_tokens)
print(f" kv-cache : 填充前缀约 {args.prefix_tokens} tok(实际 {len(prompt)} 字符)")
if "batching" in scenarios:
print(f" batching : 并发扫描 {args.concurrency}")
print("=" * 68)
def parse_concurrency(value: str) -> List[int]:
try:
levels = [int(x) for x in value.split(",") if x.strip()]
except ValueError:
raise argparse.ArgumentTypeError("--concurrency 需为逗号分隔的正整数,例如 1,2,4,8")
if not levels or any(x <= 0 for x in levels):
raise argparse.ArgumentTypeError("--concurrency 中的并发度必须为正整数")
return levels
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="本地 LLM 服务性能基准:吞吐 / 延迟 / KV Cache / 批处理(实验 2-1 配套)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"场景说明:\n"
" throughput 单流解码吞吐(tok/s)与首 token 延迟(TTFT)\n"
" kv-cache 前缀缓存命中 vs 未命中的 TTFT 对比\n"
" batching 不同并发度下的聚合吞吐(批处理权衡)\n"
" all 依次运行以上全部场景\n"
),
)
parser.add_argument(
"--scenario",
choices=["throughput", "kv-cache", "batching", "all"],
default="all",
help="要运行的基准场景(默认: all",
)
parser.add_argument(
"--backend",
choices=["vllm", "ollama"],
default="vllm",
help="服务端类型,用于推断默认地址与模型名(默认: vllm)",
)
parser.add_argument(
"--base-url",
type=str,
default=None,
help="OpenAI 兼容接口地址,覆盖后端默认值(如 http://localhost:8000/v1",
)
parser.add_argument(
"--model",
type=str,
default=None,
help="模型名,覆盖后端默认值(vLLM 默认 Qwen3-0.6BOllama 默认 qwen3:0.6b",
)
parser.add_argument(
"--api-key",
type=str,
default="EMPTY",
help="API Key,本地服务端一般无需真实值(默认: EMPTY)",
)
parser.add_argument(
"--repeats",
type=int,
default=5,
help="throughput / kv-cache 场景的重复次数(默认: 5",
)
parser.add_argument(
"--max-tokens",
type=int,
default=256,
help="每次请求的最大生成 token 数(默认: 256",
)
parser.add_argument(
"--temperature",
type=float,
default=0.7,
help="采样温度(默认: 0.7",
)
parser.add_argument(
"--prefix-tokens",
type=int,
default=1024,
help="kv-cache 场景中共享前缀的近似 token 长度,越长缓存效果越明显(默认: 1024)",
)
parser.add_argument(
"--concurrency",
type=parse_concurrency,
default=[1, 2, 4, 8],
help="batching 场景的并发度列表,逗号分隔(默认: 1,2,4,8",
)
parser.add_argument(
"--output",
type=str,
default=None,
help="将结果以 JSON 写入指定文件",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="离线打印计划而不访问服务端,用于验证配置",
)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
# 用后端默认值补全 base_url / model
defaults = BACKEND_DEFAULTS[args.backend]
if args.base_url is None:
args.base_url = defaults["base_url"]
if args.model is None:
args.model = defaults["model"]
print("=" * 68)
print("🚀 本地 LLM 服务性能基准(实验 2-1)")
print("=" * 68)
if args.dry_run:
describe_dry_run(args)
return 0
client = make_client(args.base_url, args.api_key)
logger.info("连接服务端: %s(模型: %s", args.base_url, args.model)
scenarios = (
["throughput", "kv-cache", "batching"]
if args.scenario == "all"
else [args.scenario]
)
dispatch = {
"throughput": scenario_throughput,
"kv-cache": scenario_kv_cache,
"batching": scenario_batching,
}
results: List[Dict[str, Any]] = []
try:
for name in scenarios:
logger.info("开始场景: %s", name)
results.append(dispatch[name](client, args.model, args))
except Exception as e: # noqa: BLE001
logger.error("基准执行失败: %s", e)
logger.info(
"请确认服务端已启动:vLLM 用 `python server.py`"
"Ollama 用 `ollama serve` 并已 `ollama pull %s`",
args.model,
)
return 1
print_report(results)
if args.output:
payload = {
"backend": args.backend,
"base_url": args.base_url,
"model": args.model,
"results": results,
}
with open(args.output, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
logger.info("结果已写入: %s", args.output)
return 0
if __name__ == "__main__":
sys.exit(main())