golem-server

command
v0.45.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 5, 2026 License: MIT Imports: 35 Imported by: 0

README

golem-server

An OpenAI-compatible API over a GGUF, on the CPU or — with -vulkan — with the model's blocks and logit head on a Vulkan device. Gemma 4, Qwen3 or Qwen3.8: the file declares its own architecture and engine/ opens whichever implements it, so this command names none of them, and tools work on all three.

go build ./cmd/golem-server
./golem-server -model gemma-4-E2B-it-QAT-Q4_0.gguf -addr 127.0.0.1:8080
./golem-server -model Qwen3-4B-Q4_0.gguf -addr 127.0.0.1:8080

One model per server, and the request's model field routes nothing: there is one set of weights, so there is nothing to route to. -model names the file; /v1/models reports it.

The endpoints:

POST /v1/chat/completions a conversation, streamed or not, tool declarations included
POST /v1/audio/transcriptions audio transcription (WAV, MP3, FLAC), streamed or not, using Kyutai STT (requires -stt)
POST /v1/embeddings OpenAI's embeddings, float or base64 (requires -embed)
POST /api/embed, POST /api/embeddings ollama's two embedding endpoints, so a client pointed at ollama needs only the port changed (requires -embed)
POST /v1/systemone typed questions about a state, answered by Laya in Jev's shape, so a Jev or laya-serve client needs only its base URL changed (requires -laya)
GET /v1/models the loaded models, for clients that probe at startup

Tools

Declare them the way the API declares them, and the calls come back the way it returns them:

curl -s localhost:8080/v1/chat/completions -H 'content-type: application/json' -d '{
  "messages": [{"role": "user", "content": "What is the weather in Lyon?"}],
  "tools": [{"type": "function", "function": {
    "name": "get_weather", "description": "Current weather in a city.",
    "parameters": {"type": "object",
      "properties": {"city": {"type": "string", "description": "The city."}},
      "required": ["city"]}}}]}'
{"choices":[{"index":0,"message":{"role":"assistant","content":"",
  "tool_calls":[{"id":"call_1","type":"function",
    "function":{"name":"get_weather","arguments":"{\"city\":\"Lyon\"}"}}]},
  "finish_reason":"tool_calls"}]}

Running the function is the client's part. Send the result back as a tool message carrying the call's tool_call_id, as the protocol has it, and the model speaks on inside the same turn — that is the template's own rule, not a choice made here.

Streamed, a call never arrives in fragments. Prose leaves as it is drawn; from the moment <|tool_call> appears the output is held back, and the whole call leaves in a single tool_calls delta once it has closed. OpenAI's API allows fragments and clients do reassemble them, but half a function's arguments is a thing nothing can check.

Reasoning

What the model thinks before it answers leaves in reasoning_content, where llama-server puts it, and never in content: Gemma's thought channel (<|channel>thought … <channel|>) and Qwen's <think> … </think> alike. Streamed, it arrives as reasoning_content deltas while it is drawn. The markers themselves never leave, nor do the newlines the template writes around them. A thought left open when the answer ends is reasoning to the end, as the template reads it back.

The server never asks for thinking, and Gemma's template closes an empty thought channel at the end of the prompt. The 12B sometimes opens one anyway; it is dropped here rather than sent to the client as prose.

Several conversations, in one pass

-parallel is how many conversations the server holds at once, and they are answered together rather than in turn. Each slot is a KV cache holding its own conversation; the context is cut rather than multiplied, as llama.cpp's --parallel cuts it, so -context 4096 -parallel 2 is two conversations of 2048 positions and the memory is what it was.

What makes them cheap together is that they go through the model together. One goroutine owns the weights, and whatever is waiting when it builds a pass goes into that pass: four conversations each wanting one token are four tokens in one read of a gigabyte of weights, rather than four reads. That is llama.cpp's continuous batching, and on this engine it is worth this much — Gemma 4 E2B on an eight-thread i7-9700K, clients asking for 64 tokens each at once:

clients tokens a second, all together
1 20.7
2 34.7
4 54.2
8 73.8

Against llama.cpp's own server on the same machine and the same file (llama-server -dev none -ngl 0 -t 8 -c 4096 -np 4 -cb), three rounds, a different prompt every round so that neither server is answering out of a cache it filled a moment ago:

