Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
901232c75f | ||
|
|
3fe82328b6 | ||
|
|
35589b1e9b |
@@ -4,7 +4,7 @@
|
||||
|
||||
- **访问地址:** `http://<IP>:16097/`
|
||||
- **技术栈:** Python 3 + Flask + SQLite(纯 REST,无额外依赖)
|
||||
- **版本:** v2.3.0
|
||||
- **版本:** v2.5.1
|
||||
|
||||
---
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
### 🔌 大模型接口配置
|
||||
- 支持 **OpenAI 兼容**(OpenAI / DeepSeek / 火山方舟 / 任意兼容网关)、**Anthropic(Claude)**、**Google Gemini** 三类提供商
|
||||
- 配置项:配置名称、Base URL(留空自动使用官方默认)、API Key(可显示/隐藏)、模型名称、温度
|
||||
- **📋 查看模型按钮**:填好 Base URL + API Key 后点击,实时调用该接口的 `/models` 获取全部模型(OpenAI 兼容 `GET /models`、Anthropic `GET /v1/models`、Gemini `GET /v1beta/models`),弹出下拉面板可**搜索**(输入关键词实时过滤)/ 点击直接**填入模型名称**;仍支持手动直接输入模型名(搜索框里直接输入回车也能确认填入)
|
||||
- 配置可**保存/加载/删除**,方便多模型对比
|
||||
|
||||
### 🚀 速度测试配置
|
||||
@@ -29,7 +30,8 @@
|
||||
### 📊 指标与结果
|
||||
- 实时指标卡:首字延迟、预填充速度、解码速度、上文/输出 tokens、总耗时
|
||||
- **整体统计(平均/最小/最大)**:详情弹窗与 Excel 汇总展示首字延迟、预填充速度、解码速度、总耗时的平均/最小/最大
|
||||
- **推理型模型兼容**:支持 Qwen3 / DeepSeek 等思维链模型(`reasoning_content` / `thinking` / `thought`),思维过程计入输出,不会误报“未收到输出”
|
||||
- **推理型模型兼容**:支持 Qwen3 / DeepSeek 等思维链模型(`reasoning_content` / `reasoning` / `thinking` / `thought`),思维过程计入输出,不会误报“未收到输出”
|
||||
- **解码速度防荒谬值**:兼容 vLLM/Qwen3.8-27B 用 `reasoning` 字段流式输出思维链(此前未识别导致 output_chars=0、解码时间≈0、解码速度飙到数百万 tok/s,已修复);另防“幻影 token”——usage 报了输出 token 但流里没收到任何文本时(如推理用满 max_tokens 没正文),解码速度标记为不可测(—)而非荒谬数字
|
||||
- **采样失败不中断**:单次采样失败会记录并继续,不会让整个测试半途终止;全部失败才标记 error
|
||||
- 实时控制台日志:校准、预热、每次采样明细全程可追溯
|
||||
- **每次完整测试**支持:
|
||||
@@ -177,4 +179,4 @@ llm-speed-tester/
|
||||
## Git
|
||||
|
||||
- **仓库:** `hz4th_coder/llm-speed-tester`
|
||||
- **版本:** v2.3.0(多并发测试(默认单流,预设2/4可自定义并发档,整批吞吐聚合+按并发分组)+ 多测试结果对比(历史勾选→对比表/柱状图/并发折线图))
|
||||
- **版本:** v2.5.1(修复解码速度荒谬值:兼容 vLLM `reasoning` 字段思维链 + 幻影token防护(usage 有 token 但流无正文时解码速度置空))
|
||||
@@ -9,7 +9,7 @@ from flask import Flask, jsonify, request, send_file, send_from_directory
|
||||
|
||||
import config
|
||||
import database as db
|
||||
from llm_providers import DEFAULT_URLS, ProviderError, call_stream
|
||||
from llm_providers import DEFAULT_URLS, ProviderError, call_stream, list_models
|
||||
from tester import TestRunner
|
||||
|
||||
app = Flask(__name__, static_folder="static", static_url_path="")
|
||||
@@ -93,6 +93,22 @@ def del_config(cid):
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/api/models", methods=["POST"])
|
||||
def list_models_api():
|
||||
"""实时获取某接口(Base URL + API Key)下的所有模型列表,供前端选择填入"""
|
||||
cfg = _fill_defaults(request.get_json(force=True) or {})
|
||||
try:
|
||||
models = list_models(cfg)
|
||||
if not models:
|
||||
return jsonify({"ok": True, "models": [], "count": 0,
|
||||
"note": "接口可用,但未返回任何模型"})
|
||||
return jsonify({"ok": True, "models": models, "count": len(models)})
|
||||
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/configs/test", methods=["POST"])
|
||||
def test_config():
|
||||
cfg = _fill_defaults(request.get_json(force=True) or {})
|
||||
|
||||
+57
-2
@@ -63,6 +63,10 @@ def _metrics(start, first_token_at, end, prompt_tokens, output_tokens,
|
||||
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
|
||||
# 防“幻影 token”:usage 报了输出 token,但整个流没收到任何文本(如推理全用满 max_tokens
|
||||
# 或提供商只回 usage 不正文),此时解码时间无意义,不报出数百万的荒谬速度
|
||||
if output_tokens and decode is not None and not output_chars:
|
||||
decode = None
|
||||
return {
|
||||
"prompt_tokens": int(prompt_tokens or 0),
|
||||
"output_tokens": int(output_tokens or 0),
|
||||
@@ -132,8 +136,9 @@ def stream_openai(cfg, prompt, gen, log, should_stop=None):
|
||||
event_count += 1
|
||||
if obj.get("choices"):
|
||||
delta = obj["choices"][0].get("delta") or {}
|
||||
# 兼容推理型模型:Qwen3/DeepSeek 思维链在 reasoning_content
|
||||
piece = delta.get("content") or delta.get("reasoning_content") or ""
|
||||
# 兼容推理型模型思维链:DeepSeek 用 reasoning_content,部分 vLLM(Qwen3.8-27B-FP8) 用 reasoning,Anthropic/Gemini 分别在各自适配器处理
|
||||
piece = (delta.get("content") or delta.get("reasoning_content")
|
||||
or delta.get("reasoning") or "")
|
||||
if piece:
|
||||
if first_token_at is None:
|
||||
first_token_at = time.time()
|
||||
@@ -307,6 +312,56 @@ def stream_google(cfg, prompt, gen, log, should_stop=None):
|
||||
cached_tokens, output_chars, len(prompt))
|
||||
|
||||
|
||||
# ───────────────────────── 模型列表获取 ─────────────────────────
|
||||
|
||||
def list_models(cfg):
|
||||
"""实时获取该接口(Base URL + API Key)下的所有模型 ID 列表。
|
||||
支持 OpenAI 兼容 / Anthropic / Google Gemini 三种提供商。"""
|
||||
provider = cfg.get("provider", "openai")
|
||||
api_key = cfg.get("api_key") or ""
|
||||
if not api_key:
|
||||
raise ProviderError("请填写 API Key")
|
||||
base = (cfg.get("base_url") or DEFAULT_URLS.get(provider, "")).rstrip("/")
|
||||
|
||||
if provider == "openai":
|
||||
# GET {base}/models Authorization: Bearer <key> → data[].id
|
||||
headers = {"Authorization": "Bearer " + api_key}
|
||||
resp = requests.get(base + "/models", headers=headers,
|
||||
timeout=config.CONNECT_TIMEOUT)
|
||||
if resp.status_code != 200:
|
||||
raise ProviderError("HTTP %s: %s" % (resp.status_code, resp.text[:400]))
|
||||
data = resp.json()
|
||||
return [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||
|
||||
if provider == "anthropic":
|
||||
# GET {base}/v1/models x-api-key + anthropic-version → data[].id
|
||||
headers = {"x-api-key": api_key, "anthropic-version": "2023-06-01"}
|
||||
resp = requests.get(base + "/v1/models", headers=headers,
|
||||
timeout=config.CONNECT_TIMEOUT)
|
||||
if resp.status_code != 200:
|
||||
raise ProviderError("HTTP %s: %s" % (resp.status_code, resp.text[:400]))
|
||||
data = resp.json()
|
||||
return [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||
|
||||
if provider == "google":
|
||||
# GET {base}/v1beta/models?key=<key> → models[].name(形如 models/gemini-1.5-pro)
|
||||
resp = requests.get(base + "/v1beta/models", params={"key": api_key},
|
||||
timeout=config.CONNECT_TIMEOUT)
|
||||
if resp.status_code != 200:
|
||||
raise ProviderError("HTTP %s: %s" % (resp.status_code, resp.text[:400]))
|
||||
data = resp.json()
|
||||
out = []
|
||||
for m in (data.get("models") or []):
|
||||
name = m.get("name") or ""
|
||||
if name.startswith("models/"):
|
||||
name = name[len("models/"):]
|
||||
if name and name not in out:
|
||||
out.append(name)
|
||||
return out
|
||||
|
||||
raise ProviderError("不支持的提供商类型: %s" % provider)
|
||||
|
||||
|
||||
# ───────────────────────── 统一入口 ─────────────────────────
|
||||
|
||||
def call_stream(cfg, prompt, gen, log=None, should_stop=None):
|
||||
|
||||
+30
-6
@@ -1,11 +1,10 @@
|
||||
:root {
|
||||
color-scheme: dark; /* 原生下拉/滚动条/表单控件用浅色文字,避免黑字 */
|
||||
--bg: #0f1420;
|
||||
--panel: #171e2e;
|
||||
--panel2: #1d2538;
|
||||
--border: #2a3550;
|
||||
--text: #eef2fb;
|
||||
--muted: #a9b4cd;
|
||||
--text: #e6ebf5;
|
||||
--muted: #8b96ad;
|
||||
--accent: #4f8cff;
|
||||
--accent2: #22c58b;
|
||||
--warn: #f5a623;
|
||||
@@ -65,9 +64,6 @@ body {
|
||||
padding: 8px 10px; font-size: 13px; outline: none;
|
||||
}
|
||||
.field input:focus, .field select:focus { border-color: var(--accent); }
|
||||
.field select option { background-color: var(--panel2); color: var(--text); }
|
||||
.field select option:checked { background-color: var(--accent); color: #fff; }
|
||||
input::placeholder { color: var(--muted); opacity: 1; }
|
||||
.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; }
|
||||
@@ -133,6 +129,34 @@ input::placeholder { color: var(--muted); opacity: 1; }
|
||||
.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); }
|
||||
|
||||
/* ── 查看模型(实时获取模型列表下拉面板) ── */
|
||||
.model-pick {
|
||||
margin-top: 8px; background: var(--panel2); border: 1px solid var(--accent);
|
||||
border-radius: 10px; overflow: hidden;
|
||||
}
|
||||
.model-pick[hidden] { display: none; }
|
||||
.model-pick-head {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 8px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.model-pick-head input {
|
||||
flex: 1; background: var(--bg); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 6px 8px; font-size: 12px; outline: none;
|
||||
}
|
||||
.model-pick-head input:focus { border-color: var(--accent); }
|
||||
.model-pick-list { max-height: 240px; overflow-y: auto; }
|
||||
.model-pick-item {
|
||||
padding: 7px 10px; font-size: 12.5px; cursor: pointer;
|
||||
font-family: var(--mono); word-break: break-all;
|
||||
border-bottom: 1px solid rgba(42,53,80,.5);
|
||||
}
|
||||
.model-pick-item:hover { background: rgba(79,140,255,.16); color: var(--accent); }
|
||||
.model-pick-item.active { background: rgba(34,197,139,.14); color: var(--accent2); }
|
||||
.model-pick-empty {
|
||||
padding: 14px 10px; color: var(--muted); font-size: 12px; text-align: center;
|
||||
}
|
||||
|
||||
/* ── 指标卡 ── */
|
||||
.metrics { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; }
|
||||
.metric-card {
|
||||
|
||||
+13
-2
@@ -55,8 +55,19 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>模型名称</label>
|
||||
<div class="icon-input"><span class="icon">🤖</span><input id="cfg-model" placeholder="如 gpt-4o / deepseek-chat / claude-sonnet-4"></div>
|
||||
<label>模型名称 <span class="hint-inline">可直接输入,也可点击「查看模型」实时获取</span></label>
|
||||
<div class="row">
|
||||
<div class="icon-input" style="flex:1"><span class="icon">🤖</span><input id="cfg-model" placeholder="如 gpt-4o / deepseek-chat / claude-sonnet-4"></div>
|
||||
<button class="btn small" id="btn-list-models" title="实时获取此接口(Base URL + API Key)下的所有模型并选择填入">📋 查看模型</button>
|
||||
</div>
|
||||
<div class="model-pick" id="model-pick" hidden>
|
||||
<div class="model-pick-head">
|
||||
<input id="model-pick-search" placeholder="🔍 搜索模型(可直接输入模型名回车确认)" autocomplete="off">
|
||||
<button class="btn small" id="model-pick-close" title="关闭">✕</button>
|
||||
</div>
|
||||
<div class="model-pick-list" id="model-pick-list"></div>
|
||||
</div>
|
||||
<div class="conn-result" id="model-result" hidden></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>温度 Temperature <b id="tmp-val">0.7</b></label>
|
||||
|
||||
+82
-2
@@ -191,6 +191,7 @@ function loadSelectedConfig() {
|
||||
$("#cfg-temp").value = c.temperature ?? 0.7;
|
||||
$("#tmp-val").textContent = (c.temperature ?? 0.7).toFixed(1);
|
||||
updateDefaultUrlHint();
|
||||
hideModelPick();
|
||||
flash("已加载配置:" + c.name);
|
||||
}).catch(() => toast("加载失败"));
|
||||
}
|
||||
@@ -223,6 +224,81 @@ function showConn(ok, text) {
|
||||
box.textContent = text;
|
||||
}
|
||||
|
||||
/* ───────────────────────── 查看模型(实时获取接口下所有模型) ───────────────────────── */
|
||||
|
||||
function hideModelPick() {
|
||||
$("#model-pick").hidden = true;
|
||||
$("#model-result").hidden = true;
|
||||
}
|
||||
|
||||
async function listModels() {
|
||||
const cfg = currentConfig();
|
||||
hideModelPick();
|
||||
if (!cfg.base_url) { toast("请先填写 Base URL"); return; }
|
||||
if (!cfg.api_key) { toast("请先填写 API Key"); return; }
|
||||
const panel = $("#model-pick");
|
||||
panel.hidden = false;
|
||||
const listEl = $("#model-pick-list");
|
||||
listEl.innerHTML = '<div class="model-pick-empty">⏳ 正在获取模型列表(GET /models)...</div>';
|
||||
try {
|
||||
const r = await api("/api/models", "POST", cfg);
|
||||
if (!r.ok) throw new Error(r.error || "获取失败");
|
||||
const models = r.models || [];
|
||||
if (!models.length) {
|
||||
listEl.innerHTML = '<div class="model-pick-empty">⚠️ ' + esc(r.note || "接口可用,但未返回任何模型") + '</div>';
|
||||
return;
|
||||
}
|
||||
renderModelList(models);
|
||||
} catch (e) {
|
||||
panel.hidden = true;
|
||||
const box = $("#model-result");
|
||||
box.hidden = false;
|
||||
box.className = "conn-result fail";
|
||||
box.textContent = "❌ 获取模型失败:" + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function renderModelList(models) {
|
||||
const listEl = $("#model-pick-list");
|
||||
const search = $("#model-pick-search");
|
||||
search.value = "";
|
||||
listEl.innerHTML = "";
|
||||
const fill = (m) => {
|
||||
$("#cfg-model").value = m;
|
||||
hideModelPick();
|
||||
toast("已填入模型:" + m);
|
||||
};
|
||||
const render = () => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
const cur = $("#cfg-model").value.trim();
|
||||
const filtered = q ? models.filter((m) => m.toLowerCase().includes(q)) : models;
|
||||
if (!filtered.length) {
|
||||
listEl.innerHTML = '<div class="model-pick-empty">无匹配模型,可直接在上方输入模型名</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = "";
|
||||
for (const m of filtered) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "model-pick-item" + (m === cur ? " active" : "");
|
||||
item.textContent = m;
|
||||
item.title = "点击填入模型名称";
|
||||
item.addEventListener("click", () => fill(m));
|
||||
listEl.appendChild(item);
|
||||
}
|
||||
};
|
||||
search.addEventListener("input", render);
|
||||
search.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
const v = search.value.trim();
|
||||
if (v) fill(v); // 手动输入回车直接确认填入
|
||||
} else if (e.key === "Escape") {
|
||||
hideModelPick();
|
||||
}
|
||||
});
|
||||
render();
|
||||
search.focus();
|
||||
}
|
||||
|
||||
/* ───────────────────────── 测试运行 ───────────────────────── */
|
||||
|
||||
function setStatus(state, label) {
|
||||
@@ -951,13 +1027,17 @@ function flash(msg) {
|
||||
/* ───────────────────────── 事件绑定 ───────────────────────── */
|
||||
|
||||
function bind() {
|
||||
$("#cfg-provider").addEventListener("change", updateDefaultUrlHint);
|
||||
$("#cfg-provider").addEventListener("change", () => { updateDefaultUrlHint(); hideModelPick(); });
|
||||
$("#cfg-baseurl").addEventListener("input", hideModelPick);
|
||||
$("#cfg-apikey").addEventListener("input", hideModelPick);
|
||||
$("#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-list-models").addEventListener("click", listModels);
|
||||
$("#model-pick-close").addEventListener("click", hideModelPick);
|
||||
$("#btn-save-config").addEventListener("click", async () => {
|
||||
const r = await api("/api/configs", "POST", currentConfig());
|
||||
if (r.ok) { toast("配置已保存"); loadConfigs(); }
|
||||
@@ -1027,7 +1107,7 @@ function bind() {
|
||||
$("#dt-export-xlsx").addEventListener("click", () => { if (window.__detail) exportXlsx(window.__detail.id); });
|
||||
$("#dt-send-eval").addEventListener("click", () => { if (window.__detail) sendToEval(window.__detail.id); });
|
||||
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { closeDetail(); closeCompare(); } });
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { hideModelPick(); closeDetail(); closeCompare(); } });
|
||||
}
|
||||
|
||||
/* ───────────────────────── 初始化 ───────────────────────── */
|
||||
|
||||
@@ -200,6 +200,10 @@ class TestRunner(threading.Thread):
|
||||
total_ms = max((batch_end - batch_start) * 1000.0, 0.1)
|
||||
prefill = (total_prompt / (ttft_ms / 1000.0)) if total_prompt else None
|
||||
decode = (total_output / (decode_ms / 1000.0)) if total_output else None
|
||||
# 防“幻影 token”:所有流都没收到任何流式文本但 usage 报了输出 token(如推理用满 max_tokens),
|
||||
# 整批解码时间无意义,不报数百万的荒谬解码速度
|
||||
if total_output and decode is not None and not total_ochars:
|
||||
decode = None
|
||||
|
||||
agg = {
|
||||
"concurrency": concurrency,
|
||||
|
||||
Reference in New Issue
Block a user