diff --git a/app.py b/app.py index 7a2c856..374b6c6 100644 --- a/app.py +++ b/app.py @@ -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}) diff --git a/config.py b/config.py index 273d21c..428e896 100644 --- a/config.py +++ b/config.py @@ -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,两次数据包间隔最长 1800s(30 分钟) +# 注意:慢接口长上下文预处理(预填充)期间服务端可能长时间不返回任何字节, +# 等待首字受 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" diff --git a/database.py b/database.py index ad6e848..059128f 100644 --- a/database.py +++ b/database.py @@ -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() diff --git a/llm_providers.py b/llm_providers.py index 55aabb4..40c8d60 100644 --- a/llm_providers.py +++ b/llm_providers.py @@ -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: diff --git a/static/index.html b/static/index.html index ba7edd5..86d6b82 100644 --- a/static/index.html +++ b/static/index.html @@ -125,6 +125,11 @@ +
+ +
+
连接测试与速度采样共用:超过此时长仍未收到数据才判定超时(默认 1800 秒 = 30 分钟)。测慢接口/超长上下文时建议调大,如 3600。
+
diff --git a/static/js/app.js b/static/js/app.js index b39c27f..a1dc1d8 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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) { diff --git a/tester.py b/tester.py index e7e0881..e34eab1 100644 --- a/tester.py +++ b/tester.py @@ -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