LLM速度测试台 v1.0.0:多提供商(OpenAI/Anthropic/Gemini)速度测试,支持上文长度/生成长度/采样次数/避免缓存,实时日志+指标(首字延迟/预填充/解码速度)+历史留存
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
data/
|
||||
logs/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
@@ -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 / ttft(tok/s)
|
||||
decode_speed 解码速度 = output_tokens / decode_time(tok/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)
|
||||
@@ -0,0 +1,2 @@
|
||||
flask>=3.0
|
||||
requests>=2.31
|
||||
@@ -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
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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">Anthropic(Claude)</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>
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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); // 空闲时定期刷新历史
|
||||
})();
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user