Compare commits

..
5 Commits
Author SHA1 Message Date
hz4th_coder fc2005806a v2.8.3 新增测试间隔时间:每次采样之间让大模型接口空闲休息,默认5秒可配置(0关闭),等待期间支持停止 2026-09-15 14:56:52 +08:00
hz4th_coder 9580372f85 v2.8.2 上下文长度默认值改为常用列表:4096/8192/16384/32768/65536/98304/131072(去掉512/2048,加98304) 2026-09-15 13:32:30 +08:00
hz4th_coder 9e457cd22e v2.8.1 修复测试日志重复:并发数表头只在切换并发档时打印一次;日志条数计数修正;轮询加防重叠锁 2026-09-15 13:28:37 +08:00
hz4th_coder ccfd7eb689 v2.8.0 清空历史真正物理删除 + 请求超时可配修复慢接口被掐断
1. 清空历史=真正清空(不再只是删行留痕):
   - 清空前先停止所有运行中测试线程,避免其继续写入孤儿数据
   - DELETE 后重置自增序列(sqlite_sequence),新测试 ID 从 1 开始
   - WAL checkpoint(TRUNCATE) + VACUUM 物理回收文件空间,数据不可恢复
   - 单条删除删光时同样重置自增序列

2. 慢接口被掐断修复(预处理/首字耗时过长 Read timed out):
   - 读取超时默认 300s→1800s(30分钟),连接 60s
   - 「速度测试配置」新增 请求超时(秒) 输入框(默认1800),测试连接+速度采样共用,按次可调(如3600)
   - 超时错误改为友好提示:等待响应超时(超过N秒未收到数据),引导调大请求超时,不再裸抛 Read timed out
   - 测试启动日志打印当前超时配置
   - 修复案例: 18008 Qwen3.8-FP8 131072 长上下文预填充被 300s 掐断
