feat(perf): 容量压测器 + 平台天花板实测曲线

cmd/loadtest:闭环加压器,阶梯并发,经 SSE 流检测完成(非轮询,避免轮询放大把网关
压成假瓶颈),输出吞吐 + 延迟分位。配套两个 benchmark 开关:
- dispatcher LLM_FORCE_STUB=1 + LLM_STUB_TTFT_MS/INTERTOKEN_MS=0:绕开真实 LLM 推理,
  量平台自身全链路天花板(不烧 token、不被模型节奏掩盖)。
- gateway RATE_LIMIT_PER_MIN 可配(缺省 120):压测放开限流。

实测(单 dispatcher、并发64、stub):
- 单任务纯平台开销 ~42ms;吞吐峰值 ~110 全链路任务/秒(饱和点 并发32–64);
  并发128 优雅降速,256 硬崩。
- 吞吐瓶颈不是 DB 连接数(池 25→80 吞吐不变),是每任务多跳管线综合成本;
  256 崩根因为 DSN 用 localhost、pgx 每连接解析 → 连接churn致 DNS 取消(易修)。
- 关键判断:平台 42ms ≪ LLM 秒级出答案,平台不是瓶颈、GPU 才是;横向拆服务喂满 GPU 集群
  的意义成立。细节与曲线见 project_analysis「容量实测」。

stub 延迟改 env 可配(默认值不变);不影响线上路径。dispatcher+gateway build/vet/test 全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-26 11:46:50 +08:00
parent 075d41f5b3
commit 7bfea74cc0
5 changed files with 246 additions and 12 deletions
+30 -9
View File
@@ -7,6 +7,8 @@ import (
"context"
"fmt"
"io"
"os"
"strconv"
"strings"
"sync"
"time"
@@ -39,10 +41,14 @@ type Pool struct {
func NewPool() *Pool { return &Pool{} }
// forceStub 报告是否强制走降级桩(LLM_FORCE_STUB=1)——压测平台自身吞吐时用,
// 绕开真实 LLM 推理与计费,只压全链路 plumbing(网关→NATS→调度→工具RTT→回流)。
func forceStub() bool { return os.Getenv("LLM_FORCE_STUB") == "1" }
// SetConfig 热更新后端配置:重建 ChatModel 实例(控制面变更时调用)。
func (p *Pool) SetConfig(cfg *contract.ModelConfig) {
var cm model.BaseChatModel
if cfg != nil && cfg.Ready() {
if cfg != nil && cfg.Ready() && !forceStub() {
built, err := buildChatModel(cfg)
if err != nil {
fmt.Printf("[llm] 构建 ChatModel 失败(降级桩运行): %v\n", err)
@@ -222,17 +228,30 @@ func toSchema(msgs []ChatMessage) []*schema.Message {
// ---- 占位降级(未配置后端时)----
// 占位参数:模拟真实后端的 TTFT(首 token 延迟) 与逐 token 间隔。
const (
timeToFirstToken = 700 * time.Millisecond
interTokenDelay = 60 * time.Millisecond
// 可经 env 调整(压测平台吞吐时设 0 → 近瞬时桩,测出 plumbing 天花板而非被桩节奏掩盖)。
var (
timeToFirstToken = envDuration("LLM_STUB_TTFT_MS", 700*time.Millisecond)
interTokenDelay = envDuration("LLM_STUB_INTERTOKEN_MS", 60*time.Millisecond)
)
// envDuration 读毫秒 env(允许 0),缺省回退 def。
func envDuration(key string, def time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
return time.Duration(n) * time.Millisecond
}
}
return def
}
// StreamText 按节奏把给定文本流式回调(未配置真实后端时的降级桩)。
func (p *Pool) StreamText(ctx context.Context, text string, onToken func([]byte)) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(timeToFirstToken):
if timeToFirstToken > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(timeToFirstToken):
}
}
for _, tok := range tokenize(text) {
select {
@@ -241,7 +260,9 @@ func (p *Pool) StreamText(ctx context.Context, text string, onToken func([]byte)
default:
}
onToken([]byte(tok))
time.Sleep(interTokenDelay)
if interTokenDelay > 0 {
time.Sleep(interTokenDelay)
}
}
return nil
}