Skip to content

Commit 5261691

Browse files
committed
feat(realtime): prewarm the classifier scoring prompt on registration
Swapping a session's classifier option list (a voice-switched command mode, for instance) made the next turns pay a full re-prefill of the new option-list prompt — measured 2.4s vs 0.3s warm on a desktop CPU, and worse: on hybrid-memory models like LFM2.5, whose state cannot be partially rewound (llama.cpp can only restore checkpoints), *every* probe change re-prefilled from scratch whenever the last checkpoint missed the probe boundary, so even same-list turns intermittently cost full prefills. Registering an option list (pipeline seed or session.update) now fires a best-effort background prewarm: two throwaway scores with distinct probes. The first prefills the new option-list prompt; the second, diverging exactly where per-turn probe text starts, plants the backend's rewind point (KV checkpoint) at the stable-prefix boundary that every real turn reuses. The prewarm hides behind the canned mode-switch reply — by the time it finishes speaking, the cache is warm. Idempotent per option set, detached from the registering request's lifetime. Measured on the drone demo (LFM2.5-1.2B, desktop CPU): first turn after a mode switch 2374ms -> 340ms; intermittent same-list full prefills (1.3-2.1s) all -> under 0.5s. For clients that swap lists frequently, options: [parallel:2] on the scoring model additionally keeps one slot per list via prefix-similarity routing (+26MB RSS, unified KV). Assisted-by: Claude:claude-fable-5 Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent f25dc5a commit 5261691

6 files changed

Lines changed: 124 additions & 0 deletions

File tree

core/http/endpoints/openai/realtime.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,11 @@ type Model interface {
280280
// pipeline's scoring model (classifier.model, defaulting to the LLM) —
281281
// no autoregressive decode happens.
282282
ClassifyTurn(ctx context.Context, messages schema.Messages, options []types.ClassifierOption, normalization string) ([]router.LabelScore, error)
283+
// PrewarmClassifier primes the scoring backend's prompt cache for a
284+
// newly registered option list (fired async on registration) so the
285+
// first turns after a session.update don't pay the option-list
286+
// prefill. Best-effort and idempotent per option set.
287+
PrewarmClassifier(ctx context.Context, options []types.ClassifierOption, normalization string)
283288
// FillToolArguments completes the chosen option's argument slots with a
284289
// short grammar-constrained completion that continues the exact scoring
285290
// prompt (so the backend's prompt cache stays warm) and returns the
@@ -616,6 +621,10 @@ func runRealtimeSession(application *application.Application, t Transport, model
616621
return
617622
}
618623
session.ModelInterface = m
624+
// A pipeline-seeded option list gets its scoring prompt prewarmed
625+
// alongside the model warm-up below, so the session's first turn
626+
// doesn't pay the option-list prefill.
627+
prewarmClassifier(session)
619628

620629
// The voice gate is built before the warm-up below so its
621630
// speaker-recognition model can warm alongside the pipeline stages.
@@ -1269,6 +1278,7 @@ func updateSession(session *Session, update *types.SessionUnion, cl *config.Mode
12691278
return err
12701279
}
12711280
session.Classifier = rt.LocalAIClassifier
1281+
prewarmClassifier(session)
12721282
}
12731283