clients golem llama-server golem ÷ llama-server
1 20.7 t/s 20.3 ×1.02
2 34.7 33.1 ×1.05
4 54.2 60.5 ×0.90
8 73.8 72.9 ×1.01

Where the four-client deficit is, exactly: GOLEM_DEBUG_BATCHES=1 over that run says the batches are as wide as they can be — 63 of the 67 passes carried all four conversations, 3.88 on average — and that a pass with its head takes 60ms. What is left is the 655ms spent reading the four prompts, which is not scheduling but the rate this engine reads prompts at. Generation is at parity; the prompt is the half that is behind, which gemma/README.md already says.

The engine's own figure for generation, without a server around it, is TestMixedBatchCost in gemma/: 21.4 tokens a second for one conversation, 42.3 for two, 73.3 for four, 104.1 for eight.

Two things bound it. Generation is limited by reading the weights, so the first conversations added are nearly free and the later ones are not — by eight, the arithmetic is what is left. And the output head is the largest matrix in the model: it is read once for every state being scored in a pass, which is why the scoring travels with the pass rather than after it.

On the card

-vulkan -parallel N batches the same way. The caches are cut the same way too: one ring a conversation, laid end to end in the buffer a block already had, with the slot travelling beside the position in vk/attention.go's position buffer — so the card holds N conversations for the memory one of N times the context held. TestMixedBatchCostVulkan in gemma/, the 26B A4B on an RX 9070 XT:

conversations pass head tokens a second
1 11ms 1ms 82.1
2 14ms 2ms 125.9
4 14ms 5ms 211.4
8 22ms 9ms 255.0

Taken from four hundred and forty-eight positions of context, because a token drawn at position 64 costs 7.6ms on this model and one drawn at 512 costs 14.1ms — the attention reads everything before it, and a table taken at the shallow end would say a number no server ever sees.

That is 1.53, 2.57 and 3.11 of one conversation. What keeps it from being two, four and eight is that generation is limited by reading the weights only while there is nothing else to do: by eight columns the arithmetic and the head are what is left.

A mixture had a threshold in the way of this. It answers a single column by reading the eight matrices that column routed to, and a prompt by reading each expert once for the columns that chose it — and it used to switch to the second at two columns, so a token drawn for each of two conversations read a hundred and twenty-eight experts to answer sixteen pairs. The two costs cross near thirty-two on this checkpoint, which is where vk/mixture.go's byExpertFrom now sits; the by-column kernels take a column offset so they can answer a handful of columns rather than one.

The scores kernel is what limits how a pass may be mixed. It answers thirty-two columns to a workgroup off the keys of the union of their ranges, and two conversations cannot share that scratch — a position means a different entry of the cache in each. So a pass is cut into runs of one conversation before the tiles are laid out. A prompt is one run and tiles as it always did; a token drawn for each of four conversations is four runs of one column, which is the shape generation has anyway.

How wide a pass may be is the other thing the card changes. A prompt is read in chunks of thirty-two positions on the processor, because past sixty-four the activations stop fitting in its caches; a card is idle at that width and reads a prompt some five times faster at two hundred and fifty-six. Positions a second by the width, the same 26B on the same card:

width 32 64 128 256 512
positions a second 950 2044 3797 4981 5486

Runner.PassWidth is which of the two this server uses, and main.go asks the model where its blocks are and says so once. Five hundred and twelve is the widest the stack carries and also the longest pass — 93ms, against 51ms at 256 — so a conversation waiting on its next token waits that much longer for one that is reading a prompt. That wait is a fraction of a prompt, once; the rate is every prompt.

End to end, through the API, with prompts of about five hundred and forty tokens and nothing else on the card:

prompt drawn
one client 4055/s 75.3/s
two clients, each ~2130/s ~57.8/s
two clients, together ~4260/s ~115.6/s

The prompt figure was 804/s before the width followed the device, and two clients drew 100/s together before the mixture's threshold moved.

Qwen3.8's pipeline holds its slots the same way, with one difference: a delta net keeps a state matrix a head rather than a ring, so a slot's share of the buffer is a whole copy of every recurrence. The slot travels beside the position all the same, and a pass carries several conversations, a run of columns each; only the recurrences and the scores are dispatched a run at a time. Bonsai 2 27B, one token for each conversation drawing:

