// Command loadtest 是平台容量压测器:闭环加压、阶梯并发,输出吞吐/延迟分位曲线。 // // 它只通过网关 HTTP API 打真实流量(登录 → POST /tasks → SSE 流等收尾), // 故压的是「网关→NATS→调度→工具RTT→状态回流」全链路。配合 dispatcher 的 LLM_FORCE_STUB=1 // + LLM_STUB_*_MS=0 可绕开真实 LLM 推理,量出平台自身吞吐天花板(而非被模型节奏掩盖)。 // // 用法: // // go run ./cmd/loadtest -url http://localhost:8080 -email keytest@local.dev -pass keytest123456 \ // -levels 1,2,4,8,16,32,64 -dur 12s package main import ( "bytes" "context" "encoding/json" "flag" "fmt" "io" "net/http" "os" "sort" "strconv" "strings" "sync" "sync/atomic" "time" ) func main() { var ( base = flag.String("url", "http://localhost:8080", "网关地址") email = flag.String("email", "keytest@local.dev", "登录邮箱") pass = flag.String("pass", "keytest123456", "登录密码") levelStr = flag.String("levels", "1,2,4,8,16,32,64", "并发阶梯(逗号分隔)") dur = flag.Duration("dur", 12*time.Second, "每个阶梯加压时长") prompt = flag.String("prompt", "用一句话介绍杭州", "任务输入") ) flag.Parse() token, err := login(*base, *email, *pass) if err != nil { fmt.Printf("登录失败: %v\n", err) os.Exit(1) } body := taskBody(*prompt) fmt.Printf("== 平台容量压测 == 目标 %s 每阶梯 %s (FORCE_STUB 下为平台天花板)\n", *base, *dur) fmt.Printf("%-6s %-10s %-10s %-9s %-9s %-9s %-7s\n", "并发", "完成", "吞吐/s", "p50ms", "p95ms", "max ms", "错误") for _, ls := range strings.Split(*levelStr, ",") { c, err := strconv.Atoi(strings.TrimSpace(ls)) if err != nil || c <= 0 { continue } r := runLevel(*base, token, body, c, *dur) fmt.Printf("%-6d %-10d %-10.1f %-9d %-9d %-9d %-7d\n", c, r.done, float64(r.done)/dur.Seconds(), r.p50, r.p95, r.max, r.errs) } } type result struct { done, errs int64 p50, p95, max int64 } // runLevel 在并发 c 下闭环加压 dur:c 个 worker 不停 submit→等终态→再来,统计完成数与 e2e 延迟。 func runLevel(base, token string, body []byte, c int, dur time.Duration) result { ctx, cancel := context.WithTimeout(context.Background(), dur) defer cancel() var done, errs int64 var mu sync.Mutex var lats []int64 var wg sync.WaitGroup for i := 0; i < c; i++ { wg.Add(1) go func() { defer wg.Done() cl := &http.Client{Timeout: 60 * time.Second} for ctx.Err() == nil { t0 := time.Now() if runOne(ctx, cl, base, token, body) { ms := time.Since(t0).Milliseconds() mu.Lock() lats = append(lats, ms) mu.Unlock() atomic.AddInt64(&done, 1) } else { atomic.AddInt64(&errs, 1) } } }() } wg.Wait() return result{done: done, errs: errs, p50: pct(lats, 50), p95: pct(lats, 95), max: pct(lats, 100)} } // runOne 提交一个任务并经 SSE token 流等到收尾(流结束即任务完成);返回是否成功。 // 用流而非轮询:每任务 1 条推送连接,避免轮询放大把网关自身压成瓶颈,量出真实管线吞吐。 func runOne(ctx context.Context, cl *http.Client, base, token string, body []byte) bool { id, err := submit(ctx, cl, base, token, body) if err != nil || id == "" { return false } req, _ := http.NewRequestWithContext(ctx, "GET", base+"/api/v1/tasks/"+id+"/stream", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := cl.Do(req) if err != nil { return false } defer resp.Body.Close() // 读到 EOF:网关在 token 流结束(任务收尾)时关闭 SSE。 _, err = io.Copy(io.Discard, resp.Body) return err == nil && ctx.Err() == nil } func submit(ctx context.Context, cl *http.Client, base, token string, body []byte) (string, error) { req, _ := http.NewRequestWithContext(ctx, "POST", base+"/api/v1/tasks", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, err := cl.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusAccepted { io.Copy(io.Discard, resp.Body) return "", fmt.Errorf("submit %d", resp.StatusCode) } var r struct { TaskID string `json:"task_id"` } json.NewDecoder(resp.Body).Decode(&r) return r.TaskID, nil } func login(base, email, pass string) (string, error) { b, _ := json.Marshal(map[string]string{"email": email, "password": pass}) resp, err := http.Post(base+"/api/v1/auth/login", "application/json", bytes.NewReader(b)) if err != nil { return "", err } defer resp.Body.Close() var r struct { Token string `json:"token"` } json.NewDecoder(resp.Body).Decode(&r) if r.Token == "" { return "", fmt.Errorf("no token (status %d)", resp.StatusCode) } return r.Token, nil } func taskBody(prompt string) []byte { graph := map[string]any{ "nodes": []any{ map[string]any{"id": "i", "kind": "input", "config": map[string]any{"text": prompt}}, map[string]any{"id": "a", "kind": "agent", "config": map[string]any{"system": "你是简洁的助手"}}, }, "edges": []any{map[string]any{"source": "i", "target": "a"}}, } b, _ := json.Marshal(map[string]any{"graph": graph, "meta": map[string]any{}}) return b } // pct 返回延迟切片的第 p 百分位(p=100 即 max)。 func pct(v []int64, p int) int64 { if len(v) == 0 { return 0 } sort.Slice(v, func(i, j int) bool { return v[i] < v[j] }) idx := (p * (len(v) - 1)) / 100 return v[idx] }