📎 配套脚本附件正文未内嵌 · 共 5 个 · 点开可在线查看
📂 查看全部 761 个脚本附件 →
⚠️ 脚本内含明文凭据(与本页正文一致),请勿在公网环境直接运行。
本周实战

作业12 · 推理延迟压测与健康检查

curl 记延迟、高并发观察、失败重试 3 次 + 告警文案。

作业12:推理延迟压测与健康检查(curl → 并发 → 告警脚本)

作业目标:学会用 curl/脚本 打 /v1/chat/completions 记录延迟;故意抬高并发观察延迟与错误;用 Cursor 写出「失败重试 3 次 + 告警文案」的健康检查脚本。

验收标准:

1. 至少 3 次单请求延迟 CSV(含 http_code、time_total_s)

2. 一组高并发结果 + 自己填写的 5 行「现象→原因→处理」

3. 健康检查脚本:成功打印 [PASS];--fail-demo 时重试 3 次并写出告警文案到 alerts.jsonl

环境:192.168.110.207 LiteLLM :4000(推荐)或 Higress :18080/llm;上游 vLLM 116.62.10.183:8000;模型 qwen3.6-27b

HTML:docs/html_cursor/作业12_推理延迟压测与健康检查_cursor.html

运行环境:文档与脚本在 Windows 仓库维护;压测与验收建议在 207(或本机对 207 网络可达)。Python:/usr/bin/python3(207)或本机 python。

路径:/opt/workbuddy/scripts_cursor/作业12_推理延迟压测与健康检查/

结果目录:/opt/workbuddy/data/homework12/(Windows 默认同仓库 data/homework12/)


目录

  1. 做完长什么样
  2. 学习路线(照着做)
  3. 第一部分 原理
  4. 第二部分 前置条件
  5. 第三部分 详细操作步骤
  6. 第四部分 五联表:现象→原因→处理
  7. 第五部分 完整代码程序
  8. 第六部分 常见问题
  9. 第七部分 验收清单

做完长什么样

项本环境值
推荐入口http://192.168.110.207:4000/v1/chat/completions
备用入口http://192.168.110.207:18080/llm/v1/chat/completions
Bearersk-litellm-bocheng-207
modelqwen3.6-27b
单次延迟参考(短 prompt)约 0.5~3s(视排队/冷启动)
结果目录data/homework12/
健康检查成功[PASS] 推理接口健康
健康检查失败演示--fail-demo → 告警文案 + alerts.jsonl

目录里应出现类似:

text
latency_single_YYYYMMDD_HHMMSS.csv
latency_conc_YYYYMMDD_HHMMSS.csv
health_YYYYMMDD_HHMMSS.json
alerts.jsonl          # 仅失败或 --fail-demo 时

学习路线(照着做)

text
步骤 A  手工 curl 一次,看懂 time_total
   │
步骤 B  跑 hw12_01 单次延迟 ×3,保存 CSV
   │
步骤 C  跑 hw12_02 并发(先 8×24,再可选 20×40)
   │
步骤 D  根据汇总填写「现象→原因→处理」五联表
   │
步骤 E  跑 hw12_03 健康检查(正常 + --fail-demo)
   │
步骤 F  对照验收清单勾选

Windows 本机可直接跑 Python 版(推荐学习路径);207 可跑 bash 版(与生产运维习惯一致)。两套逻辑等价。


第一部分 原理

1.1 延迟里有什么

text
客户端 curl
   → LiteLLM :4000(鉴权、选模型、转发)
      → vLLM :8000(排队 + 预填充 + 解码)
         → 返回首包/整包

curl -w %{time_total} ≈ 端到端墙钟时间(含网络、网关、排队、生成)。 短 max_tokens 时,延迟里排队+预填充占比更大;长生成则解码时间变长。

1.2 为什么要故意打高并发

单机 vLLM 吞吐有限。并发升高后常见:

现象常见原因
time_total 变大请求在 vLLM 队列等待
偶发超时客户端 --max-time / 网关超时过短
HTTP 429网关/限流插件(若已启用)
HTTP 500/502上游 OOM、进程重启、代理错误
连接失败LiteLLM/vLLM 挂了或网络不通