12741284
if rt.MaxOutputTokens != 0 {

core/http/endpoints/openai/realtime_classifier.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,27 @@ func classifierConfigFromPipeline(p *config.PipelineClassifier) (*types.Classifi
8383
return cc, nil
8484
}
8585

86+
// prewarmClassifier primes the scoring prompt cache for the session's
87+
// current classifier config in the background: registration returns
88+
// immediately, and by the time the canned mode-switch reply finishes
89+
// speaking, the new option list's prompt (and, on hybrid/recurrent
90+
// models, a rewind checkpoint at the per-turn probe boundary) is already
91+
// in the backend's cache. The context is deliberately detached from the
92+
// registering request — the warmed cache belongs to the backend, not the
93+
// request.
94+
func prewarmClassifier(session *Session) {
95+
cc := session.Classifier
96+
if session.ModelInterface == nil || !cc.Active() {
97+
return
98+
}
99+
options, normalization := cc.Options, cc.Normalization
100+
go func() {
101+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
102+
defer cancel()
103+
session.ModelInterface.PrewarmClassifier(ctx, options, normalization)
104+
}()
105+
}
106+
86107
// resolveClassifier merges the session classifier config with a
87108
// response-level override: a non-nil override replaces the whole block
88109
// (same replace-not-merge semantics as tools), so {"enabled": false} runs

core/http/endpoints/openai/realtime_classifier_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,32 @@ func replyTexts(t *fakeTransport) []string {
6565
return out
6666
}
6767

68+
var _ = Describe("prewarmClassifier", func() {
69+
It("prewarms an active option list in the background", func() {
70+
m := &fakeModel{}
71+
session := classifierTestSession(m)
72+
session.Classifier = classifierTestConfig(0.35, nil)
73+
74+
prewarmClassifier(session)
75+
76+
Eventually(func() int { n, _ := m.prewarmed(); return n }).Should(Equal(1))
77+
_, opts := m.prewarmed()
78+
Expect(opts).To(HaveLen(len(session.Classifier.Options)))
79+
})
80+
81+
It("does nothing without an active classifier", func() {
82+
m := &fakeModel{}
83+
session := classifierTestSession(m)
84+
prewarmClassifier(session)
85+
86+
off := false
87+
session.Classifier = &types.ClassifierConfig{Enabled: &off, Options: classifierTestConfig(0.35, nil).Options}
88+
prewarmClassifier(session)
89+
90+
Consistently(func() int { n, _ := m.prewarmed(); return n }, "150ms").Should(BeZero())
91+
})
92+
})
93+
6894
var _ = Describe("classifierConfigFromPipeline", func() {
6995
It("returns nil for an absent block", func() {
7096
cc, err := classifierConfigFromPipeline(nil)

core/http/endpoints/openai/realtime_doubles_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package openai
33
import (
44
"context"
55
"strings"
6+
"sync"
67

78
"github.com/mudler/LocalAI/core/backend"
89
"github.com/mudler/LocalAI/core/config"
@@ -119,6 +120,12 @@ type fakeModel struct {
119120
fillCalls int
120121
lastFillChosen *types.ClassifierOption
121122

123+
// PrewarmClassifier runs on a background goroutine, so its recording
124+
// is mutex-guarded; specs poll prewarmCalls with Eventually.
125+
prewarmMu sync.Mutex
126+
prewarmCalls int
127+
lastPrewarmOptions []types.ClassifierOption
128+
122129
// VAD scripting: vadFn, when set, decides per call (specs vary the
123130
// answer across ticks or record the request); otherwise
124131
// vadSegments/vadErr answer every call.
@@ -129,6 +136,19 @@ type fakeModel struct {
129136
lastMessages schema.Messages
130137
}
131138

139+
func (m *fakeModel) PrewarmClassifier(_ context.Context, options []types.ClassifierOption, _ string) {
140+
m.prewarmMu.Lock()
141+
defer m.prewarmMu.Unlock()
142+
m.prewarmCalls++
143+
m.lastPrewarmOptions = options
144+
}
145+
146+
func (m *fakeModel) prewarmed() (int, []types.ClassifierOption) {
147+
m.prewarmMu.Lock()
148+
defer m.prewarmMu.Unlock()
149+
return m.prewarmCalls, m.lastPrewarmOptions
150+
}
151+
132152
func (m *fakeModel) FillToolArguments(_ context.Context, msgs schema.Messages, options []types.ClassifierOption, _ string, chosen *types.ClassifierOption) (string, map[string]string, error) {
133153
m.fillCalls++
134154
m.lastFillChosen = chosen

core/http/endpoints/openai/realtime_model.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"fmt"
1010
"strings"
1111
"sync"
12+
"time"
1213

1314
"github.com/mudler/LocalAI/core/application"
1415
"github.com/mudler/LocalAI/core/backend"
@@ -57,6 +58,9 @@ type wrappedModel struct {
5758
classifier *router.ScoreClassifier
5859
classifierKey string
5960
classifierWarn sync.Once
61+
// prewarmKey remembers the option set whose scoring prompt was last
62+
// prewarmed, so re-pushing an unchanged config doesn't re-score.
63+
prewarmKey string
6064

6165
// Routing — populated by newModel when the application wires routing
6266
// deps in. nil-safe: with classifierRegistry == nil the per-turn
@@ -114,6 +118,9 @@ func (m *transcriptOnlyModel) FillToolArguments(ctx context.Context, messages sc
114118
return "", nil, fmt.Errorf("classifier mode not supported in transcript-only mode")
115119
}
116120

121+
func (m *transcriptOnlyModel) PrewarmClassifier(ctx context.Context, options []types.ClassifierOption, normalization string) {
122+
}
123+
117124
func (m *transcriptOnlyModel) TTS(ctx context.Context, text, voice, language string) (string, *proto.Result, error) {
118125
return "", nil, fmt.Errorf("TTS not supported in transcript-only mode")
119126
}
@@ -488,6 +495,44 @@ func (m *wrappedModel) classifierFor(options []types.ClassifierOption, normaliza
488495
return m.classifier, nil
489496
}
490497

498+
// PrewarmClassifier primes the scoring backend's prompt cache for a newly
499+
// registered option list so the first real turns don't pay the prefill.
500+
// Two throwaway scores with distinct probes run back to back: the first
501+
// prefills the new option-list prompt, and the second — diverging exactly
502+
// where the per-turn probe text starts — leaves the backend a rewind point
503+
// (a KV checkpoint on hybrid/recurrent models, which cannot rewind
504+
// arbitrarily) at the stable-prefix boundary every subsequent turn reuses.
505+
// Best-effort: errors are logged, never surfaced.
506+
func (m *wrappedModel) PrewarmClassifier(ctx context.Context, options []types.ClassifierOption, normalization string) {
507+
classifier, err := m.classifierFor(options, normalization)
508+
if err != nil {
509+
xlog.Debug("realtime classifier: prewarm skipped", "error", err)
510+
return
511+
}
512+
m.classifierMu.Lock()
513+
key := m.classifierKey
514+
done := m.prewarmKey == key
515+
m.classifierMu.Unlock()
516+
if done {
517+
return
518+
}
519+
start := time.Now()
520+
for _, probe := range []string{"warmup", "standing by"} {
521+
if ctx.Err() != nil {
522+
return
523+
}
524+
if _, err := classifier.Classify(ctx, router.Probe{Prompt: probe, Messages: []string{probe}}); err != nil {
525+
xlog.Warn("realtime classifier: prewarm scoring failed", "error", err)
526+
return
527+
}
528+
}
529+
m.classifierMu.Lock()
530+
m.prewarmKey = key
531+
m.classifierMu.Unlock()
532+
xlog.Debug("realtime classifier: prewarmed scoring prompt cache",
533+
"options", len(options), "latency_ms", time.Since(start).Milliseconds())
534+
}
535+
491536
func (m *wrappedModel) ClassifyTurn(ctx context.Context, messages schema.Messages, options []types.ClassifierOption, normalization string) ([]router.LabelScore, error) {
492537
classifier, err := m.classifierFor(options, normalization)
493538
if err != nil {

docs/content/features/openai-realtime.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,8 @@ The option's spoken `reply` can reference the same placeholders — `reply: "Goi
246246
247247
Slot declarations (and hints) are appended to the option's description in the shared system prompt, so they also inform scoring and cost no extra per-turn tokens. The `localai.classifier.result` event carries the final `arguments` and a `fill_latency_ms`. On an inference failure the slots' defaults apply (and template the reply); if any slot lacks a default the response fails (or falls through to generation with `fallback.mode: generate`). Slot filling requires `completion` in the scoring model's `known_usecases` alongside `score`.
248248

249+
Registering an option list (pipeline seed or `session.update`) prewarms the scoring prompt in the background: two throwaway scores prefill the option-list prompt and leave the backend a reuse point at the per-turn probe boundary — on hybrid/recurrent models (which cannot rewind their state arbitrarily, only restore checkpoints) this is what keeps every turn fast, not just repeats of the same probe. A client that swaps option lists at runtime (e.g. voice-switched command modes) pays nothing on the first turn after a swap: the prewarm hides behind the acknowledgement reply. Swapping between lists frequently? `options: [parallel:2]` on the scoring model gives llama.cpp a slot per list (prefix-similarity routing picks the matching one) at a small KV memory cost.
250+
249251
The concrete scoring model must declare `score` in `known_usecases`. A single llama.cpp model can serve ordinary inference and classification concurrently by declaring multiple use cases, for example `known_usecases: [chat, completion, score]`; LocalAI reserves the scoring slots only when `score` is present. Scoring also requires the unified KV cache, which is enabled by default, so a score-enabled model cannot set `kv_unified:false`.
250252

251253
## Transports

0 commit comments

Comments
 (0)