Compare commits

1 Commits
Author SHA1 Message Date
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
7 changed files with 94 additions and 12 deletions
+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:
+5
View File
@@ -125,6 +125,11 @@
<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="btn-group">
<button class="btn primary block" id="btn-start">▶ 开始测试</button>
<button class="btn danger block" id="btn-cancel" disabled>■ 停止</button>
+4 -1
View File
@@ -69,6 +69,7 @@ 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,
};
}
@@ -201,9 +202,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) {
+9 -2
View File
@@ -77,6 +77,9 @@ class TestRunner(threading.Thread):
% (" / ".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 ""))
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
@@ -157,7 +160,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 +245,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