v2.0.1:修复推理型模型(Qwen3/DeepSeek reasoning_content)误报未收到输出+连接测试按连通判定+采样失败不中断+流式超时放宽60s/300s

This commit is contained in:
2026-08-23 18:49:09 +08:00
parent 87b836b52a
commit 5de118a7b5
6 changed files with 55 additions and 22 deletions
+5 -2
View File
@@ -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 导出 + 界面优化 + 推理型模型兼容 + 采样失败不中断
+7 -2
View File
@@ -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:
+3 -3
View File
@@ -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
+23 -11
View File
@@ -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 thinkingthinking 文本也算输出
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))
+1 -1
View File
@@ -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 || "连接失败"));
}
+16 -3
View File
@@ -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,