Teach a Local Model an Agent Command With LoRA
A step-by-step Mac tutorial that shows what LoRA changes, how to test it, and how a Quarkus app can use the result.
When developers see agents in action, the discussion usually lands on harnesses or raw model capability. If the result is weak, the next suggestion is predictable: use a bigger model, add a smarter planner, bolt on more tools, or find a fancier framework that promises to keep the whole thing under control.
Some of that helps. Some of it is also a distraction. Agent systems do not fail only because the model is too small or the harness is too simple. They also fail because the model is unreliable at the tiny private contracts that make cooperative behavior possible: route labels, planner tags, tool payloads, hand-off markers, or one odd internal command that means “switch into protocol mode now.”
That is the part people tend to miss. Effective agent teams are not just a story about capabilities. They are a story about capabilities plus adapters. Tools give the model reach. Adapters can give it a repeatable behavior inside one narrow lane, which is often the difference between “interesting demo” and “system I can actually wire into code.”
This tutorial uses a deliberately small example to make that visible. We are going to teach a local model one agent-specific trick and then make Quarkus prove whether it learned the trick or not.
Here is the prompt that should make an agent developer slightly nervous:
devcard Dev Services with PostgreSQLA human can guess what that means. A small local model usually cannot. It may ignore devcard, turn the whole thing into a normal explanation, or produce JSON that looks close enough to fool you right up to the moment your parser throws an exception.
That is a real agent problem. Prompting helps, but small models are not consistently obedient just because we put “return JSON only” in uppercase.
This is where LoRA becomes interesting in a way that is easy to show and honest to explain. We are not going to teach the model all of Quarkus. We are going to teach it one repeatable habit:
when the prompt contains
devcard, emit a strict JSON objectwhen the prompt does not contain
devcard, answer like a normal assistant
That sounds small because it is small. That is also why it makes a good tutorial. The adapter effect is visible, the Quarkus app stays understandable, and the agentic point is hard to miss.
By the end, we will have a local Quarkus app that compares the base model with the adapted one side by side, validates the output, and renders a deterministic developer card only when the model follows the contract.
What We Build
The flow is intentionally boring:
The contract looks like this:
{
"command": "devcard",
"topic": "dev-services",
"technologies": ["postgresql"],
"includeExample": true,
"includeWarning": true
}The model is only responsible for producing that object. The Quarkus app does the rest. That split matters because it is how real agent systems stay sane: let the model classify or structure the request, then let ordinary code validate, route, and render.
One more detail is worth saying out loud before we start. The MLX LM docs note that if you train against a quantized model, the command uses QLoRA under the covers. We are going to use the default 4-bit MLX model from the README, so the mechanics here are technically QLoRA. I am still using “LoRA” in the article title and the everyday explanation because that is the umbrella term most people already know.
What You Need
You need one Mac and a little patience for the first model download. I have not uploaded the sources to my Github because it’s easy to follow and I really want you to play with the LoRA approach and not the Quarkus features here.
Apple Silicon Mac
Java 21 installed
Python 3 installed
Quarkus CLI installed
Enough disk space for one local MLX model and adapter artifacts
Internet access for the first model download from Hugging Face
Two free local ports:
8080for MLX LM and8081for QuarkusSome more ☕️☕️ this time. Mostly because there is way more Python in here than I normally accept.
I am keeping the Java side on plain Quarkus REST plus the REST client. We could hide the model call behind LangChain4j, but I would not do that for the first pass. The interesting part here is the adapter, not another layer of abstraction. BUT make sure to not do this in you production apps. This is a concept implementation to help you understand all of this better!
Create the Base Project
Let’s create a workspace for the app and the training artifacts:
mkdir devcard-lora-demo
cd devcard-lora-demoCreate the Quarkus app :
quarkus create app dev.mthread:devcard-agent \
--extension='rest-jackson,rest-client-jackson'The two extensions are enough for this walkthrough:
rest-jacksongives us a small JSON API surfacerest-client-jacksonlets Quarkus call the local MLX LM HTTP server
mkdir -p trainer/data
mkdir -p trainer/adapters
python3 -m venv .venv
source .venv/bin/activate
pip install -U "mlx-lm[train]"You should end up with this shape:
devcard-lora-demo/
├── .venv/
├── devcard-agent/
└── trainer/
├── adapters/
└── data/The model runtime and the Java app live side by side, but they stay separate. That keeps the demo easy to reason about. The training artifacts belong to trainer/. The Quarkus app only knows that there is a local model endpoint and an adapter path string it can send in a request.
Teach the Model One New Habit
The devcard word is not magic. It is just a token pattern we decide to make meaningful. The training data teaches two behaviors at the same time:
When the prompt includes
devcard, return JSON onlyWhen the prompt does not include
devcard, answer normally
That second half matters more than people think. If you only train the command path, the model tends to overlearn the trick and starts spitting JSON at prompts that were supposed to stay conversational.
Create trainer/build_dataset.py:
from __future__ import annotations
import json
import random
from pathlib import Path
ROOT = Path(__file__).resolve().parent
DATA_DIR = ROOT / "data"
RANDOM = random.Random(7)
CARD_DEFINITIONS = [
{
"label": "Dev Services with PostgreSQL",
"topic": "dev-services",
"technologies": ["postgresql"],
"normal": (
"Quarkus Dev Services starts required infrastructure automatically during "
"development and tests. If your application needs PostgreSQL and you did "
"not configure a JDBC URL, Quarkus can start a container for you so you "
"can stay focused on the application.\n\n"
"Example:\n\n"
"```properties\n"
"quarkus.datasource.db-kind=postgresql\n"
"```\n\n"
"Practical warning: this is a local convenience. Production still needs "
"an explicit datasource configuration."
),
},
{
"label": "Dev Services with Kafka",
"topic": "dev-services",
"technologies": ["kafka"],
"normal": (
"Dev Services can also start Kafka automatically during local development. "
"That is useful when you want messaging in `quarkus:dev` without managing "
"a broker yourself.\n\n"
"Example:\n\n"
"```properties\n"
"mp.messaging.outgoing.orders.connector=smallrye-kafka\n"
"```\n\n"
"Practical warning: startup feels simple until the container runtime is "
"missing or blocked, so keep that dependency visible in the sample."
),
},
{
"label": "REST Client with Jackson",
"topic": "rest-client",
"technologies": ["jackson"],
"normal": (
"Quarkus REST Client with Jackson gives you a typed Java interface for HTTP "
"calls and handles JSON serialization for request and response payloads.\n\n"
"Example:\n\n"
"```java\n"
"@Path(\"/extensions\")\n"
"@RegisterRestClient(configKey = \"extensions-api\")\n"
"public interface ExtensionsClient {\n"
" @GET\n"
" Set<Extension> list();\n"
"}\n"
"```\n\n"
"Practical warning: treat remote calls like remote calls. A clean Java "
"interface does not remove timeout, retry, and failure concerns."
),
},
{
"label": "Panache entity basics",
"topic": "panache",
"technologies": ["hibernate-orm"],
"normal": (
"Panache removes a lot of Hibernate ORM boilerplate in Quarkus by giving "
"you a more direct entity or repository model.\n\n"
"Example:\n\n"
"```java\n"
"@Entity\n"
"public class Book extends PanacheEntity {\n"
" public String title;\n"
"}\n"
"```\n\n"
"Practical warning: Panache makes persistence code shorter, not free. Keep "
"business logic out of entities unless you really want that coupling."
),
},
{
"label": "Typed config with Config Mapping",
"topic": "config-mapping",
"technologies": ["smallrye-config"],
"normal": (
"Quarkus `@ConfigMapping` turns related configuration keys into a typed Java "
"interface instead of a stringly-typed scavenger hunt.\n\n"
"Example:\n\n"
"```java\n"
"@ConfigMapping(prefix = \"shipping\")\n"
"public interface ShippingConfig {\n"
" URI endpoint();\n"
" Duration timeout();\n"
"}\n"
"```\n\n"
"Practical warning: typed config is only safer if the property names and "
"scopes stay boring and consistent."
),
},
{
"label": "Continuous testing in dev mode",
"topic": "continuous-testing",
"technologies": ["junit"],
"normal": (
"Continuous testing reruns relevant tests while you stay in `quarkus:dev`, "
"which tightens the feedback loop without another manual test command.\n\n"
"Example:\n\n"
"```bash\n"
"./mvnw quarkus:dev\n"
"```\n\n"
"Practical warning: quick feedback only helps when the tests say something "
"useful. Bad tests just fail faster."
),
},
]
COMMAND_TEMPLATES = [
"devcard {label}",
"devcard: {label}",
"please run devcard for {label}",
"use devcard for {label}",
]
NORMAL_TEMPLATES = [
"Explain {label} in Quarkus.",
"Give me a short explanation of {label}.",
"How does {label} work in Quarkus?",
]
def json_contract(definition: dict[str, object]) -> dict[str, object]:
return {
"command": "devcard",
"topic": definition["topic"],
"technologies": definition["technologies"],
"includeExample": True,
"includeWarning": True,
}
def command_rows() -> list[dict[str, object]]:
rows = []
for definition in CARD_DEFINITIONS:
expected = json_contract(definition)
answer = json.dumps(expected, separators=(",", ":"))
for template in COMMAND_TEMPLATES:
rows.append(
{
"kind": "command",
"expected": expected,
"messages": [
{
"role": "user",
"content": template.format(label=definition["label"]),
},
{
"role": "assistant",
"content": answer,
},
],
}
)
return rows
def normal_rows() -> list[dict[str, object]]:
rows = []
for definition in CARD_DEFINITIONS:
for template in NORMAL_TEMPLATES:
rows.append(
{
"kind": "normal",
"messages": [
{
"role": "user",
"content": template.format(label=definition["label"]),
},
{
"role": "assistant",
"content": definition["normal"],
},
],
}
)
return rows
def write_jsonl(path: Path, rows: list[dict[str, object]]) -> None:
with path.open("w", encoding="utf-8") as handle:
for row in rows:
handle.write(json.dumps(row, ensure_ascii=False))
handle.write("\n")
def main() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
rows = command_rows() + normal_rows()
RANDOM.shuffle(rows)
total = len(rows)
train_cutoff = int(total * 0.7)
valid_cutoff = int(total * 0.85)
train_rows = rows[:train_cutoff]
valid_rows = rows[train_cutoff:valid_cutoff]
test_rows = rows[valid_cutoff:]
write_jsonl(DATA_DIR / "train.jsonl", train_rows)
write_jsonl(DATA_DIR / "valid.jsonl", valid_rows)
write_jsonl(DATA_DIR / "test.jsonl", test_rows)
summary = {
"train": len(train_rows),
"valid": len(valid_rows),
"test": len(test_rows),
"total": total,
}
print(json.dumps(summary, indent=2))
if __name__ == "__main__":
main()Build the dataset:
python trainer/build_dataset.pyThe MLX LM LoRA guide says local datasets need train.jsonl and optionally valid.jsonl, with test.jsonl used for evaluation. It also says unknown keys are ignored by the loader. That is why the script adds kind and expected metadata for our evaluation step without breaking training.
The output count is intentionally small. That is enough to make the behavior visible on a local Mac. It is not enough to brag about a robust benchmark. If you want stronger results, add more phrasing variation before you add more topics.
Train the Adapter
The current MLX LM README uses mlx-community/Llama-3.2-3B-Instruct-4bit as the default quick-start model, so I am sticking with that here. It is a safer tutorial choice than inventing a random model pick.
Train from the project root:
MODEL="mlx-community/Llama-3.2-3B-Instruct-4bit"
mlx_lm.lora \
--model "$MODEL" \
--train \
--data trainer/data \
--adapter-path trainer/adapters/devcard-lora \
--mask-prompt \
--iters 300 \
--batch-size 1 \
--learning-rate 1e-5There are three details worth calling out:
--mask-promptis important for this dataset shape because we only want the loss on the assistant answer, not on the prompt tokensthe model path points at a quantized 4-bit model, so per the MLX LM docs this run is effectively QLoRA
the adapter is saved separately from the base model, which is exactly what we want for the comparison later
You can inspect the adapter size after training:
du -sh trainer/adapters/devcard-loraThat number is the easiest way to make “parameter-efficient” stop sounding like a conference slide. The base model stays where it is. The adapter is the learned delta. In my demo case it has 107M.
Measure the Behavior Before You Touch Java
The MLX LM docs include a --test mode that calculates perplexity, and that is fine as far as it goes. For this tutorial, I care more about contract obedience than a language-model metric. I want to know how often the model emits valid devcard JSON when it should, and how often it wrongly emits devcard JSON when it should not.
Create trainer/evaluate.py:
from __future__ import annotations
import argparse
import json
from pathlib import Path
from urllib import request
ROOT = Path(__file__).resolve().parent
TEST_DATA = ROOT / "data" / "test.jsonl"
def extract_message(choice: dict[str, object]) -> str:
message = choice["message"]
if isinstance(message, str):
return message
if isinstance(message, dict):
content = message.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
texts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
text = item.get("text")
if isinstance(text, str):
texts.append(text)
if texts:
return "".join(texts)
raise TypeError(
"Unsupported message shape in response: "
+ f"{type(message).__name__}"
)
def call_model(url: str, model: str, prompt: str, adapter: str | None) -> str:
payload = {
"model": model,
"messages": [
{
"role": "user",
"content": prompt,
}
],
"temperature": 0.0,
"max_tokens": 200,
}
if adapter:
payload["adapters"] = adapter
body = json.dumps(payload).encode("utf-8")
req = request.Request(
url,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with request.urlopen(req) as response:
data = json.load(response)
return extract_message(data["choices"][0])
def load_rows() -> list[dict[str, object]]:
rows = []
with TEST_DATA.open(encoding="utf-8") as handle:
for line in handle:
rows.append(json.loads(line))
return rows
def evaluate_command_case(raw: str, expected: dict[str, object]) -> bool:
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return False
return parsed == expected
def evaluate_normal_case(raw: str) -> bool:
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return True
return not (
isinstance(parsed, dict) and parsed.get("command") == "devcard"
)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--url",
default="http://127.0.0.1:8080/v1/chat/completions",
)
parser.add_argument(
"--model",
default="mlx-community/Llama-3.2-3B-Instruct-4bit",
)
parser.add_argument("--adapter")
args = parser.parse_args()
rows = load_rows()
command_total = 0
command_ok = 0
normal_total = 0
normal_ok = 0
failures = []
for row in rows:
prompt = row["messages"][0]["content"]
raw = call_model(args.url, args.model, prompt, args.adapter)
if row["kind"] == "command":
command_total += 1
ok = evaluate_command_case(raw, row["expected"])
if ok:
command_ok += 1
else:
failures.append({"kind": "command", "prompt": prompt, "raw": raw})
else:
normal_total += 1
ok = evaluate_normal_case(raw)
if ok:
normal_ok += 1
else:
failures.append({"kind": "normal", "prompt": prompt, "raw": raw})
overall_ok = command_ok + normal_ok
overall_total = command_total + normal_total
print(
json.dumps(
{
"adapter": args.adapter or "base",
"command_ok": f"{command_ok}/{command_total}",
"normal_ok": f"{normal_ok}/{normal_total}",
"overall_ok": f"{overall_ok}/{overall_total}",
"failures": failures,
},
indent=2,
)
)
if __name__ == "__main__":
main()If you mostly live in Java, read this script as a tiny integration test harness, not as “now we switch to a Python article.”
The structure is simple:
load_rows() loads the held-out test cases from test.jsonl
call_model(...) sends one HTTP request to the MLX LM server, with or without an adapter path
evaluate_command_case(...) is the strict assertion for command prompts: the model output must parse as JSON and match the expected object exactly
evaluate_normal_case(...) is the negative assertion for ordinary prompts: the model should not suddenly emit a fake devcard command object
main() loops over the test cases, counts passes and failures, and prints one JSON summary at the end
If you want the Java mental model, this is closer to a parameterized integration test than to a training script. The test fixture is test.jsonl. The system under test is the model server. The assertions are “did the command prompt produce the exact contract?” and “did the normal prompt stay normal?”
Start the base model server from the project root:
mlx_lm.server --model "$MODEL"The current MLX LM server guide says this starts on localhost:8080 by default. It also documents an OpenAI-like /v1/chat/completions endpoint and an adapters request field, which is what lets us compare base and adapted behavior without swapping the whole model server.
In another terminal, still from the project root, run the evaluation once without an adapter and once with it:
source .venv/bin/activate
python trainer/evaluate.py --model "$MODEL"
python trainer/evaluate.py --model "$MODEL" --adapter trainer/adapters/devcard-loraThe output is a compact scorecard:
adapter tells you whether you ran the base model or the adapted one
command_ok is how many command-style prompts produced the exact expected JSON contract
normal_ok is how many non-command prompts stayed non-command prompts
overall_ok is the combined total
failures contains the raw model output for any missed case, which is usually the most interesting part
A base-model run often looks something like this:
{
"adapter": "base",
"command_ok": "0/5",
"normal_ok": "2/2",
"overall_ok": "2/7",
"failures": [
{
"kind": "command",
"prompt": "devcard Dev Services with Kafka",
"raw": "**DevCard: Dev Services with Kafka**\n..."
}
]
}That result is more or less a random failure. The base model kept treating devcard as ordinary language and invented a meaning for it. In my runs, that usually shows up as a confident prose answer, a hallucinated product name, or a near miss that looks plausible to a human and useless to a parser.
The adapted run should move in the opposite direction:
{
"adapter": "trainer/adapters/devcard-lora",
"command_ok": "5/5",
"normal_ok": "2/2",
"overall_ok": "7/7",
"failures": []
}This is the behavior change we care about. The adapter did not make the model “more intelligent” in some vague general sense. It made the model better at one narrow contract:
when the prompt contains the private command word, emit the house JSON shape
when the prompt is ordinary, stay ordinary
What I want to see is not perfection. I want to see direction:
the base run should fail more command cases
the adapted run should pass more command cases
the adapted run should still behave normally on non-command prompts
If the adapted run starts failing normal prompts, the usual fix is not “train longer.” The usual fix is “add better negative examples.” If the base run already passes everything, the command was probably too easy or too close to ordinary language to make the adapter effect visible.
Build the Quarkus App
Now we wire the same comparison into Java.
Set the Quarkus app to 8081 so it does not collide with the MLX server, and keep the model settings in typed config because plain strings in random services are exactly how these demos become annoying to maintain.
Replace devcard-agent/src/main/resources/application.properties with this:
quarkus.http.port=8081
quarkus.rest-client.mlx.url=http://127.0.0.1:8080
quarkus.rest-client.mlx.connect-timeout=2000
quarkus.rest-client.mlx.read-timeout=120000
devcard.model=mlx-community/Llama-3.2-3B-Instruct-4bit
devcard.adapter=trainer/adapters/devcard-loraCreate devcard-agent/src/main/java/dev/mthread/devcard/config/DevcardConfig.java:
package dev.mthread.devcard.config;
import io.smallrye.config.ConfigMapping;
@ConfigMapping(prefix = "devcard")
public interface DevcardConfig {
String model();
String adapter();
}Create devcard-agent/src/main/java/dev/mthread/devcard/mlx/MlxChatClient.java:
package dev.mthread.devcard.mlx;
import java.util.List;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/v1/chat/completions")
@RegisterRestClient(configKey = "mlx")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public interface MlxChatClient {
@POST
ChatResponse chat(ChatRequest request);
record Message(String role, String content) {
}
@JsonInclude(JsonInclude.Include.NON_NULL)
record ChatRequest(
String model,
List<Message> messages,
String adapters,
Double temperature,
@JsonProperty("max_tokens") Integer maxTokens) {
}
@JsonIgnoreProperties(ignoreUnknown = true)
record Choice(
int index,
JsonNode message,
@JsonProperty("finish_reason") String finishReason) {
}
@JsonIgnoreProperties(ignoreUnknown = true)
record Usage(
@JsonProperty("prompt_tokens") int promptTokens,
@JsonProperty("completion_tokens") int completionTokens,
@JsonProperty("total_tokens") int totalTokens) {
}
@JsonIgnoreProperties(ignoreUnknown = true)
record ChatResponse(
String model,
List<Choice> choices,
Usage usage) {
}
}The slightly odd part is the response shape. The MLX server guide documents choices[].message as plain text, not the nested message.content shape some OpenAI-style clients expect. That is why I am using a direct REST client here instead of pretending every compatible API is identical in practice.
Create devcard-agent/src/main/java/dev/mthread/devcard/DevcardCommand.java:
package dev.mthread.devcard;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public record DevcardCommand(
String command,
String topic,
List<String> technologies,
boolean includeExample,
boolean includeWarning) {
public DevcardCommand normalized() {
return new DevcardCommand(
command == null ? "" : command.trim(),
topic == null ? "" : topic.trim(),
technologies == null ? List.of() : List.copyOf(technologies),
includeExample,
includeWarning);
}
public void validate() {
if (!"devcard".equals(command)) {
throw new IllegalArgumentException("Expected command=devcard");
}
if (topic.isBlank()) {
throw new IllegalArgumentException("Expected a non-empty topic");
}
if (!includeExample || !includeWarning) {
throw new IllegalArgumentException(
"Expected includeExample=true and includeWarning=true");
}
}
}Create devcard-agent/src/main/java/dev/mthread/devcard/DevcardService.java:
package dev.mthread.devcard;
import java.util.List;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.mthread.devcard.config.DevcardConfig;
import dev.mthread.devcard.mlx.MlxChatClient;
import dev.mthread.devcard.mlx.MlxChatClient.ChatRequest;
import dev.mthread.devcard.mlx.MlxChatClient.ChatResponse;
import dev.mthread.devcard.mlx.MlxChatClient.Message;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class DevcardService {
private final MlxChatClient client;
private final DevcardConfig config;
private final ObjectMapper objectMapper;
public DevcardService(
@RestClient MlxChatClient client,
DevcardConfig config,
ObjectMapper objectMapper) {
this.client = client;
this.config = config;
this.objectMapper = objectMapper;
}
public ComparisonResponse compare(String prompt) {
return new ComparisonResponse(
prompt,
run(prompt, null),
run(prompt, config.adapter()));
}
private VariantResult run(String prompt, String adapter) {
ChatRequest request = new ChatRequest(
config.model(),
List.of(new Message("user", prompt)),
adapter,
0.0,
200);
ChatResponse response = client.chat(request);
String raw = extractMessage(response);
try {
DevcardCommand command = parseCommand(raw);
return new VariantResult(
raw,
true,
command,
render(command),
null);
} catch (IllegalArgumentException exception) {
return new VariantResult(
raw,
false,
null,
null,
exception.getMessage());
}
}
private String extractMessage(ChatResponse response) {
if (response.choices() == null || response.choices().isEmpty()) {
throw new IllegalStateException("Model response did not contain choices");
}
var message = response.choices().get(0).message();
if (message == null || message.isNull()) {
throw new IllegalStateException("Model response message was empty");
}
if (message.isTextual()) {
return message.asText();
}
if (message.isObject()) {
var content = message.get("content");
if (content != null && content.isTextual()) {
return content.asText();
}
if (content != null && content.isArray()) {
StringBuilder builder = new StringBuilder();
for (var item : content) {
if ("text".equals(item.path("type").asText()) && item.has("text")) {
builder.append(item.get("text").asText());
}
}
if (builder.length() > 0) {
return builder.toString();
}
}
}
throw new IllegalStateException("Model response message was not a supported text shape");
}
private DevcardCommand parseCommand(String raw) {
try {
DevcardCommand command = objectMapper.readValue(raw, DevcardCommand.class)
.normalized();
command.validate();
return command;
} catch (JsonProcessingException exception) {
throw new IllegalArgumentException(exception.getOriginalMessage(), exception);
}
}
private String render(DevcardCommand command) {
return switch (command.topic()) {
case "dev-services" -> renderDevServices(command.technologies());
case "rest-client" -> renderRestClient();
case "panache" -> renderPanache();
case "config-mapping" -> renderConfigMapping();
case "continuous-testing" -> renderContinuousTesting();
default -> throw new IllegalArgumentException(
"Unsupported topic: " + command.topic());
};
}
private String renderDevServices(List<String> technologies) {
if (technologies.contains("postgresql")) {
return """
Dev Services starts required infrastructure automatically during local development and tests when Quarkus can infer what you need and you have not already configured it yourself. In the PostgreSQL case, that means you can add the driver, run `quarkus:dev`, and let Quarkus spin up a database container for you instead of wiring a JDBC URL by hand.
Example:
```properties
quarkus.datasource.db-kind=postgresql
```
Practical warning: this is a development convenience, not a deployment plan. It also depends on a working container runtime, which means the demo feels magical right up to the moment Podman or Docker is missing.
""";
}
if (technologies.contains("kafka")) {
return """
Dev Services can do the same thing for Kafka, which is useful when you want messaging in local development without maintaining a broker by hand. The Java part stays small because the infrastructure bootstrapping moves into the Quarkus extension.
Example:
```properties
mp.messaging.outgoing.orders.connector=smallrye-kafka
```
Practical warning: the convenience is real, but so is the hidden dependency on local containers. Keep that visible in your docs and your onboarding steps.
""";
}
throw new IllegalArgumentException("Unsupported Dev Services technology");
}
private String renderRestClient() {
return """
Quarkus REST Client with Jackson gives you a typed Java interface for HTTP calls while Jackson handles JSON serialization. The useful part for an agentic application is not elegance. It is that you can keep the model boundary explicit and still write ordinary Java on your side of the fence.
Example:
```java
@Path("/v1/chat/completions")
@RegisterRestClient(configKey = "mlx")
public interface MlxChatClient {
@POST
ChatResponse chat(ChatRequest request);
}
```
Practical warning: a neat Java interface does not make the network local. Keep timeouts, retries, and failure behavior visible in the code.
""";
}
private String renderPanache() {
return """
Panache removes a lot of the repetitive Hibernate ORM code that turns simple examples into longer articles than they need to be. For a Java developer, the appeal is not that it is magical. It is that the persistence intent becomes easier to read.
Example:
```java
@Entity
public class Book extends PanacheEntity {
public String title;
}
```
Practical warning: Panache makes entity code shorter, but it does not protect you from bad boundaries. Do not turn entities into a storage layer and a business layer at the same time.
""";
}
private String renderConfigMapping() {
return """
`@ConfigMapping` gives you typed configuration instead of a scattered collection of property lookups. That fits agentic apps nicely because model settings, endpoint URLs, and timeouts usually travel together and deserve one explicit home.
Example:
```java
@ConfigMapping(prefix = "devcard")
public interface DevcardConfig {
String model();
String adapter();
}
```
Practical warning: typed config only helps if the property names stay stable and boring. If every sample invents a new prefix, you just moved the mess into an interface.
""";
}
private String renderContinuousTesting() {
return """
Continuous testing keeps the feedback loop inside `quarkus:dev`, which is useful when you are changing prompts, parser rules, and renderer code in short cycles. You notice breakage sooner, which is the whole point.
Example:
```bash
./mvnw quarkus:dev
```
Practical warning: fast feedback is only useful when the tests say something meaningful about the contract. A flaky parser test is still flaky, just sooner.
""";
}
public record VariantResult(
String raw,
boolean parsed,
DevcardCommand command,
String renderedCard,
String error) {
}
public record ComparisonResponse(
String prompt,
VariantResult base,
VariantResult adapted) {
}
}Create devcard-agent/src/main/java/dev/mthread/devcard/CompareResource.java:
package dev.mthread.devcard;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/api/devcards")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class CompareResource {
private final DevcardService service;
public CompareResource(DevcardService service) {
this.service = service;
}
@POST
public DevcardService.ComparisonResponse compare(PromptRequest request) {
if (request == null || request.prompt() == null || request.prompt().isBlank()) {
throw new WebApplicationException(
"prompt is required",
Response.Status.BAD_REQUEST);
}
return service.compare(request.prompt().trim());
}
public record PromptRequest(String prompt) {
}
}There is no model magic in the Java layer. That is the point. The model either gives us a valid DevcardCommand or it does not. If it does, normal Java takes over. If it does not, we keep the raw output and the parse error visible instead of pretending everything is fine.
Run the Comparison End to End
Keep the MLX server running from the project root because the server resolves the adapters path relative to the directory it started in. That detail is easy to miss and wastes a lot of time the first time you move files around.
In another terminal, start Quarkus:
cd devcard-lora-demo/devcard-agent
./mvnw quarkus:devNow hit the comparison endpoint:
curl -s http://127.0.0.1:8081/api/devcards \
-H 'Content-Type: application/json' \
-d '{"prompt":"devcard Dev Services with PostgreSQL"}' \
| python -m json.toolThe exact text will vary, but the shape you want is simple:
base.parsedis oftenfalseadapted.parsedistrueadapted.commandcontains the structured contractadapted.renderedCardcontains the deterministic explanation produced by Quarkus
A representative response looks like this:
{
"prompt": "devcard Dev Services with PostgreSQL",
"base": {
"raw": "Quarkus Dev Services starts infrastructure automatically during development and tests when your application needs it.",
"parsed": false,
"command": null,
"renderedCard": null,
"error": "Unrecognized token 'Quarkus'"
},
"adapted": {
"raw": "{\"command\":\"devcard\",\"topic\":\"dev-services\",\"technologies\":[\"postgresql\"],\"includeExample\":true,\"includeWarning\":true}",
"parsed": true,
"command": {
"command": "devcard",
"topic": "dev-services",
"technologies": [
"postgresql"
],
"includeExample": true,
"includeWarning": true
},
"renderedCard": "Dev Services starts required infrastructure automatically during local development and tests when Quarkus can infer what you need and you have not already configured it yourself.",
"error": null
}
}That is the whole teaching moment in one payload. Same base model. Same server. Same prompt. One request adds the adapter path, and the app suddenly has something stable enough to validate and route.
Try a normal prompt next:
curl -s http://127.0.0.1:8081/api/devcards \
-H 'Content-Type: application/json' \
-d '{"prompt":"Explain Dev Services with PostgreSQL in Quarkus."}' \
| python -m json.toolFor this one, I want both branches to fail strict parsing because the prompt did not ask for protocol mode. That is not a bug. That is proof that the command word is carrying the behavior.
Why This Helps Agentic Applications
Agentic apps do not just need fluent language. They need durable little agreements.
Sometimes that agreement is a tool call schema. Sometimes it is a planner label. Sometimes it is a routing payload that three downstream components quietly depend on. The ugly truth is that a model can know plenty about your domain and still be bad at that part. General competence is not the same thing as protocol obedience.
LoRA helps when the gap is narrow and behavioral:
teach a model a private command word
teach it a house JSON schema
teach it a small routing vocabulary
teach it that one branch must stay terse and deterministic
It is the wrong tool when the gap is factual freshness or broad new knowledge. If you need today’s docs, customer-specific records, or fast-changing operational state, use retrieval or tools. A small adapter is not a substitute for a real data boundary.
That is why I like this demo more than “fine-tune the model to sound like my blog.” Style transfer is real, but it does not explain the agentic value nearly as clearly. A command contract does.
Where This Breaks First
This kind of demo fails in predictable ways, which is honestly a good sign.
The model starts returning JSON too often
That usually means the positive examples drowned out the negative ones. Add more ordinary prompts that talk about the same Quarkus topics without the command word. The fix is usually dataset balance, not more training steps.
The model invents a near-miss topic
You ask for dev-services and get devservice or dev-services-postgres. That is why the Java side validates the contract instead of trusting vibes. Keep the topic set small, explicit, and boring.
The adapter path works in one shell and breaks in another
The current MLX LM server guide says the adapters path is resolved relative to the directory where the server started. If you start the server from the wrong place, the model request fails even though the Quarkus app is configured correctly.
The local demo turns into an accidental production design
The current MLX LM HTTP server guide explicitly says the built-in server is not recommended for production because it only implements basic security checks. Treat this tutorial as a local development pattern and a learning aid, not a deployment recipe.
People start asking whether the adapter learned Quarkus
No. It learned a narrow response pattern around prompts you cared about. That is still useful. Just do not oversell what happened.
A Few Useful Extensions
Once the base demo works, there are a few directions worth exploring:
add more command words such as
toolplanorroutecardswap the deterministic renderer for real tool routing
keep the same Quarkus app and compare multiple adapters against the same base model
move the model call behind LangChain4j after the protocol behavior is stable
I would do that in exactly that order. First prove the behavior change. Then add framework niceties.
Close the Loop
The reason this tutorial works is that it stays honest. We did not train a local model to become a Quarkus expert. We trained it to honor one small contract that a Java application can validate and use. That is a much better story for LoRA in agentic systems because it lines up with how these systems actually fail.
Prompting alone gets you part of the way there. A tiny adapter can make the model much more predictable inside one narrow lane. Once you have that, ordinary Quarkus code can do the rest of the job, which is exactly where I prefer the complexity to live.