2026-09-13 12:38:40 +08:00
hz4th_coder 31e5bbb873 v2.7.2 查看模型接口不通时不显示原始错误,优雅提示接口不通 2026-09-09 11:06:39 +08:00
8 changed files with 141 additions and 22 deletions
+5 -3
View File
@@ -114,11 +114,12 @@
},
"gen": {
"name": "Qwen3 不同上下文长度速度对比",
"context_lengths": [512, 2048, 4096, 8192, 16384, 32768, 65536, 131072],
"context_lengths": [4096, 8192, 16384, 32768, 65536, 98304, 131072],
"max_tokens": 128,
"samples": 2,
"warmup": true,
"avoid_cache": true
"avoid_cache": true,
"interval": 5
}
}
```
@@ -128,12 +129,13 @@
| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `name` | string | `""` | 测试名称/主题(会存入测试记录并展示在历史与详情) |
| `context_lengths` | number[] | `[512,2048,4096,8192,16384,32768,65536,131072]` | 要测试的上下文长度列表,每个长度独立校准+预热+采样 |
| `context_lengths` | number[] | `[4096,8192,16384,32768,65536,98304,131072]` | 要测试的上下文长度列表,每个长度独立校准+预热+采样 |
| `max_tokens` | number | `128` | 解码输出 token 长度 |
| `samples` | number | `2` | 每个(长度×并发)组合的采样次数 |
| `concurrency_levels` | number[] | `[1]` | 并发数列表(默认单流)。>1 时每采样同时发起 N 个并行流,聚合为整批吞吐指标;多档自动并排对比 |
| `warmup` | bool | `true` | 测试前空转预热(不计速度,按并发数预热) |
| `avoid_cache` | bool | `true` | 随机前缀避免缓存命中(每个并发流独立前缀) |
| `interval` | number | `5` | 每次采样之间的间隔秒数,让接口空闲休息(0 表示不等待) |
**响应:** `{ "ok": true, "id": 9 }`
+21 -3
View File
@@ -3,6 +3,7 @@
import io
import json
import subprocess
import time
from datetime import datetime
import requests
@@ -112,13 +113,17 @@ def list_models_api():
@app.route("/api/configs/test", methods=["POST"])
def test_config():
cfg = _fill_defaults(request.get_json(force=True) or {})
body = request.get_json(force=True) or {}
cfg = _fill_defaults(body)
if not cfg.get("api_key"):
return jsonify({"ok": False, "error": "请填写 API Key"}), 400
try:
# 连接测试:只要流式请求成功返回(哪怕正文为空/只有思维链)都算连通
# 慢接口长预处理可能长时间等不到首字,这里直接用请求里带的超时(默认 30 分钟),避免误判接口不通
m = call_stream(cfg, "你好,请简要回答:1+1=",
{"max_tokens": 32, "avoid_cache": False})
{"max_tokens": 32, "avoid_cache": False,
"read_timeout": body.get("read_timeout"),
"connect_timeout": body.get("connect_timeout")})
note = ""
if not (m.get("output_tokens") or m.get("output_chars")):
note = "(连接正常,但本次未返回正文内容,可能为推理型模型)"
@@ -188,7 +193,20 @@ def list_tests():
@app.route("/api/tests", methods=["DELETE"])
def clear_tests():
"""一键清空全部测试历史(含采样指标与日志)"""
"""一键清空全部测试历史(含采样指标与日志)
先停止所有运行中的测试线程,避免清空后它们继续写入孤儿数据;
再由 db.clear_tests 做物理删除(重置自增 + VACUUM 瘦身),真正清空、不可恢复。"""
# 1) 停止所有运行中的测试,并等待其退出
alive = [r for r in RUNNERS.values() if r.is_alive()]
for r in alive:
r.request_cancel()
if alive:
deadline = time.time() + 5
for r in alive:
if r.is_alive():
r.join(timeout=max(0.1, deadline - time.time()))
RUNNERS.clear()
# 2) 物理清空数据库
db.clear_tests()
return jsonify({"ok": True})
+4 -2
View File
@@ -10,9 +10,11 @@ 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")
# 流式请求超时:连接 60s,两次数据包间隔最长 300s推理型/长上文模型也够用
# 流式请求超时:连接 60s,两次数据包间隔最长 1800s30 分钟
# 注意:慢接口长上下文预处理(预填充)期间服务端可能长时间不返回任何字节,
# 等待首字受 STREAM_READ_TIMEOUT 限制;如接口更慢可在「速度测试配置」里按次调大请求超时。
CONNECT_TIMEOUT = 60
STREAM_READ_TIMEOUT = 300
STREAM_READ_TIMEOUT = 1800
# data-chart-tool 图表服务地址(用它的 /api/chart 画折线图:预填充左轴虚线 / 解码右轴实线)
CHART_API_BASE = "http://127.0.0.1:16016"
+12 -1
View File
@@ -277,14 +277,21 @@ def query_tests(q="", status="", provider="", sort="id", order="desc",
def clear_tests():
"""一键清空全部历史(测试记录 + 采样指标 + 日志)"""
"""一键清空全部历史(测试记录 + 采样指标 + 日志)
真正清空:删除全部行 + 重置自增序列 + WAL 检查点 + VACUUM 物理回收文件空间,
数据不可恢复(不是只从列表里移除,文件里也不再残留)。"""
with _lock:
conn = _connect()
try:
conn.execute("DELETE FROM test_runs")
conn.execute("DELETE FROM logs")
conn.execute("DELETE FROM tests")
# 重置自增序列:清空后新测试 ID 从 1 重新开始
conn.execute("DELETE FROM sqlite_sequence WHERE name IN ('tests','test_runs','logs')")
conn.commit()
# WAL 检查点截断 + VACUUM:把文件里已删除的数据页物理抹掉、文件瘦身
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.execute("VACUUM")
finally:
conn.close()
@@ -296,6 +303,10 @@ def delete_test(tid: int):
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,))
# 全部删光时顺带重置自增序列
n = conn.execute("SELECT COUNT(*) FROM tests").fetchone()[0]
if n == 0:
conn.execute("DELETE FROM sqlite_sequence WHERE name IN ('tests','test_runs','logs')")
conn.commit()
finally:
conn.close()
+39 -3
View File
@@ -116,7 +116,7 @@ def stream_openai(cfg, prompt, gen, log, should_stop=None):
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))
timeout=_timeouts(gen))
if resp.status_code == 200:
break
err = resp.text[:400]
@@ -152,6 +152,10 @@ def stream_openai(cfg, prompt, gen, log, should_stop=None):
cached_tokens = details.get("cached_tokens") or 0
except StopRequested:
raise
except requests.exceptions.ReadTimeout:
raise ProviderError(_read_timeout_err(gen))
except requests.exceptions.ConnectTimeout:
raise ProviderError(_connect_timeout_err(gen))
except Exception as e:
raise ProviderError("流式请求异常: %s" % e)
finally:
@@ -171,6 +175,30 @@ def _bad_stream_options(err: str):
or "additional properties" in err)
def _timeouts(gen):
"""从测试参数 gen 里取超时配置(秒),未配置则用全局默认。
返回 (connect, read) 元组,供 requests timeout 使用。"""
try:
connect = float(gen.get("connect_timeout") or config.CONNECT_TIMEOUT)
except (TypeError, ValueError):
connect = config.CONNECT_TIMEOUT
try:
read = float(gen.get("read_timeout") or config.STREAM_READ_TIMEOUT)
except (TypeError, ValueError):
read = config.STREAM_READ_TIMEOUT
return (max(connect, 5), max(read, 10))
def _read_timeout_err(gen):
"""读超时的友好报错:提示这是等待数据超时,可调大请求超时"""
return ("等待响应超时:超过 %.0f 秒未收到数据(接口预处理/首字耗时过长或网络过慢),"
"请在「速度测试配置」中调大“请求超时(秒)”" % _timeouts(gen)[1])
def _connect_timeout_err(gen):
return "连接超时:超过 %.0f 秒未建立连接(请检查地址/网络)" % _timeouts(gen)[0]
def _iter_json(resp):
"""解析 SSE data: 行,逐个返回 JSON 对象"""
for raw in resp.iter_lines(decode_unicode=True):
@@ -206,7 +234,7 @@ def stream_anthropic(cfg, prompt, gen, log, should_stop=None):
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))
timeout=_timeouts(gen))
if resp.status_code != 200:
err = resp.text[:400]
resp.close()
@@ -233,6 +261,10 @@ def stream_anthropic(cfg, prompt, gen, log, should_stop=None):
output_tokens = usage.get("output_tokens") or output_tokens
except StopRequested:
raise
except requests.exceptions.ReadTimeout:
raise ProviderError(_read_timeout_err(gen))
except requests.exceptions.ConnectTimeout:
raise ProviderError(_connect_timeout_err(gen))
except Exception as e:
raise ProviderError("流式请求异常: %s" % e)
finally:
@@ -272,7 +304,7 @@ def stream_google(cfg, prompt, gen, log, should_stop=None):
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))
timeout=_timeouts(gen))
if resp.status_code != 200:
err = resp.text[:400]
resp.close()
@@ -299,6 +331,10 @@ def stream_google(cfg, prompt, gen, log, should_stop=None):
cached_tokens = um.get("cachedContentTokenCount") or 0
except StopRequested:
raise
except requests.exceptions.ReadTimeout:
raise ProviderError(_read_timeout_err(gen))
except requests.exceptions.ConnectTimeout:
raise ProviderError(_connect_timeout_err(gen))
except Exception as e:
raise ProviderError("流式请求异常: %s" % e)
finally:
+10
View File
@@ -125,6 +125,16 @@
<span class="slider"></span>
</label>
</div>
<div class="field">
<label>请求超时(秒)<span class="hint-inline">等待首字/预处理上限,接口慢就调大</span></label>
<div class="icon-input"><span class="icon"></span><input id="gen-timeout" type="number" min="30" step="30" value="1800"></div>
<div class="hint">连接测试与速度采样共用:超过此时长仍未收到数据才判定超时(默认 1800 秒 = 30 分钟)。测慢接口/超长上下文时建议调大,如 3600。</div>
</div>
<div class="field">
<label>测试间隔(秒)<span class="hint-inline">每次采样之间让接口空闲</span></label>
<div class="icon-input"><span class="icon"></span><input id="gen-interval" type="number" min="0" step="1" value="5"></div>
<div class="hint">每次采样完成后等待 N 秒再发下一次请求,让大模型接口空闲休息一下(默认 5 秒,设为 0 关闭)。</div>
</div>
<div class="btn-group">
<button class="btn primary block" id="btn-start">▶ 开始测试</button>
<button class="btn danger block" id="btn-cancel" disabled>■ 停止</button>
+15 -5
View File
@@ -19,10 +19,11 @@ const STATUS_LABEL = {
let currentTestId = null; // 正在跑的测试 id
let pollTimer = null;
let lastLogId = 0;
let polling = false; // 防止轮询请求重叠:上一请求未返回前不再发,避免同批日志被追加两次
let consoleLogs = []; // 当前测试已加载日志 [{id,level,msg,rel}]
// 上下文长度:chips 列表 + 启用集合(默认 512/2048/4096/8192/16384/32768/65536/131072
const DEFAULT_CONTEXT_LENGTHS = [512, 2048, 4096, 8192, 16384, 32768, 65536, 131072];
// 上下文长度:chips 列表 + 启用集合(默认 4096/8192/16384/32768/65536/98304/131072
const DEFAULT_CONTEXT_LENGTHS = [4096, 8192, 16384, 32768, 65536, 98304, 131072];
let contextLengths = [...DEFAULT_CONTEXT_LENGTHS];
let contextLengthsActive = new Set(contextLengths);
@@ -69,6 +70,8 @@ function currentGen() {
concurrency_levels: [...concurrencyLevelsActive].sort((a, b) => a - b),
avoid_cache: $("#gen-avoid-cache").checked,
warmup: $("#gen-warmup").checked,
read_timeout: parseInt($("#gen-timeout").value) || 1800,
interval: Math.max(0, parseInt($("#gen-interval").value) || 5),
};
}
@@ -201,9 +204,11 @@ function loadSelectedConfig() {
async function testConnection() {
const cfg = currentConfig();
if (!cfg.api_key) { showConn(false, "请先填写 API Key"); return; }
// 慢接口长预处理可能等首字很久,连接测试也带上请求超时,避免误判接口不通
cfg.read_timeout = parseInt($("#gen-timeout").value) || 1800;
const box = $("#conn-result");
box.hidden = false; box.className = "conn-result";
box.textContent = "⏳ 正在测试连接...";
box.textContent = "⏳ 正在测试连接(等待首字,最长约 " + cfg.read_timeout + " 秒)...";
try {
const r = await api("/api/configs/test", "POST", cfg);
if (r.ok) {
@@ -254,7 +259,8 @@ async function listModels() {
const box = $("#model-result");
box.hidden = false;
box.className = "conn-result fail";
box.textContent = "❌ 获取模型失败:" + e.message;
box.textContent = "❌ 接口不通,请检查 Base URL / API Key 配置后重试";
console.warn("[listModels] 获取模型失败(已隐藏原始错误):", e);
}
}
@@ -322,6 +328,7 @@ function appendLogs(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);
consoleLogs.push(...logs); // 记录已加载日志,修正“日志条数”一直显示 0 条的问题
$("#log-count").textContent = `${consoleLogs.length}`;
if (atBottom) box.scrollTop = box.scrollHeight;
}
@@ -382,7 +389,8 @@ function startTest() {
}
async function pollLogs() {
if (currentTestId == null) return;
if (currentTestId == null || polling) return;
polling = true;
try {
const d = await api(`/api/tests/${currentTestId}/logs?after=${lastLogId}`);
if (d.logs && d.logs.length) {
@@ -395,6 +403,8 @@ async function pollLogs() {
}
} catch (e) {
/* 网络抖动忽略 */
} finally {
polling = false;
}
}
+35 -5
View File
@@ -32,6 +32,15 @@ class TestRunner(threading.Thread):
def log(self, level, msg):
db.add_log(self.test_id, level, msg)
def _sleep_interval(self, secs):
"""测试间隔等待:期间可被用户停止,返回 False 表示已被取消"""
deadline = time.time() + secs
while time.time() < deadline:
if self.should_stop():
return False
time.sleep(0.2)
return not self.should_stop()
# ───────────────────────── 主流程 ─────────────────────────
def run(self):
@@ -61,6 +70,11 @@ class TestRunner(threading.Thread):
max_tokens = max(1, int(gen.get("max_tokens", 128))) # 解码输出长度
avoid_cache = bool(gen.get("avoid_cache"))
warmup = bool(gen.get("warmup", True)) # 测试前空转预热
# 测试间隔(秒):每次采样之间让接口空闲休息,默认 5 秒,0 表示不等待
try:
interval = 5 if gen.get("interval") in (None, "") else max(0, float(gen.get("interval")))
except (TypeError, ValueError):
interval = 5
# 并发数列表(默认单流 [1];支持 2/4 及自定义,如 [1,2,4,8]
raw_concs = gen.get("concurrency_levels") or []
@@ -73,16 +87,21 @@ class TestRunner(threading.Thread):
if name:
self.log("INFO", "测试名称(主题): %s" % name)
self.log("INFO", "提供商: %s | 模型: %s" % (lp.PROVIDER_LABELS.get(provider, provider), model))
self.log("INFO", "上下文长度: %s tokens | 生成长度: %d tokens | 并发数: %s | 每个组合采样: %d 次 | 预热: %s | 避免缓存: %s"
self.log("INFO", "上下文长度: %s tokens | 生成长度: %d tokens | 并发数: %s | 每个组合采样: %d 次 | 预热: %s | 避免缓存: %s | 测试间隔: %g"
% (" / ".join(str(x) for x in lengths), max_tokens,
" / ".join(str(x) for x in concurrency_levels), n,
"" if warmup else "", "" if avoid_cache else ""))
"" if warmup else "", "" if avoid_cache else "", interval))
to = self.gen.get("read_timeout") or "默认(1800)"
self.log("INFO", "请求超时: 连接 %s s | 等待首字/预处理 %s s(接口慢可在左侧调大)"
% (self.gen.get("connect_timeout") or 60, to))
ratio = self._calibrate()
self.ratio = ratio
self.log("INFO", "校准完成: %.3f tok/字符(%.2f 字符/token" % (ratio, 1.0 / ratio))
run_seq = 0
sample_done = 0 # 已完成的采样数,用于控制测试间隔(首个采样不等待)
last_conc = None # 只在实际切换并发档时打印一次表头,避免同一并发数重复刷屏
for L in lengths:
if self.should_stop():
raise StopRequested()
@@ -91,13 +110,20 @@ class TestRunner(threading.Thread):
for C in concurrency_levels:
if self.should_stop():
raise StopRequested()
self.log("INFO", "══ 并发数 %d(同时 %d 个流)══" % (C, C))
if C != last_conc:
self.log("INFO", "══ 并发数 %d(同时 %d 个流)══" % (C, C))
last_conc = C
if warmup:
self._warmup(base_prompt, C)
for i in range(1, n + 1):
if self.should_stop():
raise StopRequested()
if sample_done > 0 and interval > 0:
self.log("INFO", "⏳ 接口空闲休息 %g 秒后继续下一采样..." % interval)
if not self._sleep_interval(interval):
raise StopRequested()
run_seq += 1
sample_done += 1
self.log("INFO", "── [%d tok · 并发%d] 采样 %d/%d 开始 ──" % (L, C, i, n))
try:
m = self._run_sample(C, base_prompt, max_tokens, avoid_cache)
@@ -157,7 +183,9 @@ class TestRunner(threading.Thread):
"""
from concurrent.futures import ThreadPoolExecutor
gen_opt = {"max_tokens": max_tokens, "avoid_cache": avoid_cache}
gen_opt = {"max_tokens": max_tokens, "avoid_cache": avoid_cache,
"connect_timeout": self.gen.get("connect_timeout"),
"read_timeout": self.gen.get("read_timeout")}
def worker(idx):
prompt = self._finalize_prompt(base_prompt) # 每流独立随机前缀,避免共享缓存
@@ -240,7 +268,9 @@ class TestRunner(threading.Thread):
self.log("INFO", "正在校准 token/字符 比例(发送小探测请求)...")
try:
m = lp.call_stream(self.cfg, probe,
{"max_tokens": 8, "avoid_cache": False},
{"max_tokens": 8, "avoid_cache": False,
"connect_timeout": self.gen.get("connect_timeout"),
"read_timeout": self.gen.get("read_timeout")},
log=lambda lv, msg: self.log(lv, msg),
should_stop=self.should_stop)
pt = m.get("prompt_tokens") or 0