commit 1b08c9592985d3ab843a350c74983c76fc98c818 Author: hz4th_coder Date: Wed Sep 2 13:14:16 2026 +0800 v1.0.0 模型评测网站:模型运行速度排行(解码/预填充/首字) + 账号体系 + 接收llm-speed-tester一键发送 + 演示数据 + 图表(16016) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..917a1f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +logs/ +data/ +__pycache__/ +*.pyc diff --git a/API.md b/API.md new file mode 100644 index 0000000..164afa9 --- /dev/null +++ b/API.md @@ -0,0 +1,89 @@ +# 模型评测网站 API 文档 + +Base: `http://:16066`(端口 16066) + +## 1. 接收提交(llm-speed-tester 一键发送) + +`POST /api/submit` + +Header: `Content-Type: application/json`,`X-Token: meval16066` + +```bash +curl -X POST http://127.0.0.1:16066/api/submit \ + -H "Content-Type: application/json" \ + -H "X-Token: meval16066" \ + -d '{ + "account": "性能评测组", # 账号,不存在自动创建 + "source_test_id": 25, # llm-speed-tester 测试ID + "source_site": "llm-speed-tester", + "provider": "openai", + "model": "nvidia/Qwen3.6-35B-A3B-NVFP4", + "test_name": "我的测试", + "summary": { "samples_ok":2, "samples_total":2, "avg_ttft_ms":180, + "avg_prefill_speed":320.5, "avg_decode_speed":88.2, + "avg_output_tokens":128, "avg_total_ms":2500, + "concurrency_levels":[1], + "by_length": {"2048": {"avg_ttft_ms":150,"avg_prefill_speed":350, + "avg_decode_speed":92,"avg_prompt_tokens":1500, + "avg_output_tokens":128,"avg_total_ms":2000}}, + "by_concurrency": {} }, + "gen": {"context_lengths":[2048,8192], "max_tokens":128, "samples":2, "concurrency_levels":[1]}, + "runs": [] + }' +``` + +响应: + +```json +{ "ok": true, "id": 21, "account_id": 2, "account": "性能评测组", + "model": "nvidia/Qwen3.6-35B-A3B-NVFP4", + "url": "/model.html?provider=openai&model=nvidia/Qwen3.6-35B-A3B-NVFP4" } +``` + +## 2. 排行 / 统计 + +```bash +# 模型速度排行(按 provider+model 聚合) +GET /api/leaderboard?sort=avg_decode_speed&order=desc&limit=200 +# sort 可选: avg_decode_speed / avg_prefill_speed / avg_ttft_ms / best_decode / cnt / accounts / last_tested + +GET /api/stats # 统计卡片 +GET /api/health # 健康检查 + stats +``` + +## 3. 模型详情 + +```bash +GET /api/model?provider=openai&model=nvidia/Qwen3.6-35B-A3B-NVFP4 +``` + +返回:该模型全部提交(submissions,含账号)+ 按上下文长度聚合(by_length)+ 折线图数据(line_csv/line_payload)。 + +## 4. 提交管理 + +```bash +GET /api/submissions?page=1&page_size=20&q=关键词&account_id=2&model=xxx&provider=xxx +GET /api/submissions/ # 单条详情 +DELETE /api/submissions/ # 删除 +GET /api/submissions//chart # 上下文长度→速度折线图 PNG +``` + +## 5. 账号管理 + +```bash +GET /api/accounts +POST /api/accounts {"name":"xxx","remark":"yyy"} # 新增(重名 400) +PUT /api/accounts/ {"name":"xxx","remark":"yyy"} # 改名/备注 +DELETE /api/accounts/ # 删除(连带其提交) +``` + +## 6. 其他 + +```bash +POST /api/seed-demo # 🎲 生成演示评测数据 +POST /api/chart # 图表代理(转发 data-chart-tool 16016,返回 PNG) +``` + +## 错误 + +所有接口失败返回 `{"ok": false, "error": "..."}` + 对应 HTTP 状态码。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..43d18b3 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# ⚡ 模型评测网站(model-eval-site) + +聚合展示各种大模型的**运行速度评测**(解码 / 预填充 / 首字延迟),并支持从 +[llm-speed-tester](../llm-speed-tester/) 一键把测试结果发送到**对应账号**下展示。 + +- 端口:**16066** +- 技术栈:Flask + SQLite + data-chart-tool(16016) 图表 + +## 页面 + +| 页面 | 说明 | +|------|------| +| `/`(index.html) | 🏆 模型速度排行:按模型+提供商聚合所有提交,表头可排序 + 解码速度 TOP20 对比图 | +| `/model.html` | 单模型详情:按上下文长度的速度折线图 + 各账号提交明细 | +| `/submissions.html` | 📥 提交管理:分页/搜索/详情弹窗/删除 + 「🎲 生成演示数据」 | +| `/accounts.html` | 👥 账号管理:账号列表(含提交数)/ 新增 / 编辑 / 删除 | + +## 与 llm-speed-tester 联动 + +1. 在 llm-speed-tester 左侧「📤 评测站同步」填评测站地址(默认 `http://127.0.0.1:16066`)和账号名 +2. 测试完成后,在测试历史行点「📤 发送」,或在详情弹窗点「📤 发送到评测站」 +3. 结果以 **POST /api/submit** 提交,账号不存在自动创建,提交后排行榜实时更新 + +## API + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/submit` | 接收 llm-speed-tester 提交(头 `X-Token: meval16066`) | +| GET | `/api/health` | 健康检查 + 统计 | +| GET | `/api/stats` | 统计卡片(模型数/提交数/账号数/采样数) | +| GET | `/api/leaderboard` | 模型速度排行(可 `sort`/`order`/`limit`) | +| GET | `/api/model` | 单模型详情(`provider` + `model` 参数) | +| GET | `/api/submissions` | 提交列表(`page`/`page_size`/`q`/`account_id`/`model`/`provider`) | +| GET | `/api/submissions/` | 单条提交详情 | +| DELETE | `/api/submissions/` | 删除提交 | +| GET | `/api/submissions//chart` | 单条提交:上下文长度→速度折线图 PNG | +| GET/POST | `/api/accounts` | 账号列表 / 新增 | +| PUT/DELETE | `/api/accounts/` | 改账号 / 删账号(连带删其提交) | +| POST | `/api/seed-demo` | 🎲 生成演示评测数据(便于看排行效果) | +| POST | `/api/chart` | 图表代理(转发 data-chart-tool 16016) | + +## 数据 + +- `data/model_eval.db`:SQLite(accounts / submissions) +- 详细字段见 `database.py` + +## 部署 + +```bash +./start.sh # 启动(默认 16066,PID 文件管理) +./start.sh stop # 停止 +# 依赖: openclaw conda 环境 flask/requests;图表走 data-chart-tool(16016) +# Git: hz4th_coder/model-eval-site +``` diff --git a/app.py b/app.py new file mode 100644 index 0000000..7dba527 --- /dev/null +++ b/app.py @@ -0,0 +1,320 @@ +# -*- coding: utf-8 -*- +"""模型评测网站 - Flask 主应用 +功能: + 1. 聚合展示各种模型的运行速度(解码/预填充/首字延迟 排行 + 图表) + 2. 账号体系:每个提交挂到对应账号下 + 3. POST /api/submit 接收 llm-speed-tester 一键发送的测试结果 +""" +import io + +import requests +from flask import Flask, jsonify, request, send_file, send_from_directory + +import config +import database as db + +app = Flask(__name__, static_folder="static", static_url_path="") +app.json.ensure_ascii = False + +db.init_db() + + +@app.after_request +def _cors(resp): + resp.headers["Access-Control-Allow-Origin"] = "*" + resp.headers["Access-Control-Allow-Headers"] = "Content-Type, X-Token" + resp.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" + return resp + + +@app.route("/") +def index(): + return send_from_directory(app.static_folder, "index.html") + + +@app.route("/health") +@app.route("/api/health") +def health(): + return jsonify({"ok": True, "port": config.PORT, "service": "model-eval-site", + "stats": db.get_stats()}) + + +# ───────────────────────── 接收 llm-speed-tester 提交 ───────────────────────── + +@app.route("/api/submit", methods=["POST"]) +def submit(): + """接收速度测试结果,挂到对应账号下(账号不存在自动创建)""" + body = request.get_json(force=True) or {} + token = request.headers.get("X-Token") or body.get("token") or "" + if token != config.SUBMIT_TOKEN: + return jsonify({"ok": False, "error": "提交密钥错误"}), 403 + model = (body.get("model") or "").strip() + if not model: + return jsonify({"ok": False, "error": "缺少模型名称"}), 400 + summary = body.get("summary") or {} + if not (summary.get("samples_ok") or 0): + return jsonify({"ok": False, "error": "该测试没有成功采样数据,无法发布到评测站"}), 400 + + account_name = (body.get("account") or "").strip() or "默认账号" + aid = db.get_or_create_account(account_name, remark="来自 llm-speed-tester") + sid = db.add_submission(aid, body) + account = db.list_accounts() + acc = next((a for a in account if a["id"] == aid), None) + return jsonify({ + "ok": True, "id": sid, "account_id": aid, + "account": acc["name"] if acc else account_name, + "model": model, + "url": "/model.html?provider=%s&model=%s" % ( + requests.utils.quote(body.get("provider") or ""), + requests.utils.quote(model)), + }) + + +# ───────────────────────── 汇总 / 排行 ───────────────────────── + +@app.route("/api/stats") +def stats(): + return jsonify(db.get_stats()) + + +@app.route("/api/leaderboard") +def leaderboard(): + sort = request.args.get("sort", "avg_decode_speed") + order = request.args.get("order", "desc") + limit = min(int(request.args.get("limit", 200) or 200), 500) + rows = db.leaderboard(sort=sort, order=order, limit=limit) + # 按排行榜生成柱状图 CSV(前 20 名,解码速度) + top = rows[:20] + csv_lines = ["模型, 解码速度(tok/s), 预填充速度(tok/s), 首字延迟(ms)"] + for r in top: + label = "%s %s" % (r["provider"], r["model"]) + csv_lines.append("%s, %s, %s, %s" % ( + label.replace(",", " "), _fmt(r["avg_decode_speed"]), + _fmt(r["avg_prefill_speed"]), _fmt(r["avg_ttft_ms"]))) + chart_csv = "\n".join(csv_lines) + bar_payload = { + "data": chart_csv, "chartType": "bar", + "title": "模型解码速度排行 TOP%s(tok/s)" % len(top), + "theme": "default", "showLegend": True, "showGrid": True, "showLabel": True, + "seriesTypes": ["bar", "bar", "line"], + "seriesAxis": [0, 0, 1], + "seriesStyles": ["solid", "hollow", "dashed"], + "width": 1100, "height": 560, "pixelRatio": 2, + } + return jsonify({"ok": True, "rows": rows, "chart_csv": chart_csv, + "bar_payload": bar_payload}) + + +@app.route("/api/model") +def model_detail(): + provider = request.args.get("provider", "") + model = request.args.get("model", "") + if not model: + return jsonify({"ok": False, "error": "缺少模型名称"}), 400 + d = db.get_model_detail(provider, model) + if not d["submissions"]: + return jsonify({"ok": False, "error": "该模型暂无评测数据"}), 404 + # 按上下文长度聚合 → 折线图 CSV + csv_lines = ["上下文长度(tok), 解码速度(tok/s), 预填充速度(tok/s)"] + for r in d["by_length"]: + csv_lines.append("%d, %s, %s" % (r["length"], r["avg_decode_speed"], r["avg_prefill_speed"])) + line_csv = "\n".join(csv_lines) + line_payload = { + "data": line_csv, "chartType": "line", + "title": "%s %s · 解码速度随上下文长度变化" % (provider or "", model), + "theme": "default", "showLegend": True, "showGrid": True, "showLabel": False, + "smoothLine": True, "dualYAxis": True, + "leftAxisName": "预填充速度(tok/s)", "rightAxisName": "解码速度(tok/s)", + "seriesTypes": ["line", "line"], "seriesAxis": [0, 1], + "seriesStyles": ["dashed", "solid"], + "width": 1000, "height": 520, "pixelRatio": 2, + } + d["line_csv"] = line_csv + d["line_payload"] = line_payload + return jsonify({"ok": True, **d}) + + +# ───────────────────────── 提交管理 ───────────────────────── + +@app.route("/api/submissions") +def submissions(): + page = max(1, int(request.args.get("page", 1))) + page_size = min(max(1, int(request.args.get("page_size", 20))), 100) + q = request.args.get("q", "") + account_id = request.args.get("account_id") or None + model = request.args.get("model", "") + provider = request.args.get("provider", "") + return jsonify(db.list_submissions(page=page, page_size=page_size, q=q, + account_id=int(account_id) if account_id else None, + model=model, provider=provider)) + + +@app.route("/api/submissions/") +def submission_detail(sid): + s = db.get_submission(sid) + if not s: + return jsonify({"ok": False, "error": "提交不存在"}), 404 + return jsonify(s) + + +@app.route("/api/submissions/", methods=["DELETE"]) +def submission_delete(sid): + db.delete_submission(sid) + return jsonify({"ok": True}) + + +@app.route("/api/submissions//chart") +def submission_chart(sid): + """单条提交:上下文长度 → 解码/预填充速度 折线图""" + s = db.get_submission(sid) + if not s: + return jsonify({"ok": False, "error": "提交不存在"}), 404 + by = s["by_length"] + csv_lines = ["上下文长度(tok), 预填充速度(tok/s), 解码速度(tok/s)"] + for L in sorted(by, key=int): + bl = by[L] + pre = bl.get("avg_prefill_speed") + dec = bl.get("avg_decode_speed") + if pre is None or dec is None: + continue + csv_lines.append("%s, %.2f, %.2f" % (L, pre, dec)) + if len(csv_lines) < 2: + return jsonify({"ok": False, "error": "该提交无可画图的长度分组数据"}), 400 + payload = { + "data": "\n".join(csv_lines), "chartType": "line", + "title": "%s %s · 速度随上下文长度(提交#%d)" % (s["provider"], s["model"], sid), + "theme": "default", "showLegend": True, "showGrid": True, "showLabel": False, + "smoothLine": True, "dualYAxis": True, + "leftAxisName": "预填充速度(tok/s)", "rightAxisName": "解码速度(tok/s)", + "seriesTypes": ["line", "line"], "seriesAxis": [0, 1], + "seriesStyles": ["dashed", "solid"], + "width": 1000, "height": 480, "pixelRatio": 2, + } + return _chart_proxy(payload) + + +# ───────────────────────── 账号管理 ───────────────────────── + +@app.route("/api/accounts") +def accounts(): + return jsonify(db.list_accounts()) + + +@app.route("/api/accounts", methods=["POST"]) +def account_add(): + body = request.get_json(force=True) or {} + try: + aid = db.add_account(body.get("name", ""), body.get("remark", "")) + return jsonify({"ok": True, "id": aid}) + except ValueError as e: + return jsonify({"ok": False, "error": str(e)}), 400 + + +@app.route("/api/accounts/", methods=["PUT"]) +def account_update(aid): + body = request.get_json(force=True) or {} + db.rename_account(aid, body.get("name", ""), body.get("remark", "")) + return jsonify({"ok": True}) + + +@app.route("/api/accounts/", methods=["DELETE"]) +def account_delete(aid): + db.delete_account(aid) + return jsonify({"ok": True}) + + +# ───────────────────────── 图表代理(data-chart-tool) ───────────────────────── + +def _chart_proxy(payload): + try: + resp = requests.post(config.CHART_API_BASE + "/api/chart", json=payload, timeout=60) + except requests.RequestException as e: + return jsonify({"ok": False, "error": "图表服务不可用: %s" % e}), 502 + if resp.status_code != 200: + return jsonify({"ok": False, "error": "图表生成失败(%d): %s" % (resp.status_code, resp.text[:300])}), 502 + return send_file(io.BytesIO(resp.content), mimetype="image/png") + + +@app.route("/api/chart", methods=["POST"]) +def chart_proxy(): + payload = request.get_json(force=True) or {} + return _chart_proxy(payload) + + +# ───────────────────────── 演示数据 ───────────────────────── + +DEMO_MODELS = [ + ("autodl", "qwen3.5-plus", [512, 2048, 4096, 8192, 16384]), + ("autodl", "glm-5.3-flash", [512, 2048, 4096, 8192]), + ("siliconflow", "deepseek-v4-flash", [512, 2048, 4096, 8192, 16384, 32768]), + ("siliconflow", "longcat", [512, 2048, 4096, 8192]), + ("local-qwen", "qwen3.5-plus", [512, 2048, 4096, 8192, 16384]), + ("openai", "gpt-4o-mini", [512, 2048, 4096, 8192, 16384, 32768]), + ("anthropic", "claude-3.5-haiku", [512, 2048, 4096, 8192, 16384]), + ("google", "gemini-2.0-flash", [512, 2048, 4096, 8192, 16384, 32768]), +] + + +@app.route("/api/seed-demo", methods=["POST"]) +def seed_demo(): + """生成一批演示评测数据(便于查看排行榜效果),重复调用会追加""" + import random + random.seed() + accounts = ["测试小组A", "性能评测组", "模型研究所"] + cnt = 0 + for ai, acc_name in enumerate(accounts): + aid = db.get_or_create_account(acc_name, remark="演示账号") + for mi, (provider, model, lens) in enumerate(DEMO_MODELS): + if (mi + ai) % 3 == 0 and ai != 0: + continue # 让数据分布有点差异 + # 基础解码速度:给每个模型一个基准(越靠后越快一些随机) + base_decode = 40 + mi * 12 + random.uniform(-5, 12) + base_prefill = base_decode * random.uniform(0.55, 0.9) + base_ttft = 200 + mi * 15 + random.uniform(-40, 80) + by_length = {} + for L in lens: + # 长度越长解码略降 + k = 1 - (L / 65536) * 0.3 + by_length[str(L)] = { + "samples_ok": 2, "samples_total": 2, + "avg_decode_speed": round(base_decode * k, 1), + "avg_prefill_speed": round(base_prefill * k, 1), + "avg_ttft_ms": round(base_ttft + L * 0.02, 0), + "avg_prompt_tokens": int(L * 0.75), "avg_output_tokens": 128, + "avg_total_ms": round((L * 0.75 / base_prefill + 128 / base_decode) * 1000, 0), + } + speeds = [v["avg_decode_speed"] for v in by_length.values()] + payload = { + "token": config.SUBMIT_TOKEN, + "account": acc_name, + "source_test_id": 9000 + cnt, "source_site": "llm-speed-tester(演示)", + "provider": provider, "model": model, + "test_name": "%s 基准评测" % model, + "summary": { + "samples_ok": 2, "samples_total": 2, + "avg_decode_speed": round(sum(speeds) / len(speeds), 1), + "avg_prefill_speed": round(sum(v["avg_prefill_speed"] for v in by_length.values()) / len(by_length), 1), + "avg_ttft_ms": round(sum(v["avg_ttft_ms"] for v in by_length.values()) / len(by_length), 0), + "avg_output_tokens": 128, "avg_total_ms": 3200, + "min_decode_speed": min(speeds), "max_decode_speed": max(speeds), + "concurrency_levels": [1], + "by_length": by_length, "by_concurrency": {}, + }, + "gen": {"context_lengths": lens, "max_tokens": 128, + "samples": 2, "concurrency_levels": [1]}, + "runs": [], + } + db.add_submission(aid, payload) + cnt += 1 + return jsonify({"ok": True, "seeded": cnt, "stats": db.get_stats()}) + + +def _fmt(v): + try: + return "%.2f" % float(v) + except Exception: + return "" + + +if __name__ == "__main__": + app.run(host=config.HOST, port=config.PORT, threaded=True, debug=False) diff --git a/config.py b/config.py new file mode 100644 index 0000000..1fbe464 --- /dev/null +++ b/config.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +"""模型评测网站 - 全局配置""" +import os + +PORT = 16066 +HOST = "0.0.0.0" + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.path.join(BASE_DIR, "data") +LOG_DIR = os.path.join(BASE_DIR, "logs") +DB_PATH = os.path.join(DATA_DIR, "model_eval.db") + +# 提交接口共享密钥(llm-speed-tester 发送时携带,防止被随意写入) +SUBMIT_TOKEN = "meval16066" + +# data-chart-tool 图表服务地址(画速度对比图) +CHART_API_BASE = "http://127.0.0.1:16016" diff --git a/database.py b/database.py new file mode 100644 index 0000000..41ad87e --- /dev/null +++ b/database.py @@ -0,0 +1,350 @@ +# -*- coding: utf-8 -*- +"""SQLite 存储:账号 / 评测提交 / 提交明细""" +import os +import json +import sqlite3 +import threading + +import config + +_lock = threading.RLock() + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS accounts( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + remark TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now','localtime')) +); + +CREATE TABLE IF NOT EXISTS submissions( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + source_test_id INTEGER DEFAULT 0, + source_site TEXT DEFAULT 'llm-speed-tester', + created_at TEXT DEFAULT (datetime('now','localtime')), + provider TEXT DEFAULT '', + model TEXT DEFAULT '', + test_name TEXT DEFAULT '', + samples_ok INTEGER DEFAULT 0, + samples_total INTEGER DEFAULT 0, + avg_ttft_ms REAL, + avg_prefill_speed REAL, + avg_decode_speed REAL, + avg_stream_decode REAL, + avg_prompt_tokens REAL, + avg_output_tokens REAL, + avg_total_ms REAL, + min_ttft_ms REAL, max_ttft_ms REAL, + min_prefill_speed REAL, max_prefill_speed REAL, + min_decode_speed REAL, max_decode_speed REAL, + min_total_ms REAL, max_total_ms REAL, + context_lengths TEXT DEFAULT '[]', + concurrency_levels TEXT DEFAULT '[]', + by_length TEXT DEFAULT '{}', + by_concurrency TEXT DEFAULT '{}', + runs TEXT DEFAULT '[]', + raw TEXT DEFAULT '{}' +); + +CREATE INDEX IF NOT EXISTS idx_sub_model ON submissions(model, provider); +CREATE INDEX IF NOT EXISTS idx_sub_account ON submissions(account_id); +CREATE INDEX IF NOT EXISTS idx_sub_created ON submissions(created_at); +""" + + +def _connect(): + os.makedirs(config.DATA_DIR, exist_ok=True) + conn = sqlite3.connect(config.DB_PATH, check_same_thread=False, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + return conn + + +def init_db(): + with _lock: + conn = _connect() + try: + conn.executescript(SCHEMA) + conn.commit() + finally: + conn.close() + + +# ───────────────────────── 账号 ───────────────────────── + +def get_or_create_account(name: str, remark: str = "") -> int: + name = (name or "").strip() or "默认账号" + with _lock: + conn = _connect() + try: + r = conn.execute("SELECT id FROM accounts WHERE name=?", (name,)).fetchone() + if r: + return r["id"] + cur = conn.execute("INSERT INTO accounts(name, remark) VALUES(?,?)", + (name, (remark or "").strip())) + conn.commit() + return cur.lastrowid + finally: + conn.close() + + +def add_account(name: str, remark: str = "") -> int: + name = (name or "").strip() + if not name: + raise ValueError("账号名称不能为空") + with _lock: + conn = _connect() + try: + r = conn.execute("SELECT id FROM accounts WHERE name=?", (name,)).fetchone() + if r: + raise ValueError("账号已存在") + cur = conn.execute("INSERT INTO accounts(name, remark) VALUES(?,?)", (name, remark)) + conn.commit() + return cur.lastrowid + finally: + conn.close() + + +def list_accounts(): + with _lock: + conn = _connect() + try: + rows = conn.execute( + "SELECT a.id,a.name,a.remark,a.created_at," + "(SELECT COUNT(*) FROM submissions s WHERE s.account_id=a.id) AS cnt " + "FROM accounts a ORDER BY cnt DESC, a.id").fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +def rename_account(aid: int, name: str, remark: str): + with _lock: + conn = _connect() + try: + conn.execute("UPDATE accounts SET name=?, remark=? WHERE id=?", + (name.strip(), (remark or "").strip(), aid)) + conn.commit() + finally: + conn.close() + + +def delete_account(aid: int): + with _lock: + conn = _connect() + try: + conn.execute("DELETE FROM submissions WHERE account_id=?", (aid,)) + conn.execute("DELETE FROM accounts WHERE id=?", (aid,)) + conn.commit() + finally: + conn.close() + + +# ───────────────────────── 提交 ───────────────────────── + +def add_submission(account_id: int, data: dict) -> int: + """data 是 llm-speed-tester 发来的整包(含 summary/gen/runs/config)""" + summary = data.get("summary") or {} + gen = data.get("gen") or {} + runs = data.get("runs") or [] + with _lock: + conn = _connect() + try: + cur = conn.execute( + "INSERT INTO submissions(account_id,source_test_id,source_site,provider,model,test_name," + "samples_ok,samples_total,avg_ttft_ms,avg_prefill_speed,avg_decode_speed,avg_stream_decode," + "avg_prompt_tokens,avg_output_tokens,avg_total_ms,min_ttft_ms,max_ttft_ms,min_prefill_speed," + "max_prefill_speed,min_decode_speed,max_decode_speed,min_total_ms,max_total_ms," + "context_lengths,concurrency_levels,by_length,by_concurrency,runs,raw) VALUES(" + "?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + (account_id, + int(data.get("source_test_id") or 0), + data.get("source_site") or "llm-speed-tester", + data.get("provider") or "", + data.get("model") or "", + data.get("test_name") or data.get("name") or "", + int(summary.get("samples_ok") or 0), + int(summary.get("samples_total") or 0), + summary.get("avg_ttft_ms"), summary.get("avg_prefill_speed"), + summary.get("avg_decode_speed"), summary.get("avg_stream_decode"), + summary.get("avg_prompt_tokens"), summary.get("avg_output_tokens"), + summary.get("avg_total_ms"), + summary.get("min_ttft_ms"), summary.get("max_ttft_ms"), + summary.get("min_prefill_speed"), summary.get("max_prefill_speed"), + summary.get("min_decode_speed"), summary.get("max_decode_speed"), + summary.get("min_total_ms"), summary.get("max_total_ms"), + json.dumps(gen.get("context_lengths") or summary.get("context_lengths") or [], + ensure_ascii=False), + json.dumps(summary.get("concurrency_levels") or gen.get("concurrency_levels") or [1], + ensure_ascii=False), + json.dumps(summary.get("by_length") or {}, ensure_ascii=False), + json.dumps(summary.get("by_concurrency") or {}, ensure_ascii=False), + json.dumps(runs[:200], ensure_ascii=False), + json.dumps(data, ensure_ascii=False))) + conn.commit() + return cur.lastrowid + finally: + conn.close() + + +def get_submission(sid: int): + with _lock: + conn = _connect() + try: + r = conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone() + if not r: + return None + d = dict(r) + d["account"] = conn.execute("SELECT name FROM accounts WHERE id=?", + (d["account_id"],)).fetchone()["name"] + for k in ("context_lengths", "concurrency_levels", "by_length", + "by_concurrency", "runs", "raw"): + d[k] = json.loads(d[k] or ("[]" if k in ("context_lengths", "concurrency_levels", "runs") else "{}")) + return d + finally: + conn.close() + + +def list_submissions(page=1, page_size=20, q="", account_id=None, model="", provider=""): + with _lock: + conn = _connect() + try: + where = ["1=1"] + args = [] + if q: + where.append("(s.model LIKE ? OR s.test_name LIKE ? OR a.name LIKE ? OR s.provider LIKE ?)") + like = "%" + q + "%" + args += [like, like, like, like] + if account_id: + where.append("s.account_id=?") + args.append(account_id) + if model: + where.append("s.model=?") + args.append(model) + if provider: + where.append("s.provider=?") + args.append(provider) + wsql = " AND ".join(where) + total = conn.execute( + "SELECT COUNT(*) c FROM submissions s LEFT JOIN accounts a ON a.id=s.account_id WHERE " + wsql, + args).fetchone()["c"] + rows = conn.execute( + "SELECT s.*, a.name AS account FROM submissions s " + "LEFT JOIN accounts a ON a.id=s.account_id WHERE " + wsql + + " ORDER BY s.id DESC LIMIT ? OFFSET ?", + args + [page_size, (page - 1) * page_size]).fetchall() + out = [] + for r in rows: + d = dict(r) + for k in ("context_lengths", "concurrency_levels"): + d[k] = json.loads(d[k] or "[]") + out.append(d) + return {"total": total, "items": out, "page": page, "page_size": page_size, + "pages": max(1, -(-total // page_size))} + finally: + conn.close() + + +def delete_submission(sid: int): + with _lock: + conn = _connect() + try: + conn.execute("DELETE FROM submissions WHERE id=?", (sid,)) + conn.commit() + finally: + conn.close() + + +# ───────────────────────── 汇总统计 ───────────────────────── + +def get_stats(): + with _lock: + conn = _connect() + try: + model_cnt = conn.execute("SELECT COUNT(DISTINCT model) c FROM submissions WHERE model<>''").fetchone()["c"] + sub_cnt = conn.execute("SELECT COUNT(*) c FROM submissions").fetchone()["c"] + acc_cnt = conn.execute("SELECT COUNT(*) c FROM accounts").fetchone()["c"] + ok_cnt = conn.execute("SELECT SUM(samples_ok) c FROM submissions").fetchone()["c"] or 0 + return {"models": model_cnt, "submissions": sub_cnt, + "accounts": acc_cnt, "samples_ok": ok_cnt} + finally: + conn.close() + + +def leaderboard(sort="avg_decode_speed", order="desc", limit=200): + """按 (provider, model) 聚合所有提交 → 模型速度排行""" + sort_whitelist = { + "avg_decode_speed": "avg_decode_speed", "avg_prefill_speed": "avg_prefill_speed", + "avg_ttft_ms": "avg_ttft_ms", "best_decode": "best_decode", + "cnt": "cnt", "last_tested": "last_tested", "avg_stream_decode": "avg_stream_decode", + } + srt = sort_whitelist.get(sort, "avg_decode_speed") + od = "DESC" if order == "asc" and srt not in ("avg_ttft_ms", "avg_total_ms") else ( + "ASC" if order == "asc" else "DESC") + with _lock: + conn = _connect() + try: + rows = conn.execute( + "SELECT provider, model, " + "COUNT(*) AS cnt, " + "COUNT(DISTINCT account_id) AS accounts, " + "AVG(avg_decode_speed) AS avg_decode_speed, " + "AVG(avg_prefill_speed) AS avg_prefill_speed, " + "AVG(avg_stream_decode) AS avg_stream_decode, " + "AVG(avg_ttft_ms) AS avg_ttft_ms, " + "AVG(avg_output_tokens) AS avg_output_tokens, " + "AVG(avg_total_ms) AS avg_total_ms, " + "MAX(avg_decode_speed) AS best_decode, " + "MAX(created_at) AS last_tested " + "FROM submissions WHERE model<>'' " + "GROUP BY provider, model ORDER BY %s %s LIMIT ?" % (srt, od), + (limit,)).fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +def get_model_detail(provider, model): + """单个模型的所有提交 + 按上下文长度聚合""" + with _lock: + conn = _connect() + try: + rows = conn.execute( + "SELECT s.*, a.name AS account FROM submissions s " + "LEFT JOIN accounts a ON a.id=s.account_id " + "WHERE s.model=? AND s.provider=? ORDER BY s.id DESC", + (model, provider)).fetchall() + subs = [] + agg_length = {} + for r in rows: + d = dict(r) + d["by_length"] = json.loads(d.pop("by_length") or "{}") + d["by_concurrency"] = json.loads(d.pop("by_concurrency") or "{}") + d["context_lengths"] = json.loads(d.pop("context_lengths") or "[]") + d["concurrency_levels"] = json.loads(d.pop("concurrency_levels") or "[]") + subs.append(d) + for L, bl in d["by_length"].items(): + a = agg_length.setdefault(str(L), {"count": 0, "sum_decode": 0.0, + "sum_prefill": 0.0, "sum_ttft": 0.0}) + if bl.get("avg_decode_speed") is not None: + a["count"] += 1 + a["sum_decode"] += float(bl["avg_decode_speed"] or 0) + a["sum_prefill"] += float(bl.get("avg_prefill_speed") or 0) + a["sum_ttft"] += float(bl.get("avg_ttft_ms") or 0) + length_rows = [] + for L in sorted(agg_length, key=int): + a = agg_length[L] + if not a["count"]: + continue + length_rows.append({ + "length": int(L), "count": a["count"], + "avg_decode_speed": round(a["sum_decode"] / a["count"], 2), + "avg_prefill_speed": round(a["sum_prefill"] / a["count"], 2), + "avg_ttft_ms": round(a["sum_ttft"] / a["count"], 1), + }) + return {"provider": provider, "model": model, + "submissions": subs, "by_length": length_rows, + "sub_cnt": len(subs)} + finally: + conn.close() diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..1fb8286 --- /dev/null +++ b/start.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# 模型评测网站 启动/停止脚本 +cd "$(dirname "$0")" +PY=/home/hz1/miniconda3/envs/openclaw/bin/python3 +PORT=16066 +PID_FILE=logs/app.pid +mkdir -p logs data + +start() { + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "已在运行 (PID $(cat "$PID_FILE"))" + return + fi + nohup "$PY" app.py >> logs/app.log 2>&1 & + echo $! > "$PID_FILE" + sleep 1 + if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "启动成功 → http://:$PORT/" + else + echo "启动失败,请查看 logs/app.log" + fi +} + +stop() { + if [ -f "$PID_FILE" ]; then + kill "$(cat "$PID_FILE")" 2>/dev/null + rm -f "$PID_FILE" + echo "已停止" + else + echo "未在运行" + fi +} + +case "${1:-start}" in + start) start ;; + stop) stop ;; + restart) stop; sleep 1; start ;; + *) echo "用法: $0 [start|stop|restart]" ;; +esac diff --git a/static/accounts.html b/static/accounts.html new file mode 100644 index 0000000..85dc8eb --- /dev/null +++ b/static/accounts.html @@ -0,0 +1,39 @@ + + + + + +账号管理 · 模型评测站 + + + +
+
模型评测站
+ +
+
+ +
+
+

