Files
llmgw/internal/sim/engine_test.go
T
ilkerandClaude Opus 5 e07bc46c57 Faz 0: iskelet ve sim-engine
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>
2026-09-16 01:00:03 +02:00

248 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}