1.3 健康检查为何要重试 3 次

瞬时抖动(GC、短暂排队、网络毛刺)不应立刻告警。 3 次失败再告警是运维最小可用策略;告警文案要含:时间、目标、错误、建议动作。


第二部分 前置条件

  1. LiteLLM 可访问:
bash
curl -sS http://192.168.110.207:4000/v1/models \
  -H "Authorization: Bearer sk-litellm-bocheng-207" | head -c 200
  1. 仓库脚本已在本机;若要在 207 跑 bash,把目录同步到 /opt/workbuddy/(与其它作业相同方式)。
  2. 压测会占用 GPU:教学用默认 max_tokens=32、并发 8;不要在业务高峰对生产用户无感实验。
  3. 鉴权 Key(完整值):sk-litellm-bocheng-207。

第三部分 详细操作步骤

步骤 A:手工 curl(建立手感)

在 207 bash 或安装了 curl 的环境:

bash
curl -sS -o /tmp/hw12_one.json -w "http_code=%{http_code} time_total=%{time_total}\n" \
  http://192.168.110.207:4000/v1/chat/completions \
  -H "Authorization: Bearer sk-litellm-bocheng-207" \
  -H "Content-Type: application/json" \
  -d '{"model":"qwen3.6-27b","messages":[{"role":"user","content":"只回复:pong"}],"max_tokens":8,"stream":false}'

# 看返回
head -c 300 /tmp/hw12_one.json; echo

经 Higress:

bash
curl -sS -o /tmp/hw12_one.json -w "http_code=%{http_code} time_total=%{time_total}\n" \
  http://192.168.110.207:18080/llm/v1/chat/completions \
  -H "Authorization: Bearer sk-litellm-bocheng-207" \
  -H "Content-Type: application/json" \
  -d '{"model":"qwen3.6-27b","messages":[{"role":"user","content":"只回复:pong"}],"max_tokens":8,"stream":false}'

记录:把打印的 time_total 抄到笔记本。期望 http_code=200。

Windows PowerShell 直接 -d '{...}' 容易把 JSON 弄坏;请用步骤 B 的 Python 脚本,或把 JSON 写入文件再用 curl --data-binary @file.json。


步骤 B:单次延迟脚本(×3)

Windows / 通用(推荐):

bash
cd D:\workbuddy\192.168.110.207
python scripts_cursor/作业12_推理延迟压测与健康检查/hw12_01_单次延迟.py --rounds 3

207 bash:

bash
sed -i 's/\r$//' /opt/workbuddy/scripts_bash_cursor/作业12_推理延迟压测与健康检查/hw12_01_单次延迟.sh
chmod +x /opt/workbuddy/scripts_bash_cursor/作业12_推理延迟压测与健康检查/hw12_01_单次延迟.sh
ROUNDS=3 bash /opt/workbuddy/scripts_bash_cursor/作业12_推理延迟压测与健康检查/hw12_01_单次延迟.sh

打开生成的 latency_single_*.csv,确认有 3 行成功数据。


步骤 C:故意抬高并发

先温和:

bash
python scripts_cursor/作业12_推理延迟压测与健康检查/hw12_02_并发压测.py --concurrency 8 --n 24

再加压(学习用,可能变慢或出错):

bash
python scripts_cursor/作业12_推理延迟压测与健康检查/hw12_02_并发压测.py --concurrency 20 --n 40

207 bash 等价:

bash
CONCURRENCY=8 N=24 bash /opt/workbuddy/scripts_bash_cursor/作业12_推理延迟压测与健康检查/hw12_02_并发压测.sh
CONCURRENCY=20 N=40 bash /opt/workbuddy/scripts_bash_cursor/作业12_推理延迟压测与健康检查/hw12_02_并发压测.sh

脚本结束会打印 HTTP 码分布 与 min/p50/p95/max。把这两行截图或抄下,用于填五联表。


步骤 D:填写五联表

