From 5de118a7b513b608ed2eb6e55c739256ee3efaff Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Sun, 23 Aug 2026 18:49:09 +0800 Subject: [PATCH] =?UTF-8?q?v2.0.1=EF=BC=9A=E4=BF=AE=E5=A4=8D=E6=8E=A8?= =?UTF-8?q?=E7=90=86=E5=9E=8B=E6=A8=A1=E5=9E=8B(Qwen3/DeepSeek=20reasoning?= =?UTF-8?q?=5Fcontent)=E8=AF=AF=E6=8A=A5=E6=9C=AA=E6=94=B6=E5=88=B0?= =?UTF-8?q?=E8=BE=93=E5=87=BA+=E8=BF=9E=E6=8E=A5=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=8C=89=E8=BF=9E=E9=80=9A=E5=88=A4=E5=AE=9A+=E9=87=87?= =?UTF-8?q?=E6=A0=B7=E5=A4=B1=E8=B4=A5=E4=B8=8D=E4=B8=AD=E6=96=AD+?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E8=B6=85=E6=97=B6=E6=94=BE=E5=AE=BD60s/300s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 +++++-- app.py | 9 +++++++-- config.py | 6 +++--- llm_providers.py | 34 +++++++++++++++++++++++----------- static/js/app.js | 2 +- tester.py | 19 ++++++++++++++++--- 6 files changed, 55 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index ec43dc6..138160d 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ ### 📊 指标与结果 - 实时指标卡:首字延迟、预填充速度、解码速度、上文/输出 tokens、总耗时 +- **推理型模型兼容**:支持 Qwen3 / DeepSeek 等思维链模型(`reasoning_content` / `thinking` / `thought`),思维过程计入输出,不会误报“未收到输出” +- **采样失败不中断**:单次采样失败会记录并继续,不会让整个测试半途终止;全部失败才标记 error - 实时控制台日志:校准、预热、每次采样明细全程可追溯 - **每次完整测试**支持: - **网页点击查看**:历史记录「查看」按钮弹出详情(整体平均 + 按上下文长度分组 + 每次采样明细 + 完整日志) @@ -139,13 +141,14 @@ llm-speed-tester/ ## 常见问题 +- **连接测试提示“未收到任何输出内容”**:多为推理型模型(Qwen3/DeepSeek 思维链)或只返回 usage 的网关。已兼容 `reasoning_content` 等思维字段,连接成功即视为通过;若仍出现,请检查 API Key/Base URL/模型名。 - **无 openpyxl**:`pip install openpyxl`(已加入 requirements.txt) - **老版本数据库**:程序启动时自动迁移,为 `test_runs` 表补充 `context_length` 列,无需手动处理 -- **慢模型超时**:连接超时 30s、两次数据包间隔 120s,足够覆盖大多数慢模型;超长文(131072)生成慢属正常,请耐心等待 +- **慢模型/长上文超时**:连接超时 60s、两次数据包间隔 300s(`config.STREAM_READ_TIMEOUT` 可调);推理型模型思考阶段停顿不计超时,超长文(131072)生成慢属正常,请耐心等待 --- ## Git - **仓库:** `hz4th_coder/llm-speed-tester` -- **版本:** v2.0.0(新增多上下文长度测试 + 预热 + Excel 导出 + 界面优化) +- **版本:** v2.0.1(新增多上下文长度测试 + 预热 + Excel 导出 + 界面优化 + 推理型模型兼容 + 采样失败不中断) diff --git a/app.py b/app.py index 96cece5..115512d 100644 --- a/app.py +++ b/app.py @@ -74,8 +74,13 @@ def test_config(): 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}) + # 连接测试:只要流式请求成功返回(哪怕正文为空/只有思维链)都算连通 + m = call_stream(cfg, "你好,请简要回答:1+1=?", + {"max_tokens": 32, "avoid_cache": False}) + note = "" + if not (m.get("output_tokens") or m.get("output_chars")): + note = "(连接正常,但本次未返回正文内容,可能为推理型模型)" + return jsonify({"ok": True, "total_ms": m["total_ms"], "metrics": m, "note": note}) except ProviderError as e: return jsonify({"ok": False, "error": str(e)}) except Exception as e: diff --git a/config.py b/config.py index 30a27cb..e801e33 100644 --- a/config.py +++ b/config.py @@ -10,6 +10,6 @@ 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 +# 流式请求超时:连接 60s,两次数据包间隔最长 300s(推理型/长上文模型也够用) +CONNECT_TIMEOUT = 60 +STREAM_READ_TIMEOUT = 300 diff --git a/llm_providers.py b/llm_providers.py index 2954e2d..3c89c26 100644 --- a/llm_providers.py +++ b/llm_providers.py @@ -56,6 +56,8 @@ def _parse_sse_line(line): def _metrics(start, first_token_at, end, prompt_tokens, output_tokens, cached_tokens, output_chars, prompt_chars): + if first_token_at is None: + first_token_at = end # 未收到正文但请求完成(如纯 usage 响应) ttft_ms = (first_token_at - start) * 1000 decode_ms = (end - first_token_at) * 1000 total_ms = (end - start) * 1000 @@ -103,6 +105,7 @@ def stream_openai(cfg, prompt, gen, log, should_stop=None): first_token_at = None output_chars = 0 prompt_tokens = output_tokens = cached_tokens = 0 + event_count = 0 resp = None try: while True: @@ -126,13 +129,15 @@ def stream_openai(cfg, prompt, gen, log, should_stop=None): for obj in _iter_json(resp): if should_stop and should_stop(): raise StopRequested() + event_count += 1 if obj.get("choices"): delta = obj["choices"][0].get("delta") or {} - text = delta.get("content") or "" - if text: + # 兼容推理型模型:Qwen3/DeepSeek 思维链在 reasoning_content + piece = delta.get("content") or delta.get("reasoning_content") or "" + if piece: if first_token_at is None: first_token_at = time.time() - output_chars += len(text) + output_chars += len(piece) usage = obj.get("usage") if usage: prompt_tokens = usage.get("prompt_tokens") or 0 @@ -148,8 +153,8 @@ def stream_openai(cfg, prompt, gen, log, should_stop=None): if resp is not None: resp.close() - if first_token_at is None: - raise ProviderError("未收到任何输出内容") + if event_count == 0: + raise ProviderError("未收到任何输出内容(HTTP 200 但响应流为空)") end = time.time() return _metrics(start, first_token_at, end, prompt_tokens, output_tokens, cached_tokens, output_chars, len(prompt)) @@ -190,6 +195,7 @@ def stream_anthropic(cfg, prompt, gen, log, should_stop=None): first_token_at = None output_chars = 0 prompt_tokens = output_tokens = 0 + event_count = 0 resp = None try: if should_stop and should_stop(): @@ -204,12 +210,15 @@ def stream_anthropic(cfg, prompt, gen, log, should_stop=None): for obj in _iter_json(resp): if should_stop and should_stop(): raise StopRequested() + event_count += 1 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 "" + delta = obj.get("delta") or {} + # 兼容 extended thinking:thinking 文本也算输出 + text = delta.get("text") or delta.get("thinking") or "" if text: if first_token_at is None: first_token_at = time.time() @@ -225,8 +234,8 @@ def stream_anthropic(cfg, prompt, gen, log, should_stop=None): if resp is not None: resp.close() - if first_token_at is None: - raise ProviderError("未收到任何输出内容") + if event_count == 0: + raise ProviderError("未收到任何输出内容(HTTP 200 但响应流为空)") end = time.time() return _metrics(start, first_token_at, end, prompt_tokens, output_tokens, 0, output_chars, len(prompt)) @@ -252,6 +261,7 @@ def stream_google(cfg, prompt, gen, log, should_stop=None): first_token_at = None output_chars = 0 prompt_tokens = output_tokens = cached_tokens = 0 + event_count = 0 resp = None try: if should_stop and should_stop(): @@ -266,11 +276,13 @@ def stream_google(cfg, prompt, gen, log, should_stop=None): for obj in _iter_json(resp): if should_stop and should_stop(): raise StopRequested() + event_count += 1 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 "" + # 兼容 thinking 模型:thought 文本也算输出 + text = part.get("text") or part.get("thought") or "" if text: if first_token_at is None: first_token_at = time.time() @@ -288,8 +300,8 @@ def stream_google(cfg, prompt, gen, log, should_stop=None): if resp is not None: resp.close() - if first_token_at is None: - raise ProviderError("未收到任何输出内容") + if event_count == 0: + raise ProviderError("未收到任何输出内容(HTTP 200 但响应流为空)") end = time.time() return _metrics(start, first_token_at, end, prompt_tokens, output_tokens, cached_tokens, output_chars, len(prompt)) diff --git a/static/js/app.js b/static/js/app.js index 99bf13d..66a17cd 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -158,7 +158,7 @@ async function testConnection() { 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`); + `✅ 连接成功(${r.total_ms}ms)| 首字 ${fmt(m.ttft_ms)}ms | 提示词 ${fmt(m.prompt_tokens)} tok | 输出 ${fmt(m.output_tokens)} tok${r.note ? " " + r.note : ""}`); } else { showConn(false, "❌ " + (r.error || "连接失败")); } diff --git a/tester.py b/tester.py index 9f564d2..9a4d141 100644 --- a/tester.py +++ b/tester.py @@ -21,6 +21,7 @@ class TestRunner(threading.Thread): self.start_wall = time.time() self.ratio = None self.samples = [] + self.last_error = None def request_cancel(self): self.cancel_flag = True @@ -97,14 +98,26 @@ class TestRunner(threading.Thread): except StopRequested: raise except ProviderError as e: + # 单次采样失败:记录并继续后续采样,不让整个测试中断 + self.last_error = str(e) self.log("ERROR", "[%d tok] 采样 %d/%d 失败: %s" % (L, i, n, e)) self.samples.append({"run_index": i, "context_length": L, "ok": False, "error": str(e)}) db.add_run(self.test_id, i, {}, str(e), context_length=L) - raise e summary = self._make_summary() - db.update_status(self.test_id, "done", summary=summary) - self.log("INFO", "═══ 测试完成 ═══") + ok_count = summary.get("samples_ok") or 0 + fail_count = summary.get("samples_total", 0) - ok_count + if ok_count: + db.update_status(self.test_id, "done", summary=summary, + error=("%d 次采样失败:%s" % (fail_count, self.last_error)) if fail_count else "") + self.log("INFO", "═══ 测试完成 ═══") + if fail_count: + self.log("WARN", "共 %d 次采样失败(最后错误:%s)" % (fail_count, self.last_error)) + else: + db.update_status(self.test_id, "error", summary=summary, + error=self.last_error or "所有采样均失败") + self.log("ERROR", "所有采样均失败,测试标记为 error(最后错误:%s)" % (self.last_error or "未知")) + return 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,