From 1b08c9592985d3ab843a350c74983c76fc98c818 Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Wed, 2 Sep 2026 13:14:16 +0800 Subject: [PATCH] =?UTF-8?q?v1.0.0=20=E6=A8=A1=E5=9E=8B=E8=AF=84=E6=B5=8B?= =?UTF-8?q?=E7=BD=91=E7=AB=99=EF=BC=9A=E6=A8=A1=E5=9E=8B=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E9=80=9F=E5=BA=A6=E6=8E=92=E8=A1=8C(=E8=A7=A3=E7=A0=81/?= =?UTF-8?q?=E9=A2=84=E5=A1=AB=E5=85=85/=E9=A6=96=E5=AD=97)=20+=20=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E4=BD=93=E7=B3=BB=20+=20=E6=8E=A5=E6=94=B6llm-speed-t?= =?UTF-8?q?ester=E4=B8=80=E9=94=AE=E5=8F=91=E9=80=81=20+=20=E6=BC=94?= =?UTF-8?q?=E7=A4=BA=E6=95=B0=E6=8D=AE=20+=20=E5=9B=BE=E8=A1=A8(16016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + API.md | 89 ++++++++ README.md | 54 +++++ app.py | 320 ++++++++++++++++++++++++++ config.py | 17 ++ database.py | 350 +++++++++++++++++++++++++++++ start.sh | 39 ++++ static/accounts.html | 39 ++++ static/css/style.css | 172 ++++++++++++++ static/index.html | 65 ++++++ static/js/app.js | 482 ++++++++++++++++++++++++++++++++++++++++ static/model.html | 69 ++++++ static/submissions.html | 57 +++++ 13 files changed, 1757 insertions(+) create mode 100644 .gitignore create mode 100644 API.md create mode 100644 README.md create mode 100644 app.py create mode 100644 config.py create mode 100644 database.py create mode 100755 start.sh create mode 100644 static/accounts.html create mode 100644 static/css/style.css create mode 100644 static/index.html create mode 100644 static/js/app.js create mode 100644 static/model.html create mode 100644 static/submissions.html 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测试名称操作
加载中…
+
+ +
+
+ + + + +
+ + +