1 Commits
Author SHA1 Message Date
hz4th_coder 35ac2a8e40 v2.3.0 多并发测试 + 多测试结果对比
- 并发数配置:默认单流(1),预设2/4并发档,支持丝滑添加任意自定义并发数(≥1),
  多档勾选时同一测试分别跑各并发档并并排对比
- 并发执行:每采样同时发起N个并行流(ThreadPoolExecutor),整批吞吐聚合
  (多流prompt/output之和/批首字/批耗时),并记录每流明细streams;
  并发=1 与旧版单流行为一致;每流独立随机前缀避免共享缓存
- 汇总:新增 by_concurrency(按并发分组,含单流均解码) + by_length_concurrency(长度x并发网格) + concurrency_levels
- 详情页:新增「按并发数汇总」表 + 并发对比折线图(X=并发数,预填充左虚线/解码右实线, /concurrency-chart)
- 多测试对比:历史表格勾选(可全选)多个测试 -> /api/compare 返回对比表+柱状图CSV+并发折线图CSV,
  前端弹窗展示指标对比表 + 柱状图(预填充空心/解码实心) + 解码随并发折线图,画图CSV可复制/PNG可下载
- 通用图表代理 POST /api/chart(转发 data-chart-tool 请求体返回 PNG)
- 历史列表增加「并发」列与勾选列;Excel 导出增加并发数列表/按并发分组/采样并发列
- 文档:README/API.md 同步更新
2026-09-01 19:33:41 +08:00
8 changed files with 797 additions and 77 deletions
+73 -14
View File
@@ -130,9 +130,10 @@
| `name` | string | `""` | 测试名称/主题(会存入测试记录并展示在历史与详情) |
| `context_lengths` | number[] | `[512,2048,4096,8192,16384,32768,65536,131072]` | 要测试的上下文长度列表,每个长度独立校准+预热+采样 |
| `max_tokens` | number | `128` | 解码输出 token 长度 |
| `samples` | number | `2` | 每个上下文长度的采样次数 |
| `warmup` | bool | `true` | 测试前空转预热(不计速度) |
| `avoid_cache` | bool | `true` | 随机前缀避免缓存命中 |
| `samples` | number | `2` | 每个(长度×并发)组合的采样次数 |
| `concurrency_levels` | number[] | `[1]` | 并发数列表(默认单流)。>1 时每采样同时发起 N 个并行流,聚合为整批吞吐指标;多档自动并排对比 |
| `warmup` | bool | `true` | 测试前空转预热(不计速度,按并发数预热) |
| `avoid_cache` | bool | `true` | 随机前缀避免缓存命中(每个并发流独立前缀) |
**响应:** `{ "ok": true, "id": 9 }`
@@ -169,9 +170,10 @@
"id": 9, "created_at": "...", "status": "done",
"provider": "openai", "model": "...", "name": "...", "error": "",
"config": { "base_url": "...", "api_key": "sk-x****", ... },
"gen": { "name": "...", "context_lengths": [512, 2048], "max_tokens": 128, "samples": 1, "warmup": true, "avoid_cache": true },
"gen": { "name": "...", "context_lengths": [512, 2048], "max_tokens": 128, "samples": 1, "concurrency_levels": [1, 2, 4], "warmup": true, "avoid_cache": true },
"summary": {
"samples_total": 2, "samples_ok": 2,
"samples_total": 6, "samples_ok": 6,
"concurrency_levels": [1, 2, 4],
"calibration_chars_per_token": 1.82,
"avg_ttft_ms": 1808.7, "min_ttft_ms": 1122.8, "max_ttft_ms": 2494.6,
"avg_prefill_speed": 694.2, "min_prefill_speed": 515.7, "max_prefill_speed": 872.7,
@@ -179,9 +181,14 @@
"avg_prompt_tokens": 1378.0, "avg_output_tokens": 128.0,
"avg_total_ms": 4148.4, "min_total_ms": 3449.9, "max_total_ms": 4846.9,
"by_length": {
"512": { "samples_total": 1, "samples_ok": 1, "avg_ttft_ms": 1122.8, "avg_prefill_speed": 515.7, "avg_decode_speed": 55.0, "avg_prompt_tokens": 579, "avg_output_tokens": 128, "avg_total_ms": 3449.9 },
"2048": { "samples_total": 1, "samples_ok": 1, "avg_ttft_ms": 2494.6, "avg_prefill_speed": 872.7, "avg_decode_speed": 54.4, "avg_prompt_tokens": 2177, "avg_output_tokens": 128, "avg_total_ms": 4846.9 }
}
"512": { "samples_total": 3, "samples_ok": 3, "avg_ttft_ms": 1122.8, "avg_prefill_speed": 515.7, "avg_decode_speed": 55.0, "avg_prompt_tokens": 579, "avg_output_tokens": 128, "avg_total_ms": 3449.9 },
"2048": { "samples_total": 3, "samples_ok": 3, "avg_ttft_ms": 2494.6, "avg_prefill_speed": 872.7, "avg_decode_speed": 54.4, "avg_prompt_tokens": 2177, "avg_output_tokens": 128, "avg_total_ms": 4846.9 }
},
"by_concurrency": {
"1": { "samples_total": 2, "samples_ok": 2, "avg_ttft_ms": 1122.8, "avg_prefill_speed": 515.7, "avg_decode_speed": 55.0, "avg_stream_decode": 55.0, "avg_prompt_tokens": 579, "avg_output_tokens": 128, "avg_total_ms": 3449.9 },
"2": { "samples_total": 2, "samples_ok": 2, "avg_ttft_ms": 2494.6, "avg_prefill_speed": 872.7, "avg_decode_speed": 108.0, "avg_stream_decode": 54.0, "avg_prompt_tokens": 1158, "avg_output_tokens": 256, "avg_total_ms": 4846.9 }
},
"by_length_concurrency": { "512": { "1": {...}, "2": {...} }, "2048": {...} }
},
"runs": [
{ "run_index": 1, "context_length": 512,
@@ -245,6 +252,52 @@
```
> `csv` 即画图数据(第一列=上下文长度,第二列=预填充速度,第三列=解码速度),前端「复制画图数据」按钮复制的就是它。
### `GET /api/tests/<id>/concurrency-chart`
并发对比折线图 PNG(X 轴=**并发数**,左轴=预填充速度虚线、右轴=解码速度实线),用于直观展示吞吐随并发的变化。仅当本次测试包含**多个并发档**时才有数据。
**失败响应:** `{ "ok": false, "error": "无并发分组采样数据(本次测试可能只测了单流),无法画图" }`400
### `GET /api/tests/<id>/concurrency-chart-data`
并发对比画图数据(CSV + 图表请求配置)。
```json
{
"ok": true,
"csv": "并发数, 预填充速度(tok/s), 解码速度(tok/s)\n1, 767.30, 43.40\n2, 1388.20, 70.90\n4, 2085.00, 78.70",
"rows": [[1, 767.3, 43.4], [2, 1388.2, 70.9], [4, 2085.0, 78.7]],
"payload": { "data": "...", "chartType": "line", "title": "...", "dualYAxis": true, ... }
}
```
### `POST /api/chart`
通用图表代理:把任意 data-chart-tool `/api/chart` 请求体转发过去并返回 PNG(多测试对比面板用)。请求体即 data-chart-tool 的参数(`data`/`chartType`/`seriesTypes`/`seriesStyles`/`dualYAxis`…)。
**成功响应:** `Content-Type: image/png`
### `POST /api/compare`
把多个测试结果放在一起对比。
**请求:** `{ "ids": [9, 10, 11] }`(最多 20 个)
**响应:**
```json
{
"ok": true,
"rows": [ { "id": 9, "label": "#9 xxx", "created_at": "...", "name": "...", "model": "...",
"concurrency_levels": [1, 2, 4], "by_concurrency": {...},
"samples_ok": 6, "samples_total": 6,
"avg_ttft_ms": 1122.8, "avg_prefill_speed": 515.7, "avg_decode_speed": 55.0,
"avg_stream_decode": 55.0, "avg_output_tokens": 128, "avg_total_ms": 3449.9 } ],
"bar_csv": "测试, 预填充速度(tok/s), 解码速度(tok/s)\n#9 xxx, 515.70, 55.00",
"bar_payload": { "data": "...", "chartType": "bar", "seriesTypes": ["bar", "bar"], "seriesStyles": ["hollow", "solid"], ... },
"line": { // 仅当所选测试存在 >=2 个共同并发档时返回,否则为 null
"csv": "并发数, #9 xxx, #10 yyy\n1, 55.00, 52.10\n2, 108.00, 99.30\n4, 190.20, 175.60",
"payload": { "data": "...", "chartType": "line", ... },
"levels": [1, 2, 4]
}
}
```
---
## 7. 导出(Excel / JSON
@@ -252,8 +305,8 @@
### `GET /api/tests/<id>/export.xlsx`
导出 Excel 报告(**3 个 Sheet**:汇总 / 采样明细 / 日志),`Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
- **汇总**:测试信息 + 整体统计指标(平均/最大/最小)+ 按上下文长度分组
- **采样明细**:每次采样的上下文长度与全部指标
- **汇总**:测试信息(含并发数列表)+ 整体统计指标(平均/最大/最小)+ 按上下文长度分组 + **按并发数分组**(整批吞吐,含单流均解码)
- **采样明细**:每次采样的上下文长度、**并发**、流成功/总数与全部指标
- **日志**:完整测试日志
### `GET /api/tests/<id>/export.json`
@@ -267,12 +320,14 @@
|----|------|----------|
| `configs` | 保存的接口配置 | id, name, provider, base_url, api_key, model, temperature |
| `tests` | 测试记录 | id, status(running/done/error/canceled), provider, model, **name**, config_json, gen_cfg_json, summary_json, error |
| `test_runs` | 每次采样 | id, test_id, run_index, **context_length**, metrics_json, error |
| `test_runs` | 每次采样 | id, test_id, run_index, **context_length**, metrics_json, errormetrics 含 `concurrency`/`streams_total`/`streams_ok`/`avg_stream_decode`/`streams`(每流明细) |
| `logs` | 测试日志 | id, test_id, level, msg, rel |
**summary 整体指标字段:**
`avg_/min_/max_` 前缀 × `ttft_ms` / `prefill_speed` / `decode_speed` / `total_ms`,以及 `avg_prompt_tokens` / `avg_output_tokens` / `avg_cached_tokens` / `best_ttft_ms`= min_ttft_ms)。
**分组字段:** `by_length`(按上下文长度)、`by_concurrency`(按并发数,含 `avg_stream_decode` 单流均解码)、`by_length_concurrency`(长度×并发全网格)、`concurrency_levels`(本次测试的并发档列表)。
---
## 9. curl 使用示例
@@ -291,10 +346,10 @@ curl -X POST $BASE/api/configs -H 'Content-Type: application/json' \
curl -X POST $BASE/api/configs/test -H 'Content-Type: application/json' \
-d '{"provider":"openai","base_url":"http://121.40.164.32:18003/v1","api_key":"sk-xxx","model":"unsloth/Qwen3.8-27B-Q4_K_M"}'
# 启动速度测试(异步)
# 启动速度测试(异步,含多并发档
curl -X POST $BASE/api/tests -H 'Content-Type: application/json' -d '{
"config": {"provider":"openai","base_url":"http://121.40.164.32:18003/v1","api_key":"sk-xxx","model":"unsloth/Qwen3.8-27B-Q4_K_M"},
"gen": {"name":"各长度对比","context_lengths":[512,2048,8192],"max_tokens":128,"samples":2,"warmup":true,"avoid_cache":true}
"gen": {"name":"并发对比","context_lengths":[2048],"max_tokens":128,"samples":2,"concurrency_levels":[1,2,4],"warmup":true,"avoid_cache":true}
}'
# 查询测试列表 / 详情
@@ -303,8 +358,12 @@ curl $BASE/api/tests/9
# 画图数据(CSV
curl $BASE/api/tests/9/chart-data
# 折线图 PNG(预填充左轴虚线 / 解码右轴实线)
# 折线图 PNG(预填充左轴虚线 / 解码右轴实线X=上下文长度
curl -o chart.png $BASE/api/tests/9/chart
# 并发对比折线图(X=并发数)
curl -o cc.png $BASE/api/tests/9/concurrency-chart
# 多测试对比
curl -X POST $BASE/api/compare -H 'Content-Type: application/json' -d '{"ids":[9,10,11]}'
# 导出
curl -OJ $BASE/api/tests/9/export.xlsx
+24 -11
View File
@@ -4,7 +4,7 @@
- **访问地址:** `http://<IP>:16097/`
- **技术栈:** Python 3 + Flask + SQLite(纯 REST,无额外依赖)
- **版本:** v2.2.0
- **版本:** v2.3.0
---
@@ -19,9 +19,12 @@
- **测试名称(主题)**:可为每次测试命名,用于标注测试内容/主题,展示在历史列表、详情弹窗与导出报表中
- **多上下文长度测试**:默认为 `512 / 2048 / 4096 / 8192 / 16384 / 32768 / 65536 / 131072` tokens,可通过标签点击启用/禁用,并支持手动添加任意自定义长度(≥16)
- **解码输出长度(max tokens**:默认为 `128`,可手动自定义
- **每个长度采样次数**:默认为 `2`,可手动自定义
- **测试前预热(空转)**:默认开启,先发一次不计速度的空转请求,避免冷启动/首请求偏慢污染真实采样数据
- **避免缓存**:默认开启,为每次采样追加随机前缀,测量真实预填充性能
- **每个长度×并发采样次数**:默认为 `2`,可手动自定义
- **多并发测试**:默认为**单流**(并发 1);支持预设 **2 / 4** 并发档,并可“丝滑”添加任意自定义并发数(如 8、16、3…,≥1)。勾选多个并发档时,同一测试会分别跑各并发档,**把多个并发下的测试结果放在一起对比**:
- 并发采样时同时发起 N 个并行流,**整批吞吐**(聚合 prompt/output token / 批首字 / 批耗时)作为该采样指标,并记录每流明细
- 汇总按并发数分组 + 长度×并发全网格;详情页「按并发数汇总」表 + **并发对比折线图**(X=并发数,预填充左轴虚线 / 解码右轴实线)直观展示吞吐随并发的变化
- **测试前预热(空转)**:默认开启,先发一次不计速度的空转请求(按并发数预热),避免冷启动/首请求偏慢污染真实采样数据
- **避免缓存**:默认开启,为每次采样追加随机前缀(每个并发流独立前缀),测量真实预填充性能
### 📊 指标与结果
- 实时指标卡:首字延迟、预填充速度、解码速度、上文/输出 tokens、总耗时
@@ -35,6 +38,10 @@
- **文件下载 Excelxlsx)**:历史记录「Excel」按钮或详情弹窗「导出 Excel」,包含 汇总 / 采样明细 / 日志 三个 Sheet
- **文件下载 JSON**:详情弹窗「导出 JSON」
- 测试历史留存(含测试名称),可随时刷新、查看、导出、删除
- **⚖️ 多测试结果对比**:测试历史表格勾选多个测试(可全选),点「⚖️ 对比所选」弹出对比面板:
- **指标对比表**:时间 / 名称 / 模型 / 并发档 / 采样 / 首字 / 预填充 / 解码 / 单流均解码 / 输出 / 总耗时 并排展示(点击测试名可跳详情)
- **柱状图**:各测试预填充(空心柱)vs 解码(实心柱)速度对比
- **折线图**:解码速度随并发数的变化(各测试一条线,取共同并发档);画图 CSV 可一键复制、PNG 可下载
### 🔌 开放 API
- 页面所有功能均通过 REST API 提供,前端只是可视化客户端
@@ -69,17 +76,18 @@ pip install -r requirements.txt
## 使用说明
1. **配置接口**:选择提供商 → 填写配置名称 / Base URL(可留空)/ API Key / 模型名称 → 点「保存」可留存,或直接点「🔍 测试连接」验证连通性
2. **配置测试参数**:勾选要测试的上下文长度(默认 5 档),设置解码输出长度与采样次数,按需开关预热/避免缓存
2. **配置测试参数**:勾选要测试的上下文长度(默认 5 档),设置解码输出长度、每个组合采样次数与**并发数**(默认单流,可加 2/4/自定义并发档,多档自动并排对比),按需开关预热/避免缓存
3. **开始测试**:点「▶ 开始测试」,右侧实时展示指标与日志;可随时「■ 停止」
4. **查看与导出**:测试完成后,在「测试历史」中点「查看」看完整详情,点「Excel」或详情内「导出 Excel」下载 xlsx 报告
### 指标含义
| 指标 | 含义 |
|------|------|
| 首字延迟 TTFT (ms) | 从请求发出到收到第一个 token 的时间(含预填充) |
| 预填充速度 (tok/s) | prompt tokens / 首字延迟,衡量上文处理吞吐 |
| 解码速度 (tok/s) | 输出 tokens / 解码阶段耗时,衡量逐 token 生成吞吐 |
| 上下文/输出 tokens | 实际发送的提示词 token 数与模型返回的 token 数 |
| 首字延迟 TTFT (ms) | 从请求发出到收到第一个 token 的时间(含预填充);并发时=整批任一流最早首字 |
| 预填充速度 (tok/s) | prompt tokens / 首字延迟,衡量上文处理吞吐;并发时=多流 prompt 之和 / 批首字,即**整批吞吐** |
| 解码速度 (tok/s) | 输出 tokens / 解码阶段耗时,衡量逐 token 生成吞吐;并发时=多流输出之和 / 批解码耗时(**整批吞吐**) |
| 单流均解码 (tok/s) | 并发整批解码吞吐 ÷ 并发数,衡量单流平均生成速率 |
| 上下文/输出 tokens | 实际发送的提示词 token 数与模型返回的 token 数(并发时为多流之和) |
---
@@ -98,8 +106,12 @@ pip install -r requirements.txt
| DELETE | `/api/tests/<id>` | 删除测试 |
| GET | `/api/tests/<id>/export.xlsx` | 导出 Excel 报告(汇总/采样明细/日志三 Sheet) |
| GET | `/api/tests/<id>/export.json` | 导出完整测试 JSON |
| GET | `/api/tests/<id>/chart` | 用 data-chart-tool 生成折线图 PNG(预填充左轴虚线 / 解码右轴实线) |
| GET | `/api/tests/<id>/chart` | 用 data-chart-tool 生成折线图 PNG(预填充左轴虚线 / 解码右轴实线X=上下文长度 |
| GET | `/api/tests/<id>/chart-data` | 画图数据(CSV + 图表请求配置,供一键复制) |
| GET | `/api/tests/<id>/concurrency-chart` | 并发对比折线图 PNG(X=并发数,预填充左轴虚线 / 解码右轴实线) |
| GET | `/api/tests/<id>/concurrency-chart-data` | 并发对比画图数据(CSV + 图表请求配置) |
| POST | `/api/chart` | 通用图表代理:转发任意 data-chart-tool 请求体,返回 PNG(多测试对比用) |
| POST | `/api/compare` | 多测试对比:`{ids:[...]}` → 对比表 + 柱状图CSV + 并发折线图CSV |
> 完整字段说明、响应示例与 curl 示例见 **API.md**。
@@ -119,6 +131,7 @@ POST /api/tests
"context_lengths": [512, 2048, 4096, 8192, 16384, 32768, 65536, 131072],
"max_tokens": 128,
"samples": 2,
"concurrency_levels": [1, 2, 4],
"warmup": true,
"avoid_cache": true
}
@@ -164,4 +177,4 @@ llm-speed-tester/
## Git
- **仓库:** `hz4th_coder/llm-speed-tester`
- **版本:** v2.2.0(多上下文长度测试 + 预热 + Excel/JSON 导出 + 测试名称 + 整体统计平均/最小/最大 + 开放 API + 推理型模型兼容 + 📈 data-chart-tool 双Y轴折线图/画图数据复制 + 默认上下文长度增加 4096/16384/65536
- **版本:** v2.3.0(多并发测试(默认单流,预设2/4可自定义并发档,整批吞吐聚合+按并发分组)+ 测试结果对比(历史勾选→对比表/柱状图/并发折线图)
+204 -6
View File
@@ -184,8 +184,28 @@ def _build_chart_csv(t):
return {"csv": "\n".join(csv_lines), "rows": rows}
def _build_concurrency_chart_csv(t):
"""由测试汇总 by_concurrency 构建画图 CSV:并发数, 预填充速度(tok/s), 解码速度(tok/s)"""
s = t.get("summary") or {}
by = s.get("by_concurrency") or {}
rows = []
for C in sorted(int(k) for k in by):
bl = by.get(str(C)) if str(C) in by else by.get(C) or {}
pre = bl.get("avg_prefill_speed")
dec = bl.get("avg_decode_speed")
if pre is None or dec is None:
continue
rows.append([C, round(pre, 2), round(dec, 2)])
if not rows:
return None
csv_lines = ["并发数, 预填充速度(tok/s), 解码速度(tok/s)"]
for C, pre, dec in rows:
csv_lines.append("%d, %.2f, %.2f" % (C, pre, dec))
return {"csv": "\n".join(csv_lines), "rows": rows}
def _chart_payload(t, csv_text):
"""组装 data-chart-tool /api/chart 请求体(双Y轴折线图)"""
"""组装 data-chart-tool /api/chart 请求体(双Y轴折线图:长度对比"""
title = ("%s %s" % (t.get("model") or "", t.get("name") or "速度对比")).strip()
return {
"data": csv_text,
@@ -208,6 +228,155 @@ def _chart_payload(t, csv_text):
}
def _concurrency_chart_payload(t, csv_text):
"""并发对比折线图请求体(X=并发数,双Y轴:预填充左虚线 / 解码右实线)"""
title = ("%s %s · 并发对比" % (t.get("model") or "", t.get("name") or "速度对比")).strip()
p = _chart_payload(t, csv_text)
p["title"] = title
return p
def _fmt_num(v):
if v is None or v == "":
return ""
try:
return "%.2f" % float(v)
except Exception:
return ""
@app.route("/api/tests/<int:tid>/concurrency-chart-data")
def test_concurrency_chart_data(tid):
t = db.get_test(tid)
if not t:
return jsonify({"ok": False, "error": "测试不存在"}), 404
built = _build_concurrency_chart_csv(t)
if not built:
return jsonify({"ok": False, "error": "无并发分组采样数据(本次测试可能只测了单流),无法画图"}), 400
return jsonify({
"ok": True,
"csv": built["csv"],
"rows": built["rows"],
"payload": _concurrency_chart_payload(t, built["csv"]),
})
@app.route("/api/tests/<int:tid>/concurrency-chart")
def test_concurrency_chart(tid):
"""并发对比折线图 PNG(X=并发数,预填充左轴虚线 / 解码右轴实线)"""
t = db.get_test(tid)
if not t:
return jsonify({"ok": False, "error": "测试不存在"}), 404
built = _build_concurrency_chart_csv(t)
if not built:
return jsonify({"ok": False, "error": "无并发分组采样数据,无法画图"}), 400
try:
resp = requests.post(config.CHART_API_BASE + "/api/chart",
json=_concurrency_chart_payload(t, built["csv"]), timeout=60)
except requests.RequestException as e:
return jsonify({"ok": False, "error": "图表服务不可用: %s" % e}), 502
if resp.status_code != 200:
return jsonify({"ok": False, "error": "图表生成失败(%d): %s" % (resp.status_code, resp.text[:300])}), 502
return send_file(io.BytesIO(resp.content), mimetype="image/png")
@app.route("/api/chart", methods=["POST"])
def chart_proxy():
"""通用图表代理:把任意 data-chart-tool /api/chart 请求体转发,返回 PNG(多测试对比用)"""
payload = request.get_json(force=True) or {}
try:
resp = requests.post(config.CHART_API_BASE + "/api/chart", json=payload, timeout=60)
except requests.RequestException as e:
return jsonify({"ok": False, "error": "图表服务不可用: %s" % e}), 502
if resp.status_code != 200:
return jsonify({"ok": False, "error": "图表生成失败(%d): %s" % (resp.status_code, resp.text[:300])}), 502
return send_file(io.BytesIO(resp.content), mimetype="image/png")
# ───────────────────────── 多测试对比 ─────────────────────────
@app.route("/api/compare", methods=["POST"])
def compare_tests():
"""把多个测试结果放在一起对比:返回对比表 + 柱状图CSV + 并发折线图CSV"""
body = request.get_json(force=True) or {}
ids = [int(x) for x in (body.get("ids") or []) if str(x).isdigit()][:20]
rows = []
for tid in ids:
t = db.get_test(tid)
if not t:
continue
s = t.get("summary") or {}
g = t.get("gen") or {}
cls = sorted(set(int(x) for x in (s.get("concurrency_levels") or g.get("concurrency_levels") or [1])))
label = "#%d %s" % (t["id"], (t.get("name") or t.get("model") or "未命名"))
rows.append({
"id": t["id"],
"label": label,
"created_at": t.get("created_at", ""),
"name": t.get("name", ""),
"provider": t.get("provider", ""),
"model": t.get("model", ""),
"status": t.get("status", ""),
"concurrency_levels": cls,
"by_concurrency": s.get("by_concurrency") or {},
"samples_ok": s.get("samples_ok"),
"samples_total": s.get("samples_total"),
"avg_ttft_ms": s.get("avg_ttft_ms"),
"avg_prefill_speed": s.get("avg_prefill_speed"),
"avg_decode_speed": s.get("avg_decode_speed"),
"avg_stream_decode": s.get("avg_stream_decode"),
"avg_output_tokens": s.get("avg_output_tokens"),
"avg_total_ms": s.get("avg_total_ms"),
})
if not rows:
return jsonify({"ok": False, "error": "未找到可对比的测试"}), 400
# 柱状图:预填充 vs 解码(X=测试)
bar_csv_lines = ["测试, 预填充速度(tok/s), 解码速度(tok/s)"]
for r in rows:
bar_csv_lines.append("%s, %s, %s" % (r["label"], _fmt_num(r["avg_prefill_speed"]), _fmt_num(r["avg_decode_speed"])))
bar_csv = "\n".join(bar_csv_lines)
bar_payload = {
"data": bar_csv, "chartType": "bar",
"title": "多测试速度对比(预填充空心 / 解码实心)",
"theme": "default", "showLegend": True, "showGrid": True, "showLabel": True,
"smoothLine": True,
"seriesTypes": ["bar", "bar"],
"seriesStyles": ["hollow", "solid"],
"width": 1000, "height": 520, "pixelRatio": 2,
}
# 折线图:解码速度随并发变化(取各测试共同并发档,>=2 档才有意义)
line_part = None
if rows:
common = sorted(set.intersection(*[set(r["concurrency_levels"]) for r in rows]))
if len(common) >= 2:
headers = ["并发数"] + [r["label"] for r in rows]
lines = []
for C in common:
cells = [str(C)]
for r in rows:
bc = r.get("by_concurrency") or {}
bl = bc.get(str(C)) if str(C) in bc else bc.get(C) or {}
cells.append(_fmt_num(bl.get("avg_decode_speed")))
lines.append(", ".join(cells))
line_csv = "\n".join([", ".join(headers)] + lines)
nser = len(rows)
line_payload = {
"data": line_csv, "chartType": "line",
"title": "解码速度随并发变化(各测试对比)",
"theme": "default", "showLegend": True, "showGrid": True, "showLabel": False,
"smoothLine": True,
"seriesTypes": ["line"] * nser,
"seriesStyles": (["solid", "dashed", "dotted"] * nser)[:nser],
"width": 1000, "height": 520, "pixelRatio": 2,
}
line_part = {"csv": line_csv, "payload": line_payload, "levels": common}
return jsonify({"ok": True, "rows": rows, "bar_csv": bar_csv,
"bar_payload": bar_payload, "line": line_part})
@app.route("/api/tests/<int:tid>/chart-data")
def test_chart_data(tid):
t = db.get_test(tid)
@@ -317,6 +486,7 @@ def _build_xlsx(t):
["模型", t.get("model", "")],
["Base URL", cfg.get("base_url") or "(默认)"],
["上下文长度列表", " / ".join(str(x) for x in (g.get("context_lengths") or []))],
["并发数列表", " / ".join(str(x) for x in (s.get("concurrency_levels") or g.get("concurrency_levels") or [1]))],
["生成长度(max tokens)", g.get("max_tokens", 128)],
["每个长度采样次数", g.get("samples", 2)],
["预热(空转)", "" if g.get("warmup", True) else ""],
@@ -368,13 +538,38 @@ def _build_xlsx(t):
rr += 1
else:
ws.cell(row=r1 + 2, column=1, value="(无成功采样数据)")
for col, w in zip("ABCDEFGH", [22, 20, 12, 14, 14, 12, 12, 14]):
ws.column_dimensions[col].width = w
# 按并发数分组(多测试/多并发对比核心数据)
by_conc = s.get("by_concurrency") or {}
r2 = r1 + (len(by_length) if by_length else 1) + 3
ws.cell(r2, 1, "按并发数分组(整批吞吐,tok/s").font = title_font
ccols = ["并发数", "采样(成功/总数)", "首字ms", "预填充tok/s", "解码tok/s", "单流均解码tok/s", "输出tok", "总耗时ms"]
ws.append([])
for j, c in enumerate(ccols, start=1):
ws.cell(row=r2 + 1, column=j, value=c)
style_header(ws, r2 + 1, len(ccols))
if by_conc:
rr = r2 + 2
for C in sorted(int(k) for k in by_conc):
bl = by_conc[str(C)] if str(C) in by_conc else by_conc[C]
ws.cell(row=rr, column=1, value=C)
ws.cell(row=rr, column=2, value="%s / %s" % (bl.get("samples_ok"), bl.get("samples_total")))
ws.cell(row=rr, column=3, value=bl.get("avg_ttft_ms"))
ws.cell(row=rr, column=4, value=bl.get("avg_prefill_speed"))
ws.cell(row=rr, column=5, value=bl.get("avg_decode_speed"))
ws.cell(row=rr, column=6, value=bl.get("avg_stream_decode"))
ws.cell(row=rr, column=7, value=bl.get("avg_output_tokens"))
ws.cell(row=rr, column=8, value=bl.get("avg_total_ms"))
rr += 1
else:
ws.cell(row=r2 + 2, column=1, value="(仅单流,无并发分组)")
for col, w in zip("ABCDEFGH", [12, 20, 12, 14, 14, 16, 12, 14]):
ws.column_dimensions[col].width = max(w, ws.column_dimensions[col].width or 0)
# ── Sheet2 采样明细 ──
ws2 = wb.create_sheet("采样明细")
h2 = ["序号", "上下文长度tok", "提示词tok", "缓存tok", "首字ms", "预填充tok/s",
"输出tok", "解码tok/s", "总耗时ms", "备注"]
h2 = ["序号", "上下文长度tok", "并发", "流(成功/总数)", "提示词tok", "缓存tok", "首字ms", "预填充tok/s",
"输出tok", "解码tok/s", "单流均解码tok/s", "总耗时ms", "备注"]
ws2.append(h2)
style_header(ws2, 1, len(h2))
for i, r in enumerate(runs, start=1):
@@ -382,16 +577,19 @@ def _build_xlsx(t):
ws2.append([
i,
r.get("context_length") or m.get("context_length") or "",
m.get("concurrency") or 1,
"%s/%s" % (m.get("streams_ok"), m.get("streams_total")) if m.get("streams_total") else 1,
m.get("prompt_tokens") or "",
m.get("cached_tokens") if m.get("cached_tokens") else "",
m.get("ttft_ms"),
m.get("prefill_speed"),
m.get("output_tokens"),
m.get("decode_speed"),
m.get("avg_stream_decode"),
m.get("total_ms"),
r.get("error") or "OK",
])
for col, w in zip("ABCDEFGHIJ", [8, 14, 12, 10, 12, 14, 12, 14, 12, 30]):
for col, w in zip("ABCDEFGHIJKLM", [8, 14, 8, 14, 12, 10, 12, 14, 12, 14, 16, 12, 30]):
ws2.column_dimensions[col].width = w
# ── Sheet3 日志 ──
+2 -1
View File
@@ -209,12 +209,13 @@ def list_tests(limit=100):
conn = _connect()
try:
rows = conn.execute(
"SELECT id,created_at,status,provider,model,name,summary_json,error "
"SELECT id,created_at,status,provider,model,name,summary_json,gen_cfg_json,error "
"FROM tests ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
out = []
for r in rows:
d = dict(r)
d["summary"] = json.loads(d.pop("summary_json") or "{}")
d["gen"] = json.loads(d.pop("gen_cfg_json") or "{}")
out.append(d)
return out
finally:
+7 -1
View File
@@ -119,6 +119,8 @@ body {
.btn.danger:disabled { opacity: .4; cursor: not-allowed; }
.btn.small { padding: 5px 10px; font-size: 12px; }
.btn.block { width: 100%; }
.btn.link { background: none; border: none; padding: 0; color: var(--accent); font-size: 12.5px; text-decoration: underline; cursor: pointer; }
.btn.link:hover { color: #fff; }
.btn-group { display: flex; gap: 8px; margin-top: 6px; }
.btn-group .btn { flex: 1; }
@@ -160,6 +162,8 @@ table.history { width: 100%; border-collapse: collapse; font-size: 13px; }
.history th { color: var(--muted); font-weight: 600; font-size: 12px; }
.history tbody tr:hover { background: var(--panel2); }
.history td.num { font-family: var(--mono); }
.history .chk-col { width: 34px; text-align: center; }
.history .chk-col input { width: 15px; height: 15px; cursor: pointer; accent-color: var(--accent); }
.status-pill { padding: 2px 10px; border-radius: 12px; font-size: 11px; }
.status-pill.running { background: rgba(79,140,255,.15); color: var(--accent); }
.status-pill.done { background: rgba(34,197,139,.15); color: var(--accent2); }
@@ -181,7 +185,7 @@ table.history { width: 100%; border-collapse: collapse; font-size: 13px; }
padding: 14px 18px; border-bottom: 1px solid var(--border);
}
.modal-head h2 { font-size: 16px; }
.modal-body { padding: 16px 18px; overflow-y: auto; }
.modal-body { padding: 16px 18px; overflow-y: auto; overflow-x: auto; }
.modal-body h3 { font-size: 14px; margin: 16px 0 8px; color: var(--muted); }
.modal-body h3:first-child { margin-top: 0; }
.detail-name {
@@ -199,6 +203,8 @@ table.mini { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.mini th { color: var(--muted); font-size: 11px; }
.mini td.num { font-family: var(--mono); }
.mini tr.err td { color: var(--danger); }
.mini.compare { min-width: 860px; }
.mini.compare th, .mini.compare td { white-space: nowrap; }
.detail-log { background: #0a0e17; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; font: 12px/1.7 var(--mono); max-height: 240px; overflow-y: auto; }
.detail-log .ln { white-space: pre-wrap; }
+28 -3
View File
@@ -85,9 +85,18 @@
<input id="gen-max-tokens" type="number" min="1" step="1" value="128">
</div>
<div class="field">
<label>每个长度采样次数</label>
<label>每个长度×并发采样次数</label>
<input id="gen-samples" type="number" min="1" max="50" value="2">
</div>
<div class="field">
<label>并发数(同时请求流数,默认单流)</label>
<div class="chips" id="gen-concurrency"></div>
<div class="row" style="margin-top:6px">
<input id="gen-conc-add" type="number" min="1" max="64" placeholder="自定义并发数(≥1">
<button class="btn small" id="btn-conc-add"> 添加</button>
</div>
<div class="hint">选中多个并发数时,同一测试会分别跑各并发档并放在一起对比</div>
</div>
<div class="field switch-field">
<label>测试前预热<span class="hint-inline">空转不计速度,避免冷启动偏差</span></label>
<label class="switch">
@@ -135,12 +144,17 @@
<div class="card">
<div class="card-head">
<h2>🗂 测试历史</h2>
<button class="btn small" id="btn-refresh-history">刷新</button>
<div class="log-actions">
<span class="hint" id="hist-selected"></span>
<button class="btn small primary" id="btn-compare">⚖️ 对比所选</button>
<button class="btn small" id="btn-refresh-history">刷新</button>
</div>
</div>
<div class="table-wrap">
<table class="history" id="history">
<thead><tr>
<th>#</th><th>时间</th><th>名称</th><th>提供商</th><th>模型</th><th>采样</th>
<th class="chk-col"><input type="checkbox" id="hist-check-all" title="全选"></th>
<th>#</th><th>时间</th><th>名称</th><th>提供商</th><th>模型</th><th>并发</th><th>采样</th>
<th>首字 ms</th><th>预填充 tok/s</th><th>解码 tok/s</th><th>状态</th><th>操作</th>
</tr></thead>
<tbody></tbody>
@@ -165,6 +179,17 @@
</div>
</div>
<!-- 多测试对比弹窗 -->
<div class="modal-mask" id="cmp-mask" hidden>
<div class="modal">
<div class="modal-head">
<h2>⚖️ 多测试结果对比</h2>
<button class="btn small" id="cmp-close"></button>
</div>
<div class="modal-body" id="cmp-body"></div>
</div>
</div>
<script src="/js/app.js"></script>
</body>
</html>
+294 -4
View File
@@ -26,6 +26,11 @@ const DEFAULT_CONTEXT_LENGTHS = [512, 2048, 4096, 8192, 16384, 32768, 65536, 131
let contextLengths = [...DEFAULT_CONTEXT_LENGTHS];
let contextLengthsActive = new Set(contextLengths);
// 并发数:chips 列表 + 启用集合(默认单流 [1],预设 2/4,可丝滑添加自定义并发数)
const DEFAULT_CONCURRENCY_LEVELS = [1, 2, 4];
let concurrencyLevels = [...DEFAULT_CONCURRENCY_LEVELS];
let concurrencyLevelsActive = new Set([1]); // 默认单流
const esc = (s) => String(s ?? "").replace(/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const fmt = (v, d = "—") => (v === null || v === undefined || isNaN(v) ? d : v);
@@ -61,6 +66,7 @@ function currentGen() {
context_lengths: lens.length ? lens : [2048],
max_tokens: parseInt($("#gen-max-tokens").value) || 128,
samples: parseInt($("#gen-samples").value) || 2,
concurrency_levels: [...concurrencyLevelsActive].sort((a, b) => a - b),
avoid_cache: $("#gen-avoid-cache").checked,
warmup: $("#gen-warmup").checked,
};
@@ -108,6 +114,49 @@ function addContextLength() {
$("#gen-context-add").value = "";
}
/* ───────────────────────── 并发数 chips ───────────────────────── */
function renderConcurrencyChips() {
const box = $("#gen-concurrency");
if (!box) return;
box.innerHTML = "";
if (!concurrencyLevels.length) {
box.innerHTML = '<span class="chips-empty">暂无并发档,点击下方“添加”自定义</span>';
return;
}
for (const c of concurrencyLevels) {
const active = concurrencyLevelsActive.has(c);
const el = document.createElement("span");
el.className = "chip" + (active ? "" : " off");
el.title = "点击启用/禁用并发档";
el.innerHTML = `<span class="chip-v">${c}</span><span class="chip-x">✕</span>`;
el.addEventListener("click", (e) => {
if (e.target.closest(".chip-x")) {
concurrencyLevels = concurrencyLevels.filter((x) => x !== c);
concurrencyLevelsActive.delete(c);
} else {
if (concurrencyLevelsActive.has(c)) concurrencyLevelsActive.delete(c);
else concurrencyLevelsActive.add(c);
}
if (!concurrencyLevelsActive.size) concurrencyLevelsActive.add(1); // 至少保留一档
renderConcurrencyChips();
});
box.appendChild(el);
}
}
function addConcurrency() {
const v = parseInt($("#gen-conc-add").value);
if (!v || v < 1) { toast("请输入有效并发数(≥1)"); return; }
if (!concurrencyLevels.includes(v)) {
concurrencyLevels.push(v);
concurrencyLevelsActive.add(v);
concurrencyLevels.sort((a, b) => a - b);
renderConcurrencyChips();
}
$("#gen-conc-add").value = "";
}
function updateDefaultUrlHint() {
const p = $("#cfg-provider").value;
$("#cfg-default-url").textContent = "默认地址:" + PROVIDER_DEFAULT_URL[p];
@@ -296,23 +345,37 @@ function stopTest() {
/* ───────────────────────── 测试历史 ───────────────────────── */
let histSelected = new Set(); // 勾选用于对比的测试 id
function updateHistSelectedLabel() {
const el = $("#hist-selected");
if (el) el.textContent = histSelected.size ? `已选 ${histSelected.size}` : "";
}
async function loadHistory() {
const list = await api("/api/tests");
const tb = $("#history tbody");
tb.innerHTML = "";
const selAll = $("#hist-check-all");
if (selAll) selAll.checked = false;
if (!list.length) {
tb.innerHTML = '<tr><td colspan="11" style="color:var(--muted);text-align:center">暂无测试记录</td></tr>';
tb.innerHTML = '<tr><td colspan="13" style="color:var(--muted);text-align:center">暂无测试记录</td></tr>';
updateHistSelectedLabel();
return;
}
for (const t of list) {
const s = t.summary || {};
const g = t.gen || {};
const cls = s.concurrency_levels || g.concurrency_levels || [1];
const tr = document.createElement("tr");
tr.innerHTML = `
<td class="chk-col"><input type="checkbox" class="hist-chk" data-id="${t.id}" ${histSelected.has(t.id) ? "checked" : ""}></td>
<td>#${t.id}</td>
<td>${esc(t.created_at)}</td>
<td title="${esc(t.name || "")}">${esc(t.name || "—")}</td>
<td>${esc(PROVIDER_LABEL[t.provider] || t.provider)}</td>
<td>${esc(t.model)}</td>
<td class="num">${esc(cls.join("/"))}</td>
<td class="num">${fmt(s.samples_ok)}/${fmt(s.samples_total)}</td>
<td class="num">${fmt(s.avg_ttft_ms)}</td>
<td class="num">${fmt(s.avg_prefill_speed)}</td>
@@ -325,8 +388,131 @@ async function loadHistory() {
</td>`;
tb.appendChild(tr);
}
updateHistSelectedLabel();
}
/* ───────────────────────── 多测试对比 ───────────────────────── */
function cmpChartBlock(id, csv, payload, title) {
return `<div class="chart-block">
<div class="chart-toolbar">
<button class="btn small primary" data-cmp-gen="${id}">🎨 生成/刷新图表</button>
<span class="hint" data-cmp-status="${id}"></span>
</div>
<div class="chart-img-wrap" data-cmp-img="${id}"><div class="hint">点击“生成/刷新图表”绘制:${esc(title)}</div></div>
<div class="chart-data-head">📋 画图数据(CSV,可一键复制)</div>
<textarea class="chart-csv" data-cmp-csv="${id}" readonly spellcheck="false">${esc(csv)}</textarea>
<div class="chart-toolbar" style="margin-top:6px">
<button class="btn small" data-cmp-copy="${id}">📋 复制画图数据</button>
<button class="btn small" data-cmp-dl="${id}">⬇ 下载 PNG</button>
</div>
</div>`;
}
async function openCompare() {
const ids = [...histSelected];
if (!ids.length) { toast("请先在测试历史中勾选要对比的测试"); return; }
const mask = $("#cmp-mask");
const body = $("#cmp-body");
mask.hidden = false;
body.innerHTML = '<div class="hint">⏳ 正在汇总对比数据...</div>';
let d;
try {
d = await api("/api/compare", "POST", { ids });
} catch (e) {
body.innerHTML = '<div class="conn-result fail">对比失败:' + esc(e.message) + "</div>"; return;
}
if (!d.ok) {
body.innerHTML = '<div class="conn-result fail">' + esc(d.error || "对比失败") + "</div>"; return;
}
window.__cmpPayloads = {
bar: d.bar_payload,
line: d.line ? d.line.payload : null,
};
const table = `<table class="mini compare"><thead><tr>
<th>测试</th><th>时间</th><th>名称</th><th>模型</th><th>并发档</th><th>采样(成功/总数)</th>
<th>首字ms</th><th>预填充tok/s</th><th>解码tok/s</th><th>单流均解码tok/s</th><th>输出tok</th><th>总耗时ms</th>
</tr></thead><tbody>` +
d.rows.map((r) => `<tr>
<td><button class="btn link" data-vd="${r.id}">${esc(r.label)}</button></td>
<td>${esc(r.created_at)}</td>
<td title="${esc(r.name || "")}">${esc(r.name || "—")}</td>
<td>${esc(r.model)}</td>
<td>${(r.concurrency_levels || []).join("/") || "1"}</td>
<td class="num">${fmt(r.samples_ok)}/${fmt(r.samples_total)}</td>
<td class="num">${fmt(r.avg_ttft_ms)}</td>
<td class="num">${fmt(r.avg_prefill_speed)}</td>
<td class="num">${fmt(r.avg_decode_speed)}</td>
<td class="num">${fmt(r.avg_stream_decode)}</td>
<td class="num">${fmt(r.avg_output_tokens)}</td>
<td class="num">${fmt(r.avg_total_ms)}</td>
</tr>`).join("") + `</tbody></table>`;
const lineBlock = d.line
? cmpChartBlock("line", d.line.csv, d.line.payload, "解码速度随并发变化(各测试对比)")
: '<div class="hint">未生成并发折线图:所选测试需至少有 2 个共同并发档(同一批并发数都测过)。可改用上方柱状图对比。</div>';
body.innerHTML = `
<h3>📊 指标对比表(点击测试名可查看详情)</h3>
${table}
<h3>📈 柱状图:速度对比</h3>
${cmpChartBlock("bar", d.bar_csv, d.bar_payload, "预填充空心柱 / 解码实心柱")}
<h3>📈 折线图:解码速度随并发变化</h3>
${lineBlock}
`;
// 绑定图表生成/复制/下载
body.querySelectorAll("[data-cmp-gen]").forEach((btn) => {
const id = btn.dataset.cmpGen;
const payload = window.__cmpPayloads[id];
const imgWrap = body.querySelector(`[data-cmp-img="${id}"]`);
const statusEl = body.querySelector(`[data-cmp-status="${id}"]`);
const csvTa = body.querySelector(`[data-cmp-csv="${id}"]`);
let url = "";
btn.addEventListener("click", async () => {
statusEl.textContent = "⏳ 正在生成...";
try {
const resp = await fetch("/api/chart", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const j = await resp.json().catch(() => ({}));
statusEl.textContent = "❌ " + (j.error || "生成失败"); return;
}
const blob = await resp.blob();
if (url) URL.revokeObjectURL(url);
url = URL.createObjectURL(blob);
imgWrap.innerHTML = "";
const img = document.createElement("img");
img.src = url;
img.style.maxWidth = "100%";
img.onload = () => { statusEl.textContent = "✅ 生成完成"; };
imgWrap.appendChild(img);
} catch (e) { statusEl.textContent = "❌ " + e.message; }
});
body.querySelector(`[data-cmp-copy="${id}"]`).addEventListener("click", () => {
csvTa.select();
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(csvTa.value).then(() => toast("画图数据已复制"));
else document.execCommand("copy");
});
body.querySelector(`[data-cmp-dl="${id}"]`).addEventListener("click", () => {
if (!url) { toast("请先生成图表"); return; }
const a = document.createElement("a");
a.href = url; a.download = `compare_${id}.png`;
document.body.appendChild(a); a.click(); a.remove();
});
});
// 对比表里的“查看”按钮
body.querySelectorAll("[data-vd]").forEach((b) => {
b.addEventListener("click", () => viewDetail(Number(b.dataset.vd)));
});
}
function closeCompare() { $("#cmp-mask").hidden = true; }
/* ───────────────────────── 详情弹窗 ───────────────────────── */
async function viewDetail(id) {
@@ -362,6 +548,32 @@ async function viewDetail(id) {
byLengthHtml = '<div class="hint">无成功采样数据</div>';
}
// 按并发数分组汇总(多并发测试的对比数据)
const byConc = s.by_concurrency || {};
let byConcHtml;
const concs = Object.keys(byConc).sort((a, b) => a - b);
if (concs.length > 1) {
byConcHtml = `<table class="mini"><thead><tr>
<th>并发数</th><th>采样(成功/总数)</th><th>首字ms</th><th>预填充tok/s</th>
<th>解码tok/s</th><th>单流均解码tok/s</th><th>输出tok</th><th>总耗时ms</th>
</tr></thead><tbody>` +
concs.map((C) => {
const bl = byConc[C] || {};
return `<tr>
<td class="num">${C}</td>
<td class="num">${fmt(bl.samples_ok)}/${fmt(bl.samples_total)}</td>
<td class="num">${fmt(bl.avg_ttft_ms)}</td>
<td class="num">${fmt(bl.avg_prefill_speed)}</td>
<td class="num">${fmt(bl.avg_decode_speed)}</td>
<td class="num">${fmt(bl.avg_stream_decode)}</td>
<td class="num">${fmt(bl.avg_output_tokens)}</td>
<td class="num">${fmt(bl.avg_total_ms)}</td>
</tr>`;
}).join("") + `</tbody></table>`;
} else {
byConcHtml = '<div class="hint">本次仅测试了单流(并发 1),无并发对比数据;勾选多个并发档后再测即可对比。</div>';
}
let runsHtml;
if (runs.length) {
runsHtml = `<table class="mini"><thead><tr>
@@ -421,6 +633,9 @@ async function viewDetail(id) {
<h3>📏 按上下文长度汇总</h3>
${byLengthHtml}
<h3>🔀 按并发数汇总(整批吞吐)</h3>
${byConcHtml}
<h3>📈 折线图(预填充左轴虚线 / 解码右轴实线)</h3>
<div class="chart-block">
<div class="chart-toolbar">
@@ -438,12 +653,30 @@ async function viewDetail(id) {
</div>
</div>
<h3>🔀 并发对比折线图(X=并发数)</h3>
<div class="chart-block">
<div class="chart-toolbar">
<button class="btn small primary" id="dt-cc-gen">🎨 生成/刷新图表</button>
<span class="hint" id="dt-cc-status"></span>
</div>
<div class="chart-img-wrap" id="dt-cc-img">
<div class="hint">X 轴=并发数,左轴=预填充(虚线),右轴=解码(实线),直观看出吞吐随并发的变化</div>
</div>
<div class="chart-data-head">📋 画图数据(CSV,可一键复制)</div>
<textarea class="chart-csv" id="dt-cc-csv" readonly spellcheck="false" placeholder="(生成图表后自动填充,或手动复制)"></textarea>
<div class="chart-toolbar" style="margin-top:6px">
<button class="btn small" id="dt-cc-copy">📋 复制画图数据</button>
<button class="btn small" id="dt-cc-download">⬇ 下载图表 PNG</button>
</div>
</div>
<h3>⚙️ 测试参数</h3>
<div class="kv">
<div class="kv-item"><div class="kv-k">测试名称</div><div class="kv-v">${esc(t.name || "—")}</div></div>
<div class="kv-item"><div class="kv-k">上下文长度</div><div class="kv-v">${(g.context_lengths || []).join(" / ") || "—"} tok</div></div>
<div class="kv-item"><div class="kv-k">并发数</div><div class="kv-v">${(s.concurrency_levels || g.concurrency_levels || [1]).join(" / ")}</div></div>
<div class="kv-item"><div class="kv-k">解码输出长度</div><div class="kv-v">${g.max_tokens ?? "—"} tok</div></div>
<div class="kv-item"><div class="kv-k">每个长度采样</div><div class="kv-v">${g.samples ?? "—"}</div></div>
<div class="kv-item"><div class="kv-k">每个组合采样</div><div class="kv-v">${g.samples ?? "—"}</div></div>
<div class="kv-item"><div class="kv-k">预热(空转)</div><div class="kv-v">${g.warmup === false ? "关" : "开"}</div></div>
<div class="kv-item"><div class="kv-k">避免缓存</div><div class="kv-v">${g.avoid_cache ? "开" : "关"}</div></div>
<div class="kv-item"><div class="kv-k">温度</div><div class="kv-v">${fmt(cfg.temperature)}</div></div>
@@ -500,6 +733,41 @@ async function viewDetail(id) {
a.download = `llm_speed_chart_${id}.png`;
document.body.appendChild(a); a.click(); a.remove();
});
// 并发对比折线图:生成/刷新 + 复制数据 + 下载
const ccGen = $("#dt-cc-gen");
const ccCsv = $("#dt-cc-csv");
const ccImg = $("#dt-cc-img");
const ccStatus = $("#dt-cc-status");
let ccUrl = "";
const genCcChart = () => {
ccStatus.textContent = "⏳ 正在生成...";
fetch(`/api/tests/${id}/concurrency-chart-data`).then((r) => r.json()).then((d) => {
if (!d.ok) { ccStatus.textContent = "❌ " + (d.error || "生成失败"); return; }
ccCsv.value = d.csv;
ccUrl = `/api/tests/${id}/concurrency-chart?t=${Date.now()}`;
const img = document.createElement("img");
img.src = ccUrl;
img.alt = "并发对比折线图";
img.style.maxWidth = "100%";
img.onload = () => { ccImg.innerHTML = ""; ccImg.appendChild(img); ccStatus.textContent = "✅ 生成完成"; };
img.onerror = () => { ccStatus.textContent = "❌ 图表生成失败"; };
}).catch((e) => { ccStatus.textContent = "❌ " + e.message; });
};
ccGen.addEventListener("click", genCcChart);
$("#dt-cc-copy").addEventListener("click", () => {
if (!ccCsv.value) { toast("暂无画图数据,请先生成图表"); return; }
ccCsv.select();
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(ccCsv.value).then(() => toast("画图数据已复制"));
else { document.execCommand("copy"); toast("画图数据已复制"); }
});
$("#dt-cc-download").addEventListener("click", () => {
if (!ccUrl) { toast("请先生成图表"); return; }
const a = document.createElement("a");
a.href = ccUrl;
a.download = `llm_concurrency_chart_${id}.png`;
document.body.appendChild(a); a.click(); a.remove();
});
}
function closeDetail() { $("#detail-mask").hidden = true; }
@@ -599,10 +867,13 @@ function bind() {
$("#btn-cancel").addEventListener("click", stopTest);
$("#btn-context-add").addEventListener("click", addContextLength);
$("#gen-context-add").addEventListener("keydown", (e) => { if (e.key === "Enter") addContextLength(); });
$("#btn-conc-add").addEventListener("click", addConcurrency);
$("#gen-conc-add").addEventListener("keydown", (e) => { if (e.key === "Enter") addConcurrency(); });
$("#btn-clear-console").addEventListener("click", clearConsole);
$("#btn-export-log").addEventListener("click", exportCurrentLog);
$("#btn-refresh-history").addEventListener("click", loadHistory);
$("#btn-compare").addEventListener("click", openCompare);
$("#history tbody").addEventListener("click", (e) => {
const v = e.target.closest("[data-view]");
@@ -613,17 +884,35 @@ function bind() {
if (d) {
const id = Number(d.dataset.del);
if (confirm(`确定删除测试 #${id} 及其全部日志?`)) {
api(`/api/tests/${id}`, "DELETE").then(() => loadHistory());
histSelected.delete(id);
api(`/api/tests/${id}`, "DELETE").then(() => { loadHistory(); updateHistSelectedLabel(); });
}
}
});
// 历史勾选(用于多测试对比)
$("#history tbody").addEventListener("change", (e) => {
const chk = e.target.closest(".hist-chk");
if (!chk) return;
const id = Number(chk.dataset.id);
if (chk.checked) histSelected.add(id);
else histSelected.delete(id);
updateHistSelectedLabel();
});
$("#hist-check-all").addEventListener("change", (e) => {
$$(".hist-chk").forEach((c) => { c.checked = e.target.checked; });
histSelected.clear();
if (e.target.checked) $$(".hist-chk").forEach((c) => histSelected.add(Number(c.dataset.id)));
updateHistSelectedLabel();
});
$("#dt-close").addEventListener("click", closeDetail);
$("#detail-mask").addEventListener("click", (e) => { if (e.target === $("#detail-mask")) closeDetail(); });
$("#cmp-close").addEventListener("click", closeCompare);
$("#cmp-mask").addEventListener("click", (e) => { if (e.target === $("#cmp-mask")) closeCompare(); });
$("#dt-export").addEventListener("click", exportDetail);
$("#dt-export-xlsx").addEventListener("click", () => { if (window.__detail) exportXlsx(window.__detail.id); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeDetail(); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { closeDetail(); closeCompare(); } });
}
/* ───────────────────────── 初始化 ───────────────────────── */
@@ -631,6 +920,7 @@ function bind() {
(async function init() {
bind();
renderChips();
renderConcurrencyChips();
updateDefaultUrlHint();
loadConfigs();
loadHistory();
+165 -37
View File
@@ -57,55 +57,65 @@ class TestRunner(threading.Thread):
# 兼容旧版单值配置
raw_lengths = [int(gen.get("prompt_tokens", 2048))]
lengths = sorted(set(int(x) for x in raw_lengths if int(x) >= 16)) or [2048]
n = max(1, int(gen.get("samples", 2))) # 每个长度采样次数
n = max(1, int(gen.get("samples", 2))) # 每个 (长度×并发) 组合采样次数
max_tokens = max(1, int(gen.get("max_tokens", 128))) # 解码输出长度
avoid_cache = bool(gen.get("avoid_cache"))
warmup = bool(gen.get("warmup", True)) # 测试前空转预热
# 并发数列表(默认单流 [1];支持 2/4 及自定义,如 [1,2,4,8]
raw_concs = gen.get("concurrency_levels") or []
if not raw_concs:
raw_concs = [int(gen.get("concurrency", 1))]
concurrency_levels = sorted(set(int(x) for x in raw_concs if int(x) >= 1)) or [1]
self.log("INFO", "═══ 开始速度测试 ═══")
name = gen.get("name") or self.cfg.get("name") or ""
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 | 每个长度采样: %d 次 | 预热: %s | 避免缓存: %s"
% (" / ".join(str(x) for x in lengths), max_tokens, n,
self.log("INFO", "上下文长度: %s tokens | 生成长度: %d tokens | 并发数: %s | 每个组合采样: %d 次 | 预热: %s | 避免缓存: %s"
% (" / ".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 ""))
ratio = self._calibrate()
self.ratio = ratio
self.log("INFO", "校准完成: %.3f tok/字符(%.2f 字符/token" % (ratio, 1.0 / ratio))
run_seq = 0
for L in lengths:
if self.should_stop():
raise StopRequested()
base_prompt = self._build_prompt(L, ratio)
self.log("INFO", "▸▸ 上下文长度 %d tokens(基准提示词构造完成)" % L)
if warmup:
self._warmup(base_prompt)
for i in range(1, n + 1):
for C in concurrency_levels:
if self.should_stop():
raise StopRequested()
prompt = self._finalize_prompt(base_prompt)
self.log("INFO", "── [%d tok] 采样 %d/%d 开始 ──" % (L, i, n))
try:
m = lp.call_stream(
self.cfg, prompt,
{"max_tokens": max_tokens, "avoid_cache": avoid_cache},
log=lambda lv, msg: self.log(lv, msg),
should_stop=self.should_stop)
m["run_index"] = i
m["context_length"] = L
self.samples.append({"run_index": i, "context_length": L, "ok": True, "metrics": m})
db.add_run(self.test_id, i, m, context_length=L)
self.log("METRIC", self._fmt_metric(L, i, n, m))
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)
self.log("INFO", "══ 并发数 %d(同时 %d 个流)══" % (C, C))
if warmup:
self._warmup(base_prompt, C)
for i in range(1, n + 1):
if self.should_stop():
raise StopRequested()
run_seq += 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)
m["run_index"] = i
m["context_length"] = L
self.samples.append({"run_index": i, "context_length": L,
"concurrency": C, "ok": True, "metrics": m})
db.add_run(self.test_id, run_seq, m, context_length=L)
self.log("METRIC", self._fmt_metric(L, C, i, n, m))
except StopRequested:
raise
except ProviderError as e:
# 单次采样失败:记录并继续后续采样,不让整个测试中断
self.last_error = str(e)
self.log("ERROR", "[%d tok · 并发%d] 采样 %d/%d 失败: %s" % (L, C, i, n, e))
self.samples.append({"run_index": i, "context_length": L,
"concurrency": C, "ok": False, "error": str(e)})
db.add_run(self.test_id, run_seq, {}, str(e), context_length=L)
summary = self._make_summary()
ok_count = summary.get("samples_ok") or 0
@@ -126,20 +136,98 @@ class TestRunner(threading.Thread):
summary.get("avg_prefill_speed") or 0,
summary.get("avg_decode_speed") or 0))
def _warmup(self, base_prompt):
"""空转预热:不计入任何速度统计,用于避免冷启动/首次请求偏慢影响采样"""
self.log("INFO", "预热(空转,不计速度)...")
def _warmup(self, base_prompt, concurrency=1):
"""空转预热:不计入任何速度统计,用于避免冷启动/首次请求偏慢影响采样(按并发数预热)"""
self.log("INFO", "预热(空转,不计速度,并发 %d..." % concurrency)
try:
lp.call_stream(self.cfg, base_prompt,
{"max_tokens": 8, "avoid_cache": False},
log=lambda lv, msg: self.log(lv, msg),
should_stop=self.should_stop)
self._run_sample(concurrency, base_prompt, 8, False)
self.log("INFO", "预热完成(不纳入统计)")
except StopRequested:
raise
except Exception as e:
self.log("WARN", "预热失败(继续测试): %s" % e)
# ───────────────────────── 并发采样 ─────────────────────────
def _run_sample(self, concurrency, base_prompt, max_tokens, avoid_cache):
"""
运行一个采样:concurrency 个流同时并发请求(并发=1 即单流)。
返回聚合指标:prompt/output tokens 为 N 流之和,
prefill/decode 速度为“整批吞吐”(tok/s),并附每流明细 streams。
"""
from concurrent.futures import ThreadPoolExecutor
gen_opt = {"max_tokens": max_tokens, "avoid_cache": avoid_cache}
def worker(idx):
prompt = self._finalize_prompt(base_prompt) # 每流独立随机前缀,避免共享缓存
t0 = time.time()
try:
m = lp.call_stream(self.cfg, prompt, gen_opt,
log=lambda lv, msg: self.log(lv, msg),
should_stop=self.should_stop)
m["_wall_start"] = t0
m["_wall_end"] = time.time()
m["_stream_idx"] = idx
return {"ok": True, "metrics": m}
except StopRequested:
raise
except ProviderError as e:
return {"ok": False, "error": str(e)}
except Exception as e:
return {"ok": False, "error": str(e)}
with ThreadPoolExecutor(max_workers=concurrency) as ex:
results = list(ex.map(worker, range(concurrency)))
ok = [r["metrics"] for r in results if r.get("ok")]
streams_detail = [self._clean_stream(m, m.get("_stream_idx", 0)) for m in ok]
if not ok:
errs = [r.get("error") or "未知错误" for r in results if not r.get("ok")]
raise ProviderError("并发 %d 全部失败: %s" % (concurrency, " | ".join(errs[:3])))
total_prompt = sum(m.get("prompt_tokens") or 0 for m in ok)
total_output = sum(m.get("output_tokens") or 0 for m in ok)
total_cached = sum(m.get("cached_tokens") or 0 for m in ok)
total_pchars = sum(m.get("prompt_chars") or 0 for m in ok)
total_ochars = sum(m.get("output_chars") or 0 for m in ok)
batch_start = min(m["_wall_start"] for m in ok)
# 整批首字时刻 = 任一流最早收到第一个 token 的时刻
first_at = min(m["_wall_start"] + (m.get("ttft_ms") or 0) / 1000.0 for m in ok)
batch_end = max(m["_wall_end"] for m in ok)
ttft_ms = max((first_at - batch_start) * 1000.0, 0.1)
decode_ms = max((batch_end - first_at) * 1000.0, 0.1)
total_ms = max((batch_end - batch_start) * 1000.0, 0.1)
prefill = (total_prompt / (ttft_ms / 1000.0)) if total_prompt else None
decode = (total_output / (decode_ms / 1000.0)) if total_output else None
agg = {
"concurrency": concurrency,
"streams_total": concurrency,
"streams_ok": len(ok),
"prompt_tokens": int(total_prompt),
"output_tokens": int(total_output),
"cached_tokens": int(total_cached),
"prompt_chars": int(total_pchars),
"output_chars": int(total_ochars),
"ttft_ms": round(ttft_ms, 1),
"decode_ms": round(decode_ms, 1),
"total_ms": round(total_ms, 1),
"prefill_speed": round(prefill, 1) if prefill else None,
"decode_speed": round(decode, 1) if decode else None,
"avg_stream_prefill": round(prefill / concurrency, 1) if prefill else None,
"avg_stream_decode": round(decode / concurrency, 1) if decode else None,
"streams": streams_detail,
}
return agg
@staticmethod
def _clean_stream(m, idx):
"""去掉内部 _wall 字段,保留每流可展示指标"""
keep = {k: v for k, v in m.items() if not k.startswith("_")}
keep["stream_idx"] = idx
return keep
# ───────────────────────── 工具方法 ─────────────────────────
def _calibrate(self):
@@ -175,10 +263,11 @@ class TestRunner(threading.Thread):
return "[cache-bust %s]\n%s" % (uuid.uuid4().hex, base)
return base
def _fmt_metric(self, L, i, n, m):
return ("[%d tok] 采样 %d/%d 完成 | 提示词 %d tok | 缓存 %d tok | 首字 %s ms | 预填充 %s tok/s"
def _fmt_metric(self, L, C, i, n, m):
return ("[%d tok · 并发%d] 采样 %d/%d 完成 |%d/%d 成功 | 提示词 %d tok | 缓存 %d tok | 首字 %s ms | 预填充 %s tok/s"
" | 输出 %d tok | 解码 %s tok/s | 总耗时 %s ms"
% (L, i, n, m.get("prompt_tokens") or 0, m.get("cached_tokens") or 0,
% (L, C, i, n, m.get("streams_ok") or 0, m.get("streams_total") or C,
m.get("prompt_tokens") or 0, m.get("cached_tokens") or 0,
m.get("ttft_ms"), m.get("prefill_speed"), m.get("output_tokens") or 0,
m.get("decode_speed"), m.get("total_ms")))
@@ -190,6 +279,7 @@ class TestRunner(threading.Thread):
"gen": self.gen,
"samples_total": len(self.samples),
"samples_ok": len(ok),
"concurrency_levels": sorted(set(s.get("concurrency", 1) for s in self.samples)) or [1],
"calibration_chars_per_token": round(1 / self.ratio, 2) if self.ratio else None,
}
if not ok:
@@ -214,6 +304,42 @@ class TestRunner(threading.Thread):
"avg_total_ms": avg(group, "total_ms"),
}
# 按并发数分组汇总(多测试结果并排对比的核心数据)
by_concurrency = {}
for C in sorted(set(s.get("concurrency", 1) for s in ok)):
group = [s["metrics"] for s in ok if s.get("concurrency", 1) == C]
by_concurrency[C] = {
"samples_total": sum(1 for s in self.samples if s.get("concurrency", 1) == C),
"samples_ok": len(group),
"avg_ttft_ms": avg(group, "ttft_ms"),
"avg_prefill_speed": avg(group, "prefill_speed"),
"avg_decode_speed": avg(group, "decode_speed"),
"avg_stream_decode": avg(group, "avg_stream_decode"),
"avg_prompt_tokens": avg(group, "prompt_tokens"),
"avg_output_tokens": avg(group, "output_tokens"),
"avg_total_ms": avg(group, "total_ms"),
}
# 长度 × 并发 全网格(详情/Excel 用)
by_length_concurrency = {}
for L in sorted(set(s["context_length"] for s in ok)):
grid = {}
for C in sorted(set(s.get("concurrency", 1) for s in ok)):
group = [s["metrics"] for s in ok
if s["context_length"] == L and s.get("concurrency", 1) == C]
grid[C] = {
"samples_total": sum(1 for s in self.samples
if s["context_length"] == L and s.get("concurrency", 1) == C),
"samples_ok": len(group),
"avg_ttft_ms": avg(group, "ttft_ms"),
"avg_prefill_speed": avg(group, "prefill_speed"),
"avg_decode_speed": avg(group, "decode_speed"),
"avg_prompt_tokens": avg(group, "prompt_tokens"),
"avg_output_tokens": avg(group, "output_tokens"),
"avg_total_ms": avg(group, "total_ms"),
}
by_length_concurrency[L] = grid
okm = [s["metrics"] for s in ok]
def mn(k):
@@ -227,6 +353,8 @@ class TestRunner(threading.Thread):
summary = dict(base)
summary.update({
"by_length": by_length,
"by_concurrency": by_concurrency,
"by_length_concurrency": by_length_concurrency,
"avg_ttft_ms": avg(okm, "ttft_ms"),
"min_ttft_ms": mn("ttft_ms"),
"max_ttft_ms": mx("ttft_ms"),