见 第四部分。 必须自己按本次跑出来的数字改写;文中「示例答法」仅供对照,照抄不算过关。


步骤 E:健康检查脚本(Cursor 交付物)

正常探测:

bash
python scripts_cursor/作业12_推理延迟压测与健康检查/hw12_03_健康检查.py

故意失败,看重试 3 次 + 告警文案:

bash
python scripts_cursor/作业12_推理延迟压测与健康检查/hw12_03_健康检查.py --fail-demo

期望:

自学要点(你用 Cursor 时应要求 AI 做到):

  1. HTTP POST /v1/chat/completions
  2. 失败则 sleep 后重试,最多 3 次
  3. 仍失败则生成可读告警文案并落盘
  4. 进程退出码:成功 0,失败非 0(本脚本用 2)

完整源码见第五部分(可直接复制;学习时建议先自己让 Cursor 写一版再对比)。


步骤 F:验收

勾选第七部分清单。


第四部分 五联表:现象→原因→处理

格式固定为 5 行。每行:现象 → 原因 → 处理。

4.1 空白表(交作业用)

text
1) 现象:________ → 原因:________ → 处理:________
2) 现象:________ → 原因:________ → 处理:________
3) 现象:________ → 原因:________ → 处理:________
4) 现象:________ → 原因:________ → 处理:________
5) 现象:________ → 原因:________ → 处理:________

4.2 示例答法(对照用,勿原样提交)

以下按「并发升高后常见情况」撰写;数字请换成你 CSV 里的真实值。

text
1) 现象:并发 8 时 p95≈数秒且仍 200 → 原因:vLLM 排队等待变长但未打满超时 → 处理:业务侧限流或扩 GPU 副本,监控 waiting 队列
2) 现象:并发 20 时部分请求 time_total 接近客户端超时 → 原因:排队+解码超过 curl/脚本 max-time → 处理:加大超时或降并发/降 max_tokens
3) 现象:偶发 HTTP 0 / Connection refused → 原因:LiteLLM 进程重启或瞬间不可达 → 处理:systemd 保活 + 健康检查重试(本作业脚本)
4) 现象:HTTP 401/403 → 原因:Bearer 错误或网关鉴权未透传 → 处理:核对 sk-litellm-bocheng-207;经 Higress 时确认未误开 key-auth
5) 现象:HTTP 429 或主动限流文案 → 原因:Higress/LiteLLM 限流策略生效 → 处理:申请提额、分散 Key、错峰;压测环境可临时关闭演示限流

4.3 如何从 CSV 提炼「现象」

打开 latency_conc_*.csv:


第五部分 完整代码程序

仓库路径(勿改名):

文件作用
scripts_cursor/作业12_推理延迟压测与健康检查/hw12_01_单次延迟.py单次延迟
scripts_cursor/作业12_推理延迟压测与健康检查/hw12_02_并发压测.py并发压测
scripts_cursor/作业12_推理延迟压测与健康检查/hw12_03_健康检查.py重试+告警
scripts_bash_cursor/作业12_推理延迟压测与健康检查/hw12_01_单次延迟.shbash 单次
scripts_bash_cursor/作业12_推理延迟压测与健康检查/hw12_02_并发压测.shbash 并发

5.1 hw12_03_健康检查.py(完整)

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业12 · 步骤3:推理健康检查(失败重试 3 次 + 告警文案)

用法(207):
  /usr/bin/python3 /opt/workbuddy/scripts_cursor/作业12_推理延迟压测与健康检查/hw12_03_健康检查.py
  /usr/bin/python3 .../hw12_03_健康检查.py --via-higress
  /usr/bin/python3 .../hw12_03_健康检查.py --fail-demo   # 故意指向坏地址,演示告警

Windows 也可直接对本机可达的 207 执行:
  python scripts_cursor/作业12_推理延迟压测与健康检查/hw12_03_健康检查.py
