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>
248 lines
5.7 KiB
Go
248 lines
5.7 KiB
Go
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)
|
||
}
|