Faz 0: iskelet ve sim-engine
CI / test (push) Has been cancelled

Plan v0.2'nin Faz 0 çıktısı. Yazılan sıraya göre:

- cobra tabanlı CLI iskeleti; server/agent/ctl komutları henüz stub.
- internal/sim: OpenAI uyumlu sahte inference engine. Router'ı GPU olmadan
  gerçekçi koşullarda test edebilmek için gerçek bir engine'in router
  açısından önemli davranışlarını modeller:
    * continuous batching ve KV blok muhasebesi (head-of-line blocking dahil)
    * zincirlenmiş blok hash'leri + LRU ile otomatik prefix cache
    * batch büyüdükçe artan token gecikmesi
    * istemci koptuğunda isteğin düşürülüp blokların bırakılması
    * vLLM metrik adlarıyla /metrics (plan §7.6 engine kontratı)
- Testler: prefix hash zincirlemesi, LRU tahliyesi, iptal propagasyonu,
  kuyruklama, SSE formatı, usage/cached_tokens, metriklerin gerçek
  Prometheus parser'ıyla okunabilirliği.
- Gitea Actions CI: gofmt, vet, race detector.

Modül yolu gitea.mynodes.xyz/ilker/llmgw; `make rename` ile değiştirilebilir.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ilkerandClaude Opus 5 committed 2026-09-16 01:00:03 +02:00
commit e07bc46c57
24 files changed
+3443

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
- name: Biçim kontrolü
run: |
out=$(gofmt -l .)
if [ -n "$out" ]; then echo "gofmt gerekli:"; echo "$out"; exit 1; fi
- name: Vet
run: go vet ./...
- name: Test (race detector ile)
run: go test -race ./...
- name: Build
run: go build ./...
+7
View File
@@ -0,0 +1,7 @@
/bin/
/dist/
*.db
*.db-wal
*.db-shm
.DS_Store
coverage.out
+30
View File
@@ -0,0 +1,30 @@
version: "2"
linters:
default: standard
enable:
- bodyclose
- errorlint
- gocritic
- misspell
- noctx
- revive
- unconvert
- usetesting
settings:
revive:
rules:
- name: exported
- name: package-comments
exclusions:
rules:
# Testlerde okunabilirlik uğruna gevşetilenler.
- path: _test\.go
linters: [bodyclose, noctx, errorlint]
formatters:
enable:
- gofmt
- goimports
+32
View File
@@ -0,0 +1,32 @@
MODULE := gitea.mynodes.xyz/ilker/llmgw
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -X '$(MODULE)/internal/version.Version=$(VERSION)'
.PHONY: build test race lint fmt clean rename
build:
go build -ldflags "$(LDFLAGS)" -o bin/llmgw ./cmd/llmgw
test:
go test ./...
race:
go test -race ./...
lint:
golangci-lint run
fmt:
go fmt ./...
go vet ./...
clean:
rm -rf bin dist
# Modül yolunu değiştirmek için: make rename NEW=git.ornek.com/ekip/llmgw
rename:
@test -n "$(NEW)" || (echo "kullanim: make rename NEW=<yeni/modul/yolu>"; exit 1)
go mod edit -module=$(NEW)
grep -rl '$(MODULE)' --include='*.go' . | xargs sed -i '' 's|$(MODULE)|$(NEW)|g'
sed -i '' 's|^MODULE := .*|MODULE := $(NEW)|' Makefile
go build ./...
+69
View File
@@ -0,0 +1,69 @@
# llmgw
Çoklu InfiniBand fabric ve heterojen GPU için tek binary LLM gateway.
Kendi GPU sunucularında çalışan LLM'leri (vLLM / SGLang) tek bir OpenAI uyumlu
uç noktadan sunar. Tasarım dokümanı: [llmgw-plan.md](llmgw-plan.md).
## Durum
| Faz | Kapsam | Durum |
|---|---|---|
| 0 | İskelet + sim-engine | ✅ |
| 1 | Statik router (filter/score/pick, streaming proxy) | ⬜ |
| 1.5 | Admission control (öncelik bantları) | ⬜ |
| 2 | Agent: GPU/IB keşfi, tek-node engine | ⬜ |
| 3 | Control plane: katalog, scheduler, reconciler | ⬜ |
| 4 | Multi-node & fabric farkındalığı | ⬜ |
| 5 | Tenancy, prefix affinity, bench | ⬜ |
## Kurulum
```sh
make build # bin/llmgw
make test # birim testleri
make race # yarış dedektörüyle
```
## sim-engine
Gerçek bir inference engine'in **router açısından önemli** davranışlarını simüle
eder; GPU olmadan geliştirme ve test yapabilmek için. Modellediği şeyler:
- continuous batching (sınırlı eşzamanlı dizi sayısı)
- KV cache blok muhasebesi; blok yetmediğinde kuyruğa alma (head-of-line blocking dahil)
- otomatik prefix cache (zincirlenmiş blok hash'leri + LRU) ve cache hit'in TTFT'ye etkisi
- batch büyüdükçe token başına gecikmenin artması
- istemci koptuğunda isteğin batch'ten düşürülüp blokların bırakılması
- vLLM metrik adlarıyla `/metrics`
```sh
./bin/llmgw sim-engine --listen :8000 --model llama-3.3-70b
curl -N localhost:8000/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"messages":[{"role":"user","content":"merhaba"}],"max_tokens":8,"stream":true}'
```
Yük/gecikme davranışını ayarlamak için: `--max-num-seqs`, `--num-gpu-blocks`,
`--prefill-tps`, `--decode-tps`, `--batch-contention`, `--prefix-cache-blocks`.
Kaos testleri için: `--fail-rate`, deterministik koşu için `--seed`.
### Engine metrik kontratı
Router'ın yönlendirme yapabilmesi için bir engine'in vermesi **zorunlu** üç metrik
(plan §7.6). sim-engine bunları gerçek vLLM adlarıyla yayınlar:
```
vllm:num_requests_running
vllm:num_requests_waiting
vllm:kv_cache_usage_perc
```
## Modül yolu
`gitea.mynodes.xyz/ilker/llmgw`. Değiştirmek için:
```sh
make rename NEW=git.ornek.com/ekip/llmgw
```
+17
View File
@@ -0,0 +1,17 @@
// Command llmgw, tek binary halinde router, control plane, agent ve yardımcı
// araçları barındırır. Hangi rolde çalışacağı alt komutla belirlenir.
package main
import (
"fmt"
"os"
"gitea.mynodes.xyz/ilker/llmgw/internal/cli"
)
func main() {
if err := cli.Root().Execute(); err != nil {
fmt.Fprintln(os.Stderr, "hata:", err)
os.Exit(1)
}
}
+16
View File
@@ -0,0 +1,16 @@
module gitea.mynodes.xyz/ilker/llmgw
go 1.26.3
require (
github.com/prometheus/common v0.71.0
github.com/spf13/cobra v1.10.2
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
google.golang.org/protobuf v1.36.12 // indirect
)
+26
View File
@@ -0,0 +1,26 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8=
github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+48
View File
@@ -0,0 +1,48 @@
// Package cli, llmgw'nin komut satırı yüzeyini kurar.
package cli
import (
"log/slog"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/obs"
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
)
type globalFlags struct {
logLevel string
logFormat string
}
var global globalFlags
// Root, kök komutu ve tüm alt komutları döner.
func Root() *cobra.Command {
root := &cobra.Command{
Use: "llmgw",
Short: "Çoklu IB fabric ve heterojen GPU için LLM gateway",
Long: "llmgw, kendi GPU sunucularında çalışan LLM'leri tek bir OpenAI uyumlu\n" +
"uç noktadan sunar. Tek binary, alt komutla farklı roller.",
Version: version.Version,
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRun: func(_ *cobra.Command, _ []string) {
obs.Setup(obs.Options{Level: global.logLevel, Format: global.logFormat})
},
}
pf := root.PersistentFlags()
pf.StringVar(&global.logLevel, "log-level", "info", "log seviyesi: debug|info|warn|error")
pf.StringVar(&global.logFormat, "log-format", "text", "log biçimi: text|json")
root.AddCommand(
newServerCmd(),
newAgentCmd(),
newCtlCmd(),
newSimEngineCmd(),
)
return root
}
func logger() *slog.Logger { return slog.Default() }
+87
View File
@@ -0,0 +1,87 @@
package cli
import (
"context"
"errors"
"net"
"net/http"
"os/signal"
"syscall"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/sim"
)
func newSimEngineCmd() *cobra.Command {
cfg := sim.Default()
var listen string
cmd := &cobra.Command{
Use: "sim-engine",
Short: "OpenAI uyumlu sahte inference engine (test için)",
Long: "Gerçek bir engine'in router açısından önemli davranışlarını simüle eder:\n" +
"continuous batching, KV blok muhasebesi, prefix cache, vLLM formatında /metrics.\n" +
"Router'ı GPU olmadan test etmek için kullanılır.",
RunE: func(cmd *cobra.Command, _ []string) error {
log := logger()
eng := sim.New(cfg)
defer eng.Close()
srv := &http.Server{
Addr: listen,
Handler: sim.NewServer(eng, log),
ReadHeaderTimeout: 10 * time.Second,
}
ln, err := net.Listen("tcp", listen)
if err != nil {
return err
}
log.Info("sim-engine dinliyor",
"addr", ln.Addr().String(),
"model", cfg.Model,
"max_num_seqs", cfg.MaxNumSeqs,
"num_gpu_blocks", cfg.NumGPUBlocks,
"prefix_cache_blocks", cfg.PrefixCacheBlocks,
)
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() { errCh <- srv.Serve(ln) }()
select {
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
log.Info("kapanıyor")
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(shutCtx)
}
},
}
f := cmd.Flags()
f.StringVar(&listen, "listen", ":8000", "dinlenecek adres")
f.StringVar(&cfg.Model, "model", cfg.Model, "sunulan model adı")
f.IntVar(&cfg.MaxNumSeqs, "max-num-seqs", cfg.MaxNumSeqs, "eşzamanlı dizi sayısı (batch kapasitesi)")
f.IntVar(&cfg.BlockSize, "block-size", cfg.BlockSize, "KV blok başına token")
f.IntVar(&cfg.NumGPUBlocks, "num-gpu-blocks", cfg.NumGPUBlocks, "toplam KV cache bloğu")
f.IntVar(&cfg.PrefixCacheBlocks, "prefix-cache-blocks", cfg.PrefixCacheBlocks, "prefix cache blok kapasitesi (0=kapalı)")
f.Float64Var(&cfg.PrefillTokensPerSec, "prefill-tps", cfg.PrefillTokensPerSec, "prefill hızı (token/sn, engine geneli)")
f.Float64Var(&cfg.DecodeTokensPerSec, "decode-tps", cfg.DecodeTokensPerSec, "tek dizi decode hızı (token/sn)")
f.Float64Var(&cfg.BatchContention, "batch-contention", cfg.BatchContention, "batch'teki her ek dizinin yavaşlatma katsayısı")
f.IntVar(&cfg.DefaultOutputTokens, "default-output-tokens", cfg.DefaultOutputTokens, "max_tokens verilmezse üretilecek token")
f.DurationVar(&cfg.Tick, "tick", cfg.Tick, "simülasyon adımı")
f.Float64Var(&cfg.FailRate, "fail-rate", 0, "isteklerin yapay hata oranı (kaos testi)")
f.Int64Var(&cfg.Seed, "seed", 0, "rastgelelik tohumu (0=zaman)")
return cmd
}
+57
View File
@@ -0,0 +1,57 @@
package cli
import (
"errors"
"github.com/spf13/cobra"
)
// errNotImplemented, henüz yazılmamış fazların komutları için.
var errNotImplemented = errors.New("bu komut henüz uygulanmadı (bkz. llmgw-plan.md §11)")
func newServerCmd() *cobra.Command {
var configPath string
cmd := &cobra.Command{
Use: "server",
Short: "Router + control plane (Faz 1-3)",
RunE: func(_ *cobra.Command, _ []string) error {
return errNotImplemented
},
}
cmd.Flags().StringVar(&configPath, "config", "server.yaml", "konfigürasyon dosyası")
return cmd
}
func newAgentCmd() *cobra.Command {
var configPath string
cmd := &cobra.Command{
Use: "agent",
Short: "GPU node'unda çalışan agent (Faz 2)",
RunE: func(_ *cobra.Command, _ []string) error {
return errNotImplemented
},
}
cmd.Flags().StringVar(&configPath, "config", "agent.yaml", "konfigürasyon dosyası")
return cmd
}
func newCtlCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "ctl",
Short: "Yönetim CLI'ı (Faz 3)",
}
for _, sub := range []struct{ use, short string }{
{"nodes", "node'ları listele"},
{"models", "model kataloğunu göster"},
{"replicas", "replikaları listele"},
{"explain-placement", "bir modelin neden yerleştirilemediğini açıkla"},
{"bench", "profil kapasitesini ölç (§10)"},
} {
cmd.AddCommand(&cobra.Command{
Use: sub.use,
Short: sub.short,
RunE: func(_ *cobra.Command, _ []string) error { return errNotImplemented },
})
}
return cmd
}
+41
View File
@@ -0,0 +1,41 @@
// Package obs, log ve metrik kurulumunu tek yerden yapar.
package obs
import (
"log/slog"
"os"
"strings"
)
// Options, log kurulumunu belirler.
type Options struct {
Level string // debug, info, warn, error
Format string // text, json
}
// Setup, slog varsayılan logger'ını kurar ve onu döner.
func Setup(o Options) *slog.Logger {
var lvl slog.Level
switch strings.ToLower(o.Level) {
case "debug":
lvl = slog.LevelDebug
case "warn":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
hopts := &slog.HandlerOptions{Level: lvl}
var h slog.Handler
if strings.EqualFold(o.Format, "text") {
h = slog.NewTextHandler(os.Stderr, hopts)
} else {
h = slog.NewJSONHandler(os.Stderr, hopts)
}
l := slog.New(h)
slog.SetDefault(l)
return l
}
+128
View File
@@ -0,0 +1,128 @@
package sim
import (
"container/list"
"encoding/binary"
"hash/fnv"
)
// BlockHash, KV cache'in muhasebe birimi olan bir bloğun kimliği.
type BlockHash uint64
// BlockHashes, token dizisi için zincirlenmiş blok hash'leri üretir.
//
// Zincirleme (h_i = hash(h_{i-1}, block_i)) vLLM'in yaptığının aynısıdır ve
// şart: blok kimliği sadece kendi içeriğini değil, kendisinden önce gelen tüm
// blokları da temsil etmeli. Aksi halde "aynı cümleyi farklı bağlamda içeren"
// iki istek yanlışlıkla cache hit sayılır.
//
// Son eksik blok yok sayılır — yarım blok cache'lenmez.
func BlockHashes(tokens []Token, blockSize int) []BlockHash {
if blockSize <= 0 || len(tokens) < blockSize {
return nil
}
n := len(tokens) / blockSize
out := make([]BlockHash, 0, n)
var prev BlockHash
buf := make([]byte, 8)
for i := range n {
h := fnv.New64a()
binary.LittleEndian.PutUint64(buf, uint64(prev))
_, _ = h.Write(buf)
for _, t := range tokens[i*blockSize : (i+1)*blockSize] {
binary.LittleEndian.PutUint32(buf[:4], uint32(t))
_, _ = h.Write(buf[:4])
}
prev = BlockHash(h.Sum64())
out = append(out, prev)
}
return out
}
// PrefixCache, engine'in otomatik prefix cache'ini modelleyen blok bazlı LRU.
//
// Gerçek engine'de bu bir radix tree'dir; ama yönlendirme kalitesini ölçmek için
// önemli olan davranış "hangi blokların hâlâ cache'te olduğu"dur ve onu LRU
// doğru modeller.
type PrefixCache struct {
capacity int // blok cinsinden
ll *list.List
m map[BlockHash]*list.Element
queries int64
hits int64
}
// NewPrefixCache, verilen blok kapasitesinde bir cache kurar.
// capacity <= 0 ise cache devre dışıdır (her zaman miss).
func NewPrefixCache(capacity int) *PrefixCache {
return &PrefixCache{
capacity: capacity,
ll: list.New(),
m: make(map[BlockHash]*list.Element),
}
}
// Enabled, cache'in açık olup olmadığını söyler.
func (c *PrefixCache) Enabled() bool { return c != nil && c.capacity > 0 }
// Lookup, hashes dizisinin cache'te bulunan **kesintisiz ön ek** uzunluğunu
// blok cinsinden döner ve bulunan blokları LRU'da tazeler.
//
// Kesintisiz olması önemli: ortadaki bir blok düşmüşse ondan sonrası da
// yeniden hesaplanmak zorundadır, çünkü attention önceki tüm bloklara bağlıdır.
func (c *PrefixCache) Lookup(hashes []BlockHash) int {
if !c.Enabled() {
return 0
}
c.queries += int64(len(hashes))
n := 0
for _, h := range hashes {
el, ok := c.m[h]
if !ok {
break
}
c.ll.MoveToFront(el)
n++
}
c.hits += int64(n)
return n
}
// Insert, blokları cache'e yazar (LRU'nun başına) ve kapasiteyi aşarsa en eskileri atar.
func (c *PrefixCache) Insert(hashes []BlockHash) {
if !c.Enabled() {
return
}
for _, h := range hashes {
if el, ok := c.m[h]; ok {
c.ll.MoveToFront(el)
continue
}
c.m[h] = c.ll.PushFront(h)
}
for c.ll.Len() > c.capacity {
el := c.ll.Back()
if el == nil {
break
}
c.ll.Remove(el)
delete(c.m, el.Value.(BlockHash))
}
}
// Stats, cache sorgu ve isabet sayılarını döner (blok cinsinden).
func (c *PrefixCache) Stats() (queries, hits int64) {
if c == nil {
return 0, 0
}
return c.queries, c.hits
}
// Len, cache'teki blok sayısı.
func (c *PrefixCache) Len() int {
if !c.Enabled() {
return 0
}
return c.ll.Len()
}
+77
View File
@@ -0,0 +1,77 @@
package sim
import "testing"
func TestBlockHashesZincirleme(t *testing.T) {
// Aynı içerik farklı ön ekten sonra geliyorsa blok hash'i farklı olmalı.
a := BlockHashes(Tokenize("AAAAAAAABBBBBBBB"), 2)
b := BlockHashes(Tokenize("CCCCCCCCBBBBBBBB"), 2)
if len(a) != 2 || len(b) != 2 {
t.Fatalf("beklenen 2 blok, alınan a=%d b=%d", len(a), len(b))
}
if a[1] == b[1] {
t.Error("farklı ön ekten sonra gelen aynı blok aynı hash'i verdi; zincirleme çalışmıyor")
}
}
func TestBlockHashesOnEkKararli(t *testing.T) {
// Blok sınırına hizalı bir ön ek, uzun metinde aynı blok hash'lerini vermeli.
// Hizalama şart: son yarım token farklı olursa onu içeren blok da farklı
// çıkar — gerçek tokenizer'larda da olan sınır etkisi, bu yüzden prefix
// cache hit'i her zaman tam blok cinsinden ölçülür.
const aligned = "merhaba " // 8 bayt = 2 token = blockSize 2 ile 1 tam blok
short := BlockHashes(Tokenize(aligned), 2)
long := BlockHashes(Tokenize(aligned+"dunya nasilsin"), 2)
if len(short) != 1 {
t.Fatalf("beklenen 1 blok, alınan %d", len(short))
}
if short[0] != long[0] {
t.Fatal("hizalı ön ek blok hash'i değişti; ön ek kararlı değil")
}
}
func TestBlockHashesYarimBlokAtilir(t *testing.T) {
// 3 token, blockSize 2 -> 1 tam blok
if got := len(BlockHashes(make([]Token, 3), 2)); got != 1 {
t.Errorf("beklenen 1 blok, alınan %d", got)
}
if got := len(BlockHashes(make([]Token, 1), 2)); got != 0 {
t.Errorf("tam blok yokken blok üretildi: %d", got)
}
}
func TestPrefixCacheKesintisizOnEk(t *testing.T) {
c := NewPrefixCache(10)
h := []BlockHash{1, 2, 3, 4}
c.Insert(h)
if n := c.Lookup(h); n != 4 {
t.Fatalf("tam eşleşme beklendi, alınan %d", n)
}
// Ortası eksik: ilk blok hit, sonrası miss olmalı.
if n := c.Lookup([]BlockHash{1, 99, 3}); n != 1 {
t.Errorf("kesintisiz ön ek 1 olmalıydı, alınan %d", n)
}
}
func TestPrefixCacheLRUTahliye(t *testing.T) {
c := NewPrefixCache(2)
c.Insert([]BlockHash{1, 2})
c.Lookup([]BlockHash{1}) // 1'i tazele
c.Insert([]BlockHash{3}) // kapasite aşıldı, en eski (2) düşmeli
if n := c.Lookup([]BlockHash{1}); n != 1 {
t.Error("taze blok tahliye edildi")
}
if n := c.Lookup([]BlockHash{2}); n != 0 {
t.Error("en eski blok tahliye edilmeliydi")
}
}
func TestPrefixCacheKapali(t *testing.T) {
c := NewPrefixCache(0)
c.Insert([]BlockHash{1, 2})
if n := c.Lookup([]BlockHash{1}); n != 0 {
t.Errorf("kapalı cache hit verdi: %d", n)
}
}
+510
View File
@@ -0,0 +1,510 @@
// Package sim, OpenAI uyumlu sahte bir inference engine'i barındırır.
//
// Amacı router'ı GPU olmadan gerçekçi koşullarda test edebilmek. Gerçek bir
// engine'in router açısından önemli olan davranışlarını modeller:
// - continuous batching (sınırlı eşzamanlı dizi sayısı)
// - KV cache blok muhasebesi ve blok yetmediğinde kuyruğa alma
// - otomatik prefix cache (blok bazlı LRU) ve cache hit'in TTFT'ye etkisi
// - batch büyüdükçe token başına gecikmenin artması
// - vLLM formatında /metrics
package sim
import (
"context"
"errors"
"fmt"
"math/rand"
"sync"
"sync/atomic"
"time"
)
// Config, simüle edilen engine'in davranışını belirler.
type Config struct {
Model string
// MaxNumSeqs, aynı anda çalışabilecek dizi sayısı (continuous batch kapasitesi).
MaxNumSeqs int
// BlockSize, bir KV cache bloğundaki token sayısı.
BlockSize int
// NumGPUBlocks, toplam KV cache bloğu.
NumGPUBlocks int
// PrefixCacheBlocks, prefix cache'in tutacağı blok sayısı. 0 = kapalı.
PrefixCacheBlocks int
// PrefillTokensPerSec, engine genelinde paylaşılan prefill hızı.
PrefillTokensPerSec float64
// DecodeTokensPerSec, tek dizi çalışırken token üretim hızı.
DecodeTokensPerSec float64
// BatchContention, batch'teki her ek dizinin decode hızını ne kadar
// yavaşlattığı. Etkin hız = DecodeTokensPerSec / (1 + BatchContention*(n-1)).
BatchContention float64
// DefaultOutputTokens, istek max_tokens vermediğinde üretilecek token sayısı.
DefaultOutputTokens int
// Tick, simülasyon adımı. Küçük olması çözünürlüğü artırır.
Tick time.Duration
// FailRate, isteklerin hangi oranda 500 ile reddedileceği (kaos testi için).
FailRate float64
// Seed, deterministik testler için.
Seed int64
}
// Default, makul varsayılanlarla bir Config döner.
// Kabaca 8xH100 üstünde 70B bir modelin davranışına benzer ölçekler.
func Default() Config {
return Config{
Model: "sim-model",
MaxNumSeqs: 64,
BlockSize: 16,
NumGPUBlocks: 4096,
PrefixCacheBlocks: 8192,
PrefillTokensPerSec: 12000,
DecodeTokensPerSec: 60,
BatchContention: 0.012,
DefaultOutputTokens: 128,
Tick: 5 * time.Millisecond,
}
}
func (c *Config) normalize() {
if c.Model == "" {
c.Model = "sim-model"
}
if c.MaxNumSeqs <= 0 {
c.MaxNumSeqs = 64
}
if c.BlockSize <= 0 {
c.BlockSize = 16
}
if c.NumGPUBlocks <= 0 {
c.NumGPUBlocks = 4096
}
if c.PrefillTokensPerSec <= 0 {
c.PrefillTokensPerSec = 12000
}
if c.DecodeTokensPerSec <= 0 {
c.DecodeTokensPerSec = 60
}
if c.DefaultOutputTokens <= 0 {
c.DefaultOutputTokens = 128
}
if c.Tick <= 0 {
c.Tick = 5 * time.Millisecond
}
}
// EventKind, akıştaki olay türü.
type EventKind int
const (
// EventToken, üretilen bir token.
EventToken EventKind = iota
// EventDone, üretimin normal bitişi.
EventDone
// EventError, üretim sırasında oluşan hata.
EventError
)
// Event, engine'in istemciye ilettiği tek bir olay.
type Event struct {
Kind EventKind
Text string
Err error
// Result yalnızca EventDone'da doludur; prompt/cache/completion sayılarını taşır.
Result *Result
}
// Prompt, engine'e verilen istek.
type Prompt struct {
Text string
MaxTokens int
}
// Result, istek bittiğinde okunabilen özet (Done olayından sonra geçerli).
type Result struct {
PromptTokens int
CachedTokens int
CompletionTokens int
}
var (
// ErrTooLong, isteğin KV cache'e hiç sığmadığını belirtir.
ErrTooLong = errors.New("istek KV cache kapasitesine sığmıyor")
// ErrInjected, FailRate ile üretilen yapay hata.
ErrInjected = errors.New("yapay hata (sim fail_rate)")
// ErrShutdown, engine kapanırken bekleyen istekler için.
ErrShutdown = errors.New("engine kapanıyor")
)
type req struct {
id string
ctx context.Context
prompt Prompt
promptTokens int
cachedTokens int
outputTokens int
hashes []BlockHash
blocks int
prefillLeft float64
decodeLeft int
decodeAccum float64
emitted int
arrived time.Time
firstAt time.Time
lastTokAt time.Time
out chan Event
pending []Event
finished bool
res *Result
}
// flush, biriken olayları bloke olmadan kanala aktarır.
// Bloke olmamak şart: tick döngüsü kilidi tutarken yavaş bir istemci
// tüm engine'i durdurmamalı.
func (r *req) flush() {
for len(r.pending) > 0 {
select {
case r.out <- r.pending[0]:
r.pending = r.pending[1:]
default:
return
}
}
}
func (r *req) emit(ev Event) {
r.pending = append(r.pending, ev)
r.flush()
}
// Engine, simüle edilen inference engine.
type Engine struct {
cfg Config
mu sync.Mutex
waiting []*req
running []*req
draining []*req
freeBlocks int
cache *PrefixCache
rng *rand.Rand
seq atomic.Uint64
m *metrics
stopOnce sync.Once
stop chan struct{}
done chan struct{}
}
// New, verilen konfigürasyonla bir engine kurar ve simülasyon döngüsünü başlatır.
// Kapatmak için Close çağrılmalı.
func New(cfg Config) *Engine {
cfg.normalize()
seed := cfg.Seed
if seed == 0 {
seed = time.Now().UnixNano()
}
e := &Engine{
cfg: cfg,
freeBlocks: cfg.NumGPUBlocks,
cache: NewPrefixCache(cfg.PrefixCacheBlocks),
rng: rand.New(rand.NewSource(seed)), //nolint:gosec // simülasyon
m: newMetrics(),
stop: make(chan struct{}),
done: make(chan struct{}),
}
go e.run()
return e
}
// Config, engine'in aktif konfigürasyonunu döner.
func (e *Engine) Config() Config { return e.cfg }
// Close, simülasyon döngüsünü durdurur ve bekleyen istekleri hataya düşürür.
func (e *Engine) Close() {
e.stopOnce.Do(func() {
close(e.stop)
<-e.done
})
}
// Submit, isteği kuyruğa alır ve olay kanalını döner.
// Kanal, üretim bitince veya hata oluşunca kapanır.
func (e *Engine) Submit(ctx context.Context, p Prompt) (<-chan Event, error) {
if e.cfg.FailRate > 0 {
e.mu.Lock()
fail := e.rng.Float64() < e.cfg.FailRate
e.mu.Unlock()
if fail {
e.m.failed.Add(1)
return nil, ErrInjected
}
}
tokens := Tokenize(p.Text)
out := p.MaxTokens
if out <= 0 {
out = e.cfg.DefaultOutputTokens
}
r := &req{
id: fmt.Sprintf("sim-%d", e.seq.Add(1)),
ctx: ctx,
prompt: p,
promptTokens: len(tokens),
outputTokens: out,
hashes: BlockHashes(tokens, e.cfg.BlockSize),
arrived: time.Now(),
out: make(chan Event, 64),
}
if e.blocksFor(r.promptTokens+r.outputTokens) > e.cfg.NumGPUBlocks {
e.m.failed.Add(1)
return nil, ErrTooLong
}
e.mu.Lock()
select {
case <-e.stop:
e.mu.Unlock()
return nil, ErrShutdown
default:
}
e.waiting = append(e.waiting, r)
e.mu.Unlock()
e.m.received.Add(1)
return r.out, nil
}
func (e *Engine) blocksFor(tokens int) int {
return (tokens + e.cfg.BlockSize - 1) / e.cfg.BlockSize
}
func (e *Engine) run() {
defer close(e.done)
t := time.NewTicker(e.cfg.Tick)
defer t.Stop()
dt := e.cfg.Tick.Seconds()
for {
select {
case <-e.stop:
e.shutdown()
return
case <-t.C:
e.mu.Lock()
e.reap()
e.schedule()
e.advance(dt)
e.flushAll()
e.mu.Unlock()
}
}
}
// reap, istemcisi vazgeçmiş istekleri hem kuyruktan hem batch'ten düşürür.
// Router'ın iptal propagasyonunu test edebilmek için bu davranış şart:
// istemci koptuğunda GPU boşa çalışmamalı.
func (e *Engine) reap() {
e.waiting = filterInPlace(e.waiting, func(r *req) bool {
if r.ctx.Err() == nil {
return true
}
e.m.cancelled.Add(1)
close(r.out)
return false
})
e.running = filterInPlace(e.running, func(r *req) bool {
if r.ctx.Err() == nil {
return true
}
e.freeBlocks += r.blocks
e.m.cancelled.Add(1)
close(r.out)
return false
})
}
func (e *Engine) schedule() {
for len(e.waiting) > 0 && len(e.running) < e.cfg.MaxNumSeqs {
r := e.waiting[0]
need := e.blocksFor(r.promptTokens + r.outputTokens)
if need > e.freeBlocks {
// Head-of-line blocking: gerçek engine de böyle davranır.
break
}
hitBlocks := e.cache.Lookup(r.hashes)
r.cachedTokens = hitBlocks * e.cfg.BlockSize
r.blocks = need
r.prefillLeft = float64(r.promptTokens - r.cachedTokens)
if r.prefillLeft < 0 {
r.prefillLeft = 0
}
r.decodeLeft = r.outputTokens
e.freeBlocks -= need
e.running = append(e.running, r)
e.waiting = e.waiting[1:]
}
}
func (e *Engine) advance(dt float64) {
if len(e.running) == 0 {
return
}
// Prefill bütçesi engine genelinde paylaşılır ve FIFO dağıtılır.
budget := e.cfg.PrefillTokensPerSec * dt
for _, r := range e.running {
if budget <= 0 {
break
}
if r.prefillLeft <= 0 {
continue
}
used := min(budget, r.prefillLeft)
r.prefillLeft -= used
budget -= used
}
// Decode hızı batch büyüdükçe düşer.
n := 0
for _, r := range e.running {
if r.prefillLeft <= 0 {
n++
}
}
if n == 0 {
return
}
rate := e.cfg.DecodeTokensPerSec / (1 + e.cfg.BatchContention*float64(n-1))
now := time.Now()
for _, r := range e.running {
if r.prefillLeft > 0 || r.finished {
continue
}
r.decodeAccum += rate * dt
for r.decodeAccum >= 1 && r.decodeLeft > 0 {
r.decodeAccum--
r.decodeLeft--
r.emitted++
if r.firstAt.IsZero() {
r.firstAt = now
e.m.observeTTFT(now.Sub(r.arrived))
} else {
e.m.observeTPOT(now.Sub(r.lastTokAt))
}
r.lastTokAt = now
r.emit(Event{Kind: EventToken, Text: tokenText(r.emitted)})
}
if r.decodeLeft == 0 {
e.finish(r)
}
}
e.running = filterInPlace(e.running, func(r *req) bool { return !r.finished })
}
func (e *Engine) finish(r *req) {
r.finished = true
e.freeBlocks += r.blocks
// Üretilen çıktı da cache'e girer: bir sonraki turda konuşma geçmişi
// olarak geri geldiğinde hit vermeli. Çok turlu sohbeti modellemenin
// tek yolu bu.
full := Tokenize(r.prompt.Text + generatedText(r.emitted))
e.cache.Insert(BlockHashes(full, e.cfg.BlockSize))
r.res = &Result{
PromptTokens: r.promptTokens,
CachedTokens: r.cachedTokens,
CompletionTokens: r.emitted,
}
e.m.finished.Add(1)
e.m.promptTokens.Add(int64(r.promptTokens))
e.m.genTokens.Add(int64(r.emitted))
r.emit(Event{Kind: EventDone, Result: r.res})
e.draining = append(e.draining, r)
}
func (e *Engine) flushAll() {
for _, r := range e.running {
r.flush()
}
e.draining = filterInPlace(e.draining, func(r *req) bool {
r.flush()
if len(r.pending) == 0 {
close(r.out)
return false
}
return true
})
}
func (e *Engine) shutdown() {
e.mu.Lock()
defer e.mu.Unlock()
for _, r := range append(e.waiting, e.running...) {
select {
case r.out <- Event{Kind: EventError, Err: ErrShutdown}:
default:
}
close(r.out)
}
for _, r := range e.draining {
close(r.out)
}
e.waiting, e.running, e.draining = nil, nil, nil
}
// Stats, router'ın scrape edeceği anlık durumu döner.
func (e *Engine) Stats() Stats {
e.mu.Lock()
defer e.mu.Unlock()
used := e.cfg.NumGPUBlocks - e.freeBlocks
q, h := e.cache.Stats()
return Stats{
Running: len(e.running),
Waiting: len(e.waiting),
KVCacheUsage: float64(used) / float64(e.cfg.NumGPUBlocks),
BlockSize: e.cfg.BlockSize,
NumGPUBlocks: e.cfg.NumGPUBlocks,
PrefixQueries: q,
PrefixHits: h,
CachedBlocks: e.cache.Len(),
}
}
// Stats, engine'in anlık durumu.
type Stats struct {
Running int
Waiting int
KVCacheUsage float64
BlockSize int
NumGPUBlocks int
PrefixQueries int64
PrefixHits int64
CachedBlocks int
}
func filterInPlace(s []*req, keep func(*req) bool) []*req {
out := s[:0]
for _, r := range s {
if keep(r) {
out = append(out, r)
}
}
return out
}
+247
View File
@@ -0,0 +1,247 @@
package sim
import (
"context"
"strings"
"testing"
"time"
)
// testConfig, testlerin hızlı ve deterministik koşması için hızlandırılmış ayarlar.
func testConfig() Config {
c := Default()
c.Tick = time.Millisecond
c.PrefillTokensPerSec = 1e6
c.DecodeTokensPerSec = 5000
c.BatchContention = 0
c.Seed = 42
return c
}
// drain, kanaldaki tüm token'ları toplar ve bitişi bekler.
func drain(t *testing.T, ch <-chan Event) (text string, done bool) {
t.Helper()
var b strings.Builder
deadline := time.After(10 * time.Second)
for {
select {
case ev, open := <-ch:
if !open {
return b.String(), done
}
switch ev.Kind {
case EventToken:
b.WriteString(ev.Text)
case EventDone:
done = true
case EventError:
t.Fatalf("beklenmeyen hata: %v", ev.Err)
}
case <-deadline:
t.Fatal("zaman aşımı: engine token üretmedi")
}
}
}
func TestEngineTemelUretim(t *testing.T) {
e := New(testConfig())
defer e.Close()
ch, err := e.Submit(t.Context(), Prompt{Text: "merhaba dunya", MaxTokens: 10})
if err != nil {
t.Fatal(err)
}
text, done := drain(t, ch)
if !done {
t.Error("EventDone gelmedi")
}
if got := len(strings.Fields(text)); got != 10 {
t.Errorf("beklenen 10 token, alınan %d (%q)", got, text)
}
}
func TestEngineIptalBloklariSerbestBirakir(t *testing.T) {
// Router'ın iptal propagasyonunu test edebilmesi için kritik davranış:
// istemci koptuğunda engine isteği düşürmeli ve KV bloklarını bırakmalı.
cfg := testConfig()
cfg.DecodeTokensPerSec = 20 // uzun sürsün ki iptal ederken çalışıyor olsun
e := New(cfg)
defer e.Close()
ctx, cancel := context.WithCancel(context.Background())
ch, err := e.Submit(ctx, Prompt{Text: strings.Repeat("x", 4000), MaxTokens: 1000})
if err != nil {
t.Fatal(err)
}
waitFor(t, time.Second, func() bool { return e.Stats().Running == 1 })
if usage := e.Stats().KVCacheUsage; usage <= 0 {
t.Fatal("çalışan istek KV bloğu ayırmadı")
}
cancel()
waitFor(t, 2*time.Second, func() bool {
s := e.Stats()
return s.Running == 0 && s.KVCacheUsage == 0
})
// Kanal kapanmalı ki istemci tarafı bloke kalmasın.
select {
case _, open := <-ch:
if open {
// token kalmış olabilir; kapanana kadar tüket
for range ch { //nolint:revive // kanalı boşalt
}
}
case <-time.After(2 * time.Second):
t.Fatal("iptal sonrası kanal kapanmadı")
}
}
func TestEngineBatchDolunkaKuyrugaAlir(t *testing.T) {
cfg := testConfig()
cfg.MaxNumSeqs = 2
cfg.DecodeTokensPerSec = 20
e := New(cfg)
defer e.Close()
for range 5 {
if _, err := e.Submit(t.Context(), Prompt{Text: "abc", MaxTokens: 100}); err != nil {
t.Fatal(err)
}
}
waitFor(t, time.Second, func() bool { return e.Stats().Running == 2 })
s := e.Stats()
if s.Running != 2 {
t.Errorf("running %d, MaxNumSeqs=2 aşıldı", s.Running)
}
if s.Waiting != 3 {
t.Errorf("waiting %d, beklenen 3", s.Waiting)
}
}
func TestEngineKVBlogunaSigmayanIstekReddedilir(t *testing.T) {
cfg := testConfig()
cfg.NumGPUBlocks = 4
cfg.BlockSize = 16 // toplam 64 token kapasite
e := New(cfg)
defer e.Close()
_, err := e.Submit(t.Context(), Prompt{Text: strings.Repeat("x", 10000), MaxTokens: 100})
if err != ErrTooLong {
t.Errorf("ErrTooLong beklendi, alınan %v", err)
}
}
func TestEnginePrefixCacheIkinciIstegiHizlandirir(t *testing.T) {
cfg := testConfig()
cfg.PrefillTokensPerSec = 2000 // prefill maliyeti ölçülebilir olsun
cfg.DecodeTokensPerSec = 5000
e := New(cfg)
defer e.Close()
prompt := Prompt{Text: strings.Repeat("ortak sistem promptu. ", 200), MaxTokens: 5}
first := timeToFirstToken(t, e, prompt)
// İlk istek bitip cache'e yazıldıktan sonra aynısını tekrar gönder.
second := timeToFirstToken(t, e, prompt)
if second >= first {
t.Errorf("prefix cache TTFT'yi düşürmedi: ilk=%v ikinci=%v", first, second)
}
s := e.Stats()
if s.PrefixHits == 0 {
t.Error("prefix cache hit sayacı artmadı")
}
}
func TestEnginePrefixCacheKapaliykenHitYok(t *testing.T) {
cfg := testConfig()
cfg.PrefixCacheBlocks = 0
e := New(cfg)
defer e.Close()
p := Prompt{Text: strings.Repeat("ayni prompt. ", 50), MaxTokens: 3}
for range 3 {
ch, err := e.Submit(t.Context(), p)
if err != nil {
t.Fatal(err)
}
drain(t, ch)
}
if h := e.Stats().PrefixHits; h != 0 {
t.Errorf("cache kapalıyken %d hit raporlandı", h)
}
}
func TestEngineKapanisBekleyenleriBosaltir(t *testing.T) {
cfg := testConfig()
cfg.DecodeTokensPerSec = 1
e := New(cfg)
ch, err := e.Submit(t.Context(), Prompt{Text: "abc", MaxTokens: 1000})
if err != nil {
t.Fatal(err)
}
waitFor(t, time.Second, func() bool { return e.Stats().Running == 1 })
e.Close()
deadline := time.After(2 * time.Second)
for {
select {
case _, open := <-ch:
if !open {
return
}
case <-deadline:
t.Fatal("kapanışta kanal kapanmadı")
}
}
}
func TestEngineYapayHata(t *testing.T) {
cfg := testConfig()
cfg.FailRate = 1.0
e := New(cfg)
defer e.Close()
if _, err := e.Submit(t.Context(), Prompt{Text: "abc"}); err != ErrInjected {
t.Errorf("ErrInjected beklendi, alınan %v", err)
}
}
// --- yardımcılar ---
func timeToFirstToken(t *testing.T, e *Engine, p Prompt) time.Duration {
t.Helper()
start := time.Now()
ch, err := e.Submit(t.Context(), p)
if err != nil {
t.Fatal(err)
}
var ttft time.Duration
for ev := range ch {
if ev.Kind == EventToken && ttft == 0 {
ttft = time.Since(start)
}
}
if ttft == 0 {
t.Fatal("hiç token üretilmedi")
}
return ttft
}
func waitFor(t *testing.T, limit time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(limit)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("koşul %v içinde sağlanmadı", limit)
}
+142
View File
@@ -0,0 +1,142 @@
package sim
import (
"fmt"
"io"
"sync"
"sync/atomic"
"time"
)
// ttftBuckets ve tpotBuckets, vLLM'in kullandıklarına yakın saniye sınırları.
var (
ttftBuckets = []float64{0.001, 0.005, 0.01, 0.02, 0.04, 0.06, 0.08, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10, 20, 40, 80}
tpotBuckets = []float64{0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.75, 1, 2.5}
)
// histogram, Prometheus uyumlu basit bir kümülatif histogram.
type histogram struct {
bounds []float64
counts []uint64
sum float64
count uint64
}
func newHistogram(bounds []float64) *histogram {
return &histogram{bounds: bounds, counts: make([]uint64, len(bounds))}
}
func (h *histogram) observe(v float64) {
h.sum += v
h.count++
for i, b := range h.bounds {
if v <= b {
h.counts[i]++
}
}
}
// write, histogramı Prometheus text formatında yazar (kümülatif le bucket'ları).
func (h *histogram) write(w io.Writer, name, labels string) {
var cum uint64
for i, b := range h.bounds {
cum = h.counts[i]
fmt.Fprintf(w, "%s_bucket{%s,le=\"%g\"} %d\n", name, labels, b, cum)
}
fmt.Fprintf(w, "%s_bucket{%s,le=\"+Inf\"} %d\n", name, labels, h.count)
fmt.Fprintf(w, "%s_sum{%s} %g\n", name, labels, h.sum)
fmt.Fprintf(w, "%s_count{%s} %d\n", name, labels, h.count)
}
type metrics struct {
received atomic.Int64
finished atomic.Int64
failed atomic.Int64
cancelled atomic.Int64
promptTokens atomic.Int64
genTokens atomic.Int64
mu sync.Mutex
ttft *histogram
tpot *histogram
}
func newMetrics() *metrics {
return &metrics{
ttft: newHistogram(ttftBuckets),
tpot: newHistogram(tpotBuckets),
}
}
func (m *metrics) observeTTFT(d time.Duration) {
m.mu.Lock()
m.ttft.observe(d.Seconds())
m.mu.Unlock()
}
func (m *metrics) observeTPOT(d time.Duration) {
m.mu.Lock()
m.tpot.observe(d.Seconds())
m.mu.Unlock()
}
// WriteMetrics, engine durumunu vLLM'in metrik adlarıyla Prometheus text
// formatında yazar.
//
// Metrik adları §7.6'daki engine kontratıdır; router bunlara göre parse eder.
// Gerçek vLLM sürümleriyle aynı adları kullanmak şart — sim-engine'in varlık
// sebebi router'ın gerçek engine'e karşı davranışını taklit etmek.
func (e *Engine) WriteMetrics(w io.Writer) {
s := e.Stats()
lbl := fmt.Sprintf("model_name=%q", e.cfg.Model)
fmt.Fprintf(w, "# HELP vllm:num_requests_running Number of requests currently running on GPU.\n")
fmt.Fprintf(w, "# TYPE vllm:num_requests_running gauge\n")
fmt.Fprintf(w, "vllm:num_requests_running{%s} %d\n", lbl, s.Running)
fmt.Fprintf(w, "# HELP vllm:num_requests_waiting Number of requests waiting to be processed.\n")
fmt.Fprintf(w, "# TYPE vllm:num_requests_waiting gauge\n")
fmt.Fprintf(w, "vllm:num_requests_waiting{%s} %d\n", lbl, s.Waiting)
fmt.Fprintf(w, "# HELP vllm:kv_cache_usage_perc KV-cache usage. 1 means 100 percent usage.\n")
fmt.Fprintf(w, "# TYPE vllm:kv_cache_usage_perc gauge\n")
fmt.Fprintf(w, "vllm:kv_cache_usage_perc{%s} %g\n", lbl, s.KVCacheUsage)
fmt.Fprintf(w, "# HELP vllm:cache_config_info Information of the LLMEngine CacheConfig.\n")
fmt.Fprintf(w, "# TYPE vllm:cache_config_info gauge\n")
fmt.Fprintf(w, "vllm:cache_config_info{block_size=\"%d\",num_gpu_blocks=\"%d\"} 1\n", s.BlockSize, s.NumGPUBlocks)
fmt.Fprintf(w, "# HELP vllm:prefix_cache_queries_total Prefix cache queries, in terms of number of queried blocks.\n")
fmt.Fprintf(w, "# TYPE vllm:prefix_cache_queries_total counter\n")
fmt.Fprintf(w, "vllm:prefix_cache_queries_total{%s} %d\n", lbl, s.PrefixQueries)
fmt.Fprintf(w, "# HELP vllm:prefix_cache_hits_total Prefix cache hits, in terms of number of cached blocks.\n")
fmt.Fprintf(w, "# TYPE vllm:prefix_cache_hits_total counter\n")
fmt.Fprintf(w, "vllm:prefix_cache_hits_total{%s} %d\n", lbl, s.PrefixHits)
fmt.Fprintf(w, "# HELP vllm:prompt_tokens_total Number of prefill tokens processed.\n")
fmt.Fprintf(w, "# TYPE vllm:prompt_tokens_total counter\n")
fmt.Fprintf(w, "vllm:prompt_tokens_total{%s} %d\n", lbl, e.m.promptTokens.Load())
fmt.Fprintf(w, "# HELP vllm:generation_tokens_total Number of generation tokens processed.\n")
fmt.Fprintf(w, "# TYPE vllm:generation_tokens_total counter\n")
fmt.Fprintf(w, "vllm:generation_tokens_total{%s} %d\n", lbl, e.m.genTokens.Load())
fmt.Fprintf(w, "# HELP vllm:request_success_total Count of successfully processed requests.\n")
fmt.Fprintf(w, "# TYPE vllm:request_success_total counter\n")
fmt.Fprintf(w, "vllm:request_success_total{%s,finished_reason=\"length\"} %d\n", lbl, e.m.finished.Load())
// Simülasyona özgü (gerçek vLLM'de yok; kaos testlerinde işe yarar).
fmt.Fprintf(w, "# TYPE sim:requests_cancelled_total counter\n")
fmt.Fprintf(w, "sim:requests_cancelled_total{%s} %d\n", lbl, e.m.cancelled.Load())
fmt.Fprintf(w, "# TYPE sim:requests_failed_total counter\n")
fmt.Fprintf(w, "sim:requests_failed_total{%s} %d\n", lbl, e.m.failed.Load())
e.m.mu.Lock()
defer e.m.mu.Unlock()
fmt.Fprintf(w, "# TYPE vllm:time_to_first_token_seconds histogram\n")
e.m.ttft.write(w, "vllm:time_to_first_token_seconds", lbl)
fmt.Fprintf(w, "# TYPE vllm:time_per_output_token_seconds histogram\n")
e.m.tpot.write(w, "vllm:time_per_output_token_seconds", lbl)
}
+358
View File
@@ -0,0 +1,358 @@
package sim
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
)
// Server, Engine'i OpenAI uyumlu bir HTTP yüzeyiyle sarar.
type Server struct {
eng *Engine
log *slog.Logger
mux *http.ServeMux
}
// NewServer, engine için HTTP yüzeyini kurar.
func NewServer(eng *Engine, log *slog.Logger) *Server {
if log == nil {
log = slog.Default()
}
s := &Server{eng: eng, log: log, mux: http.NewServeMux()}
s.mux.HandleFunc("POST /v1/chat/completions", s.handleChat)
s.mux.HandleFunc("POST /v1/completions", s.handleCompletions)
s.mux.HandleFunc("GET /v1/models", s.handleModels)
s.mux.HandleFunc("GET /health", s.handleHealth)
s.mux.HandleFunc("GET /metrics", s.handleMetrics)
return s
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
// --- istek/yanıt tipleri (yalnızca gereken alanlar) ---
type streamOptions struct {
IncludeUsage bool `json:"include_usage"`
}
type chatMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
MaxTokens int `json:"max_tokens"`
MaxCompletionTokens int `json:"max_completion_tokens"`
Stream bool `json:"stream"`
StreamOptions *streamOptions `json:"stream_options"`
}
type completionRequest struct {
Model string `json:"model"`
Prompt json.RawMessage `json:"prompt"`
MaxTokens int `json:"max_tokens"`
Stream bool `json:"stream"`
StreamOptions *streamOptions `json:"stream_options"`
}
type usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
// OpenAI uyumlu cache raporlaması; prefix cache etkisini istemciye gösterir.
PromptTokensDetails struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
}
// text, mesaj içeriğini düz metne indirger.
// İçerik string ya da parça dizisi olabilir; sim için ikisini de kaba şekilde
// metne çeviriyoruz, çünkü tek ihtiyacımız deterministik bir token sayısı.
func (m chatMessage) text() string {
var s string
if err := json.Unmarshal(m.Content, &s); err == nil {
return s
}
return string(m.Content)
}
func promptFromMessages(msgs []chatMessage) string {
var b strings.Builder
for _, m := range msgs {
b.WriteString(m.Role)
b.WriteString(": ")
b.WriteString(m.text())
b.WriteString("\n")
}
return b.String()
}
// --- handler'lar ---
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
var req chatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
maxTok := req.MaxTokens
if maxTok == 0 {
maxTok = req.MaxCompletionTokens
}
includeUsage := req.StreamOptions != nil && req.StreamOptions.IncludeUsage
s.serve(w, r, servePlan{
prompt: Prompt{Text: promptFromMessages(req.Messages), MaxTokens: maxTok},
model: s.modelName(req.Model),
stream: req.Stream,
includeUsage: includeUsage,
object: "chat.completion",
chunkObject: "chat.completion.chunk",
chat: true,
})
}
func (s *Server) handleCompletions(w http.ResponseWriter, r *http.Request) {
var req completionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
var prompt string
if err := json.Unmarshal(req.Prompt, &prompt); err != nil {
prompt = string(req.Prompt)
}
includeUsage := req.StreamOptions != nil && req.StreamOptions.IncludeUsage
s.serve(w, r, servePlan{
prompt: Prompt{Text: prompt, MaxTokens: req.MaxTokens},
model: s.modelName(req.Model),
stream: req.Stream,
includeUsage: includeUsage,
object: "text_completion",
chunkObject: "text_completion",
chat: false,
})
}
func (s *Server) modelName(requested string) string {
if requested != "" {
return requested
}
return s.eng.Config().Model
}
func (s *Server) handleModels(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"object": "list",
"data": []map[string]any{{
"id": s.eng.Config().Model,
"object": "model",
"created": time.Now().Unix(),
"owned_by": "llmgw-sim",
}},
})
}
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleMetrics(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
s.eng.WriteMetrics(w)
}
// --- ortak akış ---
type servePlan struct {
prompt Prompt
model string
stream bool
includeUsage bool
object string
chunkObject string
chat bool
}
func (s *Server) serve(w http.ResponseWriter, r *http.Request, p servePlan) {
ch, err := s.eng.Submit(r.Context(), p.prompt)
if err != nil {
code := http.StatusInternalServerError
kind := "server_error"
if err == ErrTooLong {
code, kind = http.StatusBadRequest, "invalid_request_error"
}
writeError(w, code, kind, err.Error())
return
}
id := fmt.Sprintf("cmpl-sim-%d", time.Now().UnixNano())
created := time.Now().Unix()
if p.stream {
s.streamResponse(w, r, ch, p, id, created)
return
}
s.bufferedResponse(w, ch, p, id, created)
}
func (s *Server) bufferedResponse(w http.ResponseWriter, ch <-chan Event, p servePlan, id string, created int64) {
var b strings.Builder
n := 0
var res *Result
for ev := range ch {
switch ev.Kind {
case EventToken:
b.WriteString(ev.Text)
n++
case EventError:
writeError(w, http.StatusInternalServerError, "server_error", ev.Err.Error())
return
case EventDone:
res = ev.Result
}
}
u := s.usage(p.prompt, n, res)
body := map[string]any{
"id": id, "object": p.object, "created": created, "model": p.model,
"usage": u,
}
if p.chat {
body["choices"] = []map[string]any{{
"index": 0,
"message": map[string]string{"role": "assistant", "content": b.String()},
"finish_reason": "length",
}}
} else {
body["choices"] = []map[string]any{{
"index": 0, "text": b.String(), "finish_reason": "length",
}}
}
writeJSON(w, http.StatusOK, body)
}
func (s *Server) streamResponse(w http.ResponseWriter, r *http.Request, ch <-chan Event, p servePlan, id string, created int64) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "server_error", "streaming desteklenmiyor")
return
}
h := w.Header()
h.Set("Content-Type", "text/event-stream")
h.Set("Cache-Control", "no-cache")
h.Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
flusher.Flush()
n := 0
var res *Result
for {
select {
case <-r.Context().Done():
// İstemci koptu. Engine reap() ile isteği batch'ten düşürecek.
return
case ev, open := <-ch:
if !open {
if p.includeUsage {
s.writeChunk(w, chunk(id, p, created, nil, "", s.usage(p.prompt, n, res)))
flusher.Flush()
}
fmt.Fprint(w, "data: [DONE]\n\n")
flusher.Flush()
return
}
switch ev.Kind {
case EventToken:
n++
s.writeChunk(w, chunk(id, p, created, &ev.Text, "", nil))
flusher.Flush()
case EventDone:
res = ev.Result
s.writeChunk(w, chunk(id, p, created, nil, "length", nil))
flusher.Flush()
case EventError:
s.writeChunk(w, map[string]any{"error": map[string]any{
"message": ev.Err.Error(), "type": "server_error",
}})
flusher.Flush()
return
}
}
}
}
func (s *Server) writeChunk(w http.ResponseWriter, v any) {
b, err := json.Marshal(v)
if err != nil {
s.log.Error("chunk serileştirilemedi", "err", err)
return
}
fmt.Fprintf(w, "data: %s\n\n", b)
}
func chunk(id string, p servePlan, created int64, text *string, finish string, u any) map[string]any {
choice := map[string]any{"index": 0}
if p.chat {
delta := map[string]any{}
if text != nil {
delta["content"] = *text
}
choice["delta"] = delta
} else if text != nil {
choice["text"] = *text
} else {
choice["text"] = ""
}
if finish != "" {
choice["finish_reason"] = finish
} else {
choice["finish_reason"] = nil
}
out := map[string]any{
"id": id, "object": p.chunkObject, "created": created, "model": p.model,
}
// Usage chunk'ında choices boş dizi olur (OpenAI davranışı).
if u != nil {
out["choices"] = []map[string]any{}
out["usage"] = u
} else {
out["choices"] = []map[string]any{choice}
}
return out
}
// usage, mümkünse engine'in raporladığı gerçek sayıları kullanır.
// res nil olabilir (istek iptal edildiyse veya hata aldıysa); o durumda
// prompt token sayısı metinden hesaplanır ve cached_tokens bilinmez.
func (s *Server) usage(p Prompt, completion int, res *Result) usage {
var u usage
u.PromptTokens = CountTokens(p.Text)
u.CompletionTokens = completion
if res != nil {
u.PromptTokens = res.PromptTokens
u.CompletionTokens = res.CompletionTokens
u.PromptTokensDetails.CachedTokens = res.CachedTokens
}
u.TotalTokens = u.PromptTokens + u.CompletionTokens
return u
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, code int, kind, msg string) {
writeJSON(w, code, map[string]any{"error": map[string]any{
"message": msg, "type": kind, "code": nil,
}})
}
+383
View File
@@ -0,0 +1,383 @@
package sim
import (
"bufio"
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/prometheus/common/expfmt"
"github.com/prometheus/common/model"
)
func newTestServer(t *testing.T, cfg Config) *httptest.Server {
t.Helper()
eng := New(cfg)
srv := httptest.NewServer(NewServer(eng, nil))
t.Cleanup(func() {
srv.Close()
eng.Close()
})
return srv
}
func postJSON(t *testing.T, url string, body any) *http.Response {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
resp, err := http.Post(url, "application/json", bytes.NewReader(b)) //nolint:noctx // test
if err != nil {
t.Fatal(err)
}
return resp
}
func TestChatCompletionsBuffered(t *testing.T) {
srv := newTestServer(t, testConfig())
resp := postJSON(t, srv.URL+"/v1/chat/completions", map[string]any{
"model": "sim-model",
"messages": []map[string]string{{"role": "user", "content": "merhaba"}},
"max_tokens": 7,
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("durum %d", resp.StatusCode)
}
var out struct {
Object string `json:"object"`
Choices []struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage usage `json:"usage"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if out.Object != "chat.completion" {
t.Errorf("object=%q", out.Object)
}
if len(out.Choices) != 1 {
t.Fatalf("choices=%d", len(out.Choices))
}
if n := len(strings.Fields(out.Choices[0].Message.Content)); n != 7 {
t.Errorf("beklenen 7 token, alınan %d", n)
}
if out.Usage.CompletionTokens != 7 {
t.Errorf("usage.completion_tokens=%d", out.Usage.CompletionTokens)
}
if out.Usage.TotalTokens != out.Usage.PromptTokens+out.Usage.CompletionTokens {
t.Error("usage toplamı tutarsız")
}
}
// sseEvent, SSE akışından çözülmüş tek bir olay.
type sseEvent struct {
raw string
data map[string]any
}
func readSSE(t *testing.T, body *bufio.Reader) []sseEvent {
t.Helper()
var out []sseEvent
for {
line, err := body.ReadString('\n')
if err != nil {
return out
}
line = strings.TrimRight(line, "\r\n")
if !strings.HasPrefix(line, "data: ") {
continue
}
payload := strings.TrimPrefix(line, "data: ")
ev := sseEvent{raw: payload}
if payload != "[DONE]" {
if err := json.Unmarshal([]byte(payload), &ev.data); err != nil {
t.Fatalf("SSE yükü çözülemedi: %v (%q)", err, payload)
}
}
out = append(out, ev)
}
}
func TestChatCompletionsStreaming(t *testing.T) {
srv := newTestServer(t, testConfig())
resp := postJSON(t, srv.URL+"/v1/chat/completions", map[string]any{
"model": "sim-model",
"messages": []map[string]string{{"role": "user", "content": "merhaba"}},
"max_tokens": 5,
"stream": true,
"stream_options": map[string]any{"include_usage": true},
})
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
t.Errorf("Content-Type=%q", ct)
}
events := readSSE(t, bufio.NewReader(resp.Body))
if len(events) == 0 {
t.Fatal("hiç SSE olayı gelmedi")
}
if last := events[len(events)-1]; last.raw != "[DONE]" {
t.Errorf("akış [DONE] ile bitmedi: %q", last.raw)
}
var contentTokens, usageChunks, finishChunks int
for _, ev := range events {
if ev.raw == "[DONE]" {
continue
}
if _, ok := ev.data["usage"]; ok {
usageChunks++
continue
}
choices, _ := ev.data["choices"].([]any)
if len(choices) == 0 {
continue
}
c, _ := choices[0].(map[string]any)
if delta, ok := c["delta"].(map[string]any); ok {
if _, has := delta["content"]; has {
contentTokens++
}
}
if fr, ok := c["finish_reason"].(string); ok && fr == "length" {
finishChunks++
}
}
if contentTokens != 5 {
t.Errorf("beklenen 5 içerik chunk'ı, alınan %d", contentTokens)
}
if finishChunks != 1 {
t.Errorf("beklenen 1 finish_reason chunk'ı, alınan %d", finishChunks)
}
if usageChunks != 1 {
t.Errorf("include_usage istendi ama %d usage chunk'ı geldi", usageChunks)
}
}
func TestStreamingUsageIstenmezseGonderilmez(t *testing.T) {
srv := newTestServer(t, testConfig())
resp := postJSON(t, srv.URL+"/v1/chat/completions", map[string]any{
"messages": []map[string]string{{"role": "user", "content": "x"}},
"max_tokens": 2,
"stream": true,
})
defer resp.Body.Close()
for _, ev := range readSSE(t, bufio.NewReader(resp.Body)) {
if ev.raw == "[DONE]" {
continue
}
if _, ok := ev.data["usage"]; ok {
t.Error("istenmediği halde usage chunk'ı gönderildi")
}
}
}
func TestIstemciKopmasiEngineeYansir(t *testing.T) {
// Faz 1'in çıkış kriterinin temeli: istemci koptuğunda GPU boşa çalışmamalı.
cfg := testConfig()
cfg.DecodeTokensPerSec = 20
eng := New(cfg)
defer eng.Close()
srv := httptest.NewServer(NewServer(eng, nil))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
body, _ := json.Marshal(map[string]any{
"messages": []map[string]string{{"role": "user", "content": "merhaba"}},
"max_tokens": 5000,
"stream": true,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
srv.URL+"/v1/chat/completions", bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
// İlk token'ı bekle, sonra istemciyi kopar.
waitFor(t, 2*time.Second, func() bool { return eng.Stats().Running == 1 })
cancel()
_ = resp.Body.Close()
waitFor(t, 3*time.Second, func() bool {
s := eng.Stats()
return s.Running == 0 && s.KVCacheUsage == 0
})
}
func TestCompletionsUcNoktasi(t *testing.T) {
srv := newTestServer(t, testConfig())
resp := postJSON(t, srv.URL+"/v1/completions", map[string]any{
"model": "sim-model", "prompt": "merhaba", "max_tokens": 3,
})
defer resp.Body.Close()
var out struct {
Object string `json:"object"`
Choices []struct {
Text string `json:"text"`
} `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if out.Object != "text_completion" {
t.Errorf("object=%q", out.Object)
}
if n := len(strings.Fields(out.Choices[0].Text)); n != 3 {
t.Errorf("beklenen 3 token, alınan %d", n)
}
}
func TestModelsVeHealth(t *testing.T) {
cfg := testConfig()
cfg.Model = "llama-3.3-70b"
srv := newTestServer(t, cfg)
resp, err := http.Get(srv.URL + "/health") //nolint:noctx // test
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("/health durum %d", resp.StatusCode)
}
resp, err = http.Get(srv.URL + "/v1/models") //nolint:noctx // test
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if len(out.Data) != 1 || out.Data[0].ID != "llama-3.3-70b" {
t.Errorf("/v1/models beklenen modeli listelemedi: %+v", out.Data)
}
}
// TestMetriklerPrometheusIleParseEdilebilir, §7.6'daki engine kontratını doğrular.
// Router bu metrikleri gerçek bir Prometheus parser'ıyla okuyacak; format
// bozuksa yönlendirme kör kalır.
func TestMetriklerPrometheusIleParseEdilebilir(t *testing.T) {
srv := newTestServer(t, testConfig())
// Biraz trafik üret ki sayaçlar dolsun.
resp := postJSON(t, srv.URL+"/v1/chat/completions", map[string]any{
"messages": []map[string]string{{"role": "user", "content": "merhaba dunya"}},
"max_tokens": 4,
})
resp.Body.Close()
mresp, err := http.Get(srv.URL + "/metrics") //nolint:noctx // test
if err != nil {
t.Fatal(err)
}
defer mresp.Body.Close()
// TextParser'ın sıfır değeri geçersiz; şema açıkça verilmeli.
// Legacy şema daha sıkı: metrik adlarımızın klasik Prometheus kurallarına
// uyduğunu da doğrulamış oluyoruz (vLLM adlarındaki ':' legacy'de de geçerli).
parser := expfmt.NewTextParser(model.LegacyValidation)
families, err := parser.TextToMetricFamilies(mresp.Body)
if err != nil {
t.Fatalf("metrikler parse edilemedi: %v", err)
}
// §7.6 zorunlu üçlüsü.
for _, name := range []string{
"vllm:num_requests_running",
"vllm:num_requests_waiting",
"vllm:kv_cache_usage_perc",
} {
if _, ok := families[name]; !ok {
t.Errorf("zorunlu metrik eksik: %s", name)
}
}
// Opsiyoneller ve histogramlar.
for _, name := range []string{
"vllm:cache_config_info",
"vllm:prefix_cache_queries_total",
"vllm:prefix_cache_hits_total",
"vllm:time_to_first_token_seconds",
} {
if _, ok := families[name]; !ok {
t.Errorf("metrik eksik: %s", name)
}
}
if f, ok := families["vllm:generation_tokens_total"]; ok {
if got := f.Metric[0].GetCounter().GetValue(); got != 4 {
t.Errorf("generation_tokens_total=%v, beklenen 4", got)
}
} else {
t.Error("vllm:generation_tokens_total eksik")
}
}
// TestUsageCachedTokensRaporlanir, prefix cache etkisinin istemciye ulaştığını
// doğrular. Router ileride bunu kullanmayacak olsa da, cache'in gerçekten
// çalıştığını uçtan uca gösteren tek sinyal bu.
func TestUsageCachedTokensRaporlanir(t *testing.T) {
srv := newTestServer(t, testConfig())
body := map[string]any{
"messages": []map[string]string{{"role": "user", "content": strings.Repeat("ortak onek. ", 100)}},
"max_tokens": 3,
}
// İlk istek cache'i doldurur.
resp := postJSON(t, srv.URL+"/v1/chat/completions", body)
resp.Body.Close()
// İkincisi aynı ön eki kullanmalı.
resp = postJSON(t, srv.URL+"/v1/chat/completions", body)
defer resp.Body.Close()
var out struct {
Usage usage `json:"usage"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if out.Usage.PromptTokensDetails.CachedTokens == 0 {
t.Error("ikinci istekte cached_tokens 0; prefix cache istemciye raporlanmıyor")
}
if out.Usage.PromptTokensDetails.CachedTokens > out.Usage.PromptTokens {
t.Errorf("cached_tokens (%d) prompt_tokens'tan (%d) büyük",
out.Usage.PromptTokensDetails.CachedTokens, out.Usage.PromptTokens)
}
}
+26
View File
@@ -0,0 +1,26 @@
package sim
import "strings"
// simWords, üretilen sahte çıktının kelime havuzu.
var simWords = []string{
"alfa", "beta", "gama", "delta", "epsilon", "zeta", "eta", "teta",
"iota", "kappa", "lambda", "mu", "nu", "ksi", "omikron", "pi",
}
// tokenText, n'inci üretilen token'ın metnini verir (n 1'den başlar).
// Deterministik olması şart: finish sırasında cache'e yazılan metinle
// istemciye akıtılan metin birebir aynı olmalı.
func tokenText(n int) string {
return " " + simWords[(n-1)%len(simWords)]
}
// generatedText, ilk n token'ın birleşimini verir.
func generatedText(n int) string {
var b strings.Builder
b.Grow(n * 7)
for i := 1; i <= n; i++ {
b.WriteString(tokenText(i))
}
return b.String()
}
+37
View File
@@ -0,0 +1,37 @@
package sim
import "hash/fnv"
// TokensPerChar, sahte tokenizer'ın kaç bayta bir token ürettiği.
// Gerçek BPE tokenizer'lara kaba bir yaklaşım; simülasyon için tek gereken
// deterministik ve prefix-kararlı olması.
const bytesPerToken = 4
// Token, sahte tokenizer'ın ürettiği token kimliği.
type Token uint32
// Tokenize, metni deterministik ve prefix-kararlı şekilde token'lara böler.
//
// Prefix-kararlılık kritik: aynı ön eke sahip iki metnin token dizileri de aynı
// ön ekle başlamalı, yoksa prefix cache simülasyonu anlamsız olur. Bu yüzden
// bölme, metnin başından itibaren sabit bayt sayısıyla yapılır — içeriğe bağlı
// (BPE gibi) bir bölme kullanılmaz.
func Tokenize(s string) []Token {
if s == "" {
return nil
}
n := (len(s) + bytesPerToken - 1) / bytesPerToken
out := make([]Token, 0, n)
for i := 0; i < len(s); i += bytesPerToken {
end := min(i+bytesPerToken, len(s))
h := fnv.New32a()
_, _ = h.Write([]byte(s[i:end]))
out = append(out, Token(h.Sum32()))
}
return out
}
// CountTokens, metnin token sayısını tokenize etmeden verir.
func CountTokens(s string) int {
return (len(s) + bytesPerToken - 1) / bytesPerToken
}
+24
View File
@@ -0,0 +1,24 @@
package sim
import "testing"
func TestTokenizeOnEkKararli(t *testing.T) {
a := Tokenize("merhaba")
b := Tokenize("merhaba dunya")
if len(a) > len(b) {
t.Fatal("uzun metin daha az token verdi")
}
for i := range a[:len(a)-1] { // son token yarım olabilir
if a[i] != b[i] {
t.Fatalf("token %d ön ek kararlı değil", i)
}
}
}
func TestCountTokensTokenizeIleTutarli(t *testing.T) {
for _, s := range []string{"", "a", "abcd", "abcde", "uzunca bir prompt metni"} {
if got, want := CountTokens(s), len(Tokenize(s)); got != want {
t.Errorf("%q: CountTokens=%d Tokenize=%d", s, got, want)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
// Package version, build sırasında ldflags ile doldurulan sürüm bilgisini tutar.
package version
// Version, `make build` tarafından git describe çıktısıyla doldurulur.
var Version = "dev"
+1045
View File
File diff suppressed because it is too large. Load diff