conversations pass head
1 22ms 1.6ms
2 24ms 2.6ms
4 33ms 3.9ms
5 44ms 5.7ms

Five clients drawing two hundred tokens each went from 36.5 tokens a second together, when every conversation was a pass of its own, to 79.6. Past four the ternary mat-vec stops being a reading of the weights and becomes arithmetic, which is where the next gain is.

Waiting for a pass

The runner waits a fraction of a pass — an eighth of what the last one took, at most two milliseconds — for the other conversations in flight to arrive before going without them. A lone client cannot feel that; a second client is worth far more than it costs.

GOLEM_DEBUG_BATCHES=1 says what went into each pass, which is how the table above was checked rather than assumed.

Which slot answers is decided as llama.cpp's server decides it. The free slot whose tokens share the longest prefix with this prompt wins, provided the shared part is a tenth of the prompt or more; failing that, a slot holding no conversation; failing that, the one nobody has come back to in longest. Three requests measured on a Qwen3 0.6B — Alice's conversation, Bob's, then Alice's next turn:

Alice's second turn costs
-parallel 1 59 positions: Bob's conversation had taken the cache
-parallel 2 21 positions: the added turn, and nothing else

A request that waited for a slot — all of them held — says so in its log line, next to the slot that answered it:

POST /v1/chat/completions 200 284 bytes in 139ms — slot 0 — 21 prompt in 37ms (566.4/s), 8 drawn in 102ms (78.65/s), length

A client that hangs up while queued takes its request with it, and never reaches a slot.

The cache across stateless requests

/v1/chat/completions carries the whole conversation every time; the cache does not want to be rebuilt every time. So the server remembers which tokens sit in the cache, compares them with the prompt it just rendered, and prefills only the divergence. A conversation growing by one exchange costs one exchange.

Rewinding is where it gets particular. A sliding-window block keeps its keys in a ring of C slots, the window and one pass more, position p in slot p mod C. Writing up to position Q and then going back to P has overwritten the slots of positions up to Q-C, and some of them can still be visible from P. The server remembers which position each slot last received and resumes at the latest position whose window is intact, feeding everything after it again. A rewind that wrapped nothing feeds only the divergence, and appending, the common case, rewinds nothing.

A recurrent model cannot rewind at all: a delta net's state has read every position it holds, and there is no taking one back out. A prompt that parts from what a slot holds used to be read again from nothing. So a slot keeps a few copies of its state, -checkpoints of them, taken where a prompt parted from the one before it and every thousand and twenty-four positions of a long one, and a prompt that parts starts from the latest copy it shares. The attention's keys below that position are still the conversation's own; the copy is all it takes. A copy is a hundred and fifty-one megabytes on the 27B, and the card grants fewer than asked when they would push the head off it.

What it is worth is what a village is: five characters, each asked a prompt that starts with the same long sheet and then says what is happening now, which is new every time. Fifteen answers on Bonsai 2 27B took 196 seconds and 44 with the copies, the median wait from 61 seconds to 8.6: after its first turn a character's prompt is read from where its sheet ends, four hundred positions where it was three thousand eight hundred.

-cache-ttl bounds how long a conversation's tokens stay in memory. Say plainly what it does not do: the cache is allocated once at startup and this frees none of it. What it does is stop the server from holding one client's conversation indefinitely, and give the next request a clean state. 0, the default, never forgets.

A client that hangs up

Cancelling a request stops the drawing. With one model behind one lock, an answer nobody is waiting for is not merely wasted: it is the next request's wait. The generation loop watches the request's context and returns as soon as the connection is gone.

Where the time goes

Every request leaves a line on standard error, split between the two halves of the wait — they have nothing in common and nothing to gain from being added up:

POST /v1/chat/completions 200 268 bytes in 51.31s — 3919 prompt in 51.226s (76.5/s), 2 drawn in 70ms (28.70/s), stop

A first call carrying a large system prompt and a page of tool declarations is almost entirely prefill, and prefill is the slower half of this engine. The cache is what pays it back: the next turn of that conversation shares the whole prefix and reads only what was added. A client that rewrites the head of its conversation between turns — an hour stamped into the system message, tools reordered — diverges at position zero and repays the whole prompt every time. The prompt figure in the log says which of the two is happening.

