7955a41105
The transition lines said the card was free at 11:27. They did not say which side answered the turn at 13:24, so an offloaded turn and a floor turn read the same in the log, and QA verifying the offload had nothing to read. One line per model call, naming the side, and naming why when it was the floor: the workstation was down, or it accepted and then failed mid-request. Two lines per turn, since routing and phrasing are separate calls. Silent still means silent to him. He is not told which model phrased his reply.
202 lines
6.9 KiB
Go
202 lines
6.9 KiB
Go
package llm
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// Pair — a preferred model on another host, with the resident one as the floor.
|
|
//
|
|
// homesrv cannot grow a GPU and the workstation has 16GB of VRAM, so the big
|
|
// model runs there and the resident Qwen3-1.7B stays here. See docs/offload.md.
|
|
// The workstation is never assumed up: its GPU is often busy with CPT runs and
|
|
// the manga-recap pipeline, and the machine sleeps. So the remote is preferred,
|
|
// never required, and Pair is what makes "preferred" mean something precise.
|
|
//
|
|
// This is admission control, not a scheduler. There is no arbiter deciding who
|
|
// gets the card. A prober asks the remote whether it will take work, caches the
|
|
// answer, and every request reads that cached answer in nanoseconds. Routing
|
|
// sits on the hot path at p50 825ms and must never wait on a machine that may
|
|
// be asleep, so no request ever pays for a health check itself.
|
|
//
|
|
// Pair satisfies nothing by itself. Callers pick a method by which half of the
|
|
// degradation rule they live under:
|
|
//
|
|
// - Complete falls back silently. For routing, replies, and nudge phrasing,
|
|
// where the big model is only better and the 1.7B is today's shipping
|
|
// quality. He is not told which model phrased his reply.
|
|
// - CompleteRemote returns ErrRemoteUnavailable instead of falling back. For
|
|
// a world question, or a long Kiwix or search passage, where a 1.7B
|
|
// confabulates rather than summarises. A named gap beats an invented
|
|
// answer.
|
|
type Pair struct {
|
|
remote *Client
|
|
floor *Client
|
|
|
|
// up — the cached admission answer, written only by the prober goroutine
|
|
// and read by every request. Atomic so the read costs nanoseconds and no
|
|
// request ever contends with the prober.
|
|
up atomic.Bool
|
|
|
|
health string
|
|
interval time.Duration
|
|
http *http.Client
|
|
stop chan struct{}
|
|
}
|
|
|
|
// ErrRemoteUnavailable — the workstation model was required and is not
|
|
// answering. Callers on the naming half of the degradation rule turn this into
|
|
// a gap in the reply ("не могу сейчас"), never into a guess from the floor.
|
|
var ErrRemoteUnavailable = errors.New("llm: workstation model unavailable")
|
|
|
|
// ErrNoFloor — a Pair was built with no resident model to fall back to. A
|
|
// configuration mistake: the floor is the whole point.
|
|
var ErrNoFloor = errors.New("llm: no floor client")
|
|
|
|
// NewPair builds the two-model arrangement. remote may be nil, which is the
|
|
// unconfigured deploy and must behave exactly as the box behaves today: every
|
|
// call goes to the floor and nothing probes anything.
|
|
//
|
|
// health is the URL the prober asks. llama-server's /health answers "is a model
|
|
// loaded and ready", which is the useful signal here, because llama-server
|
|
// refuses to load at all when VRAM is short. That makes a busy card detectable
|
|
// without any cooperation from the owner's other jobs.
|
|
func NewPair(remote, floor *Client, health string, interval time.Duration) *Pair {
|
|
p := &Pair{
|
|
remote: remote,
|
|
floor: floor,
|
|
health: health,
|
|
interval: interval,
|
|
http: &http.Client{Timeout: probeTimeout},
|
|
stop: make(chan struct{}),
|
|
}
|
|
return p
|
|
}
|
|
|
|
// probeTimeout — a remote that cannot answer /health this fast is not going to
|
|
// serve a turn either. Short on purpose: the prober runs on its own goroutine,
|
|
// but a slow probe still delays the moment Maven notices the card came back.
|
|
const probeTimeout = 2 * time.Second
|
|
|
|
// Start begins probing. It returns immediately, and the first probe runs before
|
|
// the first tick so a remote that is already up is used on the first turn
|
|
// rather than after one interval of falling back. Safe to call with a nil
|
|
// remote; it does nothing.
|
|
func (p *Pair) Start(ctx context.Context) {
|
|
if p.remote == nil || p.health == "" {
|
|
return
|
|
}
|
|
go func() {
|
|
p.probe(ctx)
|
|
t := time.NewTicker(p.interval)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-p.stop:
|
|
return
|
|
case <-t.C:
|
|
p.probe(ctx)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Stop ends the prober. Idempotent.
|
|
func (p *Pair) Stop() {
|
|
select {
|
|
case <-p.stop:
|
|
default:
|
|
close(p.stop)
|
|
}
|
|
}
|
|
|
|
// Available reports whether the workstation will take work right now. It reads
|
|
// a cached flag, so it is safe to call per turn on the hot path. A false answer
|
|
// is never stale in the direction that matters: the worst case is that Maven
|
|
// falls back for up to one probe interval after the card frees up.
|
|
func (p *Pair) Available() bool {
|
|
return p.remote != nil && p.up.Load()
|
|
}
|
|
|
|
func (p *Pair) probe(ctx context.Context) {
|
|
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.health, nil)
|
|
if err != nil {
|
|
p.set(false)
|
|
return
|
|
}
|
|
resp, err := p.http.Do(req)
|
|
if err != nil {
|
|
p.set(false)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
p.set(resp.StatusCode == http.StatusOK)
|
|
}
|
|
|
|
// set records the admission answer and logs only the transitions. A machine
|
|
// that sleeps every night would otherwise write one line per interval forever.
|
|
func (p *Pair) set(up bool) {
|
|
if p.up.Swap(up) == up {
|
|
return
|
|
}
|
|
if up {
|
|
log.Printf("llm: workstation model available at %s", p.health)
|
|
} else {
|
|
log.Printf("llm: workstation model unavailable, falling back to the resident model")
|
|
}
|
|
}
|
|
|
|
// Complete runs r on the workstation when it will take work, and on the
|
|
// resident model otherwise. A remote that fails mid-request falls back too: the
|
|
// admission answer is a cache and can be one interval out of date, so an error
|
|
// here is expected rather than exceptional.
|
|
//
|
|
// This is the silent half of the degradation rule: he is not told which model
|
|
// phrased his reply. The log is told, one line per call, because the seam was
|
|
// otherwise unreadable after the fact — the transition lines say the card was
|
|
// free at 11:27, not which side answered the turn at 13:24. QA had no way to
|
|
// tell an offloaded turn from a floor one.
|
|
func (p *Pair) Complete(ctx context.Context, r Req) (string, error) {
|
|
if p.floor == nil {
|
|
return "", ErrNoFloor
|
|
}
|
|
why := "workstation down"
|
|
if p.Available() {
|
|
out, err := p.remote.Complete(ctx, r)
|
|
if err == nil {
|
|
log.Print("llm: served by the workstation model")
|
|
return out, nil
|
|
}
|
|
// The cached answer was wrong. Correct it now rather than sending the
|
|
// next request into the same hole, then fall back.
|
|
p.set(false)
|
|
why = "workstation failed mid-request"
|
|
}
|
|
log.Printf("llm: served by the resident model (%s)", why)
|
|
return p.floor.Complete(ctx, r)
|
|
}
|
|
|
|
// CompleteRemote runs r on the workstation or refuses. It never falls back,
|
|
// because for a world question the resident 1.7B does not answer worse, it
|
|
// invents. Callers turn ErrRemoteUnavailable into a named gap.
|
|
func (p *Pair) CompleteRemote(ctx context.Context, r Req) (string, error) {
|
|
if !p.Available() {
|
|
return "", ErrRemoteUnavailable
|
|
}
|
|
out, err := p.remote.Complete(ctx, r)
|
|
if err != nil {
|
|
p.set(false)
|
|
return "", errors.Join(ErrRemoteUnavailable, err)
|
|
}
|
|
log.Print("llm: served by the workstation model, no floor for this caller")
|
|
return out, nil
|
|
}
|