LLM速度测试台 v1.0.0:多提供商(OpenAI/Anthropic/Gemini)速度测试,支持上文长度/生成长度/采样次数/避免缓存,实时日志+指标(首字延迟/预填充/解码速度)+历史留存

This commit is contained in:
2026-08-23 10:36:31 +08:00
commit 17f32385a6
11 changed files with 1776 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
data/
logs/
*.pyc
__pycache__/
+143
View File
@@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
"""LLM 速度测试台 - Flask 主应用"""
import json
from flask import Flask, jsonify, request, send_from_directory
import config
import database as db
from llm_providers import DEFAULT_URLS, ProviderError, call_stream
from tester import TestRunner
app = Flask(__name__, static_folder="static", static_url_path="")
app.json.ensure_ascii = False
db.init_db()
RUNNERS = {} # test_id -> TestRunner
@app.route("/")
def index():
return send_from_directory(app.static_folder, "index.html")
@app.route("/api/health")
def health():
running = [tid for tid, r in RUNNERS.items() if r.is_alive()]
return jsonify({"ok": True, "port": config.PORT, "running_tests": running})
def _fill_defaults(cfg):
p = cfg.get("provider", "openai")
if not cfg.get("base_url"):
cfg["base_url"] = DEFAULT_URLS.get(p, "")
if cfg.get("temperature") is None:
cfg["temperature"] = 0.7
return cfg
# ───────────────────────── 提供商配置 ─────────────────────────
@app.route("/api/configs", methods=["GET"])
def list_configs():
return jsonify(db.list_configs())
@app.route("/api/configs", methods=["POST"])
def add_config():
cfg = request.get_json(force=True) or {}
if not cfg.get("name"):
return jsonify({"ok": False, "error": "请填写配置名称"}), 400
cid = db.add_config(cfg)
return jsonify({"ok": True, "id": cid})
@app.route("/api/configs/<int:cid>", methods=["GET"])
def get_one_config(cid):
c = db.get_config(cid)
if not c:
return jsonify({"ok": False, "error": "配置不存在"}), 404
return jsonify(c)
@app.route("/api/configs/<int:cid>", methods=["DELETE"])
def del_config(cid):
db.delete_config(cid)
return jsonify({"ok": True})
@app.route("/api/configs/test", methods=["POST"])
def test_config():
cfg = _fill_defaults(request.get_json(force=True) or {})
if not cfg.get("api_key"):
return jsonify({"ok": False, "error": "请填写 API Key"}), 400
try:
m = call_stream(cfg, "你好,请只回复:OK", {"max_tokens": 16, "avoid_cache": False})
return jsonify({"ok": True, "total_ms": m["total_ms"], "metrics": m})
except ProviderError as e:
return jsonify({"ok": False, "error": str(e)})
except Exception as e:
return jsonify({"ok": False, "error": str(e)})
# ───────────────────────── 测试 ─────────────────────────
@app.route("/api/tests", methods=["POST"])
def start_test():
body = request.get_json(force=True) or {}
cfg = _fill_defaults(body.get("config") or {})
gen = body.get("gen") or {}
if not cfg.get("api_key"):
return jsonify({"ok": False, "error": "请填写 API Key"}), 400
if not cfg.get("model"):
return jsonify({"ok": False, "error": "请填写模型名称"}), 400
tid = db.create_test(cfg, gen)
runner = TestRunner(tid, cfg, gen)
RUNNERS[tid] = runner
runner.start()
return jsonify({"ok": True, "id": tid})
@app.route("/api/tests", methods=["GET"])
def list_tests():
return jsonify(db.list_tests())
@app.route("/api/tests/<int:tid>", methods=["GET"])
def get_test(tid):
t = db.get_test(tid)
if not t:
return jsonify({"ok": False, "error": "测试不存在"}), 404
t["runs"] = db.get_runs(tid)
t["logs"] = db.get_logs(tid)
return jsonify(t)
@app.route("/api/tests/<int:tid>/logs", methods=["GET"])
def get_logs(tid):
after = int(request.args.get("after", 0))
data = db.get_logs_after(tid, after)
if data is None:
return jsonify({"ok": False, "error": "测试不存在"}), 404
return jsonify(data)
@app.route("/api/tests/<int:tid>/cancel", methods=["POST"])
def cancel_test(tid):
r = RUNNERS.get(tid)
if r and r.is_alive():
r.request_cancel()
return jsonify({"ok": True, "msg": "正在停止..."})
return jsonify({"ok": False, "msg": "测试未在运行"})
@app.route("/api/tests/<int:tid>", methods=["DELETE"])
def del_test(tid):
db.delete_test(tid)
RUNNERS.pop(tid, None)
return jsonify({"ok": True})
if __name__ == "__main__":
app.run(host=config.HOST, port=config.PORT, threaded=True, debug=False)
+15
View File
@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
"""全局配置"""
import os
PORT = 16097
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, "llm_speed_tester.db")
# 流式请求超时:连接 30s,两次数据包间隔最长 120s(慢模型也够用)
CONNECT_TIMEOUT = 30
STREAM_READ_TIMEOUT = 120
+294
View File
@@ -0,0 +1,294 @@
# -*- coding: utf-8 -*-
"""SQLite 存储:提供商配置 / 测试记录 / 每次采样指标 / 全量日志"""
import os
import json
import sqlite3
import threading
import time
import config
_lock = threading.RLock() # RLock:允许 get_logs_after 内嵌套调用 get_last_run
SCHEMA = """
CREATE TABLE IF NOT EXISTS configs(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT DEFAULT '',
provider TEXT DEFAULT 'openai',
base_url TEXT DEFAULT '',
api_key TEXT DEFAULT '',
model TEXT DEFAULT '',
temperature REAL DEFAULT 0.7,
created_at TEXT DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS tests(
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT DEFAULT (datetime('now','localtime')),
status TEXT DEFAULT 'running',
provider TEXT DEFAULT '',
model TEXT DEFAULT '',
config_json TEXT DEFAULT '{}',
gen_cfg_json TEXT DEFAULT '{}',
summary_json TEXT DEFAULT '{}',
error TEXT DEFAULT '',
started_at REAL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS test_runs(
id INTEGER PRIMARY KEY AUTOINCREMENT,
test_id INTEGER NOT NULL,
run_index INTEGER DEFAULT 0,
metrics_json TEXT DEFAULT '{}',
error TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS logs(
id INTEGER PRIMARY KEY AUTOINCREMENT,
test_id INTEGER NOT NULL,
level TEXT DEFAULT 'INFO',
msg TEXT DEFAULT '',
rel REAL DEFAULT 0,
ts TEXT DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_runs_test ON test_runs(test_id);
CREATE INDEX IF NOT EXISTS idx_logs_test ON logs(test_id);
"""
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 add_config(cfg: dict) -> int:
with _lock:
conn = _connect()
try:
cur = conn.execute(
"INSERT INTO configs(name,provider,base_url,api_key,model,temperature) VALUES(?,?,?,?,?,?)",
(cfg.get("name", ""), cfg.get("provider", "openai"),
cfg.get("base_url", ""), cfg.get("api_key", ""),
cfg.get("model", ""), float(cfg.get("temperature", 0.7))))
conn.commit()
return cur.lastrowid
finally:
conn.close()
def list_configs():
with _lock:
conn = _connect()
try:
rows = conn.execute("SELECT id,name,provider,base_url,model,temperature,"
"CASE WHEN api_key<>'' THEN 1 ELSE 0 END AS has_key "
"FROM configs ORDER BY id DESC").fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def get_config(cid: int):
with _lock:
conn = _connect()
try:
r = conn.execute("SELECT * FROM configs WHERE id=?", (cid,)).fetchone()
return dict(r) if r else None
finally:
conn.close()
def delete_config(cid: int):
with _lock:
conn = _connect()
try:
conn.execute("DELETE FROM configs WHERE id=?", (cid,))
conn.commit()
finally:
conn.close()
# ───────────────────────── 测试记录 ─────────────────────────
def create_test(cfg: dict, gen: dict) -> int:
with _lock:
conn = _connect()
try:
cur = conn.execute(
"INSERT INTO tests(status,provider,model,config_json,gen_cfg_json,started_at) "
"VALUES('running',?,?,?,?,?)",
(cfg.get("provider", "openai"), cfg.get("model", ""),
json.dumps(cfg, ensure_ascii=False), json.dumps(gen, ensure_ascii=False),
time.time()))
conn.commit()
return cur.lastrowid
finally:
conn.close()
def update_status(tid: int, status: str, summary: dict = None, error: str = ""):
with _lock:
conn = _connect()
try:
conn.execute("UPDATE tests SET status=?, summary_json=?, error=? WHERE id=?",
(status,
json.dumps(summary or {}, ensure_ascii=False),
error or "", tid))
conn.commit()
finally:
conn.close()
def get_test(tid: int):
with _lock:
conn = _connect()
try:
r = conn.execute("SELECT * FROM tests WHERE id=?", (tid,)).fetchone()
if not r:
return None
d = dict(r)
d["config"] = json.loads(d.pop("config_json") or "{}")
d["gen"] = json.loads(d.pop("gen_cfg_json") or "{}")
d["summary"] = json.loads(d.pop("summary_json") or "{}")
return d
finally:
conn.close()
def list_tests(limit=100):
with _lock:
conn = _connect()
try:
rows = conn.execute(
"SELECT id,created_at,status,provider,model,summary_json,error "
"FROM tests ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
out = []
for r in rows:
d = dict(r)
d["summary"] = json.loads(d.pop("summary_json") or "{}")
out.append(d)
return out
finally:
conn.close()
def delete_test(tid: int):
with _lock:
conn = _connect()
try:
conn.execute("DELETE FROM tests WHERE id=?", (tid,))
conn.execute("DELETE FROM test_runs WHERE test_id=?", (tid,))
conn.execute("DELETE FROM logs WHERE test_id=?", (tid,))
conn.commit()
finally:
conn.close()
# ───────────────────────── 采样指标 ─────────────────────────
def add_run(tid: int, run_index: int, metrics: dict, error: str = ""):
with _lock:
conn = _connect()
try:
conn.execute(
"INSERT INTO test_runs(test_id,run_index,metrics_json,error) VALUES(?,?,?,?)",
(tid, run_index, json.dumps(metrics, ensure_ascii=False), error))
conn.commit()
finally:
conn.close()
def get_runs(tid: int):
with _lock:
conn = _connect()
try:
rows = conn.execute(
"SELECT run_index,metrics_json,error FROM test_runs "
"WHERE test_id=? ORDER BY run_index", (tid,)).fetchall()
out = []
for r in rows:
d = dict(r)
d["metrics"] = json.loads(d.pop("metrics_json") or "{}")
out.append(d)
return out
finally:
conn.close()
def get_last_run(tid: int):
with _lock:
conn = _connect()
try:
r = conn.execute(
"SELECT metrics_json FROM test_runs WHERE test_id=? "
"ORDER BY run_index DESC LIMIT 1", (tid,)).fetchone()
return json.loads(r["metrics_json"] or "{}") if r else None
finally:
conn.close()
# ───────────────────────── 日志 ─────────────────────────
def add_log(tid: int, level: str, msg: str, rel: float = None):
with _lock:
conn = _connect()
try:
if rel is None:
r = conn.execute("SELECT started_at FROM tests WHERE id=?", (tid,)).fetchone()
rel = (time.time() - (r["started_at"] or time.time())) if r else 0.0
conn.execute("INSERT INTO logs(test_id,level,msg,rel) VALUES(?,?,?,?)",
(tid, level, msg, round(rel, 3)))
conn.commit()
finally:
conn.close()
def get_logs_after(tid: int, after_id: int = 0):
with _lock:
conn = _connect()
try:
t = conn.execute("SELECT status,summary_json,error FROM tests WHERE id=?", (tid,)).fetchone()
if not t:
return None
rows = conn.execute(
"SELECT id,level,msg,rel FROM logs WHERE test_id=? AND id>? ORDER BY id",
(tid, after_id)).fetchall()
logs = [dict(r) for r in rows]
last = logs[-1]["id"] if logs else after_id
return {
"status": t["status"],
"error": t["error"],
"summary": json.loads(t["summary_json"] or "{}"),
"last_run": get_last_run(tid),
"logs": logs,
"after": last,
}
finally:
conn.close()
def get_logs(tid: int):
with _lock:
conn = _connect()
try:
rows = conn.execute(
"SELECT id,level,msg,rel,ts FROM logs WHERE test_id=? ORDER BY id", (tid,)).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
+305
View File
@@ -0,0 +1,305 @@
# -*- coding: utf-8 -*-
"""大模型提供商适配器:OpenAI 兼容 / Anthropic / Google Gemini
统一接口: call_stream(cfg, prompt, gen, log, should_stop) -> metrics(dict)
度量指标:
prompt_tokens 上文/提示词 token 数(来自 provider usage
output_tokens 输出 token 数
cached_tokens 命中缓存的 token 数(provider 返回时才有)
ttft_ms 首字延迟(time to first token
decode_ms 解码阶段耗时(首字 -> 结束)
total_ms 总耗时(连接开始 -> 结束)
prefill_speed 预填充速度 = prompt_tokens / ttfttok/s
decode_speed 解码速度 = output_tokens / decode_timetok/s
"""
import json
import time
import requests
import config
class ProviderError(Exception):
"""API 调用失败"""
class StopRequested(Exception):
"""用户请求停止"""
DEFAULT_URLS = {
"openai": "https://api.openai.com/v1",
"anthropic": "https://api.anthropic.com",
"google": "https://generativelanguage.googleapis.com",
}
PROVIDER_LABELS = {
"openai": "OpenAI 兼容",
"anthropic": "Anthropic",
"google": "Google Gemini",
}
def _parse_sse_line(line):
line = (line or "").strip()
if not line.startswith("data:"):
return None
data = line[5:].strip()
if not data or data == "[DONE]":
return None
try:
return json.loads(data)
except Exception:
return None
def _metrics(start, first_token_at, end, prompt_tokens, output_tokens,
cached_tokens, output_chars, prompt_chars):
ttft_ms = (first_token_at - start) * 1000
decode_ms = (end - first_token_at) * 1000
total_ms = (end - start) * 1000
prefill = (prompt_tokens / (ttft_ms / 1000)) if prompt_tokens and ttft_ms > 0 else None
decode = (output_tokens / (decode_ms / 1000)) if output_tokens and decode_ms > 0 else None
return {
"prompt_tokens": int(prompt_tokens or 0),
"output_tokens": int(output_tokens or 0),
"cached_tokens": int(cached_tokens or 0),
"prompt_chars": int(prompt_chars or 0),
"output_chars": int(output_chars or 0),
"ttft_ms": round(ttft_ms, 1),
"decode_ms": round(decode_ms, 1),
"total_ms": round(total_ms, 1),
"prefill_speed": round(prefill, 1) if prefill else None,
"decode_speed": round(decode, 1) if decode else None,
}
# ───────────────────────── OpenAI 兼容 ─────────────────────────
def stream_openai(cfg, prompt, gen, log, should_stop=None):
base = (cfg.get("base_url") or DEFAULT_URLS["openai"]).rstrip("/")
url = base + "/chat/completions"
headers = {
"Authorization": "Bearer " + (cfg.get("api_key") or ""),
"Content-Type": "application/json",
}
payload = {
"model": cfg["model"],
"messages": [{"role": "user", "content": prompt}],
"stream": True,
"max_tokens": int(gen.get("max_tokens", 256)),
"temperature": float(cfg.get("temperature", 0.7)),
}
use_usage = True # stream_options include_usage(部分网关不支持则自动去掉重试)
def build_payload():
p = dict(payload)
if use_usage:
p["stream_options"] = {"include_usage": True}
return p
start = time.time()
first_token_at = None
output_chars = 0
prompt_tokens = output_tokens = cached_tokens = 0
resp = None
try:
while True:
if should_stop and should_stop():
raise StopRequested()
resp = requests.post(url, json=build_payload(), headers=headers, stream=True,
timeout=(config.CONNECT_TIMEOUT, config.STREAM_READ_TIMEOUT))
if resp.status_code == 200:
break
err = resp.text[:400]
code = resp.status_code
resp.close()
resp = None
if use_usage and _bad_stream_options(err):
use_usage = False
if log:
log("WARN", "提供商不支持 stream_options=include_usage,已去掉参数重试")
continue
raise ProviderError("HTTP %s: %s" % (code, err))
for obj in _iter_json(resp):
if should_stop and should_stop():
raise StopRequested()
if obj.get("choices"):
delta = obj["choices"][0].get("delta") or {}
text = delta.get("content") or ""
if text:
if first_token_at is None:
first_token_at = time.time()
output_chars += len(text)
usage = obj.get("usage")
if usage:
prompt_tokens = usage.get("prompt_tokens") or 0
output_tokens = usage.get("completion_tokens") or 0
details = usage.get("prompt_tokens_details") or {}
if isinstance(details, dict):
cached_tokens = details.get("cached_tokens") or 0
except StopRequested:
raise
except Exception as e:
raise ProviderError("流式请求异常: %s" % e)
finally:
if resp is not None:
resp.close()
if first_token_at is None:
raise ProviderError("未收到任何输出内容")
end = time.time()
return _metrics(start, first_token_at, end, prompt_tokens, output_tokens,
cached_tokens, output_chars, len(prompt))
def _bad_stream_options(err: str):
err = (err or "").lower()
return ("stream_options" in err or "unknown parameter" in err or "unknown field" in err
or "additional properties" in err)
def _iter_json(resp):
"""解析 SSE data: 行,逐个返回 JSON 对象"""
for raw in resp.iter_lines(decode_unicode=True):
obj = _parse_sse_line(raw)
if obj is not None:
yield obj
# ───────────────────────── Anthropic ─────────────────────────
def stream_anthropic(cfg, prompt, gen, log, should_stop=None):
base = (cfg.get("base_url") or DEFAULT_URLS["anthropic"]).rstrip("/")
url = base + "/v1/messages"
headers = {
"x-api-key": cfg.get("api_key") or "",
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
}
payload = {
"model": cfg["model"],
"messages": [{"role": "user", "content": prompt}],
"max_tokens": int(gen.get("max_tokens", 256)),
"temperature": float(cfg.get("temperature", 0.7)),
"stream": True,
}
start = time.time()
first_token_at = None
output_chars = 0
prompt_tokens = output_tokens = 0
resp = None
try:
if should_stop and should_stop():
raise StopRequested()
resp = requests.post(url, json=payload, headers=headers, stream=True,
timeout=(config.CONNECT_TIMEOUT, config.STREAM_READ_TIMEOUT))
if resp.status_code != 200:
err = resp.text[:400]
resp.close()
resp = None
raise ProviderError("HTTP %s: %s" % (resp.status_code, err))
for obj in _iter_json(resp):
if should_stop and should_stop():
raise StopRequested()
etype = obj.get("type")
if etype == "message_start":
usage = (obj.get("message") or {}).get("usage") or {}
prompt_tokens = usage.get("input_tokens") or 0
elif etype == "content_block_delta":
text = (obj.get("delta") or {}).get("text") or ""
if text:
if first_token_at is None:
first_token_at = time.time()
output_chars += len(text)
elif etype == "message_delta":
usage = obj.get("usage") or {}
output_tokens = usage.get("output_tokens") or output_tokens
except StopRequested:
raise
except Exception as e:
raise ProviderError("流式请求异常: %s" % e)
finally:
if resp is not None:
resp.close()
if first_token_at is None:
raise ProviderError("未收到任何输出内容")
end = time.time()
return _metrics(start, first_token_at, end, prompt_tokens, output_tokens,
0, output_chars, len(prompt))
# ───────────────────────── Google Gemini ─────────────────────────
def stream_google(cfg, prompt, gen, log, should_stop=None):
base = (cfg.get("base_url") or DEFAULT_URLS["google"]).rstrip("/")
model = cfg["model"]
url = "%s/v1beta/models/%s:streamGenerateContent" % (base, model)
params = {"alt": "sse", "key": cfg.get("api_key") or ""}
headers = {"Content-Type": "application/json"}
payload = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {
"temperature": float(cfg.get("temperature", 0.7)),
"maxOutputTokens": int(gen.get("max_tokens", 256)),
"candidateCount": 1,
},
}
start = time.time()
first_token_at = None
output_chars = 0
prompt_tokens = output_tokens = cached_tokens = 0
resp = None
try:
if should_stop and should_stop():
raise StopRequested()
resp = requests.post(url, params=params, json=payload, headers=headers, stream=True,
timeout=(config.CONNECT_TIMEOUT, config.STREAM_READ_TIMEOUT))
if resp.status_code != 200:
err = resp.text[:400]
resp.close()
resp = None
raise ProviderError("HTTP %s: %s" % (resp.status_code, err))
for obj in _iter_json(resp):
if should_stop and should_stop():
raise StopRequested()
cands = obj.get("candidates") or []
if cands:
parts = (cands[0].get("content") or {}).get("parts") or []
for part in parts:
text = part.get("text") or ""
if text:
if first_token_at is None:
first_token_at = time.time()
output_chars += len(text)
um = obj.get("usageMetadata") or {}
if um:
prompt_tokens = um.get("promptTokenCount") or 0
output_tokens = um.get("candidatesTokenCount") or 0
cached_tokens = um.get("cachedContentTokenCount") or 0
except StopRequested:
raise
except Exception as e:
raise ProviderError("流式请求异常: %s" % e)
finally:
if resp is not None:
resp.close()
if first_token_at is None:
raise ProviderError("未收到任何输出内容")
end = time.time()
return _metrics(start, first_token_at, end, prompt_tokens, output_tokens,
cached_tokens, output_chars, len(prompt))
# ───────────────────────── 统一入口 ─────────────────────────
def call_stream(cfg, prompt, gen, log=None, should_stop=None):
provider = cfg.get("provider", "openai")
fn = {"openai": stream_openai, "anthropic": stream_anthropic, "google": stream_google}.get(provider)
if fn is None:
raise ProviderError("不支持的提供商类型: %s" % provider)
return fn(cfg, prompt, gen, log, should_stop)
+2
View File
@@ -0,0 +1,2 @@
flask>=3.0
requests>=2.31
Executable
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
# LLM 速度测试台 启动/停止脚本
cd "$(dirname "$0")"
PY=/home/hz1/miniconda3/envs/openclaw/bin/python3
PORT=16097
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://<IP>:$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
+194
View File
@@ -0,0 +1,194 @@
:root {
--bg: #0f1420;
--panel: #171e2e;
--panel2: #1d2538;
--border: #2a3550;
--text: #e6ebf5;
--muted: #8b96ad;
--accent: #4f8cff;
--accent2: #22c58b;
--warn: #f5a623;
--danger: #ff5c6c;
--mono: "JetBrains Mono", "SF Mono", Consolas, Menlo, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font: 14px/1.55 -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
/* ── 顶栏 ── */
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 24px; background: var(--panel);
border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10;
}
.brand { display: flex; align-items: baseline; gap: 12px; }
.logo { font-size: 22px; }
.brand h1 { font-size: 19px; font-weight: 700; }
.brand .sub { color: var(--muted); font-size: 12px; }
.status-badge {
display: flex; align-items: center; gap: 8px; padding: 6px 14px;
border-radius: 20px; background: var(--panel2); border: 1px solid var(--border); font-size: 13px;
}
.status-badge .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); }
.status-badge.running .dot { background: var(--accent); animation: pulse 1s infinite; }
.status-badge.done .dot { background: var(--accent2); }
.status-badge.error .dot { background: var(--danger); }
@keyframes pulse { 50% { opacity: .3; } }
/* ── 布局 ── */
.layout { display: grid; grid-template-columns: 340px 1fr; gap: 16px; padding: 16px 24px; align-items: start; }
.config-panel { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 72px; }
.main { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
.card {
background: var(--panel); border: 1px solid var(--border);
border-radius: 12px; padding: 16px;
}
.card h2 { font-size: 15px; margin-bottom: 14px; }
.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.card-head h2 { margin-bottom: 0; }
/* ── 表单 ── */
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 5px; }
.field input[type="text"], .field input[type="password"], .field input[type="number"],
.field select {
width: 100%; background: var(--panel2); color: var(--text);
border: 1px solid var(--border); border-radius: 8px;
padding: 8px 10px; font-size: 13px; outline: none;
}
.field input:focus, .field select:focus { border-color: var(--accent); }
.field input[type="range"] { width: 100%; accent-color: var(--accent); }
.hint { color: var(--muted); font-size: 11px; margin-top: 4px; }
.hint-inline { color: var(--muted); font-weight: 400; margin-left: 6px; }
.row { display: flex; gap: 6px; }
.row > select { flex: 1; }
.row > input { flex: 1; }
/* 开关 */
.switch-field { display: flex; align-items: center; justify-content: space-between; }
.switch { position: relative; display: inline-block; width: 46px; height: 26px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; inset: 0; cursor: pointer; border-radius: 26px;
background: #39415a; transition: .2s;
}
.slider::before {
content: ""; position: absolute; width: 20px; height: 20px; left: 3px; top: 3px;
border-radius: 50%; background: #fff; transition: .2s;
}
.switch input:checked + .slider { background: var(--accent); }
.switch input:checked + .slider::before { transform: translateX(20px); }
/* ── 按钮 ── */
.btn {
background: var(--panel2); color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 14px; cursor: pointer; font-size: 13px; transition: .15s;
}
.btn:hover { border-color: var(--accent); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
.btn.primary:hover { filter: brightness(1.1); }
.btn.danger { background: transparent; border-color: var(--danger); color: var(--danger); }
.btn.danger:hover { background: var(--danger); color: #fff; }
.btn.danger:disabled { opacity: .4; cursor: not-allowed; }
.btn.small { padding: 5px 10px; font-size: 12px; }
.btn.block { width: 100%; }
.btn-group { display: flex; gap: 8px; margin-top: 6px; }
.btn-group .btn { flex: 1; }
/* ── 连接测试结果 ── */
.conn-result { margin-top: 10px; padding: 8px 10px; border-radius: 8px; font-size: 12px; }
.conn-result.ok { background: rgba(34,197,139,.12); color: var(--accent2); border: 1px solid rgba(34,197,139,.3); }
.conn-result.fail { background: rgba(255,92,108,.1); color: var(--danger); border: 1px solid rgba(255,92,108,.3); }
/* ── 指标卡 ── */
.metrics { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; }
.metric-card {
background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
padding: 14px 12px; text-align: center;
}
.m-label { font-size: 11px; color: var(--muted); }
.m-value { font-size: 22px; font-weight: 700; margin: 4px 0 2px; font-family: var(--mono); }
.m-unit { font-size: 11px; color: var(--muted); }
.metric-card .m-value.accent { color: var(--accent); }
.metric-card .m-value.good { color: var(--accent2); }
/* ── 日志终端 ── */
.console {
background: #0a0e17; border: 1px solid var(--border); border-radius: 10px;
height: 340px; overflow-y: auto; padding: 12px 14px;
font: 12.5px/1.7 var(--mono);
}
.console .ln { white-space: pre-wrap; word-break: break-all; }
.console .ln .ts { color: #5b6b8c; }
.console .ln.INFO { color: #c9d4ea; }
.console .ln.METRIC { color: var(--accent2); }
.console .ln.WARN { color: var(--warn); }
.console .ln.ERROR { color: var(--danger); }
.console .ln.sys { color: #5b6b8c; font-style: italic; }
/* ── 历史表 ── */
.table-wrap { overflow-x: auto; }
table.history { width: 100%; border-collapse: collapse; font-size: 13px; }
.history th, .history td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; }
.history th { color: var(--muted); font-weight: 600; font-size: 12px; }
.history tbody tr:hover { background: var(--panel2); }
.history td.num { font-family: var(--mono); }
.status-pill { padding: 2px 10px; border-radius: 12px; font-size: 11px; }
.status-pill.running { background: rgba(79,140,255,.15); color: var(--accent); }
.status-pill.done { background: rgba(34,197,139,.15); color: var(--accent2); }
.status-pill.error { background: rgba(255,92,108,.15); color: var(--danger); }
.status-pill.canceled { background: rgba(245,166,35,.15); color: var(--warn); }
/* ── 弹窗 ── */
.modal-mask {
position: fixed; inset: 0; background: rgba(4,8,16,.7); z-index: 100;
display: flex; align-items: flex-start; justify-content: center; padding: 40px 20px;
}
.modal-mask[hidden] { display: none; }
.modal {
background: var(--panel); border: 1px solid var(--border); border-radius: 14px;
width: min(920px, 96vw); max-height: 86vh; display: flex; flex-direction: column;
}
.modal-head {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px; border-bottom: 1px solid var(--border);
}
.modal-head h2 { font-size: 16px; }
.modal-body { padding: 16px 18px; overflow-y: auto; }
.modal-body h3 { font-size: 14px; margin: 16px 0 8px; color: var(--muted); }
.modal-body h3:first-child { margin-top: 0; }
.kv { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; }
.kv .kv-item { background: var(--panel2); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; }
.kv .kv-k { font-size: 11px; color: var(--muted); }
.kv .kv-v { font-family: var(--mono); font-size: 13px; margin-top: 2px; }
table.mini { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.mini th, .mini td { padding: 6px 8px; border-bottom: 1px solid var(--border); text-align: left; }
.mini th { color: var(--muted); font-size: 11px; }
.mini td.num { font-family: var(--mono); }
.mini tr.err td { color: var(--danger); }
.detail-log { background: #0a0e17; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; font: 12px/1.7 var(--mono); max-height: 240px; overflow-y: auto; }
.detail-log .ln { white-space: pre-wrap; }
.detail-log .ln.METRIC { color: var(--accent2); }
.detail-log .ln.ERROR { color: var(--danger); }
.detail-log .ln.WARN { color: var(--warn); }
pre.json-box {
background: #0a0e17; border: 1px solid var(--border); border-radius: 8px;
padding: 10px; font: 11.5px/1.6 var(--mono); overflow: auto; max-height: 260px; color: #a8f0d0;
}
@media (max-width: 1000px) {
.layout { grid-template-columns: 1fr; }
.config-panel { position: static; }
.metrics { grid-template-columns: repeat(3, 1fr); }
}
+154
View File
@@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LLM 速度测试台</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="logo"></span>
<h1>LLM 速度测试台</h1>
<span class="sub">大模型推理性能基准 · 预填充 / 首字 / 解码</span>
</div>
<div class="status-badge" id="run-status"><span class="dot"></span>空闲</div>
</header>
<main class="layout">
<!-- 左侧:配置 -->
<aside class="config-panel">
<section class="card">
<h2>🔌 大模型接口配置</h2>
<div class="field">
<label>提供商类型</label>
<select id="cfg-provider">
<option value="openai">OpenAI 兼容(OpenAI / DeepSeek / 火山方舟…)</option>
<option value="anthropic">AnthropicClaude</option>
<option value="google">Google Gemini</option>
</select>
</div>
<div class="field">
<label>已保存配置</label>
<div class="row">
<select id="cfg-list"><option value="">— 选择配置加载 —</option></select>
<button class="btn small" id="btn-load-config" title="加载选中配置">载入</button>
<button class="btn small primary" id="btn-save-config" title="保存当前输入">保存</button>
<button class="btn small danger" id="btn-del-config" title="删除选中配置">删除</button>
</div>
</div>
<div class="field">
<label>配置名称</label>
<input id="cfg-name" placeholder="例如:DeepSeek-V4">
</div>
<div class="field">
<label>Base URL</label>
<input id="cfg-baseurl" placeholder="留空使用官方默认地址">
<div class="hint" id="cfg-default-url"></div>
</div>
<div class="field">
<label>API Key</label>
<div class="row">
<input id="cfg-apikey" type="password" placeholder="sk-... / key...">
<button class="btn small" id="btn-toggle-key" title="显示/隐藏">👁</button>
</div>
</div>
<div class="field">
<label>模型名称</label>
<input id="cfg-model" placeholder="如 gpt-4o / deepseek-chat / claude-sonnet-4">
</div>
<div class="field">
<label>温度 Temperature <b id="tmp-val">0.7</b></label>
<input id="cfg-temp" type="range" min="0" max="2" step="0.1" value="0.7">
</div>
<button class="btn block" id="btn-test-conn">🔍 测试连接</button>
<div class="conn-result" id="conn-result" hidden></div>
</section>
<section class="card">
<h2>🚀 速度测试配置</h2>
<div class="field">
<label>上文长度(目标 tokens</label>
<input id="gen-prompt-tokens" type="number" min="16" step="16" value="2048">
</div>
<div class="field">
<label>生成长度(max tokens</label>
<input id="gen-max-tokens" type="number" min="1" step="1" value="256">
</div>
<div class="field">
<label>采样次数</label>
<input id="gen-samples" type="number" min="1" max="50" value="3">
</div>
<div class="field switch-field">
<label>避免缓存<span class="hint-inline">随机前缀,测真实预填充</span></label>
<label class="switch">
<input type="checkbox" id="gen-avoid-cache" checked>
<span class="slider"></span>
</label>
</div>
<div class="btn-group">
<button class="btn primary block" id="btn-start">▶ 开始测试</button>
<button class="btn danger block" id="btn-cancel" disabled>■ 停止</button>
</div>
</section>
</aside>
<!-- 右侧:指标 + 日志 + 历史 -->
<section class="main">
<div class="metrics">
<div class="metric-card"><div class="m-label">首字延迟</div><div class="m-value" id="m-ttft"></div><div class="m-unit" id="m-ttft-u">ms</div></div>
<div class="metric-card"><div class="m-label">预填充速度</div><div class="m-value" id="m-prefill"></div><div class="m-unit">tok/s</div></div>
<div class="metric-card"><div class="m-label">解码速度</div><div class="m-value" id="m-decode"></div><div class="m-unit">tok/s</div></div>
<div class="metric-card"><div class="m-label">上文 Tokens</div><div class="m-value" id="m-prompts"></div><div class="m-unit">tok</div></div>
<div class="metric-card"><div class="m-label">输出 Tokens</div><div class="m-value" id="m-outputs"></div><div class="m-unit">tok</div></div>
<div class="metric-card"><div class="m-label">总耗时</div><div class="m-value" id="m-total"></div><div class="m-unit">ms</div></div>
</div>
<div class="card log-card">
<div class="card-head">
<h2>📋 测试日志</h2>
<div class="log-actions">
<span class="hint" id="log-count"></span>
<button class="btn small" id="btn-clear-console">清屏</button>
<button class="btn small" id="btn-export-log" title="导出当前测试完整日志">导出日志</button>
</div>
</div>
<div class="console" id="console"></div>
</div>
<div class="card">
<div class="card-head">
<h2>🗂 测试历史</h2>
<button class="btn small" id="btn-refresh-history">刷新</button>
</div>
<div class="table-wrap">
<table class="history" id="history">
<thead><tr>
<th>#</th><th>时间</th><th>提供商</th><th>模型</th><th>采样</th>
<th>首字 ms</th><th>预填充 tok/s</th><th>解码 tok/s</th><th>状态</th><th>操作</th>
</tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</section>
</main>
<!-- 详情弹窗 -->
<div class="modal-mask" id="detail-mask" hidden>
<div class="modal">
<div class="modal-head">
<h2>测试详情 #<span id="dt-id"></span></h2>
<div>
<button class="btn small primary" id="dt-export">导出 JSON</button>
<button class="btn small" id="dt-close"></button>
</div>
</div>
<div class="modal-body" id="dt-body"></div>
</div>
</div>
<script src="/js/app.js"></script>
</body>
</html>
+459
View File
@@ -0,0 +1,459 @@
/* LLM 速度测试台 前端逻辑 */
"use strict";
const $ = (s) => document.querySelector(s);
const $$ = (s) => Array.from(document.querySelectorAll(s));
const PROVIDER_DEFAULT_URL = {
openai: "https://api.openai.com/v1",
anthropic: "https://api.anthropic.com",
google: "https://generativelanguage.googleapis.com",
};
const PROVIDER_LABEL = {
openai: "OpenAI 兼容", anthropic: "Anthropic", google: "Google Gemini",
};
const STATUS_LABEL = {
running: "测试中", done: "完成", error: "出错", canceled: "已取消",
};
let currentTestId = null; // 正在跑的测试 id
let pollTimer = null;
let lastLogId = 0;
let consoleLogs = []; // 当前测试已加载日志 [{id,level,msg,rel}]
const esc = (s) => String(s ?? "").replace(/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const fmt = (v, d = "—") => (v === null || v === undefined || isNaN(v) ? d : v);
const pad = (n) => String(n).padStart(2, "0");
const clock = (ts) => `${pad(ts.getHours())}:${pad(ts.getMinutes())}:${pad(ts.getSeconds())}`;
/* ───────────────────────── 通用请求 ───────────────────────── */
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);
return resp.json();
}
/* ───────────────────────── 配置管理 ───────────────────────── */
function currentConfig() {
return {
name: $("#cfg-name").value.trim(),
provider: $("#cfg-provider").value,
base_url: $("#cfg-baseurl").value.trim(),
api_key: $("#cfg-apikey").value.trim(),
model: $("#cfg-model").value.trim(),
temperature: parseFloat($("#cfg-temp").value) || 0.7,
};
}
function currentGen() {
return {
prompt_tokens: parseInt($("#gen-prompt-tokens").value) || 2048,
max_tokens: parseInt($("#gen-max-tokens").value) || 256,
samples: parseInt($("#gen-samples").value) || 3,
avoid_cache: $("#gen-avoid-cache").checked,
};
}
function updateDefaultUrlHint() {
const p = $("#cfg-provider").value;
$("#cfg-default-url").textContent = "默认地址:" + PROVIDER_DEFAULT_URL[p];
if (!$("#cfg-baseurl").value.trim()) {
$("#cfg-baseurl").placeholder = "留空使用默认:" + PROVIDER_DEFAULT_URL[p];
}
}
async function loadConfigs() {
const list = await api("/api/configs");
const sel = $("#cfg-list");
const keep = sel.value;
sel.innerHTML = '<option value="">— 选择配置加载 —</option>';
for (const c of list) {
const o = document.createElement("option");
o.value = c.id;
o.textContent = `${c.name}${PROVIDER_LABEL[c.provider] || c.provider} / ${c.model}${c.has_key ? "" : " · 无Key"}`;
sel.appendChild(o);
}
if (keep && [...sel.options].some((o) => o.value === keep)) sel.value = keep;
}
function loadSelectedConfig() {
const id = $("#cfg-list").value;
if (!id) return;
api(`/api/configs/${id}`).then((c) => {
$("#cfg-name").value = c.name || "";
$("#cfg-provider").value = c.provider || "openai";
$("#cfg-baseurl").value = c.base_url || "";
$("#cfg-apikey").value = c.api_key || "";
$("#cfg-model").value = c.model || "";
$("#cfg-temp").value = c.temperature ?? 0.7;
$("#tmp-val").textContent = (c.temperature ?? 0.7).toFixed(1);
updateDefaultUrlHint();
flash("已加载配置:" + c.name);
}).catch(() => toast("加载失败"));
}
/* ───────────────────────── 连接测试 ───────────────────────── */
async function testConnection() {
const cfg = currentConfig();
if (!cfg.api_key) { showConn(false, "请先填写 API Key"); return; }
const box = $("#conn-result");
box.hidden = false; box.className = "conn-result";
box.textContent = "⏳ 正在测试连接...";
try {
const r = await api("/api/configs/test", "POST", cfg);
if (r.ok) {
const m = r.metrics || {};
showConn(true,
`✅ 连接成功(${r.total_ms}ms| 首字 ${fmt(m.ttft_ms)}ms | 提示词 ${fmt(m.prompt_tokens)} tok | 输出 ${fmt(m.output_tokens)} tok`);
} else {
showConn(false, "❌ " + (r.error || "连接失败"));
}
} catch (e) {
showConn(false, "❌ " + e.message);
}
}
function showConn(ok, text) {
const box = $("#conn-result");
box.hidden = false;
box.className = "conn-result " + (ok ? "ok" : "fail");
box.textContent = text;
}
/* ───────────────────────── 测试运行 ───────────────────────── */
function setStatus(state, label) {
const badge = $("#run-status");
badge.className = "status-badge " + state;
badge.innerHTML = `<span class="dot"></span>${label}`;
}
function clearConsole() {
consoleLogs = [];
$("#console").innerHTML = "";
$("#log-count").textContent = "";
resetMetrics();
}
function appendLogs(logs) {
const box = $("#console");
const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
let html = "";
for (const l of logs) {
html += `<div class="ln ${esc(l.level)}"><span class="ts">[${l.rel.toFixed(3)}s]</span> ${esc(l.msg)}</div>`;
}
box.insertAdjacentHTML("beforeend", html);
$("#log-count").textContent = `${consoleLogs.length}`;
if (atBottom) box.scrollTop = box.scrollHeight;
}
function resetMetrics() {
["m-ttft", "m-prefill", "m-decode", "m-prompts", "m-outputs", "m-total"].forEach((id) => {
const el = $(`#${id}`);
el.textContent = "—";
el.className = "m-value";
});
}
function updateMetrics(m, isSummary = false) {
if (!m) return;
const val = (k, ak) => (m[k] !== null && m[k] !== undefined && !isNaN(m[k])) ? m[k] : m[ak];
const set = (id, v, accent = false, good = false) => {
const el = $(`#${id}`);
el.textContent = fmt(v);
el.className = "m-value" + (accent ? " accent" : "") + (good ? " good" : "");
};
set("m-ttft", val("ttft_ms", "avg_ttft_ms"), false, true);
set("m-prefill", val("prefill_speed", "avg_prefill_speed"), true, false);
set("m-decode", val("decode_speed", "avg_decode_speed"), false, true);
set("m-prompts", val("prompt_tokens", "avg_prompt_tokens"));
set("m-outputs", val("output_tokens", "avg_output_tokens"));
set("m-total", val("total_ms", "avg_total_ms"));
const u = isSummary ? "均 ms" : "ms";
$("#m-ttft-u").textContent = u;
}
function startTest() {
const cfg = currentConfig();
const gen = currentGen();
if (!cfg.api_key) { toast("请填写 API Key"); return; }
if (!cfg.model) { toast("请填写模型名称"); return; }
clearConsole();
currentTestId = null;
lastLogId = 0;
setStatus("running", "正在测试…");
$("#btn-start").disabled = true;
$("#btn-cancel").disabled = false;
api("/api/tests", "POST", { config: cfg, gen }).then((r) => {
if (!r.ok) {
throw new Error(r.error || "启动失败");
}
currentTestId = r.id;
appendLogs([{ id: 0, level: "sys", msg: `测试 #${r.id} 已启动(${cfg.name || cfg.model}`, rel: 0 }]);
pollTimer = setInterval(pollLogs, 800);
pollLogs();
}).catch((e) => {
setStatus("error", "启动失败");
toast(e.message);
$("#btn-start").disabled = false;
$("#btn-cancel").disabled = true;
});
}
async function pollLogs() {
if (currentTestId == null) return;
try {
const d = await api(`/api/tests/${currentTestId}/logs?after=${lastLogId}`);
if (d.logs && d.logs.length) {
appendLogs(d.logs);
lastLogId = d.after || lastLogId;
}
if (d.last_run) updateMetrics(d.last_run);
if (d.status === "done" || d.status === "error" || d.status === "canceled") {
finishTest(d);
}
} catch (e) {
/* 网络抖动忽略 */
}
}
function finishTest(d) {
clearInterval(pollTimer);
pollTimer = null;
$("#btn-start").disabled = false;
$("#btn-cancel").disabled = true;
if (d.summary && d.summary.samples_ok > 0) {
updateMetrics(d.summary, true);
}
const map = { done: ["done", "✅ 测试完成"], error: ["error", "⚠️ 测试出错"], canceled: ["canceled", "⏹ 已取消"] };
const [state, label] = map[d.status] || ["done", "完成"];
setStatus(state, label);
if (d.status === "error" && d.error) toast("测试出错:" + d.error);
loadHistory();
}
function stopTest() {
if (currentTestId == null) return;
api(`/api/tests/${currentTestId}/cancel`, "POST").then(() => toast("正在停止…"));
$("#btn-cancel").disabled = true;
}
/* ───────────────────────── 测试历史 ───────────────────────── */
async function loadHistory() {
const list = await api("/api/tests");
const tb = $("#history tbody");
tb.innerHTML = "";
if (!list.length) {
tb.innerHTML = '<tr><td colspan="10" style="color:var(--muted);text-align:center">暂无测试记录</td></tr>';
return;
}
for (const t of list) {
const s = t.summary || {};
const tr = document.createElement("tr");
tr.innerHTML = `
<td>#${t.id}</td>
<td>${esc(t.created_at)}</td>
<td>${esc(PROVIDER_LABEL[t.provider] || t.provider)}</td>
<td>${esc(t.model)}</td>
<td class="num">${fmt(s.samples_ok)}/${fmt(s.samples_total)}</td>
<td class="num">${fmt(s.avg_ttft_ms)}</td>
<td class="num">${fmt(s.avg_prefill_speed)}</td>
<td class="num">${fmt(s.avg_decode_speed)}</td>
<td><span class="status-pill ${esc(t.status)}">${STATUS_LABEL[t.status] || t.status}</span></td>
<td>
<button class="btn small" data-view="${t.id}">查看</button>
<button class="btn small danger" data-del="${t.id}">删除</button>
</td>`;
tb.appendChild(tr);
}
}
/* ───────────────────────── 详情弹窗 ───────────────────────── */
async function viewDetail(id) {
const t = await api(`/api/tests/${id}`);
const s = t.summary || {};
const g = t.gen || {};
const cfg = t.config || {};
const runs = t.runs || [];
const logs = t.logs || [];
let runsHtml;
if (runs.length) {
runsHtml = `<table class="mini"><thead><tr>
<th>采样</th><th>提示词tok</th><th>缓存tok</th><th>首字ms</th><th>预填充tok/s</th>
<th>输出tok</th><th>解码tok/s</th><th>总耗时ms</th><th>备注</th>
</tr></thead><tbody>` +
runs.map((r, i) => {
const m = r.metrics || {};
return `<tr class="${r.error ? "err" : ""}">
<td>${i + 1}</td>
<td class="num">${fmt(m.prompt_tokens)}</td>
<td class="num">${fmt(m.cached_tokens, 0)}</td>
<td class="num">${fmt(m.ttft_ms)}</td>
<td class="num">${fmt(m.prefill_speed)}</td>
<td class="num">${fmt(m.output_tokens)}</td>
<td class="num">${fmt(m.decode_speed)}</td>
<td class="num">${fmt(m.total_ms)}</td>
<td>${r.error ? "❌ " + esc(r.error.slice(0, 60)) : "✅"}</td>
</tr>`;
}).join("") + `</tbody></table>`;
} else {
runsHtml = '<div class="hint">无采样数据</div>';
}
const logHtml = `<div class="detail-log">` + logs.map((l) =>
`<div class="ln ${esc(l.level)}"><span style="color:#5b6b8c">[${l.rel.toFixed(3)}s]</span> ${esc(l.msg)}</div>`
).join("") + `</div>`;
$("#dt-id").textContent = id;
$("#dt-body").innerHTML = `
<h3>📌 汇总指标</h3>
<div class="kv">
<div class="kv-item"><div class="kv-k">状态</div><div class="kv-v">${STATUS_LABEL[t.status] || t.status}</div></div>
<div class="kv-item"><div class="kv-k">创建时间</div><div class="kv-v">${esc(t.created_at)}</div></div>
<div class="kv-item"><div class="kv-k">提供商 / 模型</div><div class="kv-v">${esc(PROVIDER_LABEL[t.provider] || t.provider)} / ${esc(t.model)}</div></div>
<div class="kv-item"><div class="kv-k">采样(成功/总数)</div><div class="kv-v">${fmt(s.samples_ok)} / ${fmt(s.samples_total)}</div></div>
<div class="kv-item"><div class="kv-k">平均首字延迟</div><div class="kv-v">${fmt(s.avg_ttft_ms)} ms</div></div>
<div class="kv-item"><div class="kv-k">最佳首字延迟</div><div class="kv-v">${fmt(s.best_ttft_ms)} ms</div></div>
<div class="kv-item"><div class="kv-k">平均预填充速度</div><div class="kv-v">${fmt(s.avg_prefill_speed)} tok/s</div></div>
<div class="kv-item"><div class="kv-k">平均解码速度</div><div class="kv-v">${fmt(s.avg_decode_speed)} tok/s</div></div>
<div class="kv-item"><div class="kv-k">平均提示词</div><div class="kv-v">${fmt(s.avg_prompt_tokens)} tok</div></div>
<div class="kv-item"><div class="kv-k">平均输出</div><div class="kv-v">${fmt(s.avg_output_tokens)} tok</div></div>
<div class="kv-item"><div class="kv-k">平均缓存命中</div><div class="kv-v">${fmt(s.avg_cached_tokens, 0)} tok</div></div>
<div class="kv-item"><div class="kv-k">平均总耗时</div><div class="kv-v">${fmt(s.avg_total_ms)} ms</div></div>
</div>
<h3>⚙️ 测试参数</h3>
<div class="kv">
<div class="kv-item"><div class="kv-k">上文长度</div><div class="kv-v">${g.prompt_tokens ?? "—"} tok</div></div>
<div class="kv-item"><div class="kv-k">生成长度</div><div class="kv-v">${g.max_tokens ?? "—"} tok</div></div>
<div class="kv-item"><div class="kv-k">采样次数</div><div class="kv-v">${g.samples ?? "—"}</div></div>
<div class="kv-item"><div class="kv-k">避免缓存</div><div class="kv-v">${g.avoid_cache ? "开" : "关"}</div></div>
<div class="kv-item"><div class="kv-k">温度</div><div class="kv-v">${fmt(cfg.temperature)}</div></div>
<div class="kv-item"><div class="kv-k">Base URL</div><div class="kv-v">${esc(cfg.base_url || "(默认)")}</div></div>
<div class="kv-item"><div class="kv-k">校准字符/token</div><div class="kv-v">${fmt(s.calibration_chars_per_token)}</div></div>
</div>
<h3>📊 每次采样明细</h3>
${runsHtml}
<h3>📋 完整日志</h3>
${logHtml}
${t.error ? `<div class="conn-result fail" style="margin-top:10px">错误信息:${esc(t.error)}</div>` : ""}
`;
$("#detail-mask").hidden = false;
$("#dt-body").scrollTop = 0;
window.__detail = t;
}
function closeDetail() { $("#detail-mask").hidden = true; }
function exportDetail() {
if (!window.__detail) return;
download(JSON.stringify(window.__detail, null, 2), `test_${window.__detail.id}.json`, "application/json");
}
/* ───────────────────────── 导出 / 下载 ───────────────────────── */
function download(text, filename, type = "text/plain") {
const blob = new Blob([text], { type });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 100);
}
function exportCurrentLog() {
if (!currentTestId) { toast("当前没有进行中的测试"); return; }
api(`/api/tests/${currentTestId}`).then((t) => {
const lines = t.logs.map((l) => `[${l.rel.toFixed(3)}s] [${l.level}] ${l.msg}`).join("\n");
const head = `# LLM 速度测试日志 test#${t.id} ${t.created_at}\n# 状态: ${t.status} 提供商: ${t.provider} 模型: ${t.model}\n`;
download(head + lines + "\n", `llm_test_${t.id}.log`, "text/plain");
});
}
/* ───────────────────────── 提示 ───────────────────────── */
function toast(msg) {
const el = document.createElement("div");
el.textContent = msg;
el.style.cssText = "position:fixed;bottom:24px;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)";
document.body.appendChild(el);
setTimeout(() => el.remove(), 3200);
}
function flash(msg) {
toast(msg);
}
/* ───────────────────────── 事件绑定 ───────────────────────── */
function bind() {
$("#cfg-provider").addEventListener("change", updateDefaultUrlHint);
$("#cfg-temp").addEventListener("input", () => $("#tmp-val").textContent = parseFloat($("#cfg-temp").value).toFixed(1));
$("#btn-toggle-key").addEventListener("click", () => {
const inp = $("#cfg-apikey");
inp.type = inp.type === "password" ? "text" : "password";
});
$("#btn-save-config").addEventListener("click", async () => {
const r = await api("/api/configs", "POST", currentConfig());
if (r.ok) { toast("配置已保存"); loadConfigs(); }
else toast(r.error || "保存失败");
});
$("#btn-load-config").addEventListener("click", loadSelectedConfig);
$("#btn-del-config").addEventListener("click", async () => {
const id = $("#cfg-list").value;
if (!id) { toast("请先选择要删除的配置"); return; }
if (!confirm("确定删除该配置?")) return;
await api(`/api/configs/${id}`, "DELETE");
toast("已删除"); loadConfigs();
});
$("#btn-test-conn").addEventListener("click", testConnection);
$("#btn-start").addEventListener("click", startTest);
$("#btn-cancel").addEventListener("click", stopTest);
$("#btn-clear-console").addEventListener("click", clearConsole);
$("#btn-export-log").addEventListener("click", exportCurrentLog);
$("#btn-refresh-history").addEventListener("click", loadHistory);
$("#history tbody").addEventListener("click", (e) => {
const v = e.target.closest("[data-view]");
const d = e.target.closest("[data-del]");
if (v) viewDetail(Number(v.dataset.view));
if (d) {
const id = Number(d.dataset.del);
if (confirm(`确定删除测试 #${id} 及其全部日志?`)) {
api(`/api/tests/${id}`, "DELETE").then(() => loadHistory());
}
}
});
$("#dt-close").addEventListener("click", closeDetail);
$("#detail-mask").addEventListener("click", (e) => { if (e.target === $("#detail-mask")) closeDetail(); });
$("#dt-export").addEventListener("click", exportDetail);
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeDetail(); });
}
/* ───────────────────────── 初始化 ───────────────────────── */
(async function init() {
bind();
updateDefaultUrlHint();
loadConfigs();
loadHistory();
setInterval(() => { if (!currentTestId) loadHistory(); }, 30000); // 空闲时定期刷新历史
})();
+167
View File
@@ -0,0 +1,167 @@
# -*- coding: utf-8 -*-
"""速度测试执行器:校准 -> 采样 -> 汇总,全程写日志与指标入库"""
import json
import statistics
import threading
import time
import uuid
import database as db
import llm_providers as lp
from llm_providers import ProviderError, StopRequested
class TestRunner(threading.Thread):
def __init__(self, test_id, cfg, gen):
super().__init__(daemon=True)
self.test_id = test_id
self.cfg = cfg
self.gen = gen
self.cancel_flag = False
self.start_wall = time.time()
self.ratio = None
self.samples = []
def request_cancel(self):
self.cancel_flag = True
def should_stop(self):
return self.cancel_flag
def log(self, level, msg):
db.add_log(self.test_id, level, msg)
# ───────────────────────── 主流程 ─────────────────────────
def run(self):
try:
self._run()
except StopRequested:
self.log("WARN", "用户请求停止测试")
db.update_status(self.test_id, "canceled",
summary=self._make_summary(), error="用户取消")
except Exception as e:
self.log("ERROR", "测试异常终止: %s" % e)
db.update_status(self.test_id, "error",
summary=self._make_summary(), error=str(e))
def _run(self):
provider = self.cfg.get("provider", "openai")
model = self.cfg.get("model", "")
n = max(1, int(self.gen.get("samples", 3)))
target_tokens = max(16, int(self.gen.get("prompt_tokens", 2048)))
max_tokens = max(1, int(self.gen.get("max_tokens", 256)))
avoid_cache = bool(self.gen.get("avoid_cache"))
self.log("INFO", "═══ 开始速度测试 ═══")
self.log("INFO", "提供商: %s | 模型: %s" % (lp.PROVIDER_LABELS.get(provider, provider), model))
self.log("INFO", "目标上文: %d tokens | 生成长度: %d tokens | 采样: %d 次 | 避免缓存: %s"
% (target_tokens, max_tokens, n, "" if avoid_cache else ""))
ratio = self._calibrate()
self.ratio = ratio
base_prompt = self._build_prompt(target_tokens, ratio)
self.log("INFO", "构造基准提示词完成,目标约 %d tokens" % target_tokens)
for i in range(1, n + 1):
if self.should_stop():
raise StopRequested()
prompt = self._finalize_prompt(base_prompt)
self.log("INFO", "── 采样 %d/%d 开始 ──" % (i, n))
try:
m = lp.call_stream(
self.cfg, prompt,
{"max_tokens": max_tokens, "avoid_cache": avoid_cache},
log=lambda lv, msg: self.log(lv, msg),
should_stop=self.should_stop)
m["run_index"] = i
self.samples.append({"run_index": i, "ok": True, "metrics": m})
db.add_run(self.test_id, i, m)
self.log("METRIC", self._fmt_metric(i, n, m))
except StopRequested:
raise
except ProviderError as e:
self.log("ERROR", "采样 %d/%d 失败: %s" % (i, n, e))
self.samples.append({"run_index": i, "ok": False, "error": str(e)})
db.add_run(self.test_id, i, {}, str(e))
raise e
summary = self._make_summary()
db.update_status(self.test_id, "done", summary=summary)
self.log("INFO", "═══ 测试完成 ═══")
self.log("INFO", "汇总: 平均首字 %.1f ms | 平均预填充 %.1f tok/s | 平均解码 %.1f tok/s"
% (summary.get("avg_ttft_ms") or 0,
summary.get("avg_prefill_speed") or 0,
summary.get("avg_decode_speed") or 0))
# ───────────────────────── 工具方法 ─────────────────────────
def _calibrate(self):
probe = ("The quick brown fox jumps over the lazy dog. 人工智能大模型推理速度基准语料,"
"用于测量提示词预填充与流式解码性能。\n") * 40
self.log("INFO", "正在校准 token/字符 比例(发送小探测请求)...")
try:
m = lp.call_stream(self.cfg, probe,
{"max_tokens": 8, "avoid_cache": False},
log=lambda lv, msg: self.log(lv, msg),
should_stop=self.should_stop)
pt = m.get("prompt_tokens") or 0
if pt and len(probe):
ratio = pt / len(probe)
self.log("INFO", "探测提示词 %d tokens / %d 字符 = %.3f tok/字符"
% (pt, len(probe), ratio))
return max(ratio, 0.001)
except StopRequested:
raise
except Exception as e:
self.log("WARN", "校准失败(%s),使用默认估算 0.55 tok/字符" % e)
return 0.55
def _build_prompt(self, target_tokens, ratio):
seg = ("基准语料:The quick brown fox jumps over the lazy dog. "
"人工智能大模型推理性能测试文本,用于测量提示词预填充速度、首字延迟与流式解码吞吐。\n")
target_chars = max(64, int(target_tokens / ratio))
repeats = max(1, target_chars // len(seg))
return seg * repeats
def _finalize_prompt(self, base):
if self.gen.get("avoid_cache"):
return "[cache-bust %s]\n%s" % (uuid.uuid4().hex, base)
return base
def _fmt_metric(self, i, n, m):
return ("采样 %d/%d 完成 | 提示词 %d tok | 缓存 %d tok | 首字 %s ms | 预填充 %s tok/s"
" | 输出 %d tok | 解码 %s tok/s | 总耗时 %s ms"
% (i, n, m.get("prompt_tokens") or 0, m.get("cached_tokens") or 0,
m.get("ttft_ms"), m.get("prefill_speed"), m.get("output_tokens") or 0,
m.get("decode_speed"), m.get("total_ms")))
def _make_summary(self):
ok = [s["metrics"] for s in self.samples if s.get("ok")]
base = {
"provider": self.cfg.get("provider"),
"model": self.cfg.get("model"),
"gen": self.gen,
"samples_total": len(self.samples),
"samples_ok": len(ok),
"calibration_chars_per_token": round(1 / self.ratio, 2) if self.ratio else None,
}
if not ok:
return base
def avg(k):
vals = [m[k] for m in ok if m.get(k) is not None]
return round(statistics.mean(vals), 1) if vals else None
summary = dict(base)
summary.update({
"avg_ttft_ms": avg("ttft_ms"),
"avg_prefill_speed": avg("prefill_speed"),
"avg_decode_speed": avg("decode_speed"),
"avg_prompt_tokens": avg("prompt_tokens"),
"avg_output_tokens": avg("output_tokens"),
"avg_cached_tokens": avg("cached_tokens"),
"avg_total_ms": avg("total_ms"),
"best_ttft_ms": min([m["ttft_ms"] for m in ok if m.get("ttft_ms") is not None], default=None),
})
return summary