Sixteen, sixty-four, and up to five hundred and twelve positions per batch were measured against thirty-two on this engine, and thirty-two won every time: at 3919 positions, 76.2/s against 71.7 at sixty-four and 46.4 at five hundred and twelve. Past that the activations stop fitting in the caches.

An answer a schema can read

response_format in its two shapes, and llama-server's grammar field beside them:

curl -s localhost:8080/v1/chat/completions -d '{
  "messages": [{"role": "user", "content": "Lyon, please."}],
  "response_format": {"type": "json_schema", "json_schema": {"name": "city", "schema": {
    "type": "object",
    "properties": {"city": {"type": "string"}, "population": {"type": "integer"}},
    "required": ["city", "population"],
    "additionalProperties": false}}}}'

{"type": "json_object"} asks for any JSON value at all; "grammar": "root ::= …" asks for whatever the GBNF describes. A request carries one of them, never two.

The token that would break the document is refused before the draw, and the turn cannot end while the braces are open. A schema that does not compile, a grammar that does not parse and a request carrying both are refused with a 400 before a slot is taken: a request that will not be answered has no business waiting behind one that will.

The vocabulary a grammar reads through is decoded on the first request that asks for one and shared by every request after. A server nobody asks a schema of never builds it.

The four penalty fields — repeat_penalty, repeat_last_n, frequency_penalty, presence_penalty — are llama.cpp's, and the window is seeded with the prompt before the first draw, as llama-server does.

A model that reasons answers without it unless the request asks, llama-server's way: "chat_template_kwargs": {"enable_thinking": true}. The reasoning then streams apart, as reasoning_content.

What it refuses

With a 400 and OpenAI's error envelope, rather than answering something else: n above 1, logprobs, tool_choice beyond auto and none, a conversation with no message, a tool result answering no call, and a prompt longer than -context. In a schema: pattern, a numeric bound, and a $ref into another document — each by name, because a constraint silently dropped is worse than a request refused.

Not implemented at all: /v1/completions, embeddings.

Pictures and recordings are served when the model was opened with -mmproj, and refused by name when it was not. A turn's content may be an array of parts: an image_url carrying a data: URI or a path on the server's own filesystem, and an input_audio carrying bare base64 or such a path. An http URL is refused on purpose — a server that fetches what a prompt names is a server that can be aimed.

Speech to text

When started with -stt <dir> (or GOLEM_STT), golem-server serves POST /v1/audio/transcriptions using the Kyutai STT model:

# Standard transcription:
curl -s http://localhost:8080/v1/audio/transcriptions \
  -F "file=@clip.wav" \
  -F "model=stt"

# Streaming transcription:
curl -s http://localhost:8080/v1/audio/transcriptions \
  -F "file=@clip.wav" \
  -F "model=stt" \
  -F "stream=true"

Embeddings

With -embed <file> (or GOLEM_EMBED), a nomic-embed-text-v2-moe GGUF — the one ollama pulls as nomic-embed-text-v2-moe — answers three endpoints. It may sit beside a conversation model and an STT, or alone:

./golem-server -embed nomic-embed-text-v2-moe.f16.gguf -addr 127.0.0.1:11434

curl -s localhost:11434/v1/embeddings -d '{"input": ["search_query: golem", "search_document: a Go engine"]}'
curl -s localhost:11434/api/embed     -d '{"model": "nomic-embed-text-v2-moe", "input": "search_query: golem"}'

Each keeps the manners of the API it copies, because clients depend on them:

  • /v1/embeddings answers unit vectors, as floats or — what OpenAI's own client asks for unless told otherwise — as base64 of little-endian float32. A text past the model's 512 positions is refused, as OpenAI and llama-server refuse it.
  • /api/embed answers unit vectors and cuts a text that is too long, unless the request says "truncate": false.
  • /api/embeddings, ollama's older endpoint, answers one vector, not normalized, as ollama does.

dimensions, on the first two, keeps a prefix of the vector and renormalizes it: the checkpoint is trained so that a prefix is a vector.

