diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec43dc6 --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# ⚡ LLM 速度测试台 + +大模型推理性能基准测试工具,用于测量不同上下文长度下的 **预填充速度(prefill)**、**首字延迟(TTFT)** 与 **解码吞吐(decode)**,并以网页 + Excel 形式输出结果。 + +- **访问地址:** `http://:16097/` +- **技术栈:** Python 3 + Flask + SQLite(纯 REST,无额外依赖) +- **版本:** v2.0.0 + +--- + +## 功能特性 + +### 🔌 大模型接口配置 +- 支持 **OpenAI 兼容**(OpenAI / DeepSeek / 火山方舟 / 任意兼容网关)、**Anthropic(Claude)**、**Google Gemini** 三类提供商 +- 配置项:配置名称、Base URL(留空自动使用官方默认)、API Key(可显示/隐藏)、模型名称、温度 +- 配置可**保存/加载/删除**,方便多模型对比 + +### 🚀 速度测试配置 +- **多上下文长度测试**:默认为 `512 / 2048 / 8192 / 32768 / 131072` tokens,可通过标签点击启用/禁用,并支持手动添加任意自定义长度(≥16) +- **解码输出长度(max tokens)**:默认为 `128`,可手动自定义 +- **每个长度采样次数**:默认为 `2`,可手动自定义 +- **测试前预热(空转)**:默认开启,先发一次不计速度的空转请求,避免冷启动/首请求偏慢污染真实采样数据 +- **避免缓存**:默认开启,为每次采样追加随机前缀,测量真实预填充性能 + +### 📊 指标与结果 +- 实时指标卡:首字延迟、预填充速度、解码速度、上文/输出 tokens、总耗时 +- 实时控制台日志:校准、预热、每次采样明细全程可追溯 +- **每次完整测试**支持: + - **网页点击查看**:历史记录「查看」按钮弹出详情(整体平均 + 按上下文长度分组 + 每次采样明细 + 完整日志) + - **文件下载 Excel(xlsx)**:历史记录「Excel」按钮或详情弹窗「导出 Excel」,包含 汇总 / 采样明细 / 日志 三个 Sheet +- 测试历史留存,可随时刷新、查看、导出、删除 + +--- + +## 快速开始 + +### 环境要求 +- Python 3.10+(推荐使用 openclaw conda 环境) +- 依赖见 `requirements.txt` + +### 安装 +```bash +pip install -r requirements.txt +# 或使用 conda 环境 +/home/hz1/miniconda3/envs/openclaw/bin/pip install -r requirements.txt +``` + +### 启动 +```bash +./start.sh # 启动(默认端口 16097) +./start.sh stop # 停止 +./start.sh restart # 重启 +``` + +启动成功后访问 `http://:16097/`。 + +--- + +## 使用说明 + +1. **配置接口**:选择提供商 → 填写配置名称 / Base URL(可留空)/ API Key / 模型名称 → 点「保存」可留存,或直接点「🔍 测试连接」验证连通性 +2. **配置测试参数**:勾选要测试的上下文长度(默认 5 档),设置解码输出长度与采样次数,按需开关预热/避免缓存 +3. **开始测试**:点「▶ 开始测试」,右侧实时展示指标与日志;可随时「■ 停止」 +4. **查看与导出**:测试完成后,在「测试历史」中点「查看」看完整详情,点「Excel」或详情内「导出 Excel」下载 xlsx 报告 + +### 指标含义 +| 指标 | 含义 | +|------|------| +| 首字延迟 TTFT (ms) | 从请求发出到收到第一个 token 的时间(含预填充) | +| 预填充速度 (tok/s) | prompt tokens / 首字延迟,衡量上文处理吞吐 | +| 解码速度 (tok/s) | 输出 tokens / 解码阶段耗时,衡量逐 token 生成吞吐 | +| 上下文/输出 tokens | 实际发送的提示词 token 数与模型返回的 token 数 | + +--- + +## API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/health` | 健康检查 | +| GET/POST | `/api/configs` | 配置列表 / 新增配置 | +| GET/DELETE | `/api/configs/` | 单个配置 / 删除 | +| POST | `/api/configs/test` | 测试连接 | +| GET/POST | `/api/tests` | 测试历史 / 启动测试 | +| GET | `/api/tests/` | 测试详情(含 runs / logs / summary) | +| GET | `/api/tests//logs?after=` | 增量日志(前端轮询用) | +| POST | `/api/tests//cancel` | 停止测试 | +| DELETE | `/api/tests/` | 删除测试 | +| GET | `/api/tests//export.xlsx` | 导出 Excel 报告 | + +### 启动测试请求示例 +```json +POST /api/tests +{ + "config": { + "provider": "openai", + "name": "DeepSeek-V4", + "base_url": "https://api.deepseek.com/v1", + "api_key": "sk-xxx", + "model": "deepseek-chat", + "temperature": 0.7 + }, + "gen": { + "context_lengths": [512, 2048, 8192, 32768, 131072], + "max_tokens": 128, + "samples": 2, + "warmup": true, + "avoid_cache": true + } +} +``` + +--- + +## 项目结构 + +``` +llm-speed-tester/ +├── app.py # Flask 主应用(路由 + Excel 导出) +├── config.py # 全局配置(端口/超时/路径) +├── database.py # SQLite 存储(配置/测试/采样/日志) +├── tester.py # 测试执行器(校准 → 预热 → 多长度采样 → 汇总) +├── llm_providers.py # 提供商适配器(OpenAI / Anthropic / Gemini) +├── requirements.txt +├── start.sh # 启动/停止脚本 +├── static/ # 前端(index.html / js/app.js / css/style.css) +├── data/ # SQLite 数据库(llm_speed_tester.db) +└── logs/ # 运行日志 + PID +``` + +### 测试执行流程 +1. **校准**:发送一个短探测请求,估算 `token/字符` 比例 +2. **逐上下文长度**:对每个选中的长度构造对应 token 量的基准提示词 +3. **预热(空转,不计速度)**:每个长度先发一次请求,规避冷启动偏差 +4. **采样**:每个长度按采样次数重复请求,记录每次指标 +5. **汇总**:按长度分组 + 整体平均,写入测试记录 + +--- + +## 常见问题 + +- **无 openpyxl**:`pip install openpyxl`(已加入 requirements.txt) +- **老版本数据库**:程序启动时自动迁移,为 `test_runs` 表补充 `context_length` 列,无需手动处理 +- **慢模型超时**:连接超时 30s、两次数据包间隔 120s,足够覆盖大多数慢模型;超长文(131072)生成慢属正常,请耐心等待 + +--- + +## Git + +- **仓库:** `hz4th_coder/llm-speed-tester` +- **版本:** v2.0.0(新增多上下文长度测试 + 预热 + Excel 导出 + 界面优化) diff --git a/app.py b/app.py index c1c1ebd..96cece5 100644 --- a/app.py +++ b/app.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- """LLM 速度测试台 - Flask 主应用""" +import io import json -from flask import Flask, jsonify, request, send_from_directory +from flask import Flask, jsonify, request, send_file, send_from_directory import config import database as db @@ -139,5 +140,150 @@ def del_test(tid): return jsonify({"ok": True}) +# ───────────────────────── Excel 导出 ───────────────────────── + +@app.route("/api/tests//export.xlsx") +def export_xlsx(tid): + t = db.get_test(tid) + if not t: + return jsonify({"ok": False, "error": "测试不存在"}), 404 + t["runs"] = db.get_runs(tid) + t["logs"] = db.get_logs(tid) + try: + data = _build_xlsx(t) + except Exception as e: + return jsonify({"ok": False, "error": "导出失败: %s" % e}), 500 + return send_file(data, as_attachment=True, + download_name="llm_speed_test_%d.xlsx" % tid, + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + + +def _build_xlsx(t): + from openpyxl import Workbook + from openpyxl.styles import Alignment, Font, PatternFill + + s = t.get("summary") or {} + g = t.get("gen") or {} + cfg = t.get("config") or {} + by_length = s.get("by_length") or {} + runs = t.get("runs") or [] + logs = t.get("logs") or [] + + wb = Workbook() + head_fill = PatternFill("solid", fgColor="2A3550") + head_font = Font(color="FFFFFF", bold=True) + title_font = Font(bold=True, size=12) + + def style_header(ws, row, ncol): + for c in range(1, ncol + 1): + cell = ws.cell(row=row, column=c) + cell.fill = head_fill + cell.font = head_font + cell.alignment = Alignment(horizontal="center", vertical="center") + + # ── Sheet1 汇总 ── + ws = wb.active + ws.title = "汇总" + ws.append(["LLM 速度测试报告"]) + ws.cell(1, 1).font = Font(bold=True, size=14) + info = [ + ["测试编号", "#%d" % t["id"]], + ["创建时间", t.get("created_at", "")], + ["状态", t.get("status", "")], + ["提供商", t.get("provider", "")], + ["模型", t.get("model", "")], + ["Base URL", cfg.get("base_url") or "(默认)"], + ["上下文长度列表", " / ".join(str(x) for x in (g.get("context_lengths") or []))], + ["生成长度(max tokens)", g.get("max_tokens", 128)], + ["每个长度采样次数", g.get("samples", 2)], + ["预热(空转)", "开" if g.get("warmup", True) else "关"], + ["避免缓存", "开" if g.get("avoid_cache") else "关"], + ["采样(成功/总数)", "%s / %s" % (s.get("samples_ok"), s.get("samples_total"))], + ["校准 字符/token", s.get("calibration_chars_per_token") or "—"], + ["错误信息", t.get("error") or ""], + ] + for row in info: + ws.append(row) + ws.cell(14, 1).font = title_font + r0 = len(info) + 2 + overall = [ + ["平均首字延迟(ms)", s.get("avg_ttft_ms")], + ["最佳首字延迟(ms)", s.get("best_ttft_ms")], + ["平均预填充速度(tok/s)", s.get("avg_prefill_speed")], + ["平均解码速度(tok/s)", s.get("avg_decode_speed")], + ["平均提示词(tok)", s.get("avg_prompt_tokens")], + ["平均输出(tok)", s.get("avg_output_tokens")], + ["平均总耗时(ms)", s.get("avg_total_ms")], + ] + ws.cell(r0, 1, "整体平均指标").font = title_font + for i, row in enumerate(overall, start=r0 + 1): + ws.append([]) + for j, v in enumerate(row, start=1): + ws.cell(row=i, column=j, value=v) + + # 按上下文长度分组 + r1 = r0 + len(overall) + 2 + ws.cell(r1, 1, "按上下文长度分组").font = title_font + cols = ["上下文长度(tok)", "采样(成功/总数)", "首字ms", "预填充tok/s", "解码tok/s", "提示词tok", "输出tok", "总耗时ms"] + ws.append([]) + for j, c in enumerate(cols, start=1): + ws.cell(row=r1 + 1, column=j, value=c) + style_header(ws, r1 + 1, len(cols)) + if by_length: + rr = r1 + 2 + for L in sorted(int(k) for k in by_length): + bl = by_length[str(L)] if str(L) in by_length else by_length[L] + ws.cell(row=rr, column=1, value=L) + ws.cell(row=rr, column=2, value="%s / %s" % (bl.get("samples_ok"), bl.get("samples_total"))) + ws.cell(row=rr, column=3, value=bl.get("avg_ttft_ms")) + ws.cell(row=rr, column=4, value=bl.get("avg_prefill_speed")) + ws.cell(row=rr, column=5, value=bl.get("avg_decode_speed")) + ws.cell(row=rr, column=6, value=bl.get("avg_prompt_tokens")) + ws.cell(row=rr, column=7, value=bl.get("avg_output_tokens")) + ws.cell(row=rr, column=8, value=bl.get("avg_total_ms")) + rr += 1 + else: + ws.cell(row=r1 + 2, column=1, value="(无成功采样数据)") + for col, w in zip("ABCDEFGH", [22, 20, 12, 14, 14, 12, 12, 14]): + ws.column_dimensions[col].width = w + + # ── Sheet2 采样明细 ── + ws2 = wb.create_sheet("采样明细") + h2 = ["序号", "上下文长度tok", "提示词tok", "缓存tok", "首字ms", "预填充tok/s", + "输出tok", "解码tok/s", "总耗时ms", "备注"] + ws2.append(h2) + style_header(ws2, 1, len(h2)) + for i, r in enumerate(runs, start=1): + m = r.get("metrics") or {} + ws2.append([ + i, + r.get("context_length") or m.get("context_length") or "", + m.get("prompt_tokens") or "", + m.get("cached_tokens") if m.get("cached_tokens") else "", + m.get("ttft_ms"), + m.get("prefill_speed"), + m.get("output_tokens"), + m.get("decode_speed"), + m.get("total_ms"), + r.get("error") or "OK", + ]) + for col, w in zip("ABCDEFGHIJ", [8, 14, 12, 10, 12, 14, 12, 14, 12, 30]): + ws2.column_dimensions[col].width = w + + # ── Sheet3 日志 ── + ws3 = wb.create_sheet("日志") + ws3.append(["相对时间(s)", "级别", "内容"]) + style_header(ws3, 1, 3) + for l in logs: + ws3.append([l.get("rel", 0), l.get("level", ""), l.get("msg", "")]) + for col, w in zip("ABC", [14, 10, 90]): + ws3.column_dimensions[col].width = w + + bio = io.BytesIO() + wb.save(bio) + bio.seek(0) + return bio + + if __name__ == "__main__": app.run(host=config.HOST, port=config.PORT, threaded=True, debug=False) diff --git a/database.py b/database.py index 966c87f..d3d663f 100644 --- a/database.py +++ b/database.py @@ -39,6 +39,7 @@ CREATE TABLE IF NOT EXISTS test_runs( id INTEGER PRIMARY KEY AUTOINCREMENT, test_id INTEGER NOT NULL, run_index INTEGER DEFAULT 0, + context_length INTEGER DEFAULT 0, metrics_json TEXT DEFAULT '{}', error TEXT DEFAULT '' ); @@ -65,11 +66,20 @@ def _connect(): return conn +def _migrate(conn): + """老库升级:为 test_runs 补 context_length 列""" + cur = conn.execute("PRAGMA table_info(test_runs)") + cols = [r[1] for r in cur.fetchall()] + if "context_length" not in cols: + conn.execute("ALTER TABLE test_runs ADD COLUMN context_length INTEGER DEFAULT 0") + + def init_db(): with _lock: conn = _connect() try: conn.executescript(SCHEMA) + _migrate(conn) conn.commit() finally: conn.close() @@ -202,13 +212,13 @@ def delete_test(tid: int): # ───────────────────────── 采样指标 ───────────────────────── -def add_run(tid: int, run_index: int, metrics: dict, error: str = ""): +def add_run(tid: int, run_index: int, metrics: dict, error: str = "", context_length: int = 0): with _lock: conn = _connect() try: conn.execute( - "INSERT INTO test_runs(test_id,run_index,metrics_json,error) VALUES(?,?,?,?)", - (tid, run_index, json.dumps(metrics, ensure_ascii=False), error)) + "INSERT INTO test_runs(test_id,run_index,context_length,metrics_json,error) VALUES(?,?,?,?,?)", + (tid, run_index, context_length, json.dumps(metrics, ensure_ascii=False), error)) conn.commit() finally: conn.close() @@ -219,7 +229,7 @@ def get_runs(tid: int): conn = _connect() try: rows = conn.execute( - "SELECT run_index,metrics_json,error FROM test_runs " + "SELECT run_index,context_length,metrics_json,error FROM test_runs " "WHERE test_id=? ORDER BY run_index", (tid,)).fetchall() out = [] for r in rows: diff --git a/requirements.txt b/requirements.txt index a0d407c..c0c822d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ flask>=3.0 requests>=2.31 +openpyxl>=3.1 diff --git a/static/css/style.css b/static/css/style.css index 0a60b52..8caef87 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -71,6 +71,26 @@ body { .row > select { flex: 1; } .row > input { flex: 1; } +/* 带图标前缀的输入框(配置名称 / Base URL / 模型名称) */ +.icon-input { display: flex; align-items: center; gap: 8px; background: var(--panel2); border: 1px solid var(--border); border-radius: 8px; padding: 0 10px; transition: border-color .15s; } +.icon-input:focus-within { border-color: var(--accent); } +.icon-input .icon { font-size: 14px; flex-shrink: 0; opacity: .9; } +.icon-input input { flex: 1; background: transparent; border: none; outline: none; color: var(--text); font-size: 13px; padding: 8px 0; } + +/* 上下文长度 chips */ +.chips { display: flex; flex-wrap: wrap; gap: 6px; min-height: 30px; } +.chips-empty { color: var(--muted); font-size: 12px; align-self: center; } +.chip { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 8px 4px 10px; border-radius: 16px; cursor: pointer; user-select: none; + background: rgba(79,140,255,.16); border: 1px solid var(--accent); color: var(--accent); + font-family: var(--mono); font-size: 12px; transition: .15s; +} +.chip:hover { filter: brightness(1.15); } +.chip.off { background: var(--panel2); border-color: var(--border); color: var(--muted); text-decoration: line-through; } +.chip .chip-x { font-size: 11px; line-height: 1; border-radius: 50%; padding: 1px 3px; } +.chip .chip-x:hover { background: rgba(255,92,108,.25); color: var(--danger); } + /* 开关 */ .switch-field { display: flex; align-items: center; justify-content: space-between; } .switch { position: relative; display: inline-block; width: 46px; height: 26px; flex-shrink: 0; } diff --git a/static/index.html b/static/index.html index 8b8967d..6de9541 100644 --- a/static/index.html +++ b/static/index.html @@ -40,11 +40,11 @@
- +
📛
- +
🌐
@@ -56,7 +56,7 @@
- +
🤖
@@ -69,16 +69,27 @@

🚀 速度测试配置

- - + +
+
+ + +
- - + +
- - + + +
+
+ +
@@ -141,6 +152,7 @@