Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35ac2a8e40 |
@@ -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, error(metrics 含 `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
|
||||
|
||||
@@ -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 @@
|
||||
- **文件下载 Excel(xlsx)**:历史记录「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可自定义并发档,整批吞吐聚合+按并发分组)+ 多测试结果对比(历史勾选→对比表/柱状图/并发折线图))
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user