Files
model-eval-site/app.py
T

320 lines
13 KiB
Python
Raw Normal View History

# -*- 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%stok/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/<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"])
def submission_delete(sid):
db.delete_submission(sid)
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")
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/<int:aid>", 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/<int:aid>", 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)