321 lines
13 KiB
Python
321 lines
13 KiB
Python
# -*- 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/<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)
|