Lesson 41 of 73
Workshop: loop serve, run, and chat
A language model does not run by itself. Something has to load the model file into memory, open a network port, and answer requests. That something is an inference engine (the program that runs the model), such as llama-server or vllm serve. This workshop builds the two commands that sit around an engine. The first one starts the engine and keeps it healthy. The second one lets a person talk to it.
Project 12, loop serve, starts an inference engine as a child process (a program that another program launches and controls). It picks a port, reads the engine's log, waits until the engine is ready, stops it when nobody uses it, and makes sure it does not stay behind when loop exits. This is the Integration A design from The Engine Landscape and How Ollama Works.
Project 13, loop chat, is a terminal chat on top of any server that speaks the OpenAI-compatible HTTP API. It shows the answer while the model writes it, remembers the conversation, understands commands that start with /, lets the user press Ctrl+C to stop a long answer, and loads settings from files. loop run <model> is the short form that serves a model and then chats with it. After the workshop, you have both commands working against llama-server and against any other OpenAI-compatible server.
| Stage | Builds | Example |
|---|---|---|
Backend, RemoteBackend |
One interface for engines loop starts and servers it only connects to |
1 |
LlamaServerBackend |
The llama-server command line from the model and loop fit |
2 |
| Free port | ServerSocket(0) and retry on a lost race |
3 |
Supervisor |
Start, log capture, health polling with backoff, crash detection | 4 |
| Idle unload | Stop the engine after a quiet period, restart on demand | 5 |
| Shutdown | Shutdown hook and process-tree kill | 6 |
VllmBackend |
The vllm serve command line |
7 |
loop serve |
Project 12 as a picocli command | 8 |
Conversation |
History, system prompt, trimming to a token budget | 9 |
| Streaming | ChatClient printing deltas as they arrive |
10 |
| Slash commands | /set, /system, /clear, /save, /load, /bye |
11 |
| Input | JLine line reader with history and multi-line input | 12 |
| Cancellation | Ctrl+C stops a generation, not the program | 13 |
| Reasoning | Thinking text shown apart from the answer | 14 |
| Profiles | System prompt and sampling settings from JSON files | 15 |
loop chat |
Project 13, and a real session with llama-server | 16 |
The stages reuse earlier work. Command-line parsing comes from picocli Basics and Subcommands, Configuration, and Packaging. The model registry and loop fit come from the model-pull workshop. loop fit is the command that calculates how much of a model fits on the GPU. The streaming client is Project 11 from The Chat Completions Protocol and Streaming in Java. Each stage that needs it restates a reduced ChatClient with the same shape: a request, a listener that is called once per text delta (one small piece of the answer), and a result with the final text, the finish reason, and the usage numbers.
Every example is a complete program, so you can run each one alone. Two kinds of fake helpers keep the runs predictable. Stages that supervise a process start a small fake engine. It is a second class in the same file, launched with the program's own java binary. It listens on 127.0.0.1 (the address that only your own machine can reach) and answers /health the way llama-server does. Stages that stream talk to a fake OpenAI-compatible server inside the program.
The last example runs the finished loop chat against the real llama-server with the course's tiny model. That model has random weights, so its answers are meaningless text, while the protocol, the timings, and the token counts are real.
Example 1: a Backend interface and a RemoteBackend
The chat code should not care which engine is underneath. loop talks to every engine through the same OpenAI-compatible HTTP API, so the rest of the program needs only two things from an engine: a base URL to send requests to, and a way to make sure the server is ready. A backend is the object that provides those two things.
Some backends start an engine themselves (llama-server, vllm serve). A RemoteBackend does not start anything. It connects to a server that already runs, for example one on another machine or a hosted service. The program below defines the interface, implements the remote case, and tests it against a fake server that requires an API key. Look for three different outcomes in the output: success, a wrong key, and no server at all.
import com.sun.net.httpserver.HttpServer;
import java.net.ConnectException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
public class BackendStage {
interface Backend extends AutoCloseable {
String name();
String baseUrl(); // ends with /v1
void ensureReady() throws Exception; // start if needed, then wait until healthy
@Override void close(); // stop only what this backend started
}
record RemoteBackend(String baseUrl, String apiKey) implements Backend {
public String name() { return "remote"; }
public void ensureReady() throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(baseUrl + "/models"));
if (apiKey != null) b.header("Authorization", "Bearer " + apiKey);
HttpResponse<String> r;
try {
r = HttpClient.newHttpClient().send(b.build(), HttpResponse.BodyHandlers.ofString());
} catch (ConnectException e) {
throw new IllegalStateException("no server is listening at the configured URL");
}
if (r.statusCode() == 401) throw new IllegalStateException("the server rejected the API key");
if (r.statusCode() != 200) throw new IllegalStateException("unexpected status " + r.statusCode());
}
public void close() { } // the server is not ours to stop
}
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/v1/models", ex -> {
boolean ok = "Bearer sk-1".equals(ex.getRequestHeaders().getFirst("Authorization"));
byte[] body = (ok ? "{\"data\":[{\"id\":\"coder\"}]}" : "{\"error\":\"Unauthorized\"}").getBytes(StandardCharsets.UTF_8);
ex.sendResponseHeaders(ok ? 200 : 401, body.length);
ex.getResponseBody().write(body);
ex.close();
});
server.start();
String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/v1";
for (String key : new String[] {"sk-1", "wrong"}) {
try (Backend b = new RemoteBackend(url, key)) {
b.ensureReady();
System.out.println(b.name() + " backend ready with key " + key);
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
}
server.stop(0);
try (Backend b = new RemoteBackend(url, "sk-1")) { b.ensureReady(); } catch (IllegalStateException e) { System.out.println(e.getMessage()); }
}
}remote backend ready with key sk-1
the server rejected the API key
no server is listening at the configured URLThe interface has four methods. baseUrl() is everything the chat code needs, because llama-server, vLLM, Ollama, and hosted APIs all serve /v1/chat/completions under it. ensureReady() hides the difference between backends: a remote backend checks the server, and a managed backend (from Example 4 on) starts the engine first and then waits. close() stops what the backend started, so a remote backend does nothing, because the server is not its to stop. Extending AutoCloseable lets callers use try-with-resources, so cleanup happens even when an error occurs.
The three output lines are the three outcomes a user can act on. The first run uses the right key, so the server answers 200 and the backend is ready. The second run uses a wrong key, so the server answers 401 (the HTTP status for "not authorized") and the backend says the key was rejected. The third run happens after the server is stopped, so the connection is refused and the backend says that nothing is listening.
Checking /models before the first chat turn has a practical benefit. A mistyped URL or a wrong key becomes one clear message at startup, instead of a confusing error in the middle of the first question.
Example 2: LlamaServerBackend: the command line from the model and loop fit
A managed backend first needs the engine's command line. Think of it as filling in a form: the engine has flags for the model file, the port, the context size, and how many layers go on the GPU. For llama-server the values come from two sources. The first is the facts about the model from the registry: its path, name, layer count, file size, and the context length it was trained with. The second is a memory plan from loop fit (Memory Math explains the formulas).
The program restates a small version of the fit calculation. The idea is as follows. A model has many layers, and each layer can live in GPU memory (fast) or in ordinary memory (slower). GPU memory must also hold the KV cache (key-value cache, the memory the model uses to remember the tokens it has already read) and a reserve for temporary work. Whatever is left is divided by the size of one layer, and that gives the number of layers to put on the GPU. llama-server calls this number -ngl. The program chooses -ngl for three machines and then builds the command.
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public class LlamaServerCommand {
record ModelInfo(String name, String gguf, int blockCount, long fileBytes, int trainCtx, long kvBytesPerToken) {}
record Fit(int gpuLayers, int ctx) {}
static final long MIB = 1L << 20;
static Fit fit(ModelInfo m, long gpuBytes, int wantedCtx) {
int ctx = Math.min(wantedCtx, m.trainCtx());
if (gpuBytes == 0) return new Fit(0, ctx);
long perLayer = m.fileBytes() / (m.blockCount() + 1); // blocks plus the output layer
long free = gpuBytes - m.kvBytesPerToken() * ctx - 512 * MIB; // KV cache and a compute reserve
int layers = (int) Math.max(0, Math.min(m.blockCount() + 1, free / perLayer));
return new Fit(layers, ctx);
}
static List<String> command(String binary, ModelInfo m, Fit f, int port, int parallel) {
List<String> c = new ArrayList<>(List.of(binary, "-m", m.gguf(), "--host", "127.0.0.1", "--port", Integer.toString(port)));
c.addAll(List.of("-c", Integer.toString(f.ctx() * parallel), "-np", Integer.toString(parallel)));
c.addAll(List.of("-ngl", Integer.toString(f.gpuLayers()), "--alias", m.name(), "--no-webui"));
return c;
}
public static void main(String[] args) {
ModelInfo coder = new ModelInfo("qwen2.5-coder-7b:q4_k_m", "/home/u/.loop/blobs/sha256-6a07", 28,
4_683_087_332L, 32768, 2L * 28 * 4 * 128 * 2);
long[] gpus = {0, 4096 * MIB, 24_576 * MIB};
for (long gpu : gpus) {
Fit f = fit(coder, gpu, 16384);
System.out.printf(Locale.ROOT, "GPU %5d MiB -> -ngl %2d of %d%n", gpu / MIB, f.gpuLayers(), coder.blockCount() + 1);
}
System.out.println(String.join(" ", command("llama-server", coder, fit(coder, 4096 * MIB, 16384), 41237, 1)));
System.out.println("context capped at training length: " + fit(coder, 0, 131072).ctx());
}
}GPU 0 MiB -> -ngl 0 of 29
GPU 4096 MiB -> -ngl 17 of 29
GPU 24576 MiB -> -ngl 29 of 29
llama-server -m /home/u/.loop/blobs/sha256-6a07 --host 127.0.0.1 --port 41237 -c 16384 -np 1 -ngl 17 --alias qwen2.5-coder-7b:q4_k_m --no-webui
context capped at training length: 32768The model has the shape of Qwen2.5-Coder-7B: 28 blocks, 4 KV heads, and a head size of 128. It is stored in a 4.68 GB Q4_K_M file. Its KV cache costs 56 KiB per token in F16, which is 2 * 28 * 4 * 128 * 2 bytes: two tensors (keys and values), 28 layers, 4 heads, 128 values per head, and 2 bytes per value.
The three GPU lines walk through the machines:
- On a CPU-only machine there is no GPU memory, so the plan is
-ngl 0. - On a 4 GiB GPU, a 16K-token KV cache takes 896 MiB (16,384 tokens times 56 KiB) and the compute reserve takes 512 MiB. About 2,688 MiB remain. One layer is about 154 MiB (the file size divided by 29), so 17 layers fit. The count of 29 is the 28 blocks plus the output layer, and llama.cpp offloads the output layer first.
- On a 24 GiB GPU there is plenty of room, so all 29 layers fit.
The command line has the same parts as the one Ollama builds in The Engine Landscape and How Ollama Works:
--host 127.0.0.1makes only this machine able to reach the engine.--portis a port chosen byloop.-cis the context size times the number of slots, and-npis the number of slots. A slot is one conversation the server can work on at the same time, and each slot gets its own share of the context.--aliasmakes responses report the name the user typed.--no-webuiturns off the built-in web page.
Every flag was checked against llama-server --help at the pinned commit. loop passes an explicit -ngl so the plan it printed with loop fit is the plan that runs. If -ngl were left out, llama-server's own --fit option (on by default) would choose, which is what Ollama does. The last output line shows the context cap: a request for 131,072 tokens is reduced to the model's 32,768-token training length, because the model was never trained to use more.
Example 3: a free port with ServerSocket(0)
Every network server listens on a numbered port. If loop always used the same port, two engines would collide, and so would an engine and any other program that already uses that port. So loop does not use a fixed port. It asks the operating system for one. Binding a socket to port 0 means "give me any free port", and the operating system chooses.
There is one small risk. loop must close its probe socket before the engine can use the port, and another program could take the port in that gap. The program below finds a port, shows what happens when a port is already taken, and retries a start that loses this race. Watch the attempt lines in the output.
import java.io.IOException;
import java.net.BindException;
import java.net.InetAddress;
import java.net.ServerSocket;
public class FreePortStage {
static final InetAddress LOOPBACK = InetAddress.getLoopbackAddress();
static int freePort() throws IOException {
try (ServerSocket s = new ServerSocket(0, 0, LOOPBACK)) { return s.getLocalPort(); }
}
/** Stands in for an engine that binds the port it is given. */
static ServerSocket startEngine(int port) throws IOException { return new ServerSocket(port, 0, LOOPBACK); }
static ServerSocket startWithRetry(int attempts, int firstPort) throws IOException {
int port = firstPort;
for (int i = 1; ; i++) {
try {
ServerSocket engine = startEngine(port);
System.out.println("attempt " + i + ": started");
return engine;
} catch (BindException e) {
System.out.println("attempt " + i + ": port taken, choosing another");
if (i == attempts) throw e;
port = freePort();
}
}
}
public static void main(String[] args) throws IOException {
int port = freePort();
System.out.println("port is in the ephemeral range: " + (port >= 1024 && port <= 65535));
try (ServerSocket held = new ServerSocket(0, 0, LOOPBACK)) {
try (ServerSocket second = new ServerSocket(held.getLocalPort(), 0, LOOPBACK)) {
System.out.println("second bind succeeded");
} catch (BindException e) {
System.out.println("second bind on a used port: " + e.getClass().getSimpleName());
}
try (ServerSocket engine = startWithRetry(3, held.getLocalPort())) {
System.out.println("engine port differs from the taken one: " + (engine.getLocalPort() != held.getLocalPort()));
}
}
}
}port is in the ephemeral range: true
second bind on a used port: BindException
attempt 1: port taken, choosing another
attempt 2: started
engine port differs from the taken one: trueThe first output line confirms that port 0 yields a real port number. The second shows the failure that a fixed port would cause: binding a port that another socket holds throws BindException.
The attempt lines simulate the race. loop closes its probe socket before the engine starts, and another program could take the port in between. startWithRetry is given a taken port on purpose. The first attempt fails, the method picks a new port, and the second attempt succeeds. The last line confirms the engine ended up on a different port than the taken one.
With a real engine the failure looks different. llama-server does not throw a Java exception. It exits with an error such as couldn't bind HTTP server socket in its log. The supervisor in the next stage notices the early exit, and it can retry with a new port. The race is rare, and a supervisor that retries after an early exit with a bind error covers it.
Example 4: starting the process, capturing logs, and polling health with backoff
This stage is the core of loop serve. A Supervisor is an object that owns the engine process and watches it. It has four jobs:
- Start the engine as a child process.
- Read its log continuously into a ring buffer (a fixed-size buffer that drops the oldest line when it is full), so the last lines are available when something goes wrong.
- Poll
/healthuntil it answers 200. Polling means asking again and again until the answer changes. - Report a crash together with the last log lines.
The fake engine imitates two behaviors of llama-server. It answers 503 ("not ready") for its first two health checks, as llama-server does while it loads the model. Started with the argument crash, it prints an error and exits with code 1, as llama-server does when the model file is missing. Look at the two output lines: one for the normal start and one for the crash.
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.ConnectException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class SupervisorStage {
static class Supervisor {
final List<String> command; final int port; Process process; Thread reader;
final ArrayDeque<String> log = new ArrayDeque<>();
final HttpClient http = HttpClient.newHttpClient();
Supervisor(List<String> command, int port) { this.command = command; this.port = port; }
void start() throws IOException {
ProcessBuilder pb = new ProcessBuilder(command).redirectErrorStream(true);
pb.environment().remove("JAVA_TOOL_OPTIONS");
process = pb.start();
reader = Thread.ofVirtual().start(() -> process.inputReader().lines().forEach(this::remember));
}
synchronized void remember(String line) { if (log.size() == 20) log.removeFirst(); log.addLast(line); }
synchronized String lastLog() { return log.isEmpty() ? "(no output)" : log.peekLast(); }
int probe() throws Exception {
try {
return http.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/health"))
.timeout(Duration.ofSeconds(5)).build(), HttpResponse.BodyHandlers.discarding()).statusCode();
} catch (ConnectException e) { return -1; } // not listening yet
catch (HttpTimeoutException e) { return 0; } // listening but not answering
}
int awaitHealthy(Duration timeout) throws Exception {
long deadline = System.nanoTime() + timeout.toNanos(), sleep = 50;
int loadingAnswers = 0;
while (true) {
if (!process.isAlive()) {
reader.join();
throw new IllegalStateException("engine exited with code " + process.exitValue() + " during startup: " + lastLog());
}
int status = probe();
if (status == 200) return loadingAnswers;
if (status == 503) loadingAnswers++;
if (System.nanoTime() > deadline) throw new IllegalStateException("engine not ready after " + timeout);
Thread.sleep(sleep);
sleep = Math.min(sleep * 2, 1000);
}
}
}
static List<String> fakeEngine(int port, String mode) {
return List.of(ProcessHandle.current().info().command().orElse("java"), "-cp", System.getProperty("java.class.path"),
"FakeEngine", Integer.toString(port), mode);
}
static int freePort() throws IOException { try (ServerSocket s = new ServerSocket(0)) { return s.getLocalPort(); } }
public static void main(String[] args) throws Exception {
int port = freePort();
Supervisor ok = new Supervisor(fakeEngine(port, "ok"), port);
ok.start();
System.out.println("ready after " + ok.awaitHealthy(Duration.ofSeconds(30)) + " 'loading' answers; last log: " + ok.lastLog());
ok.process.toHandle().destroy();
ok.process.waitFor();
int port2 = freePort();
Supervisor bad = new Supervisor(fakeEngine(port2, "crash"), port2);
bad.start();
try { bad.awaitHealthy(Duration.ofSeconds(30)); } catch (IllegalStateException e) { System.out.println(e.getMessage()); }
}
}
class FakeEngine {
public static void main(String[] args) throws Exception {
if (args[1].equals("crash")) {
System.out.println("llama_model_load: error loading model: failed to open missing.gguf");
System.exit(1);
}
AtomicInteger checks = new AtomicInteger();
HttpServer s = HttpServer.create(new InetSocketAddress("127.0.0.1", Integer.parseInt(args[0])), 0);
s.createContext("/health", ex -> { ex.sendResponseHeaders(checks.incrementAndGet() <= 2 ? 503 : 200, -1); ex.close(); });
s.start();
System.out.println("llama_server: model loaded");
}
}ready after 2 'loading' answers; last log: llama_server: model loaded
engine exited with code 1 during startup: llama_model_load: error loading model: failed to open missing.ggufThe first output line shows the normal start. The fake engine answered 503 twice while it "loaded", and then 200, so the supervisor reports two "loading" answers. Connection-refused attempts before the server binds its port are not counted, because their number depends on how fast the child starts, and the output must be the same every run. The last log line is the fake's listening message, which the reader thread captured while the supervisor was polling. The fake copies the message that llama-server logs when its model is ready (srv llama_server: model loaded, followed by listening on http://127.0.0.1:<port>).
The second output line is the crash path. The child printed one error line and exited with code 1. The supervisor sees isAlive() turn false, waits for the reader thread to drain the pipe (reader.join()), and reports the exit code with the last log line. The wait matters. Without the join, the message could be printed before the reader has stored the error line, and the user would see (no output) instead of the reason.
The design choices in the supervisor each answer a problem seen with real engines:
The design choices map to problems seen with real engines:
| Choice | Reason |
|---|---|
Reader thread started right after start() |
llama-server writes hundreds of log lines while loading; an unread pipe fills its buffer (64 KiB on Linux) and the child blocks |
| Ring buffer of the last 20 lines | Enough to show an error with its context; the full log can also go to ~/.loop/logs/ |
| Exponential backoff from 50 ms to 1 s | Quick detection of a fast start, few requests during a slow one |
| Per-probe timeout | A stuck server cannot block the loop longer than the probe timeout (5 s here); a timed-out probe counts as "not ready" |
| Overall deadline | A load that never finishes ends with a message instead of a hang; Ollama resets its deadline whenever the child writes output, so a slow but progressing load does not time out |
Example 5: idle unload
A loaded model holds gigabytes of memory, even when nobody is asking it anything. So loop serve stops the engine after a period without requests and starts it again when the next request arrives. Ollama does the same with its keep_alive setting.
The program adds an idle tracker to the supervisor. The tracker needs to know two things: how many requests are running right now, and when the last request ended. Time comes from the caller instead of the system clock, so the run is the same every time. In the real loop serve, a scheduled executor calls tick every few seconds with the real time. The output shows a timeline with the idle limit set to 300 seconds.
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.util.List;
public class IdleUnloadStage {
static class ManagedEngine {
final long idleTimeoutSeconds;
Process process;
long lastUsed;
int active, starts;
ManagedEngine(long idleTimeoutSeconds) { this.idleTimeoutSeconds = idleTimeoutSeconds; }
void beginRequest(long now) throws Exception {
if (process == null || !process.isAlive()) {
int port;
try (ServerSocket s = new ServerSocket(0)) { port = s.getLocalPort(); }
ProcessBuilder pb = new ProcessBuilder(ProcessHandle.current().info().command().orElse("java"), "-cp",
System.getProperty("java.class.path"), "FakeEngine", Integer.toString(port));
pb.environment().remove("JAVA_TOOL_OPTIONS");
process = pb.redirectOutput(ProcessBuilder.Redirect.DISCARD).redirectErrorStream(true).start();
starts++;
System.out.printf("t=%4ds start engine (start #%d)%n", now, starts);
}
active++;
lastUsed = now;
}
void endRequest(long now) { active--; lastUsed = now; }
void tick(long now) throws InterruptedException {
boolean idle = process != null && process.isAlive() && active == 0 && now - lastUsed >= idleTimeoutSeconds;
if (idle) {
process.toHandle().destroy();
process.waitFor();
System.out.printf("t=%4ds idle for %ds, engine stopped%n", now, now - lastUsed);
} else {
System.out.printf("t=%4ds tick: %s%n", now, process != null && process.isAlive()
? (active > 0 ? "busy, keep" : "idle " + (now - lastUsed) + "s, keep") : "not running");
}
}
}
public static void main(String[] args) throws Exception {
ManagedEngine engine = new ManagedEngine(300);
engine.beginRequest(0);
engine.tick(120); // a long generation is still running
engine.endRequest(200);
engine.tick(360);
engine.tick(500);
engine.tick(600);
engine.beginRequest(650);
engine.endRequest(660);
System.out.println("engine starts: " + engine.starts);
engine.process.toHandle().destroy();
}
}
class FakeEngine {
public static void main(String[] args) throws Exception {
HttpServer.create(new InetSocketAddress("127.0.0.1", Integer.parseInt(args[0])), 0).start();
}
}t= 0s start engine (start #1)
t= 120s tick: busy, keep
t= 360s tick: idle 160s, keep
t= 500s idle for 300s, engine stopped
t= 600s tick: not running
t= 650s start engine (start #2)
engine starts: 2Here is the timeline, line by line:
- At 0 s, the first request starts the engine.
- At 120 s, the tick finds a request still running, so the engine stays. Idle time counts only from the end of the last request, not from its start.
- At 200 s, the request ends, and the idle clock starts.
- At 360 s, the tick finds 160 s of idle time. That is below the 300-second limit, so the engine stays.
- At 500 s, the tick finds exactly 300 s of idle time and stops the engine.
- At 600 s, the tick finds nothing running.
- At 650 s, a new request arrives and starts the engine again, so the run ends with two starts.
Two rules keep this safe. First, an engine with an active request is never stopped. This is the same reference counting (keeping a count of users) that Ollama's scheduler uses. Second, the timer measures from the end of the last request, not its start, so a long generation does not count as idle time.
Idle unload has a cost: the next request pays the load time. That is a few seconds for a small GGUF model that is still in the operating system's page cache (the memory the system uses to keep recently read files), and minutes for vLLM. loop serve --idle-timeout 0 disables idle unload.
Example 6: shutdown hook and process-tree kill
When loop exits, the engines it started must exit too. Otherwise each run leaves a process behind that holds gigabytes of memory. Two mechanisms cover this.
A shutdown hook is a thread that the JVM runs when it exits normally or receives SIGTERM or SIGINT (the signals that mean "please stop"). It is the place to put cleanup code.
A process-tree kill stops the engine's own child processes as well. This is needed because killing a parent on Linux does not kill its children. They are re-parented (adopted by the system's init process) and keep running. The fake engine in this example starts a worker child, as a real engine with a helper process would. Read the output as three experiments: kill the parent alone, kill the whole tree, and let the shutdown hook do the kill.
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.util.List;
import java.util.concurrent.TimeUnit;
public class ShutdownStage {
static Process startEngine() throws Exception {
int port;
try (ServerSocket s = new ServerSocket(0)) { port = s.getLocalPort(); }
ProcessBuilder pb = new ProcessBuilder(javaCommand("FakeEngine", Integer.toString(port)));
pb.environment().remove("JAVA_TOOL_OPTIONS");
Process p = pb.redirectOutput(ProcessBuilder.Redirect.DISCARD).redirectErrorStream(true).start();
for (int i = 0; i < 100 && p.toHandle().descendants().findAny().isEmpty(); i++) Thread.sleep(50); // wait for the worker
return p;
}
static List<String> javaCommand(String... args) {
List<String> c = new java.util.ArrayList<>(List.of(ProcessHandle.current().info().command().orElse("java"),
"-cp", System.getProperty("java.class.path")));
c.addAll(List.of(args));
return c;
}
static void killTree(ProcessHandle root) throws Exception {
List<ProcessHandle> children = root.descendants().toList();
root.destroy(); // SIGTERM to the engine first
root.onExit().get(10, TimeUnit.SECONDS);
for (ProcessHandle c : children) { c.destroy(); c.onExit().get(10, TimeUnit.SECONDS); }
}
public static void main(String[] args) throws Exception {
Process first = startEngine();
ProcessHandle worker = first.toHandle().descendants().findFirst().orElseThrow();
first.toHandle().destroy();
first.waitFor();
Thread.sleep(200);
System.out.println("parent killed alone -> worker still alive: " + worker.isAlive());
worker.destroy();
worker.onExit().get(10, TimeUnit.SECONDS);
Process second = startEngine();
ProcessHandle worker2 = second.toHandle().descendants().findFirst().orElseThrow();
killTree(second.toHandle());
System.out.println("tree kill -> engine alive: " + second.isAlive() + ", worker alive: " + worker2.isAlive());
Process third = startEngine();
ProcessHandle worker3 = third.toHandle().descendants().findFirst().orElseThrow();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
killTree(third.toHandle());
System.out.println("shutdown hook -> engine alive: " + third.isAlive() + ", worker alive: " + worker3.isAlive());
} catch (Exception e) { System.out.println("shutdown hook failed: " + e); }
}));
System.out.println("main returns with the third engine running");
}
}
class FakeEngine {
public static void main(String[] args) throws Exception {
if (args[0].equals("worker")) { Thread.sleep(120_000); return; }
ProcessBuilder pb = new ProcessBuilder(ShutdownStage.javaCommand("FakeEngine", "worker"));
pb.environment().remove("JAVA_TOOL_OPTIONS");
pb.redirectOutput(ProcessBuilder.Redirect.DISCARD).redirectErrorStream(true).start();
HttpServer.create(new InetSocketAddress("127.0.0.1", Integer.parseInt(args[0])), 0).start();
}
}parent killed alone -> worker still alive: true
tree kill -> engine alive: false, worker alive: false
main returns with the third engine running
shutdown hook -> engine alive: false, worker alive: falseThe first output line shows the problem. After SIGTERM to the engine alone, its worker is still running, now owned by the system's init process. Nothing would ever stop it.
The second line shows the fix. killTree works in three steps. It collects the descendants before killing the root, because after the root exits the worker is no longer its descendant and cannot be found that way. It sends SIGTERM to the engine and waits for it to exit. Then it stops each descendant. Both the engine and the worker are gone.
The third line is printed by main, which then returns. The JVM starts its exit sequence and runs the shutdown hook. The hook stops the third engine and its worker, and its line appears last. The same hook runs when the user presses Ctrl+C in a terminal where loop serve is in the foreground, and when a service manager sends SIGTERM.
The hook does not run on SIGKILL (kill -9) or a JVM crash, because the process gets no chance to run code. For that case loop serve writes the engine's process id to ~/.loop/run/<name>.pid. The next start checks for a stale process with ProcessHandle.of(pid) before starting a new one.
The order inside killTree follows llama-server's needs. SIGTERM lets it finish the current response and close its socket, and the wait (onExit().get with a timeout) gives it time to do so. A supervisor that must not hang escalates to destroyForcibly() (SIGKILL, which cannot be ignored) when the timeout expires.
Example 7: the VllmBackend command builder
vLLM is a second inference engine, aimed at GPU servers. It can use the same supervisor as llama-server, but with a different command and different timing. The flags come from Serving with vLLM. The program builds the command for a coding model with tool calls and for a reasoning model, and then prints the timing settings that differ from llama-server. In the output, compare the two vllm serve lines, and then the two Timing lines.
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public class VllmCommand {
record VllmOptions(String model, String servedName, int port, int maxModelLen, double gpuMemoryUtilization,
String toolParser, String reasoningParser) {}
static List<String> command(VllmOptions o) {
List<String> c = new ArrayList<>(List.of("vllm", "serve", o.model(), "--host", "127.0.0.1",
"--port", Integer.toString(o.port()), "--served-model-name", o.servedName(),
"--max-model-len", Integer.toString(o.maxModelLen()),
"--gpu-memory-utilization", String.format(Locale.ROOT, "%.2f", o.gpuMemoryUtilization())));
if (o.toolParser() != null) c.addAll(List.of("--enable-auto-tool-choice", "--tool-call-parser", o.toolParser()));
if (o.reasoningParser() != null) c.addAll(List.of("--reasoning-parser", o.reasoningParser()));
c.add("--enable-prompt-tokens-details");
return c;
}
record Timing(Duration startupDeadline, boolean refusedWhileLoading, boolean status503WhileLoading) {}
public static void main(String[] args) {
System.out.println(String.join(" ", command(new VllmOptions("Qwen/Qwen2.5-Coder-7B-Instruct", "coder", 41300,
32768, 0.90, "hermes", null))));
System.out.println(String.join(" ", command(new VllmOptions("Qwen/Qwen3-8B", "qwen3", 41301,
16384, 0.85, "hermes", "qwen3"))));
System.out.println("llama-server: " + new Timing(Duration.ofMinutes(2), true, true));
System.out.println("vllm serve: " + new Timing(Duration.ofMinutes(15), true, false));
}
}vllm serve Qwen/Qwen2.5-Coder-7B-Instruct --host 127.0.0.1 --port 41300 --served-model-name coder --max-model-len 32768 --gpu-memory-utilization 0.90 --enable-auto-tool-choice --tool-call-parser hermes --enable-prompt-tokens-details
vllm serve Qwen/Qwen3-8B --host 127.0.0.1 --port 41301 --served-model-name qwen3 --max-model-len 16384 --gpu-memory-utilization 0.85 --enable-auto-tool-choice --tool-call-parser hermes --reasoning-parser qwen3 --enable-prompt-tokens-details
llama-server: Timing[startupDeadline=PT2M, refusedWhileLoading=true, status503WhileLoading=true]
vllm serve: Timing[startupDeadline=PT15M, refusedWhileLoading=true, status503WhileLoading=false]The vLLM command always binds 127.0.0.1, because vLLM's default (no --host) listens on all interfaces, which would expose the model to the network. The other flags do the following:
--served-model-namemakes themodelfield in requests short and stable.--max-model-lencaps the context, so startup does not fail on a model whose full context does not fit in memory.--gpu-memory-utilizationleaves memory for other programs on the GPU. A value of 0.90 lets vLLM use 90 percent.- The tool and reasoning parsers are chosen from the model family. They tell vLLM how to read tool calls and thinking text from the model's output.
--enable-prompt-tokens-detailsmakes cached-token counts visible inusage, whichloop chatshows after each answer.
The two Timing lines are the supervisor settings. vLLM loads weights, profiles memory, and may capture CUDA graphs (a GPU speed-up that records work in advance) before it accepts connections, so its startup deadline is much longer: 15 minutes instead of 2.
The other difference is what a client sees while the engine loads. vLLM refuses connections and never answers 503. llama-server answers 503 with Loading model. The polling loop from Example 4 treats both as "not ready", so one loop serves both engines.
Example 8: the loop serve command (Project 12)
Project 12 joins the stages into one picocli command. loop serve MODEL does these steps in order: it resolves the model, computes the command, starts the engine on a free port, waits for health, prints the base URL, and runs until interrupted. On exit it stops the engine.
The program restates the supervisor in compact form. Two options exist for the demo. The --engine-command option replaces the llama-server binary with a launcher prefix. The demo sets it to the fake engine, and a real installation leaves it at its default. The hidden --once option makes the demo stop after the engine is ready instead of waiting for Ctrl+C. The output shows the summary line, the ready message, and the exit code.
import com.sun.net.httpserver.HttpServer;
import java.net.ConnectException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
@Command(name = "serve", mixinStandardHelpOptions = true, description = "Start and supervise an inference engine.")
public class LoopServe implements Callable<Integer> {
@Parameters(paramLabel = "MODEL", description = "Path of a GGUF file") String model;
@Option(names = {"-c", "--ctx-size"}, defaultValue = "4096", description = "Context per slot") int ctx;
@Option(names = "--parallel", defaultValue = "1", description = "Slots") int parallel;
@Option(names = "--gpu-layers", defaultValue = "0", description = "Layers on the GPU (from loop fit)") int gpuLayers;
@Option(names = "--engine-command", split = " ", defaultValue = "llama-server", description = "Engine launcher") List<String> engine;
@Option(names = "--once", hidden = true) boolean once;
public Integer call() throws Exception {
int port;
try (ServerSocket s = new ServerSocket(0)) { port = s.getLocalPort(); }
List<String> cmd = new ArrayList<>(engine);
cmd.addAll(List.of("-m", model, "--host", "127.0.0.1", "--port", Integer.toString(port), "-c",
Integer.toString(ctx * parallel), "-np", Integer.toString(parallel), "-ngl", Integer.toString(gpuLayers), "--no-webui"));
ProcessBuilder pb = new ProcessBuilder(cmd).redirectErrorStream(true);
pb.environment().remove("JAVA_TOOL_OPTIONS");
Process p = pb.start();
List<String> log = new ArrayList<>();
Thread reader = Thread.ofVirtual().start(() -> p.inputReader().lines().forEach(l -> { synchronized (log) { log.add(l); } }));
Thread hook = new Thread(() -> { p.toHandle().descendants().forEach(ProcessHandle::destroy); p.destroy(); });
Runtime.getRuntime().addShutdownHook(hook);
HttpClient http = HttpClient.newHttpClient();
URI health = URI.create("http://127.0.0.1:" + port + "/health");
long deadline = System.nanoTime() + Duration.ofMinutes(2).toNanos(), sleep = 50;
while (true) {
if (!p.isAlive()) { reader.join(); System.err.println("engine exited: " + (log.isEmpty() ? "" : log.get(log.size() - 1))); return 1; }
int status;
try { status = http.send(HttpRequest.newBuilder(health).build(), HttpResponse.BodyHandlers.discarding()).statusCode(); }
catch (ConnectException e) { status = -1; }
if (status == 200) break;
if (System.nanoTime() > deadline) { System.err.println("engine did not become ready"); return 1; }
Thread.sleep(sleep);
sleep = Math.min(sleep * 2, 1000);
}
System.out.println("serving " + model + " (ctx " + ctx + " x " + parallel + " slots, " + gpuLayers + " GPU layers)");
if (once) { System.out.println("engine ready"); return 0; }
System.out.println("base URL http://127.0.0.1:" + port + "/v1");
return p.waitFor(); // runs until Ctrl+C or the engine exits
}
public static void main(String[] args) {
String fake = ProcessHandle.current().info().command().orElse("java") + " -cp " + System.getProperty("java.class.path") + " FakeEngine";
String[] demo = args.length > 0 ? args : new String[] {"/models/tiny.gguf", "--ctx-size", "2048", "--parallel", "2",
"--engine-command", fake, "--once"};
int code = new CommandLine(new LoopServe()).execute(demo);
System.out.println("exit code " + code);
}
}
class FakeEngine {
public static void main(String[] args) throws Exception {
int port = Integer.parseInt(args[List.of(args).indexOf("--port") + 1]);
HttpServer s = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
s.createContext("/health", ex -> { ex.sendResponseHeaders(200, -1); ex.close(); });
s.start();
}
}serving /models/tiny.gguf (ctx 2048 x 2 slots, 0 GPU layers)
engine ready
exit code 0The demo passes the model path, a context of 2,048 tokens per slot, two slots, and the fake engine as the launcher. The program builds -c 4096 -np 2 (2,048 times 2), starts the engine, waits for /health, and prints the summary. In the demo the fake engine is ready at once.
split = " " turns the launcher string into separate arguments, so --engine-command "/opt/llama.cpp/build/bin/llama-server" and the multi-word Java launcher both work. A path that contains spaces would need a different separator.
Without --once, the command waits on the engine process. Ctrl+C triggers the shutdown hook, which stops the engine and its children (Example 6). An engine crash ends loop serve with the engine's exit.
The same program also runs the pinned llama-server with the tiny model. This is the command:
java -cp "picocli-4.7.7.jar:." LoopServe tiny-random-qwen2vocab-f32.gguf --ctx-size 2048 --parallel 2 \
--engine-command $REF/llama_cpp_python-0.3.36/vendor/llama.cpp/build/bin/llama-serverHere is the real output. It comes from the pinned llama.cpp build and the tiny random-weight model. The port differs on every run. The command keeps running until Ctrl+C, and the shutdown hook then stops llama-server:
serving tiny-random-qwen2vocab-f32.gguf (ctx 2048 x 2 slots, 0 GPU layers)
base URL http://127.0.0.1:35649/v1The output shows the same two lines as the demo, with a real port. The program stays running because the engine process is still alive.
The full loop serve adds the parts from the earlier stages that this compact version leaves out:
- Model names resolved through the registry instead of file paths.
-nglfromloop fitinstead of an option.- A log ring buffer written to
~/.loop/logs/serve.log. - The idle timer (Example 5).
- The pid file (Example 6).
--engine vllmwith the command from Example 7.
Example 9: conversation state and history
The chat half starts with the data. A chat model has no memory between requests. Every request carries the whole conversation again, and the model reads it from the start. So the program must keep the conversation itself.
A conversation object holds the system prompt (the standing instruction, such as "You are a concise Java assistant") and the turns (the user and assistant messages). It builds the message list for each request. The model can read only a limited number of tokens (its context), so when the history grows past that limit, the oldest turns must go.
The program trims by an estimated token count. It keeps the system prompt and the newest user message, and it fills the rest of the budget with the most recent turns. The program runs the same five-turn conversation with three different budgets.
import java.util.ArrayList;
import java.util.List;
public class ConversationStage {
record Message(String role, String content) {}
static class Conversation {
String system;
final List<Message> turns = new ArrayList<>();
Conversation(String system) { this.system = system; }
void add(String role, String content) { turns.add(new Message(role, content)); }
static int estimateTokens(Message m) { return 4 + (m.content().length() + 3) / 4; } // about 4 characters per token
/** The messages for the next request: system prompt, then as many recent turns as fit the budget. */
List<Message> forRequest(int budget) {
List<Message> out = new ArrayList<>();
Message sys = new Message("system", system);
int used = estimateTokens(sys);
int first = turns.size();
while (first > 0 && used + estimateTokens(turns.get(first - 1)) <= budget) used += estimateTokens(turns.get(--first));
if (first < turns.size() && turns.get(first).role().equals("assistant")) first++; // start at a user turn
out.add(sys);
out.addAll(turns.subList(first, turns.size()));
return out;
}
}
public static void main(String[] args) {
Conversation c = new Conversation("You are a concise Java assistant.");
c.add("user", "What does Optional.orElseThrow() do with an empty Optional?");
c.add("assistant", "It throws NoSuchElementException.");
c.add("user", "And orElseGet?");
c.add("assistant", "It calls the supplier and returns its result, so the default is computed only when empty.");
c.add("user", "Show a one-line example.");
for (int budget : new int[] {200, 60, 30}) {
List<Message> msgs = c.forRequest(budget);
System.out.println("budget " + budget + ": " + msgs.size() + " messages, first turn: "
+ (msgs.size() > 1 ? msgs.get(1).content() : "(none)"));
}
}
}budget 200: 6 messages, first turn: What does Optional.orElseThrow() do with an empty Optional?
budget 60: 4 messages, first turn: And orElseGet?
budget 30: 2 messages, first turn: Show a one-line example.The estimate in this program is 4 + length / 4 per message. For this conversation the system prompt costs about 13, the newest question about 10, the previous answer about 27, and "And orElseGet?" about 8.
With a budget of 200 estimated tokens, all five turns fit (about 90 in total), so the request has six messages. With 60, the window is filled from the newest turn backwards: the new question, the answer before it, and the second user question add up to about 58. The window therefore starts at "And orElseGet?", so the model sees that question and its answer before the new question. With 30, only the newest user message fits beside the system prompt, which gives two messages.
Two rules hold at every budget. The system prompt is always first. The window starts at a user turn, because a conversation that opens with an assistant message breaks some chat templates (Chat Templates, Roles, and Special Tokens). A chat template is the text format that turns messages into the exact prompt the model expects.
The four-characters-per-token estimate is rough. English prose is close to it, and code often uses more tokens per character. There are two exact alternatives. llama-server's /tokenize endpoint (llama-server: Endpoints, Slots, and Prompt Cache) gives exact counts at the cost of one extra request. The usage.prompt_tokens of the previous response gives an exact count for the history so far without any extra request.
loop chat uses the second option and keeps a margin for the answer: the budget is the context size minus max_tokens. A later lesson on context engineering replaces dropping old turns with summarizing them.
Example 10: streaming output with the Project 11 client against the fake server
A chat that waits for the whole answer feels slow, because the model writes one token at a time. The chat should show text while the model generates it. This is called streaming: the server sends the answer in small pieces over one open HTTP response, using the server-sent events format (lines that start with data: ).
The program restates the streaming part of the Project 11 ChatClient in reduced form. It sends the request with "stream": true, reads the events line by line, calls the listener for each content delta, and returns the full text with the finish reason and usage. The fake server inside the program sends the chunks in the order llama-server sends them.
The reduced client differs from Project 11 in a few ways:
- Project 11's
stream(request, listener, cancel)takes aRequestrecord. This version takes the JSON body directly. - This version leaves out cancellation. Example 13 adds it.
- The
Listenerinterface is the same as in Project 11. - Project 11's constructor also takes an API key, and its
Resultrecord also carriesreasoning,toolCalls, and a typedUsagerecord. This version keeps the usage as aJsonNode.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.sun.net.httpserver.HttpServer;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.List;
public class StreamingStage {
static final ObjectMapper JSON = new ObjectMapper();
record Result(String content, String finishReason, JsonNode usage) {}
interface Listener { // as in Project 11
void onContent(String text);
default void onReasoning(String text) { }
}
static class ChatClient {
final HttpClient http = HttpClient.newHttpClient();
final String baseUrl;
ChatClient(String baseUrl) { this.baseUrl = baseUrl; }
Result stream(ObjectNode request, Listener listener) throws Exception {
request.put("stream", true).putObject("stream_options").put("include_usage", true);
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/chat/completions"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(request))).build();
HttpResponse<InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream());
StringBuilder text = new StringBuilder();
String finish = null;
JsonNode usage = null;
try (BufferedReader in = new BufferedReader(new InputStreamReader(resp.body(), StandardCharsets.UTF_8))) {
for (String line; (line = in.readLine()) != null; ) {
if (!line.startsWith("data: ")) continue; // blank separators and comments
if (line.equals("data: [DONE]")) break;
JsonNode chunk = JSON.readTree(line.substring(6));
if (chunk.hasNonNull("usage")) usage = chunk.get("usage");
for (JsonNode choice : chunk.path("choices")) {
String delta = choice.path("delta").path("content").asText("");
if (!delta.isEmpty()) { text.append(delta); listener.onContent(delta); }
if (choice.hasNonNull("finish_reason")) finish = choice.get("finish_reason").asText();
}
}
}
return new Result(text.toString(), finish, usage);
}
}
static HttpServer fakeServer(List<String> tokens) throws Exception {
HttpServer s = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
s.createContext("/v1/chat/completions", ex -> {
ex.getResponseHeaders().add("Content-Type", "text/event-stream");
ex.sendResponseHeaders(200, 0);
try (OutputStream out = ex.getResponseBody()) {
send(out, "{\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null},\"finish_reason\":null}]}");
for (String t : tokens) send(out, "{\"choices\":[{\"index\":0,\"delta\":{\"content\":" + JSON.writeValueAsString(t) + "},\"finish_reason\":null}]}");
send(out, "{\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}");
send(out, "{\"choices\":[],\"usage\":{\"prompt_tokens\":21,\"completion_tokens\":" + tokens.size() + ",\"total_tokens\":" + (21 + tokens.size()) + "}}");
out.write("data: [DONE]\n\n".getBytes(StandardCharsets.UTF_8));
}
});
s.start();
return s;
}
static void send(OutputStream out, String json) throws java.io.IOException {
out.write(("data: " + json + "\n\n").getBytes(StandardCharsets.UTF_8));
out.flush();
}
public static void main(String[] args) throws Exception {
HttpServer server = fakeServer(List.of("Use", " `", "List", ".of", "(1, 2, 3)", "`", "."));
ChatClient client = new ChatClient("http://127.0.0.1:" + server.getAddress().getPort() + "/v1");
ObjectNode request = JSON.createObjectNode().put("model", "coder").put("max_tokens", 64);
request.putArray("messages").addObject().put("role", "user").put("content", "An immutable list of 1, 2, 3?");
Result r = client.stream(request, delta -> System.out.print("[" + delta + "]"));
System.out.println();
System.out.println("text: " + r.content());
System.out.println("finish: " + r.finishReason() + ", usage: " + r.usage());
server.stop(0);
}
}[Use][ `][List][.of][(1, 2, 3)][`][.]
text: Use `List.of(1, 2, 3)`.
finish: stop, usage: {"prompt_tokens":21,"completion_tokens":7,"total_tokens":28}The first output line shows each delta in brackets, in arrival order. loop chat prints them without brackets and flushes after each one, so the text grows on screen. Deltas are pieces of tokens chosen by the tokenizer, not words: List, .of, and the backtick are separate pieces.
The second line is the accumulated text. It goes into the conversation history after the stream ends.
The third line shows the two values that arrive after the text. The finish reason (stop) comes in a chunk with an empty delta. The usage (21 prompt tokens, 7 completion tokens, 28 in total) comes in a final chunk with an empty choices array. That chunk is sent because the request asked for it with stream_options.include_usage.
The role chunk at the start has "content": null. The call asText("") turns it into an empty string, and the client skips empty deltas.
Example 11: slash commands
A chat needs a way to control the program itself, such as clearing the history or changing a setting. Lines that start with / are commands for loop, not messages for the model. The command set follows ollama run:
| Command | Effect |
|---|---|
/set |
Changes a setting such as the temperature (how random the sampling is) |
/system |
Replaces the system prompt |
/clear |
Empties the history |
/save, /load |
Store and restore a conversation |
/bye |
Exits |
The program implements the parser and the state changes and runs a scripted sequence. Each scripted line is echoed with > in the output, followed by the reply of the command.
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
public class SlashCommands {
record Message(String role, String content) {}
record Saved(String system, Map<String, Object> settings, List<Message> turns) {}
String system = "You are a helpful assistant.";
final Map<String, Object> settings = new TreeMap<>(Map.of("temperature", 0.7));
List<Message> turns = new ArrayList<>(List.of(new Message("user", "hi"), new Message("assistant", "Hello.")));
final Path dir;
SlashCommands(Path dir) { this.dir = dir; }
/** Returns false when the chat should end. */
boolean handle(String line) throws Exception {
String[] p = line.strip().split("\\s+", 3);
switch (p[0]) {
case "/set" -> {
if (p.length < 3) { System.out.println("usage: /set <name> <value>"); return true; }
switch (p[1]) {
case "temperature", "top_p", "min_p" -> settings.put(p[1], Double.parseDouble(p[2]));
case "max_tokens", "top_k", "seed" -> settings.put(p[1], Integer.parseInt(p[2]));
default -> { System.out.println("unknown setting " + p[1]); return true; }
}
System.out.println("set " + p[1] + " = " + settings.get(p[1]));
}
case "/system" -> { system = line.strip().substring("/system".length()).strip(); System.out.println("system prompt replaced"); }
case "/clear" -> { turns = new ArrayList<>(); System.out.println("history cleared"); }
case "/save" -> {
new ObjectMapper().writerWithDefaultPrettyPrinter().writeValue(dir.resolve(p[1] + ".json").toFile(), new Saved(system, settings, turns));
System.out.println("saved " + turns.size() + " messages as " + p[1]);
}
case "/load" -> {
Saved s = new ObjectMapper().readValue(dir.resolve(p[1] + ".json").toFile(), Saved.class);
system = s.system(); settings.clear(); settings.putAll(s.settings()); turns = new ArrayList<>(s.turns());
System.out.println("loaded " + turns.size() + " messages from " + p[1]);
}
case "/bye" -> { return false; }
default -> System.out.println("unknown command " + p[0] + "; commands: /set /system /clear /save /load /bye");
}
return true;
}
public static void main(String[] args) throws Exception {
SlashCommands chat = new SlashCommands(Files.createTempDirectory("loop-sessions"));
for (String line : List.of("/set temperature 0.2", "/set max_tokens 256", "/set colour blue", "/system You review Java code.",
"/save review", "/clear", "/load review", "/help", "/bye", "/set temperature 1.0")) {
System.out.println("> " + line);
if (!chat.handle(line)) { System.out.println("bye"); break; }
}
System.out.println("settings " + chat.settings + ", system: " + chat.system + ", turns: " + chat.turns.size());
}
}> /set temperature 0.2
set temperature = 0.2
> /set max_tokens 256
set max_tokens = 256
> /set colour blue
unknown setting colour
> /system You review Java code.
system prompt replaced
> /save review
saved 2 messages as review
> /clear
history cleared
> /load review
loaded 2 messages from review
> /help
unknown command /help; commands: /set /system /clear /save /load /bye
> /bye
bye
settings {max_tokens=256, temperature=0.2}, system: You review Java code., turns: 2Each command changes one part of the state:
/setparses the value by the setting's type, sotemperaturebecomes a double andmax_tokensan integer. The next request carries them as JSON numbers. An unknown name such ascolouris rejected instead of being sent to the server./systemreplaces the system prompt for the following requests./savewrites the system prompt, the settings, and the turns as JSON with Jackson (records serialize by their component names)./clearempties the turns./loadrestores all three, so the turn count returns to 2./helpis not implemented in this stage, so it falls into the default branch and prints the list of commands./byeends the loop. The last scripted line (/set temperature 1.0) is never handled, so the final state still has temperature 0.2.
Saved sessions go to ~/.loop/sessions/ in the full program. Because the format is JSON, a saved conversation can be edited by hand, or replayed against another model with /load.
Example 12: JLine line reader with history and multi-line input
Reading input with System.in gives no line editing. There are no arrow keys, no history, and no way to type a message that spans several lines. A chat used for code needs all of these.
JLine is a Java library that provides a line editor like the one in a shell: arrow keys, history, Ctrl+R search, and completion. In JLine, a terminal object represents the screen and keyboard, and a line reader reads one line at a time from it. In a real terminal, JLine creates the system terminal. In a program that feeds text through a pipe, a dumb terminal (a terminal object without cursor control, here built over fixed streams) runs the same reader code.
The runnable program uses a dumb terminal with scripted input, so the output is the same every run. Multi-line input uses """ as the opening and closing marker, as ollama run does. Check in the output how the block between the markers becomes one message.
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import org.jline.reader.EndOfFileException;
import org.jline.reader.LineReader;
import org.jline.reader.LineReaderBuilder;
import org.jline.terminal.Terminal;
import org.jline.terminal.impl.DumbTerminal;
public class LineInputStage {
/** Reads one message; a line that starts with """ begins a block that ends at a line ending with """. */
static String readMessage(LineReader reader) {
String first = reader.readLine(">>> ");
if (!first.startsWith("\"\"\"")) return first;
StringBuilder block = new StringBuilder(first.substring(3));
while (!block.toString().endsWith("\"\"\"")) {
String next = reader.readLine("... ");
block.append(block.isEmpty() ? "" : "\n").append(next);
}
return block.substring(0, block.length() - 3).strip();
}
public static void main(String[] args) throws Exception {
String typed = "explain this\n\"\"\"\nfor (int i = 0; i <= n; i++)\n sum += a[i];\n\"\"\"\n/set temperature 0.2\nexplain this\n";
ByteArrayOutputStream screen = new ByteArrayOutputStream();
Terminal terminal = new DumbTerminal(new ByteArrayInputStream(typed.getBytes(StandardCharsets.UTF_8)), screen);
LineReader reader = LineReaderBuilder.builder().terminal(terminal)
.option(LineReader.Option.DISABLE_EVENT_EXPANSION, true).build();
try {
while (true) {
String msg = readMessage(reader);
System.out.println("message (" + msg.lines().count() + " lines): " + msg.replace("\n", "\\n"));
}
} catch (EndOfFileException e) {
System.out.println("end of input");
}
reader.getHistory().forEach(h -> System.out.println("history " + h.index() + ": " + h.line()));
System.out.println("prompts written: " + (screen.toString(StandardCharsets.UTF_8).split(">>> |\\.\\.\\. ", -1).length - 1));
}
}message (1 lines): explain this
message (2 lines): for (int i = 0; i <= n; i++)\n sum += a[i];
message (1 lines): /set temperature 0.2
message (1 lines): explain this
end of input
history 0: explain this
history 1: """
history 2: for (int i = 0; i <= n; i++)
history 3: """
history 4: /set temperature 0.2
history 5: explain this
prompts written: 8The scripted input holds three messages: a one-line question, a code block between """ markers, and a /set command, followed by the question again. The first output line is the one-line question. The second line is the code block: the reader returns it as one message with its two code lines joined by a newline, and the markers are removed. At the end of the input, readLine throws EndOfFileException. A real terminal produces the same exception for Ctrl+D at an empty prompt, so the chat ends cleanly.
The history holds every line read, including the lines inside the block, because JLine records lines, not messages. History index 0 is the first question, and the numbering continues from there.
DISABLE_EVENT_EXPANSION turns off the shell-style ! expansion. Without it, messages that contain ! (for example x != y) would be changed before they reach the program.
The last output line counts the prompts that JLine wrote to the screen stream. There is one per readLine call, plus one for the final attempt that hit the end of input.
The terminal version differs only in how the terminal is built and in a history file. It is marked java,ignore because it needs an interactive terminal, so the checker does not run it:
import java.nio.file.Path;
import org.jline.reader.EndOfFileException;
import org.jline.reader.LineReader;
import org.jline.reader.LineReaderBuilder;
import org.jline.reader.UserInterruptException;
import org.jline.terminal.Terminal;
import org.jline.terminal.TerminalBuilder;
public class TerminalInput {
public static void main(String[] args) throws Exception {
Terminal terminal = TerminalBuilder.builder().system(true).build();
LineReader reader = LineReaderBuilder.builder().terminal(terminal)
.variable(LineReader.HISTORY_FILE, Path.of(System.getProperty("user.home"), ".loop", "history"))
.option(LineReader.Option.DISABLE_EVENT_EXPANSION, true)
.build();
while (true) {
try {
String line = reader.readLine(">>> ");
if (line.equals("/bye")) break;
terminal.writer().println("you typed: " + line);
} catch (UserInterruptException e) { // Ctrl+C at the prompt clears the line
terminal.writer().println("(use /bye or Ctrl+D to exit)");
} catch (EndOfFileException e) { // Ctrl+D at an empty prompt
break;
}
}
reader.getHistory().save();
}
}TerminalBuilder.builder().system(true).build() connects to the real terminal and puts it in raw mode, which means JLine sees each key press as it happens. HISTORY_FILE makes the history persistent: JLine loads the file at start and appends to it, so the up arrow recalls lines from earlier sessions.
Two keys have special meaning at the prompt. Ctrl+C throws UserInterruptException, which the loop turns into a hint instead of an exit. Ctrl+D on an empty line throws EndOfFileException, which ends the loop. Ctrl+C during a generation is a different case, handled in the next stage.
When standard input is not a terminal (loop chat < questions.txt), loop chat reads plain lines without prompts, so scripts get clean output. To detect this case, use System.console() on Java 21, which returns null when there is no terminal. On Java 22 and later, System.console().isTerminal() answers the same question.
Example 13: Ctrl+C cancels a generation, not the program
During a long answer, the user may want to stop it and ask something else. They press Ctrl+C. By default the terminal sends SIGINT to the JVM, which starts shutdown and ends loop completely. That is the wrong behavior for a chat.
The fix is to install a handler for the signal. JLine's terminal lets the program do this with terminal.handle(Signal.INT, handler). The handler only sets a flag (a boolean that other code can read). The streaming loop checks the flag between chunks. When the flag is set, the loop closes the stream and returns to the prompt.
The program raises the signal on a dumb terminal after the third delta. This calls the same handler that a real Ctrl+C calls. The output shows what the user saw, that the program survived, and that the server stopped early.
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import org.jline.terminal.Terminal;
import org.jline.terminal.impl.DumbTerminal;
public class CancelStage {
public static void main(String[] args) throws Exception {
ObjectMapper json = new ObjectMapper();
AtomicInteger served = new AtomicInteger();
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/v1/chat/completions", ex -> {
ex.sendResponseHeaders(200, 0);
try (OutputStream out = ex.getResponseBody()) {
for (int i = 0; i < 200; i++) { // a long answer, one word every 20 ms
out.write(("data: {\"choices\":[{\"delta\":{\"content\":\"word" + i + " \"}}]}\n\n").getBytes(StandardCharsets.UTF_8));
out.flush();
served.incrementAndGet();
Thread.sleep(20);
}
} catch (Exception clientWentAway) { }
});
server.start();
Terminal terminal = new DumbTerminal(new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream());
AtomicBoolean cancel = new AtomicBoolean();
terminal.handle(Terminal.Signal.INT, signal -> cancel.set(true)); // Ctrl+C sets a flag only
HttpResponse<InputStream> resp = HttpClient.newHttpClient().send(HttpRequest.newBuilder(
URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/v1/chat/completions"))
.POST(HttpRequest.BodyPublishers.ofString("{\"stream\":true,\"messages\":[]}")).build(), HttpResponse.BodyHandlers.ofInputStream());
StringBuilder shown = new StringBuilder();
int deltas = 0;
try (BufferedReader in = new BufferedReader(new InputStreamReader(resp.body(), StandardCharsets.UTF_8))) {
for (String line; (line = in.readLine()) != null; ) {
if (cancel.get()) break; // leaving the block closes the stream
if (!line.startsWith("data: ")) continue;
shown.append(json.readTree(line.substring(6)).at("/choices/0/delta/content").asText());
if (++deltas == 3) terminal.raise(Terminal.Signal.INT); // the user presses Ctrl+C here
}
}
System.out.println("shown: " + shown.toString().strip() + " ^C");
System.out.println("generation cancelled after " + deltas + " deltas; program still running: true");
Thread.sleep(300);
System.out.println("server stopped sending early: " + (served.get() < 200));
server.stop(0);
}
}shown: word0 word1 word2 ^C
generation cancelled after 3 deltas; program still running: true
server stopped sending early: trueThe first output line is what the user saw: three words, then ^C. The handler ran when the signal was raised and set the flag. The loop saw the flag before it processed the next line and left the try block, which closed the response body.
The second line shows that the program continues. It is printed by the same JVM that received the signal, which would have exited under the default behavior.
The third line checks the server side. After the client closed the stream, the server's next writes failed and it stopped well before 200 words.
Closing the stream is the cancellation signal for the server too. llama-server checks for a closed connection while streaming (is_connection_closed in tools/server/server-http.cpp) and stops the task, which frees the slot for the next request. vLLM aborts a request whose client disconnected. A client that only stopped printing but kept the connection open would keep the server busy until max_tokens, which wastes compute that the next request needs.
The partial answer is not added to the history, because the model's next turn should not continue a sentence the user cut off. loop chat keeps the partial answer on screen only.
Example 14: displaying reasoning separately from the answer
Some models think before they answer. They write out their reasoning first, and then the final answer. The user usually wants to see the answer clearly, and sometimes to see the thinking in a different style.
Servers deliver the thinking in one of two ways. Either it comes in a separate field (reasoning_content from llama-server, reasoning from vLLM, see Serving with vLLM), or, when the server does not split it, it arrives inside <think> tags in the content.
loop chat shows thinking in a dimmed style, or with a thinking: label when colors are off, and keeps only the answer in the history. The program handles all three shapes of stream and a plain model without thinking. In the output, check that each case ends with the right text under thinking: and answer:.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
public class ReasoningStage {
static final ObjectMapper JSON = new ObjectMapper();
static class Display {
final StringBuilder thinking = new StringBuilder(), answer = new StringBuilder();
boolean insideTag;
void onDelta(JsonNode delta) {
String r = delta.hasNonNull("reasoning_content") ? delta.get("reasoning_content").asText()
: delta.path("reasoning").asText("");
if (!r.isEmpty()) thinking.append(r);
String c = delta.path("content").asText("");
while (!c.isEmpty()) { // split <think>...</think> inside content
String marker = insideTag ? "</think>" : "<think>";
int at = c.indexOf(marker);
String part = at < 0 ? c : c.substring(0, at);
(insideTag ? thinking : answer).append(part);
if (at < 0) break;
insideTag = !insideTag;
c = c.substring(at + marker.length());
}
}
void print(String label) {
System.out.println(label);
if (!thinking.isEmpty()) System.out.println(" thinking: " + thinking.toString().strip());
System.out.println(" answer: " + answer.toString().strip());
}
}
static void run(String label, List<String> deltas) throws Exception {
Display d = new Display();
for (String delta : deltas) d.onDelta(JSON.readTree(delta));
d.print(label);
}
public static void main(String[] args) throws Exception {
run("llama-server (reasoning_content):", List.of("{\"reasoning_content\":\"n starts at 0, \"}",
"{\"reasoning_content\":\"so <= reads one past the end.\"}", "{\"content\":\"Use i < n.\"}"));
run("vLLM (reasoning):", List.of("{\"reasoning\":\"Strings are immutable.\"}", "{\"content\":\"Use StringBuilder.\"}"));
run("no parser (<think> in content):", List.of("{\"content\":\"<think>\"}", "{\"content\":\"Check null first.\"}",
"{\"content\":\"</think>\"}", "{\"content\":\"Add Objects.requireNonNull(x).\"}"));
run("plain model:", List.of("{\"content\":\"Use a record.\"}"));
}
}llama-server (reasoning_content):
thinking: n starts at 0, so <= reads one past the end.
answer: Use i < n.
vLLM (reasoning):
thinking: Strings are immutable.
answer: Use StringBuilder.
no parser (<think> in content):
thinking: Check null first.
answer: Add Objects.requireNonNull(x).
plain model:
answer: Use a record.The first block comes from llama-server's field, the second from vLLM's, and the third from a server without a reasoning parser, where the tags arrive in the content and the display splits them out. The last block is a model without thinking, so there is no thinking: line.
In each case answer is what goes into the history. Thinking text is left out of later requests. The Qwen3 chat template (models/templates/Qwen-Qwen3-0.6B.jinja in llama.cpp) renders thinking only for assistant messages after the last user message. Thinking from earlier turns would therefore be dropped from the prompt anyway, and sending it would only cost request size.
The tag splitter keeps state (insideTag) between deltas, because the opening and closing tags arrive in different chunks. It assumes that a tag is not split across two deltas. For Qwen3 and DeepSeek-R1 models, <think> and </think> are single special tokens, so each arrives whole. A splitter for arbitrary text would also buffer a trailing < until the next delta shows whether a tag starts.
Example 15: profiles: system prompt and sampling settings in JSON files
Different tasks need different settings. A code reviewer works best at low temperature with a long answer limit. A brainstorming helper works better at a higher temperature. Typing these settings each time is slow, so they are stored in files.
A profile is a JSON file in ~/.loop/profiles/ that stores a system prompt and sampling settings under a name. loop chat --profile reviewer loads it. Sampling settings control how the model picks the next token: temperature sets how random the choice is, top_p limits the choice to the most likely tokens, max_tokens limits the answer length, and seed makes random choices repeatable.
The program writes two profiles, loads one with Jackson into a record, and merges it into a request. When a value is given in more than one place, the command-line value wins. The output shows the listing, the loaded record, two requests, and the file Jackson wrote.
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.nio.file.Files;
import java.nio.file.Path;
public class ProfilesStage {
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
record Profile(String system, Double temperature, @JsonProperty("top_p") Double topP,
@JsonProperty("max_tokens") Integer maxTokens, Integer seed) {}
static final ObjectMapper JSON = new ObjectMapper();
static ObjectNode request(String model, Profile p, Double cliTemperature) {
ObjectNode r = JSON.createObjectNode().put("model", model);
if (p.temperature() != null) r.put("temperature", p.temperature());
if (p.topP() != null) r.put("top_p", p.topP());
if (p.maxTokens() != null) r.put("max_tokens", p.maxTokens());
if (p.seed() != null) r.put("seed", p.seed());
if (cliTemperature != null) r.put("temperature", cliTemperature); // the command line wins
r.putArray("messages").addObject().put("role", "system").put("content", p.system());
return r;
}
public static void main(String[] args) throws Exception {
Path dir = Files.createTempDirectory("profiles");
Files.writeString(dir.resolve("reviewer.json"), """
{"system": "You review Java code. List bugs first, then style.", "temperature": 0.2,
"top_p": 0.9, "max_tokens": 1024, "seed": 42, "notes": "used for PR reviews"}""");
JSON.writeValue(dir.resolve("ideas.json").toFile(), new Profile("Suggest three alternatives.", 1.0, null, 400, null));
try (var files = Files.list(dir)) {
files.map(f -> f.getFileName().toString().replace(".json", "")).sorted().forEach(n -> System.out.println("profile: " + n));
}
Profile reviewer = JSON.readValue(dir.resolve("reviewer.json").toFile(), Profile.class);
System.out.println(reviewer);
System.out.println(JSON.writeValueAsString(request("coder", reviewer, null)));
System.out.println(JSON.writeValueAsString(request("coder", reviewer, 0.0)));
System.out.println(Files.readString(dir.resolve("ideas.json")));
}
}profile: ideas
profile: reviewer
Profile[system=You review Java code. List bugs first, then style., temperature=0.2, topP=0.9, maxTokens=1024, seed=42]
{"model":"coder","temperature":0.2,"top_p":0.9,"max_tokens":1024,"seed":42,"messages":[{"role":"system","content":"You review Java code. List bugs first, then style."}]}
{"model":"coder","temperature":0.0,"top_p":0.9,"max_tokens":1024,"seed":42,"messages":[{"role":"system","content":"You review Java code. List bugs first, then style."}]}
{"system":"Suggest three alternatives.","temperature":1.0,"max_tokens":400}The listing shows both profiles by file name. Loading reviewer.json fills the record. The unknown field notes is ignored, so users can annotate their files.
The first request carries the profile's values as typed JSON numbers. The second request is what loop chat --profile reviewer --temperature 0 would send: the command-line value replaces the profile's 0.2. The order of precedence is built-in defaults, then the profile, then the command line.
The last line is the file Jackson wrote for ideas. NON_NULL leaves out the fields that were not set, so the server's own defaults apply to them (or the model's generation_config.json on vLLM).
A seed with a temperature above zero makes sampling repeatable on the same server and build. This helps when comparing two system prompts, because the random choices stay the same. It does not make two different engines produce the same text.
Example 16: the full loop chat (Project 13) and a real session against llama-server
Project 13 joins the chat stages into one command. loop chat takes a base URL (Integration B, an engine that already runs). As loop run MODEL, it starts loop serve's supervisor first (Integration A) and then chats with it.
The program below is the chat part in one class. It has picocli options, the conversation with trimming to the context budget, streaming, a subset of the slash commands, and a short form of the reasoning display. Without arguments, it starts a fake server and feeds a scripted session through standard input, so it runs in the checker. With arguments, it is a working client for any OpenAI-compatible server. In the output, each >>> line is an input line, followed by the reply and a bracketed status line.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.sun.net.httpserver.HttpServer;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
@Command(name = "chat", mixinStandardHelpOptions = true, description = "Chat with an OpenAI-compatible server.")
public class LoopChat implements Callable<Integer> {
@Option(names = "--url", required = true, description = "Base URL ending in /v1") String url;
@Option(names = "--model", defaultValue = "default", description = "Model name sent in requests") String model;
@Option(names = "--system", defaultValue = "You are a helpful coding assistant.") String system;
@Option(names = "--temperature") Double temperature;
@Option(names = "--max-tokens", defaultValue = "256") int maxTokens;
@Option(names = "--context", defaultValue = "4096", description = "Context size for trimming") int context;
static final ObjectMapper JSON = new ObjectMapper();
record Message(String role, String content) {}
final List<Message> turns = new ArrayList<>();
int lastPromptTokens;
public Integer call() throws Exception {
BufferedReader in = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
for (String line; (line = in.readLine()) != null; ) {
if (line.isBlank()) continue;
System.out.println(">>> " + line);
if (line.startsWith("/")) {
String[] p = line.split("\\s+", 3);
switch (p[0]) {
case "/bye" -> { return 0; }
case "/clear" -> { turns.clear(); System.out.println("history cleared"); }
case "/set" -> { if (p[1].equals("temperature")) temperature = Double.valueOf(p[2]); System.out.println("set " + p[1] + " = " + p[2]); }
case "/system" -> { system = line.substring(8).strip(); System.out.println("system prompt replaced"); }
default -> System.out.println("unknown command " + p[0]);
}
continue;
}
turns.add(new Message("user", line));
String answer = streamAnswer();
turns.add(new Message("assistant", answer));
}
return 0;
}
String streamAnswer() throws Exception {
ObjectNode req = JSON.createObjectNode().put("model", model).put("max_tokens", maxTokens).put("stream", true);
if (temperature != null) req.put("temperature", temperature);
req.putObject("stream_options").put("include_usage", true);
ArrayNode msgs = req.putArray("messages");
msgs.addObject().put("role", "system").put("content", system);
int budget = context - maxTokens, used = 0, first = turns.size();
while (first > 0 && used + 4 + turns.get(first - 1).content().length() / 4 <= budget) used += 4 + turns.get(--first).content().length() / 4;
for (Message m : turns.subList(first, turns.size())) msgs.addObject().put("role", m.role()).put("content", m.content());
HttpResponse<InputStream> resp = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create(url + "/chat/completions"))
.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(req))).build(),
HttpResponse.BodyHandlers.ofInputStream());
if (resp.statusCode() != 200) { String err = new String(resp.body().readAllBytes(), StandardCharsets.UTF_8); System.out.println("error " + resp.statusCode() + ": " + err); return ""; }
StringBuilder answer = new StringBuilder();
String finish = "?";
JsonNode usage = null;
try (BufferedReader sse = new BufferedReader(new InputStreamReader(resp.body(), StandardCharsets.UTF_8))) {
for (String line; (line = sse.readLine()) != null && !line.equals("data: [DONE]"); ) {
if (!line.startsWith("data: ")) continue;
JsonNode chunk = JSON.readTree(line.substring(6));
if (chunk.hasNonNull("usage")) usage = chunk.get("usage");
for (JsonNode c : chunk.path("choices")) {
JsonNode d = c.path("delta");
String thinking = d.hasNonNull("reasoning_content") ? d.get("reasoning_content").asText() : d.path("reasoning").asText("");
if (!thinking.isEmpty()) System.out.print("(thinking: " + thinking + ") ");
String text = d.path("content").asText("");
System.out.print(text);
System.out.flush();
answer.append(text);
if (c.hasNonNull("finish_reason")) finish = c.get("finish_reason").asText();
}
}
}
System.out.println();
if (usage != null) {
lastPromptTokens = usage.path("prompt_tokens").asInt();
System.out.println("[" + finish + ", " + msgs.size() + " messages, prompt " + lastPromptTokens + " tokens, cached "
+ usage.path("prompt_tokens_details").path("cached_tokens").asInt(0) + ", output " + usage.path("completion_tokens").asInt() + "]");
}
return answer.toString();
}
public static void main(String[] args) throws Exception {
if (args.length > 0) { System.exit(new CommandLine(new LoopChat()).execute(args)); }
HttpServer fake = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
fake.createContext("/v1/chat/completions", ex -> {
JsonNode req = JSON.readTree(ex.getRequestBody());
JsonNode msgs = req.get("messages");
String reply = "You said '" + msgs.get(msgs.size() - 1).get("content").asText() + "' (temperature " + req.path("temperature").asText("default") + ").";
ex.sendResponseHeaders(200, 0);
try (OutputStream out = ex.getResponseBody()) {
for (String w : reply.split("(?<= )")) out.write(("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":" + JSON.writeValueAsString(w) + "}}]}\n\n").getBytes(StandardCharsets.UTF_8));
out.write(("data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n").getBytes(StandardCharsets.UTF_8));
out.write(("data: {\"choices\":[],\"usage\":{\"prompt_tokens\":" + (12 * msgs.size()) + ",\"completion_tokens\":" + reply.split(" ").length
+ ",\"prompt_tokens_details\":{\"cached_tokens\":" + (12 * (msgs.size() - 1)) + "}}}\n\ndata: [DONE]\n\n").getBytes(StandardCharsets.UTF_8));
}
});
fake.start();
System.setIn(new ByteArrayInputStream("hello\n/set temperature 0.2\nhow are you?\n/clear\nagain\n/bye\nnever read\n".getBytes(StandardCharsets.UTF_8)));
int code = new CommandLine(new LoopChat()).execute("--url", "http://127.0.0.1:" + fake.getAddress().getPort() + "/v1", "--model", "coder");
System.out.println("exit code " + code);
fake.stop(0);
}
}>>> hello
You said 'hello' (temperature default).
[stop, 2 messages, prompt 24 tokens, cached 12, output 5]
>>> /set temperature 0.2
set temperature = 0.2
>>> how are you?
You said 'how are you?' (temperature 0.2).
[stop, 4 messages, prompt 48 tokens, cached 36, output 7]
>>> /clear
history cleared
>>> again
You said 'again' (temperature 0.2).
[stop, 2 messages, prompt 24 tokens, cached 12, output 5]
>>> /bye
exit code 0The scripted session shows the parts working together:
- The first message goes out with the system prompt and one user turn (2 messages). The fake server reports the temperature as
defaultbecause none was set. - After
/set temperature 0.2, the second message carries the new temperature and the history: system prompt, first question, first answer, and the new question (4 messages). - The usage line shows 36 of the 48 prompt tokens as cached. This is the fake server's imitation of a prompt cache that already holds the earlier messages.
/clearempties the turns, so the third message goes out with 2 messages again./byeends the loop before the last scripted line, and the command returns exit code 0.
The real session runs the same class against the pinned llama-server with the tiny model, started as in Example 8 with -c 4096 -np 1 --alias tiny. The input is piped in, so the chat reads plain lines:
printf 'Write hello world in Java.\n/set temperature 0\nAgain, shorter.\n/bye\n' | \
java -cp "jackson-databind.jar:jackson-core.jar:jackson-annotations.jar:picocli-4.7.7.jar:." \
LoopChat --url http://127.0.0.1:8080/v1 --model tiny --max-tokens 12Here is the real output. It comes from the pinned llama.cpp build and the tiny random-weight model, so the generated text is meaningless. The token counts and cache hits are real:
>>> Write hello world in Java.
尕">
weights publishers Với Budgetacher("(%.Rowḍ Invent Với
[length, 2 messages, prompt 26 tokens, cached 0, output 12]
>>> /set temperature 0
set temperature = 0
>>> Again, shorter.
›了几了几了几了几了几)!=-riskחס">
פרד了几
[length, 4 messages, prompt 53 tokens, cached 33, output 12]
>>> /byeThe protocol works end to end. The text streams in pieces. With random weights it is a mix of scripts and code fragments. Each answer ends with length, because the 12-token limit stops a model that never produces an end token.
The token numbers show how the prompt cache behaves. The first prompt is 26 tokens: the system prompt and the question, wrapped in the model's chat template, with nothing cached on a fresh server. The second request carries 4 messages and 53 tokens, because it repeats the first exchange. Of those, 33 come from llama-server's cache: the 26 tokens of the first prompt plus the first 7 tokens of the first answer, which the slot still holds from generating them. The match stops at the first position where tokenizing the answer text again gives a different token than the one the model generated. This happens often with the odd tokens that random weights produce (llama-server: Endpoints, Slots, and Prompt Cache explains cache_n and cached_tokens).
The /set temperature 0 line changed the second request only. The first request used the server's default temperature.
The complete Project 13 adds the remaining stages to this program:
- The JLine reader with history and
"""blocks (Example 12), when standard input is a terminal. - The Ctrl+C handler (Example 13).
- Dimmed reasoning (Example 14).
--profile(Example 15)./saveand/load(Example 11).- Project 11's
ChatClient.streamin place of the inline request code ofstreamAnswer. loop run MODEL, which creates aLlamaServerBackendfrom the registry, callsensureReady(), and passes itsbaseUrl()to the chat.
A later section turns this chat loop into the agent loop by adding tools.
Interview points
- A local LLM tool needs no extra wrapper server. It supervises the engine's own OpenAI-compatible server as a child process, or connects to one that runs elsewhere, and it hides the difference behind one
Backendinterface. - Supervising an engine needs a free port from
ServerSocket(0), a reader thread that drains the child's output, health polling that treats connection refused and 503 as "not ready", crash detection that reports the last log lines, and an overall deadline. - An unread stdout or stderr pipe blocks a chatty child process once the pipe buffer fills, so the supervisor must read the output continuously.
Process.destroy()closes the parent's pipe ends, whileProcessHandle.destroy()only sends the signal, so the log can still be read to the end.- Killing a parent process does not kill its children on Linux, so collect
descendants()before killing the root. - Shutdown hooks run on normal exit, SIGINT, and SIGTERM, but not on SIGKILL, so a pid file covers the remaining case.
- Idle unload measures from the end of the last request and never stops an engine that has an active request.
- vLLM refuses connections while it loads and needs a much longer startup deadline than llama-server, which answers 503 while it loads.
- A chat request carries the whole conversation, so trimming must keep the system prompt and start the window at a user turn.
- Cancelling a stream by closing the connection also stops the server's generation and frees its slot.
- Reasoning arrives as
reasoning_content(llama-server), asreasoning(vLLM), or as<think>tags in the content, and only the answer goes back into the history.
Summary
Backendhas the methodsname(),baseUrl(),ensureReady(), andclose(), andRemoteBackend.close()does nothing because the server is not its to stop.- The llama-server command is
-m <gguf> --host 127.0.0.1 --port <free> -c <ctx * slots> -np <slots> -ngl <from loop fit> --alias <name> --no-webui. - The Qwen2.5-Coder-7B shape needs 56 KiB of F16 KV cache per token, and at 16K context on a 4 GiB GPU, 17 of 29 layers fit by this estimate.
- Health polling backs off from 50 ms to 1 s, while Ollama polls every 250 ms.
- A child that is stopped by SIGTERM exits with code 143.
- The vLLM command adds
--host 127.0.0.1,--served-model-name,--max-model-len,--gpu-memory-utilization, the parser flags, and--enable-prompt-tokens-details. loop serveis Project 12, andloop chatandloop runare Project 13.- The slash commands are
/set,/system,/clear,/save,/load, and/bye, and sessions are saved as JSON. - JLine's
LineReadergives history (HISTORY_FILE) and editing, throwsUserInterruptExceptionfor Ctrl+C at the prompt andEndOfFileExceptionfor Ctrl+D, and"""opens and closes a multi-line message. terminal.handle(Signal.INT, ...)replaces the JVM's default Ctrl+C behavior during a generation.- Profiles in
~/.loop/profiles/*.jsonhold a system prompt and sampling settings, and command-line values override them.
