Files
model-eval-site/app.py
T

321 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)