AI Gateway Go 0.10.0 源码快照 + 旗舰版需求规划报告
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。 含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type sample struct {
|
||||
latency time.Duration
|
||||
status int
|
||||
err string
|
||||
}
|
||||
type report struct {
|
||||
Requests int `json:"requests"`
|
||||
Success int `json:"success"`
|
||||
Errors int `json:"errors"`
|
||||
ErrorRate float64 `json:"error_rate"`
|
||||
RequestsPerSecond float64 `json:"requests_per_second"`
|
||||
P50MS float64 `json:"p50_ms"`
|
||||
P95MS float64 `json:"p95_ms"`
|
||||
P99MS float64 `json:"p99_ms"`
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
StatusCounts map[int]int `json:"status_counts"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
base := flag.String("url", "http://127.0.0.1:8080", "gateway base URL")
|
||||
path := flag.String("path", "/v1/chat/completions", "request path")
|
||||
key := flag.String("api-key", os.Getenv("GATEWAY_LOADTEST_API_KEY"), "dedicated load-test API key")
|
||||
model := flag.String("model", "test-model", "model or model alias")
|
||||
concurrency := flag.Int("concurrency", 16, "concurrent workers")
|
||||
duration := flag.Duration("duration", 30*time.Second, "test duration")
|
||||
timeout := flag.Duration("timeout", 60*time.Second, "per-request timeout")
|
||||
maxError := flag.Float64("max-error-rate", 0.01, "failure threshold")
|
||||
maxP95 := flag.Duration("max-p95", 2*time.Second, "p95 latency threshold")
|
||||
flag.Parse()
|
||||
if *concurrency < 1 || *concurrency > 2000 || *duration < time.Second || *key == "" {
|
||||
fmt.Fprintln(os.Stderr, "invalid arguments: api-key, positive duration and concurrency 1..2000 are required")
|
||||
os.Exit(2)
|
||||
}
|
||||
target := strings.TrimRight(*base, "/") + *path
|
||||
payload, _ := json.Marshal(map[string]any{"model": *model, "messages": []map[string]string{{"role": "user", "content": "Reply with OK."}}, "stream": false, "max_tokens": 8})
|
||||
client := &http.Client{Timeout: *timeout, Transport: &http.Transport{MaxIdleConns: *concurrency * 2, MaxIdleConnsPerHost: *concurrency, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 5 * time.Second}}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *duration)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
results := make(chan sample, *concurrency*4)
|
||||
var sequence atomic.Uint64
|
||||
var workers sync.WaitGroup
|
||||
for worker := 0; worker < *concurrency; worker++ {
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
for ctx.Err() == nil {
|
||||
number := sequence.Add(1)
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
results <- sample{err: err.Error()}
|
||||
continue
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+*key)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Request-ID", fmt.Sprintf("load-%d", number))
|
||||
request.Header.Set("Idempotency-Key", fmt.Sprintf("load-%d", number))
|
||||
begin := time.Now()
|
||||
response, err := client.Do(request)
|
||||
elapsed := time.Since(begin)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
results <- sample{latency: elapsed, err: err.Error()}
|
||||
}
|
||||
continue
|
||||
}
|
||||
_, readErr := io.Copy(io.Discard, io.LimitReader(response.Body, 2<<20))
|
||||
response.Body.Close()
|
||||
entry := sample{latency: elapsed, status: response.StatusCode}
|
||||
if readErr != nil {
|
||||
entry.err = readErr.Error()
|
||||
}
|
||||
results <- entry
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() { workers.Wait(); close(results) }()
|
||||
samples := []sample{}
|
||||
for result := range results {
|
||||
samples = append(samples, result)
|
||||
}
|
||||
elapsed := time.Since(started)
|
||||
summary := summarize(samples, elapsed)
|
||||
encoded, _ := json.MarshalIndent(summary, "", " ")
|
||||
fmt.Println(string(encoded))
|
||||
if summary.ErrorRate > *maxError || time.Duration(summary.P95MS*float64(time.Millisecond)) > *maxP95 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func summarize(samples []sample, elapsed time.Duration) report {
|
||||
summary := report{Requests: len(samples), StatusCounts: map[int]int{}, DurationSeconds: elapsed.Seconds()}
|
||||
latencies := make([]time.Duration, 0, len(samples))
|
||||
for _, item := range samples {
|
||||
summary.StatusCounts[item.status]++
|
||||
latencies = append(latencies, item.latency)
|
||||
if item.err == "" && item.status >= 200 && item.status < 300 {
|
||||
summary.Success++
|
||||
} else {
|
||||
summary.Errors++
|
||||
}
|
||||
}
|
||||
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
|
||||
if summary.Requests > 0 {
|
||||
summary.ErrorRate = float64(summary.Errors) / float64(summary.Requests)
|
||||
summary.RequestsPerSecond = float64(summary.Requests) / elapsed.Seconds()
|
||||
summary.P50MS = percentile(latencies, .50)
|
||||
summary.P95MS = percentile(latencies, .95)
|
||||
summary.P99MS = percentile(latencies, .99)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
func percentile(values []time.Duration, p float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
index := int(float64(len(values)-1) * p)
|
||||
return float64(values[index]) / float64(time.Millisecond)
|
||||
}
|
||||
Reference in New Issue
Block a user