Files
model-eval-site/app.py
T

503 lines
21 KiB
Python
Raw Permalink Normal View History

# -*- coding: utf-8 -*-
"""模型评测网站 - Flask 主应用
功能:
1. 聚合展示各种模型的运行速度(解码/预填充/首字延迟 排行 + 图表)
2. 模型对比(支持硬件显示 + 对比项可去留)
3. 模型能力测试模块(内容由后台管理编辑)
4. 账号体系:每个提交挂到对应账号下
5. POST /api/submit 接收 llm-speed-tester 一键发送的测试结果
6. 后台管理(/admin.html):提交/账号/能力测试/硬件 管理
"""
import io
from functools import wraps
import requests
from flask import Flask, jsonify, request, send_file, send_from_directory, session
import config
import database as db
app = Flask(__name__, static_folder="static", static_url_path="")
app.json.ensure_ascii = False
app.secret_key = config.SESSION_SECRET
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
def admin_required(f):
@wraps(f)
def wrapper(*a, **kw):
if not session.get("admin"):
return jsonify({"ok": False, "error": "未登录或会话过期,请先登录后台"}), 401
return f(*a, **kw)
return wrapper
@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()})
# ───────────────────────── 后台登录 ─────────────────────────
@app.route("/api/admin/login", methods=["POST"])
def admin_login():
body = request.get_json(force=True) or {}
if body.get("username") == config.ADMIN_USER and body.get("password") == config.ADMIN_PASSWORD:
session["admin"] = True
return jsonify({"ok": True})
return jsonify({"ok": False, "error": "账号或密码错误"}), 401
@app.route("/api/admin/logout", methods=["POST"])
def admin_logout():
session.clear()
return jsonify({"ok": True})
@app.route("/api/admin/session")
def admin_session():
return jsonify({"ok": True, "admin": bool(session.get("admin"))})
# ───────────────────────── 接收 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)
# 可选:指定对比哪些模型(provider|model 或 仅 model,逗号分隔)
sel = request.args.get("models", "")
if sel:
keys = set()
for item in sel.split(","):
item = item.strip()
if not item:
continue
if "|" in item:
p, m = item.split("|", 1)
keys.add((p.strip(), m.strip()))
else:
keys.add(("", item))
rows = [r for r in rows if (r["provider"], r["model"]) in keys]
chart_rows = rows[: int(request.args.get("chart_top", 30) or 30)]
chart_csv = _speed_csv(chart_rows)
return jsonify({"ok": True, "rows": rows, "chart_csv": chart_csv})
def _speed_csv(chart_rows):
"""模型 运行速度 CSV:预填充 / 解码 / 首字 三列(前端据此配置柱/折线/双轴/颜色)"""
csv_lines = ["模型, 预填充速度(tok/s), 解码速度(tok/s), 首字延迟(ms)"]
for r in chart_rows:
label = "%s %s" % (r["provider"], r["model"])
csv_lines.append("%s, %s, %s, %s" % (
label.replace(",", " "), _fmt(r["avg_prefill_speed"]),
_fmt(r["avg_decode_speed"]), _fmt(r["avg_ttft_ms"])))
return "\n".join(csv_lines)
# ───────────────────────── 模型对比(硬件 + 可去留对比项) ─────────────────────────
@app.route("/api/compare")
def compare():
"""返回指定模型(默认全部)的聚合对比行 + 运行速度 CSV。
models: 逗号分隔 provider|model(保持传入顺序=对比车添加顺序);
不传时默认全部(按解码速度排序,前端默认取前5)。
"""
sel = request.args.get("models", "").strip()
keys = []
if sel:
for item in sel.split(","):
item = item.strip()
if not item:
continue
if "|" in item:
p, m = item.split("|", 1)
keys.append((p.strip(), m.strip()))
else:
keys.append(("", item))
rows = db.compare_models(keys) # 保持传入顺序(对比车顺序)
else:
lb = db.leaderboard(limit=500)
keys = [(r["provider"], r["model"]) for r in lb]
rows = db.compare_models(keys)
rows.sort(key=lambda r: (r.get("avg_decode_speed") or 0), reverse=True)
chart_csv = _speed_csv(rows[: int(request.args.get("chart_top", 60) or 60)])
return jsonify({"ok": True, "rows": rows, "chart_csv": chart_csv})
@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_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/<int:sid>")
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/<int:sid>", methods=["DELETE"])
@admin_required
def submission_delete(sid):
db.delete_submission(sid)
return jsonify({"ok": True})
@app.route("/api/submissions/<int:sid>/hardware", methods=["PUT"])
@admin_required
def submission_hardware(sid):
body = request.get_json(force=True) or {}
db.update_submission_hardware(sid, body.get("hardware", ""))
return jsonify({"ok": True})
@app.route("/api/submissions/<int:sid>/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")
@admin_required
def accounts():
return jsonify(db.list_accounts())
@app.route("/api/accounts", methods=["POST"])
@admin_required
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/<int:aid>", methods=["PUT"])
@admin_required
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/<int:aid>", methods=["DELETE"])
@admin_required
def account_delete(aid):
db.delete_account(aid)
return jsonify({"ok": True})
# ───────────────────────── 模型能力测试模块(前台只读,内容后台编辑) ─────────────────────────
@app.route("/api/capabilities")
def capabilities_list():
return jsonify(db.list_capabilities(status_only=True))
@app.route("/api/admin/capabilities")
@admin_required
def admin_capabilities_list():
return jsonify(db.list_capabilities(status_only=False))
@app.route("/api/admin/capabilities", methods=["POST"])
@admin_required
def admin_capability_add():
body = request.get_json(force=True) or {}
if not (body.get("name") or "").strip():
return jsonify({"ok": False, "error": "请填写能力测试名称"}), 400
cid = db.add_capability(body)
return jsonify({"ok": True, "id": cid})
@app.route("/api/admin/capabilities/<int:cid>", methods=["PUT"])
@admin_required
def admin_capability_update(cid):
body = request.get_json(force=True) or {}
if not (body.get("name") or "").strip():
return jsonify({"ok": False, "error": "请填写能力测试名称"}), 400
db.update_capability(cid, body)
return jsonify({"ok": True})
@app.route("/api/admin/capabilities/<int:cid>", methods=["DELETE"])
@admin_required
def admin_capability_delete(cid):
db.delete_capability(cid)
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"])
@admin_required
def seed_demo():
"""生成一批演示评测数据(重复调用会追加)"""
import random
random.seed()
accounts = ["测试小组A", "性能评测组", "模型研究所"]
gpus = ["NVIDIA A100 80G", "NVIDIA L20 48G", "NVIDIA RTX 4090 24G"]
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,
"hardware": gpus[(mi + ai) % len(gpus)],
"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()})
# ───────────────────────── 能力测试演示数据(后台) ─────────────────────────
@app.route("/api/seed-capabilities", methods=["POST"])
@admin_required
def seed_capabilities():
"""生成一批演示能力测试模块(便于展示)"""
demos = [
{"name": "数学推理", "category": "推理", "icon": "🧮",
"description": "考察基础运算、代数、逻辑推导与数学应用题能力。",
"items": [
{"title": "四则运算", "prompt": "计算 17 × 23 - 45 ÷ 5 的结果", "expect": "能正确按运算优先级给出结果"},
{"title": "一元一次方程", "prompt": "解方程 3x + 7 = 28", "expect": "给出 x=7 并附简要过程"},
{"title": "鸡兔同笼", "prompt": "笼中共35个头、94只脚,问鸡兔各几只", "expect": "正确列出方程并求解"},
]},
{"name": "代码生成", "category": "编程", "icon": "💻",
"description": "考察代码生成、算法实现、Bug 修复与代码理解。",
"items": [
{"title": "冒泡排序", "prompt": "用 Python 写一个冒泡排序", "expect": "代码正确、缩进规范"},
{"title": "反转链表", "prompt": "实现单链表反转函数", "expect": "时间复杂度 O(n)"},
{"title": "找 Bug", "prompt": "以下代码输出错误,请找出问题并修复:...", "expect": "准确定位并给出修复"},
]},
{"name": "中文理解", "category": "语言", "icon": "📖",
"description": "考察中文阅读理解、概括、润色与改写能力。",
"items": [
{"title": "段落概括", "prompt": "用一句话概括以下段落大意:...", "expect": "抓住主旨,表述通顺"},
{"title": "润色改写", "prompt": "把这段口语化的介绍改写成正式书面语", "expect": "用词准确、逻辑清晰"},
]},
{"name": "长文本处理", "category": "长文本", "icon": "📚",
"description": "考察超长上下文的记忆、定位与跨段归纳能力。",
"items": [
{"title": "长文定位", "prompt": "在给定的 8K 文档中找出第 3 段提到的日期", "expect": "准确命中原文内容"},
{"title": "跨段归纳", "prompt": "综合全文 5 个要点做总结", "expect": "要点无遗漏、顺序合理"},
]},
{"name": "多模态理解", "category": "多模态", "icon": "🖼️",
"description": "考察图片识别、OCR 与图文关联能力。",
"items": [
{"title": "OCR 识别", "prompt": "识别图片中的文字", "expect": "识别准确、无错漏"},
{"title": "图文问答", "prompt": "根据图片回答其中的物品/场景", "expect": "描述准确、贴合图片"},
]},
{"name": "对话与指令遵循", "category": "对话", "icon": "💬",
"description": "考察多轮对话、指令理解与约束遵循能力。",
"items": [
{"title": "指令遵循", "prompt": "请只回答‘是’或‘否’:1+1=2 对吗?", "expect": "严格遵守格式约束"},
{"title": "多轮记忆", "prompt": "记住我喜欢的颜色是蓝色,并在下一轮回答中引用", "expect": "跨轮记忆正确"},
]},
]
cnt = 0
for i, c in enumerate(demos):
db.add_capability({**c, "sort": i})
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)