"""
from __future__ import annotations

import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path

DEFAULT_LITELLM = "http://192.168.110.207:4000/v1/chat/completions"
DEFAULT_HIGRESS = "http://192.168.110.207:18080/llm/v1/chat/completions"
DEFAULT_KEY = "sk-litellm-bocheng-207"
DEFAULT_MODEL = "qwen3.6-27b"
MAX_RETRIES = 3
RETRY_SLEEP_S = 2.0
TIMEOUT_S = 60.0


def _out_dir() -> Path:
    env = os.environ.get("HW12_OUT")
    if env:
        p = Path(env)
        p.mkdir(parents=True, exist_ok=True)
        return p
    # 优先仓库 data/;仅当 Linux 上真实存在 /opt/workbuddy 时才用服务器路径
    repo = Path(__file__).resolve().parents[2] / "data" / "homework12"
    candidates: list[Path] = [repo]
    opt = Path("/opt/workbuddy/data/homework12")
    if os.name != "nt" and Path("/opt/workbuddy").is_dir():
        candidates.insert(0, opt)
    for cand in candidates:
        try:
            cand.mkdir(parents=True, exist_ok=True)
            return cand
        except OSError:
            continue
    p = Path.cwd() / "data" / "homework12"
    p.mkdir(parents=True, exist_ok=True)
    return p


def build_alert(
    *,
    url: str,
    attempts: int,
    last_error: str,
    latencies: list[float],
) -> str:
    """告警文案:给值班同学可直接贴群的一段话。"""
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    lat_txt = ", ".join(f"{x:.3f}s" for x in latencies) if latencies else "无"
    return (
        "【推理健康检查告警】\n"
        f"时间:{now}\n"
        f"目标:{url}\n"
        f"结果:连续失败(已重试 {attempts}/{MAX_RETRIES} 次仍未成功)\n"
        f"末次错误:{last_error}\n"
        f"各次耗时:{lat_txt}\n"
        "建议:\n"
        "1) 查 LiteLLM/vLLM 进程与 GPU 显存(作业5 健康 Agent / nvidia-smi)\n"
        "2) 查是否限流/鉴权失败(HTTP 401/429)\n"
        "3) 高峰请降并发或扩副本;恢复后重跑本脚本确认\n"
    )


def one_probe(url: str, key: str, model: str) -> tuple[bool, float, str, dict | None]:
    payload = {
        "model": model,
        "messages": [{"role": "user", "content": "健康检查:只回复 ok"}],
        "max_tokens": 8,
        "stream": False,
        "temperature": 0,
    }
    data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
    req = urllib.request.Request(
        url,
        data=data,
        headers={
            "Authorization": f"Bearer {key}",
            "Content-Type": "application/json",
        },
        method="POST",
    )
    t0 = time.perf_counter()
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp:
            body = resp.read().decode("utf-8", errors="replace")
            elapsed = time.perf_counter() - t0
            code = getattr(resp, "status", 200)
            if code != 200:
                return False, elapsed, f"HTTP {code}: {body[:200]}", None
            obj = json.loads(body)
            content = (
                ((obj.get("choices") or [{}])[0].get("message") or {}).get("content")
                or ""
            )
            if not str(content).strip():
                return False, elapsed, "HTTP 200 但 content 为空", obj
            return True, elapsed, content.strip()[:80], obj
    except urllib.error.HTTPError as e:
        elapsed = time.perf_counter() - t0
        try:
            detail = e.read().decode("utf-8", errors="replace")[:200]
        except Exception:
            detail = str(e)
        return False, elapsed, f"HTTPError {e.code}: {detail}", None
    except Exception as e:
        elapsed = time.perf_counter() - t0
        return False, elapsed, f"{type(e).__name__}: {e}", None


def run_health_check(url: str, key: str, model: str) -> int:
    out = _out_dir()
    stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    report_path = out / f"health_{stamp}.json"
    alert_path = out / "alerts.jsonl"

    attempts_log: list[dict] = []
    latencies: list[float] = []
    last_error = ""
    ok = False
    snippet = ""

    print(f"[INFO] 健康检查 URL={url}")
    print(f"[INFO] 最多重试 {MAX_RETRIES} 次,间隔 {RETRY_SLEEP_S}s")

    for i in range(1, MAX_RETRIES + 1):
        success, elapsed, msg, raw = one_probe(url, key, model)
        latencies.append(elapsed)
        attempts_log.append(
            {
                "attempt": i,
                "ok": success,
                "latency_s": round(elapsed, 4),
                "message": msg,
            }
        )
        if success:
            ok = True
            snippet = msg
            print(f"[OK ] 第 {i}/{MAX_RETRIES} 次成功  latency={elapsed:.3f}s  reply={msg!r}")
            break
        last_error = msg
        print(f"[FAIL] 第 {i}/{MAX_RETRIES} 次失败  latency={elapsed:.3f}s  err={msg}")
        if i < MAX_RETRIES:
            time.sleep(RETRY_SLEEP_S)

    report = {
        "ok": ok,
        "url": url,
        "model": model,
        "attempts": attempts_log,
        "finished_at": datetime.now().isoformat(timespec="seconds"),
    }

    if ok:
        report["status"] = "healthy"
        report["reply_snippet"] = snippet
        print("[PASS] 推理接口健康")
        rc = 0
    else:
        alert = build_alert(
            url=url,
            attempts=MAX_RETRIES,
            last_error=last_error,
            latencies=latencies,
        )
        report["status"] = "unhealthy"
        report["alert_text"] = alert
        alert_path.parent.mkdir(parents=True, exist_ok=True)
        with alert_path.open("a", encoding="utf-8") as f:
            f.write(
                json.dumps(
                    {
                        "ts": datetime.now().isoformat(timespec="seconds"),
                        "url": url,
                        "last_error": last_error,
                        "alert_text": alert,
                    },
                    ensure_ascii=False,
                )
                + "\n"
            )
        print()
        print(alert)
        print(f"[ALERT] 已追加写入 {alert_path}")
        rc = 2

    report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"[INFO] 报告 {report_path}")
    return rc


def main() -> int:
    ap = argparse.ArgumentParser(description="作业12 推理健康检查")
    ap.add_argument("--via-higress", action="store_true", help="经 Higress /llm 入口")
    ap.add_argument("--url", default="", help="自定义 chat completions URL")
    ap.add_argument("--key", default=os.environ.get("LITELLM_MASTER_KEY", DEFAULT_KEY))
    ap.add_argument("--model", default=os.environ.get("LITELLM_CHAT_MODEL", DEFAULT_MODEL))
    ap.add_argument(
        "--fail-demo",
        action="store_true",
        help="故意打到不可达地址,演示重试与告警文案",
    )
    args = ap.parse_args()

    if args.fail_demo:
        url = "http://127.0.0.1:1/v1/chat/completions"
    elif args.url:
        url = args.url
    elif args.via_higress:
        url = os.environ.get("HIGRESS_CHAT_URL", DEFAULT_HIGRESS)
    else:
        url = os.environ.get("LITELLM_CHAT_URL", DEFAULT_LITELLM)

    return run_health_check(url, args.key, args.model)


if __name__ == "__main__":
    sys.exit(main())

其余 hw12_01 / hw12_02 全文见仓库同名文件(步骤里已给运行命令);bash 版在 scripts_bash_cursor/作业12_推理延迟压测与健康检查/。


第六部分 常见问题

问题处理
PowerShell curl JSON 400用 Python 脚本,或 JSON 落文件 + --data-binary @file
全部很慢看 183 上 vLLM 是否在跑;先把 --n 降到 4
经 Higress 失败、直连 LiteLLM 成功查路由 /llm、rewrite;见 Higress 之三
健康检查误报确认 Key/模型名;先手工 curl
不想打扰业务只跑 --rounds 1 与 --concurrency 2 --n 4

第七部分 验收清单


附录 · 与其它作业关系

作业关系
作业 5Agent 式健康检查(容器/GPU/队列);本作业是 HTTP 黑盒延迟/告警
作业 8SLA 大盘;本作业产出的 CSV 可作手工对照
Higress 之六限流/WAF 会导致本作业出现 429/403——正好写入五联表

维护:脚本与本文同步;改默认 Key/端口时同时改 _config.py 与脚本内默认值。