A request's texts go through the model in one pass, and so do the texts of requests that arrive while a pass is running: they wait for it, and are carried together by the next. A pass that would carry only a few sentences waits up to three milliseconds for more, because its cost is mostly fixed. With -vulkan the embedder's blocks go on the card, beside whatever else is there. nomic/README.md has the speed against llama.cpp and ollama, on the processor and on the card, and the vectors against both.

Pictures

With -krea2 <dir> (or GOLEM_KREA2), a ComfyUI directory holding Krea 2's three files, it draws pictures at /v1/images/generations, alone or beside the rest. krea2/README.md says what it draws and how close to ComfyUI.

./golem-server -krea2 /mnt/data/dev/ComfyUI -krea2-keep -addr 127.0.0.1:8083

curl -s localhost:8083/v1/images/generations \
  -d '{"prompt": "a red fox in the snow, photo", "size": "768x1024", "seed": 5}'

It is OpenAI's endpoint with what the ComfyUI mobile front sends beside it: negative_prompt, steps (8), cfg (1, which never reads the negative prompt) and seed (drawn when absent or -1). The answer is one picture as b64_json, a PNG, and the seed that drew it. n above 1 and response_format: url are refused. One picture is drawn at a time.

-krea2-keep leaves the twelve-gigabyte DiT on the card between pictures; without it each picture reads it again, which is most of its time when the file is not in the page cache.

Decisions

With -laya <dir> (or GOLEM_LAYA), a Laya checkpoint directory as Hugging Face ships it, it answers POST /v1/systemone, the endpoint TypeSafe Jev and Laya's own laya-serve answer on, with the same request and the same answer. laya/README.md says what the model is and how close to PyTorch.

./golem-server -laya convaiinnovations/laya -vulkan -addr 127.0.0.1:8000

curl -s localhost:8000/v1/systemone -d '{
  "state": {"document": "I was charged twice. Please fix this ASAP."},
  "questions": {"billing": {"type": "noul", "instructions": "Is this ticket about billing?"}}
}'

Refusals are written as laya-serve writes them, {"detail": "…"}: 400 for a body that is not the request, 413 past a mebibyte or sixty-four questions, 422 for a question Laya cannot read. Unknown fields are ignored, model among them: the server carries one checkpoint. Requests take turns on the model, and on an RX 9070 XT a request of three questions and 225 positions is answered in 14 ms over HTTP once the first has been.

Flags

-model the GGUF, or GOLEM_MODEL; not required when -stt, -embed, -krea2 or -laya is given
-stt directory holding the Kyutai STT model, or GOLEM_STT; a server may carry it alone, and then answers /v1/models and /v1/audio/transcriptions and nothing else
-embed a nomic-embed-text-v2-moe GGUF, or GOLEM_EMBED, for the embedding endpoints; a server may carry it alone
-laya a Laya checkpoint directory, or GOLEM_LAYA, for /v1/systemone; a server may carry it alone
-mmproj the projector GGUF, which is what lets a model see and hear, or GOLEM_MMPROJ
-vulkan put the blocks and the logit head on a Vulkan device, the embedder's blocks with -embed, and Laya with -laya; it fails rather than falling back
-addr what to listen on; 127.0.0.1:8080 by default
-context positions to keep; 4096 by default, cut between the slots
-parallel conversations at once; 1 by default
-n most tokens for one answer, when the request names no max_tokens
-cache-ttl forget a conversation's tokens after this long idle; 0 never
-checkpoints copies of a recurrent model's state each conversation keeps; 3 by default, and the card may grant fewer

Sampling follows the file's own values unless the request names temperature, top_p, top_k or seed.

Documentation

Overview

Command golem-server answers an OpenAI-compatible API over a GGUF, on the CPU or, with -vulkan, with the model's blocks and logit head on a Vulkan device.

golem-server -model gemma-4-E2B-it-QAT-Q4_0.gguf -addr :8080
golem-server -model Qwen3-4B-Q4_0.gguf -addr :8080

Which engine reads the file is read from the file: it declares its own architecture, and gemma4, qwen3 and qwen35 — which is Qwen3.8 — are the three that are implemented. It serves /v1/chat/completions, streamed or not, tool declarations included, and /v1/models. It reports the calls the model makes; running them is the client's part, as the protocol has it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL