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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user