👥 测试账号 (llm-speed-tester 发送时选择账号归属,不存在会自动创建)

+
+
+
+ +
+
+ + +
账号备注创建时间提交数操作
加载中…
+
+
+
+ +
+ + + diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..5ee3784 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,172 @@ +/* 模型评测网站 - 深色仪表盘风格 */ +:root { + --bg: #0f1420; + --panel: #171e2e; + --panel2: #1c2436; + --border: #26314a; + --text: #e6ebf5; + --muted: #8b97b3; + --accent: #4f8cff; + --accent2: #22c55e; + --warn: #f59e0b; + --danger: #ef4444; + --radius: 12px; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 14px; + line-height: 1.5; +} +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +.hint { color: var(--muted); font-size: 12px; } + +/* 顶部导航 */ +.topbar { + display: flex; align-items: center; gap: 18px; + padding: 14px 24px; background: var(--panel); + border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 20; +} +.brand { font-size: 16px; font-weight: 700; display: flex; align-items: center; gap: 8px; } +.brand .logo { font-size: 20px; } +.nav { display: flex; gap: 4px; } +.nav a { + color: var(--muted); padding: 6px 12px; border-radius: 8px; font-size: 13px; +} +.nav a:hover { color: var(--text); text-decoration: none; background: var(--panel2); } +.nav a.active { color: #fff; background: var(--accent); } +.topbar .spacer { flex: 1; } + +.container { max-width: 1280px; margin: 0 auto; padding: 20px 24px 60px; } + +/* 统计卡片 */ +.stat-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 18px; } +.stat-card { + background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); + padding: 16px 18px; +} +.stat-card .k { color: var(--muted); font-size: 12px; } +.stat-card .v { font-size: 26px; font-weight: 700; margin-top: 4px; } +.stat-card .v small { font-size: 13px; color: var(--muted); font-weight: 400; } + +.panel { + background: var(--panel); border: 1px solid var(--border); + border-radius: var(--radius); padding: 18px 20px; margin-bottom: 18px; +} +.panel h2 { font-size: 15px; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; } +.panel h3 { font-size: 14px; margin: 14px 0 10px; } + +/* 表格 */ +table { width: 100%; border-collapse: collapse; } +th, td { padding: 9px 10px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; } +th { color: var(--muted); font-weight: 600; white-space: nowrap; cursor: pointer; user-select: none; } +th.sortable:hover { color: var(--text); } +th .arrow { font-size: 10px; color: var(--accent); } +td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; } +tr:hover td { background: rgba(79, 140, 255, 0.05); } +.tbl-wrap { overflow-x: auto; } + +/* 徽标 / pill */ +.pill { + display: inline-block; padding: 2px 9px; border-radius: 20px; font-size: 11px; + background: rgba(79, 140, 255, 0.15); color: var(--accent); white-space: nowrap; +} +.pill.green { background: rgba(34, 197, 94, 0.15); color: var(--accent2); } +.pill.amber { background: rgba(245, 158, 11, 0.15); color: var(--warn); } +.pill.red { background: rgba(239, 68, 68, 0.15); color: var(--danger); } +.pill.gray { background: rgba(139, 151, 179, 0.15); color: var(--muted); } +.badge-decode { font-weight: 700; color: var(--accent2); } + +/* 按钮 */ +.btn { + display: inline-block; padding: 6px 13px; border-radius: 8px; border: 1px solid var(--border); + background: var(--panel2); color: var(--text); font-size: 12.5px; cursor: pointer; + transition: all .15s; white-space: nowrap; +} +.btn:hover { border-color: var(--accent); color: #fff; } +.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn.primary:hover { opacity: .88; } +.btn.danger { color: var(--danger); border-color: rgba(239,68,68,.4); } +.btn.danger:hover { background: rgba(239,68,68,.12); } +.btn.small { padding: 3px 9px; font-size: 12px; } +.btn.link { background: none; border: none; color: var(--accent); padding: 0; } +.btn.link:hover { text-decoration: underline; } +.btn:disabled { opacity: .5; cursor: not-allowed; } + +/* 表单 */ +input[type=text], input[type=search], input[type=number], select, textarea { + background: var(--panel2); border: 1px solid var(--border); color: var(--text); + border-radius: 8px; padding: 7px 10px; font-size: 13px; outline: none; +} +input:focus, select:focus, textarea:focus { border-color: var(--accent); } +label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 4px; } +.form-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; } +.form-row label { margin: 0; } +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +@media (max-width: 760px) { .form-grid { grid-template-columns: 1fr; } } + +/* 弹窗 */ +.mask { + position: fixed; inset: 0; background: rgba(6, 9, 16, .72); z-index: 100; + display: flex; align-items: center; justify-content: center; padding: 24px; +} +.modal { + background: var(--panel); border: 1px solid var(--border); border-radius: 14px; + width: 100%; max-width: 860px; max-height: 88vh; display: flex; flex-direction: column; + box-shadow: 0 20px 60px rgba(0,0,0,.5); +} +.modal-head { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 18px; border-bottom: 1px solid var(--border); +} +.modal-head h3 { margin: 0; font-size: 15px; } +.modal-body { padding: 16px 18px; overflow-y: auto; } +.modal-foot { padding: 12px 18px; border-top: 1px solid var(--border); display: flex; gap: 10px; justify-content: flex-end; } +.mask[hidden] { display: none; } + +/* kv 信息块 */ +.kv { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px; } +.kv-item { background: var(--panel2); border-radius: 8px; padding: 8px 12px; } +.kv-k { font-size: 11px; color: var(--muted); } +.kv-v { font-size: 14px; font-weight: 600; } + +/* 图表 */ +.chart-img-wrap { text-align: center; padding: 8px 0; } +.chart-img-wrap img { max-width: 100%; border-radius: 8px; } +.chart-csv { + width: 100%; min-height: 90px; font-family: ui-monospace, monospace; font-size: 12px; + margin-top: 6px; +} + +/* 空态 / 提示 */ +.empty { color: var(--muted); text-align: center; padding: 30px 0; } +.notice { + background: rgba(79,140,255,.1); border: 1px solid rgba(79,140,255,.3); + border-radius: 8px; padding: 10px 14px; font-size: 12.5px; color: #cfe0ff; + margin-bottom: 12px; +} +.notice.green { background: rgba(34,197,94,.1); border-color: rgba(34,197,94,.3); color: #d4f7e2; } +.notice.red { background: rgba(239,68,68,.1); border-color: rgba(239,68,68,.3); color: #ffd7d7; } + +/* toast */ +#toast { + position: fixed; bottom: 26px; left: 50%; transform: translateX(-50%); + background: #1d2538; border: 1px solid var(--accent); color: #fff; + padding: 10px 18px; border-radius: 10px; z-index: 999; font-size: 13px; + box-shadow: 0 6px 20px rgba(0,0,0,.4); display: none; max-width: 80vw; +} + +.pagination { display: flex; gap: 8px; align-items: center; margin-top: 12px; } +.pagination button { padding: 4px 10px; } +.pagination .info { color: var(--muted); font-size: 12px; } + +/* 详情弹窗内的小表 */ +.modal-body table.mini th { font-size: 12px; } +.modal-body table.mini td { font-size: 12.5px; } + +/* 进度条 */ +.meter { background: var(--panel2); border-radius: 6px; height: 8px; overflow: hidden; margin-top: 6px; } +.meter > div { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent2)); border-radius: 6px; } diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..2490a40 --- /dev/null +++ b/static/index.html @@ -0,0 +1,65 @@ + + + + + +模型评测 · 运行速度排行 + + + +
+
模型评测站
+ +
+ llm-speed-tester 一键同步 +
+ +
+
+
评测模型数
+
评测提交数
+
测试账号数
+
成功采样
+
+ +
+

🏆 模型运行速度排行 (点击表头可排序;按模型+提供商聚合所有提交)

+
+ + + + + + + + + + + + + + +
#提供商模型提交账号平均解码 tok/s平均预填充 tok/s首字 ms最佳解码 tok/s最后测试
加载中…
+
+
+ +
+

📊 解码速度 TOP20 对比图

+
+ + +
+
+
点击上方按钮绘制:模型解码速度(实心柱)+ 预填充(空心柱)+ 首字延迟(虚线)
+
+
+
+ +
+ + + diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..2a73be6 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,482 @@ +/* 模型评测网站 前端逻辑 */ +"use strict"; + +const $ = (s) => document.querySelector(s); +const $$ = (s) => Array.from(document.querySelectorAll(s)); + +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +const fmt = (v, d = "—") => (v === null || v === undefined || isNaN(v) ? d : Number(v).toFixed(2).replace(/\.?0+$/, "")); +const fmtInt = (v, d = "—") => (v === null || v === undefined || isNaN(v) ? d : Math.round(v)); +const toastEl = () => $("#toast"); +let toastTimer = null; +function toast(msg) { + const el = toastEl(); + el.textContent = msg; + el.style.display = "block"; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { el.style.display = "none"; }, 3200); +} + +async function api(path, method = "GET", body) { + const opt = { method, headers: {} }; + if (body !== undefined) { opt.headers["Content-Type"] = "application/json"; opt.body = JSON.stringify(body); } + const resp = await fetch(path, opt); + const j = await resp.json().catch(() => ({})); + if (!resp.ok && !j.ok) throw new Error(j.error || `请求失败(${resp.status})`); + return j; +} + +function providerLabel(p) { + const m = { autodl: "Autodl", siliconflow: "SiliconFlow", "local-qwen": "本地Qwen", + openai: "OpenAI", anthropic: "Anthropic", google: "Gemini", local: "本地" }; + return m[p] || p || "—"; +} + +/* 顶部导航高亮 */ +function navActive() { + const path = location.pathname.split("/").pop() || "index.html"; + $$(".nav a").forEach((a) => { + a.classList.toggle("active", a.getAttribute("href") === path); + }); +} + +/* ───────────────────────── 首页:排行 ───────────────────────── */ +let sortKey = "avg_decode_speed"; +let sortOrder = "desc"; + +async function loadStats() { + try { + const s = await api("/api/stats"); + $("#st-models").textContent = s.models; + $("#st-subs").textContent = s.submissions; + $("#st-accounts").textContent = s.accounts; + $("#st-samples").innerHTML = fmtInt(s.samples_ok) + " 次成功采样"; + } catch (e) { /* ignore */ } +} + +function sortClick(key) { + if (sortKey === key) sortOrder = sortOrder === "desc" ? "asc" : "desc"; + else { sortKey = key; sortOrder = "desc"; } + loadLeaderboard(); +} + +async function loadLeaderboard() { + const tb = $("#lb tbody"); + if (!tb) return; + tb.innerHTML = '加载中…'; + let d; + try { + d = await api(`/api/leaderboard?sort=${sortKey}&order=${sortOrder}`); + } catch (e) { + tb.innerHTML = `加载失败:${esc(e.message)}`; + return; + } + $$("#lb th[data-sort]").forEach((th) => { + th.querySelector(".arrow").textContent = (th.dataset.sort === sortKey) ? (sortOrder === "desc" ? " ▼" : " ▲") : ""; + }); + if (!d.rows.length) { + tb.innerHTML = '暂无评测数据。可在「提交管理」页面点击「🎲 生成演示数据」,或从 llm-speed-tester 一键发送。'; + return; + } + const maxDecode = Math.max(...d.rows.map((r) => r.avg_decode_speed || 0)); + tb.innerHTML = d.rows.map((r, i) => { + const meter = r.avg_decode_speed ? `
` : ""; + return ` + ${i + 1} + ${esc(providerLabel(r.provider))} + ${esc(r.model)} + ${fmtInt(r.cnt)} + ${fmtInt(r.accounts)} + ${fmt(r.avg_decode_speed)}${meter} + ${fmt(r.avg_prefill_speed)} + ${fmt(r.avg_ttft_ms)} + ${fmt(r.best_decode)} + ${esc((r.last_tested || "").slice(5, 16))} + `; + }).join(""); + window.__lbRows = d.rows; + window.__lbChart = d.bar_payload; +} + +let lbChartUrl = ""; +async function genLbChart() { + if (!window.__lbChart) { toast("暂无数据可画图"); return; } + const status = $("#lb-chart-status"); + const imgWrap = $("#lb-chart-img"); + status.textContent = "⏳ 正在生成..."; + try { + const resp = await fetch("/api/chart", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(window.__lbChart), + }); + if (!resp.ok) { const j = await resp.json().catch(() => ({})); status.textContent = "❌ " + (j.error || "生成失败"); return; } + const blob = await resp.blob(); + if (lbChartUrl) URL.revokeObjectURL(lbChartUrl); + lbChartUrl = URL.createObjectURL(blob); + imgWrap.innerHTML = ""; + const img = document.createElement("img"); + img.src = lbChartUrl; + img.alt = "模型速度排行图"; + img.onload = () => { status.textContent = "✅ 生成完成"; }; + imgWrap.appendChild(img); + } catch (e) { status.textContent = "❌ " + e.message; } +} + +/* ───────────────────────── 模型详情页 ───────────────────────── */ +async function loadModel() { + const params = new URLSearchParams(location.search); + const provider = params.get("provider") || ""; + const model = params.get("model") || ""; + if (!model) { $("#model-body").innerHTML = '
缺少模型参数
'; return; } + let d; + try { + d = await api(`/api/model?provider=${encodeURIComponent(provider)}&model=${encodeURIComponent(model)}`); + } catch (e) { + $("#model-body").innerHTML = `
${esc(e.message)}
`; + return; + } + document.title = `${d.model} · 模型评测`; + $("#m-title").textContent = `${d.model} 详情`; + $("#m-sub").textContent = `${providerLabel(d.provider)} · 共 ${d.sub_cnt} 条提交`; + $("#md-submissions").innerHTML = d.submissions.map((s) => { + const conc = (s.concurrency_levels || [1]).join("/"); + return ` + #${s.id} + ${esc(s.account)} + ${esc(s.created_at)} + ${fmt(s.samples_ok)}/${fmt(s.samples_total)} + ${fmt(s.avg_ttft_ms)} + ${fmt(s.avg_prefill_speed)} + ${fmt(s.avg_decode_speed)} + ${fmt(s.avg_stream_decode)} + ${conc} + ${fmt(s.avg_output_tokens)} + ${s.test_name ? `${esc(s.test_name.slice(0, 16))}` : "—"} + `; + }).join(""); + + // 折线图:解码速度随上下文长度 + window.__modelChart = d.line_payload; + window.__modelLineCsv = d.line_csv; + $("#m-chart-csv").value = d.line_csv; + $("#m-chart-img").innerHTML = '
点击「🎨 生成折线图」绘制
'; + $("#m-chart-status").textContent = ""; + + // 数据统计(按长度聚合表) + $("#md-lengths").innerHTML = d.by_length.map((r) => ` + ${fmtInt(r.length)} + ${fmtInt(r.count)} + ${fmt(r.avg_prefill_speed)} + ${fmt(r.avg_decode_speed)} + ${fmt(r.avg_ttft_ms)} + `).join(""); +} + +let modelChartUrl = ""; +async function genModelChart() { + if (!window.__modelChart) { toast("暂无数据可画图"); return; } + const status = $("#m-chart-status"); + const imgWrap = $("#m-chart-img"); + status.textContent = "⏳ 正在生成..."; + try { + const resp = await fetch("/api/chart", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(window.__modelChart), + }); + if (!resp.ok) { const j = await resp.json().catch(() => ({})); status.textContent = "❌ " + (j.error || "生成失败"); return; } + const blob = await resp.blob(); + if (modelChartUrl) URL.revokeObjectURL(modelChartUrl); + modelChartUrl = URL.createObjectURL(blob); + imgWrap.innerHTML = ""; + const img = document.createElement("img"); + img.src = modelChartUrl; + img.alt = "速度随上下文长度"; + img.onload = () => { status.textContent = "✅ 生成完成"; }; + imgWrap.appendChild(img); + } catch (e) { status.textContent = "❌ " + e.message; } +} + +/* ───────────────────────── 提交详情弹窗 ───────────────────────── */ +async function openSubmission(sid) { + const mask = $("#sub-mask"); + if (!mask) return; + mask.hidden = false; + $("#sub-body").innerHTML = '
加载中…
'; + let s; + try { s = await api(`/api/submissions/${sid}`); } + catch (e) { $("#sub-body").innerHTML = `
${esc(e.message)}
`; return; } + $("#sub-id").textContent = `#${sid}`; + const byLength = s.by_length || {}; + const byConc = s.by_concurrency || {}; + const lengthRows = Object.keys(byLength).sort((a, b) => a - b).map((L) => { + const bl = byLength[L]; + return `${L}${fmt(bl.avg_ttft_ms)} + ${fmt(bl.avg_prefill_speed)}${fmt(bl.avg_decode_speed)} + ${fmt(bl.avg_prompt_tokens)}${fmt(bl.avg_output_tokens)}`; + }).join(""); + const concRows = Object.keys(byConc).sort((a, b) => a - b).map((C) => { + const bl = byConc[C]; + return `${C}${fmt(bl.avg_ttft_ms)} + ${fmt(bl.avg_prefill_speed)}${fmt(bl.avg_decode_speed)} + ${fmt(bl.avg_stream_decode)}${fmt(bl.avg_total_ms)}`; + }).join(""); + const runs = s.runs || []; + const runsHtml = runs.length ? ` + ` + + runs.map((r, i) => { + const m = r.metrics || {}; + return ` + + + + `; + }).join("") + "
#上下文tok提示词tok首字ms预填充tok/s输出tok解码tok/s总耗时ms备注
${i + 1}${r.context_length || m.context_length || "—"}${fmt(m.prompt_tokens)}${fmt(m.ttft_ms)}${fmt(m.prefill_speed)}${fmt(m.output_tokens)}${fmt(m.decode_speed)}${fmt(m.total_ms)}${r.error ? "❌" : "✅"}
" + : '
无采样明细(演示数据)
'; + + $("#sub-body").innerHTML = ` +
+
账号
${esc(s.account)}
+
模型
${esc(s.model)}
+
提供商
${esc(providerLabel(s.provider))}
+
测试名称
${esc(s.test_name || "—")}
+
提交时间
${esc(s.created_at)}
+
来源
${esc(s.source_site)} #${s.source_test_id}
+
+

📊 整体指标

+
+
采样(成功/总数)
${fmtInt(s.samples_ok)} / ${fmtInt(s.samples_total)}
+
首字延迟
${fmt(s.avg_ttft_ms)} ms
+
预填充速度
${fmt(s.avg_prefill_speed)} tok/s
+
解码速度
${fmt(s.avg_decode_speed)} tok/s
+
单流均解码
${fmt(s.avg_stream_decode)} tok/s
+
平均输出
${fmtInt(s.avg_output_tokens)} tok
+
+

📏 按上下文长度

+
+ + ${lengthRows || ''}
长度tok首字ms预填充tok/s解码tok/s提示词tok输出tok
无数据
+

🔀 按并发数

+
+ + ${concRows || ''}
并发首字ms预填充tok/s解码tok/s单流均解码tok/s总耗时ms
仅单流
+

📈 图表

+
点击「🎨 生成图表」
+
+
+

📋 采样明细

+ ${runsHtml} + `; + window.__subId = sid; + let chartUrl = ""; + $("#sub-chart-gen").addEventListener("click", async () => { + const status = $("#sub-chart-status"); + status.textContent = "⏳ 正在生成..."; + try { + const resp = await fetch(`/api/submissions/${sid}/chart`); + if (!resp.ok) { const j = await resp.json().catch(() => ({})); status.textContent = "❌ " + (j.error || "生成失败"); return; } + const blob = await resp.blob(); + if (chartUrl) URL.revokeObjectURL(chartUrl); + chartUrl = URL.createObjectURL(blob); + const wrap = $("#sub-chart-img"); + wrap.innerHTML = ""; + const img = document.createElement("img"); + img.src = chartUrl; + img.onload = () => { status.textContent = "✅ 生成完成"; }; + wrap.appendChild(img); + } catch (e) { status.textContent = "❌ " + e.message; } + }); +} + +/* ───────────────────────── 提交管理页 ───────────────────────── */ +let subPage = 1; +let subQuery = ""; + +async function loadSubmissions(resetPage = false) { + const tb = $("#subs tbody"); + if (!tb) return; + if (resetPage) subPage = 1; + tb.innerHTML = '加载中…'; + let d; + try { + d = await api(`/api/submissions?page=${subPage}&page_size=20&q=${encodeURIComponent(subQuery)}`); + } catch (e) { + tb.innerHTML = `加载失败:${esc(e.message)}`; + return; + } + $("#sub-total").textContent = `共 ${d.total} 条`; + $("#sub-pg").textContent = `${d.page} / ${d.pages}`; + $("#sub-prev").disabled = d.page <= 1; + $("#sub-next").disabled = d.page >= d.pages; + if (!d.items.length) { + tb.innerHTML = '暂无提交记录。可点击右上角「🎲 生成演示数据」或从 llm-speed-tester 发送。'; + return; + } + tb.innerHTML = d.items.map((s) => ` + #${s.id} + ${esc(s.account)} + ${esc(s.created_at)} + ${esc(providerLabel(s.provider))} + ${esc(s.model)} + ${fmt(s.samples_ok)}/${fmt(s.samples_total)} + ${fmt(s.avg_prefill_speed)} + ${fmt(s.avg_decode_speed)} + ${fmt(s.avg_ttft_ms)} + ${s.test_name ? esc(s.test_name.slice(0, 14)) : "—"} + + + + + `).join(""); +} + +/* ───────────────────────── 账号管理页 ───────────────────────── */ +async function loadAccounts() { + const tb = $("#accs tbody"); + if (!tb) return; + let list; + try { list = await api("/api/accounts"); } + catch (e) { tb.innerHTML = `加载失败`; return; } + if (!list.length) { + tb.innerHTML = '暂无账号。当 llm-speed-tester 一键发送时账号会自动创建。'; + return; + } + tb.innerHTML = list.map((a) => ` + ${esc(a.name)} + ${esc(a.remark || "—")} + ${esc(a.created_at)} + ${fmtInt(a.cnt)} 条 + + + + + `).join(""); +} + +/* ───────────────────────── 事件绑定 ───────────────────────── */ +function bindIndex() { + $$("#lb th[data-sort]").forEach((th) => th.addEventListener("click", () => sortClick(th.dataset.sort))); + const gen = $("#lb-chart-gen"); + if (gen) gen.addEventListener("click", genLbChart); +} + +function bindModel() { + const gen = $("#m-chart-gen"); + if (gen) gen.addEventListener("click", genModelChart); + const copy = $("#m-chart-copy"); + if (copy) copy.addEventListener("click", () => { + const ta = $("#m-chart-csv"); + ta.select(); + if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(ta.value).then(() => toast("已复制")); + else { document.execCommand("copy"); toast("已复制"); } + }); + const dl = $("#m-chart-dl"); + if (dl) dl.addEventListener("click", () => { + if (!modelChartUrl) { toast("请先生成图表"); return; } + const a = document.createElement("a"); + a.href = modelChartUrl; + a.download = `model_chart.png`; + document.body.appendChild(a); a.click(); a.remove(); + }); + const body = $("#md-submissions"); + if (body) body.addEventListener("click", (e) => { + const link = e.target.closest(".s-link"); + if (link) { e.preventDefault(); openSubmission(link.dataset.sid); } + }); +} + +function bindSubmissions() { + const seed = $("#btn-seed"); + if (seed) seed.addEventListener("click", async () => { + seed.disabled = true; + seed.textContent = "生成中…"; + try { + const r = await api("/api/seed-demo", "POST", {}); + toast(`已生成 ${r.seeded} 条演示数据`); + loadSubmissions(true); + } catch (e) { toast("生成失败:" + e.message); } + seed.disabled = false; + seed.textContent = "🎲 生成演示数据"; + }); + const search = $("#sub-search"); + if (search) { + search.addEventListener("keydown", (e) => { if (e.key === "Enter") { subQuery = search.value.trim(); loadSubmissions(true); } }); + $("#btn-search").addEventListener("click", () => { subQuery = search.value.trim(); loadSubmissions(true); }); + } + $("#sub-prev").addEventListener("click", () => { if (subPage > 1) { subPage--; loadSubmissions(); } }); + $("#sub-next").addEventListener("click", () => { subPage++; loadSubmissions(); }); + const tb = $("#subs tbody"); + tb.addEventListener("click", (e) => { + const v = e.target.closest("[data-view]"); + const d = e.target.closest("[data-del]"); + const l = e.target.closest(".s-link"); + if (v) { e.preventDefault(); openSubmission(v.dataset.view); } + if (l) { e.preventDefault(); openSubmission(l.dataset.sid); } + if (d) { + const id = d.dataset.del; + if (confirm(`确定删除提交 #${id}?`)) { + api(`/api/submissions/${id}`, "DELETE").then(() => { toast("已删除"); loadSubmissions(); }); + } + } + }); + const close = $("#sub-close"); + if (close) { + close.addEventListener("click", () => { $("#sub-mask").hidden = true; }); + $("#sub-mask").addEventListener("click", (e) => { if (e.target === $("#sub-mask")) $("#sub-mask").hidden = true; }); + } +} + +function bindAccounts() { + const add = $("#btn-add-acc"); + if (add) add.addEventListener("click", async () => { + const name = $("#acc-name").value.trim(); + const remark = $("#acc-remark").value.trim(); + if (!name) { toast("请输入账号名称"); return; } + try { + await api("/api/accounts", "POST", { name, remark }); + toast("账号已创建"); + $("#acc-name").value = ""; $("#acc-remark").value = ""; + loadAccounts(); + } catch (e) { toast(e.message); } + }); + const tb = $("#accs tbody"); + tb.addEventListener("click", (e) => { + const ed = e.target.closest("[data-edit]"); + const dl = e.target.closest("[data-del]"); + if (ed) { + const name = prompt("账号名称:", ed.dataset.name); + if (name === null) return; + const remark = prompt("备注:", ed.dataset.remark) || ""; + api(`/api/accounts/${ed.dataset.edit}`, "PUT", { name, remark }).then(() => { toast("已保存"); loadAccounts(); }); + } + if (dl) { + const id = dl.dataset.del; + if (confirm("删除该账号会同时删除其下所有提交,确定?")) { + api(`/api/accounts/${id}`, "DELETE").then(() => { toast("已删除"); loadAccounts(); }); + } + } + }); +} + +/* ───────────────────────── 初始化 ───────────────────────── */ +document.addEventListener("DOMContentLoaded", () => { + navActive(); + const page = location.pathname.split("/").pop() || "index.html"; + if (page === "index.html" || page === "") { + loadStats(); + loadLeaderboard(); + bindIndex(); + } else if (page === "model.html") { + loadModel(); + bindModel(); + } else if (page === "submissions.html") { + loadSubmissions(true); + bindSubmissions(); + } else if (page === "accounts.html") { + loadAccounts(); + bindAccounts(); + } + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { const m = $("#sub-mask"); if (m) m.hidden = true; } + }); +}); diff --git a/static/model.html b/static/model.html new file mode 100644 index 0000000..78ed663 --- /dev/null +++ b/static/model.html @@ -0,0 +1,69 @@ + + + + + +模型详情 · 模型评测站 + + + +
+
模型评测站
+ +
+ ← 返回排行 +
+ +
+
+

模型详情

+
+
+ +
+

📈 解码速度随上下文长度变化(全部提交聚合)

+
+ + +
+
+
左轴=预填充(虚线),右轴=解码(实线)
+
+
📋 画图数据(CSV)
+ +
+ + +
+
+ +
+

📏 按上下文长度聚合

+
+ + + +
长度 tok提交数平均预填充 tok/s平均解码 tok/s首字 ms
+
+
+ +
+

📥 该模型的所有评测提交(按账号)

+
+ + + + +
提交账号时间采样首字 ms预填充 tok/s解码 tok/s单流均解码并发输出 tok测试名称
加载中…
+
+
+
+ +
+ + + diff --git a/static/submissions.html b/static/submissions.html new file mode 100644 index 0000000..717c037 --- /dev/null +++ b/static/submissions.html @@ -0,0 +1,57 @@ + + + + + +提交管理 · 模型评测站 + + + +
+
模型评测站
+ +
+ +
+ +
+
+

📥 评测提交

+
💡 这些数据来自 llm-speed-tester 的「📤 发送到评测站」按钮(按账号归属)。
+
+ + + 支持分页查看、详情、删除 +
+
+ + + + +
提交账号时间提供商模型采样预填充 tok/s解码 tok/s首字 ms测试名称操作
加载中…
+
+ +
+
+ + + + +
+ + +