Distillation¶
Distillation teaches a small model what a larger one knows about a document. A large model, the teacher, reads the document and answers questions about it. A small model, the student, trains on those answers. You get a LoRA adapter, a small GGUF file of extra weights, and with it the student answers the same questions without the document in its prompt.
# The teacher answers your prompts with the document in view.
gmlx distill gen --teacher teacher-Q6_K.gguf --prompts prompts-train.jsonl --context schema.md \
--out replies.jsonl
# Drop unfinished and repeated replies. A checker of your own also drops wrong ones.
gmlx distill filter --in replies.jsonl --out corpus.jsonl
# The teacher scores every token of the replies.
gmlx distill cache --teacher teacher-Q6_K.gguf --corpus corpus.jsonl --frame reply --out cache/
# The scores are mapped to the student's tokens.
gmlx distill align --cache cache/ --student student-Q4_K_M.gguf --out view/
# The student learns them, and you get an adapter.
gmlx distill train --view view/ --student student-Q4_K_M.gguf --adapter-out student-distill.gguf --iters 2000
# Score the student with and without the adapter on text it did not train on.
gmlx distill eval --student student-Q4_K_M.gguf --adapter student-distill.gguf --before \
--slice prose=heldout-prose.txt --md eval.md --json eval.json
Put your own files in place of these names. schema.md is the document,
the prompt file holds your questions, in the format that Write the prompts
shows, and heldout-prose.txt is text you kept out of training.
In the worked run, a 9B student learned a database schema this way. With the adapter it came close to the same student with the schema pasted into every prompt. The same steps also train a student on plain text, or on a behavior such as a fixed answer format.
What you need¶
- A teacher and a student, both GGUF files. Pick two models from one family, such as Qwen3.6 and Qwen3.5, so they share a tokenizer. A student from another family works, but learns much less.
- A chat model as the student, since the answers are conversations.
- Enough memory for the teacher, and for the student plus its training
state. No step loads both at once. On the walkthrough's pair,
trainpeaked near 51 GB andcachenear 40 GB, so a 64 GB Mac runs it with nothing else large open. - For a task: the document, a few hundred questions about it, and a script that checks an answer.
gmlx pull --to . saves a model in the current folder, so that commands
can name it by its file name. The --teacher and --student flags take
any file path.
The actions¶
Every flag is in the distillation reference.
| Action | What it does |
|---|---|
gen |
Serves a model and writes its replies to your prompts as a corpus, the rows the student trains on. |
filter |
Drops unfinished or repeated replies, and the replies your checker rejects. |
cache |
Runs the teacher over the corpus once and stores its likely next tokens at each position. |
align |
Maps the cache onto the student's tokenizer and writes a view, the targets the student trains to match. |
train |
Fits a LoRA adapter on the student against one or more views. |
eval |
Scores the student with and without the adapter on held-out text. |
census |
Measures how much the document changes what the teacher says. |
A ten-minute smoke run¶
Run the pipeline on a tiny pair before you commit hours to a real one. This run fits on any Apple Silicon Mac. The teacher is Qwen3 0.6B at Q8_0, and the student is the same model at Q4_K_M:
gmlx pull hf:unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf --to .
gmlx pull hf:unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q4_K_M.gguf --to .
python3 -c 'import json; [print(json.dumps({"text": f"Paragraph {i}. The lighthouse keeper logged every ship that passed the point."})) for i in range(24)]' > smoke.jsonl
python3 -c 'import json,sys; [print(json.loads(l)["text"]) for l in open("smoke.jsonl")]' > smoke.txt
gmlx distill cache --teacher Qwen3-0.6B-Q8_0.gguf --corpus smoke.jsonl --out smoke-cache/ \
--top-k 64 --max-len 128
gmlx distill align --cache smoke-cache/ --student Qwen3-0.6B-Q4_K_M.gguf --out smoke-view/
gmlx distill train --view smoke-view/ --student Qwen3-0.6B-Q4_K_M.gguf --adapter-out smoke.gguf \
--iters 80 --batch-size 4
gmlx distill eval --student Qwen3-0.6B-Q4_K_M.gguf --adapter smoke.gguf --before \
--slice smoke=smoke.txt --max-len 128 --md smoke.md --json smoke.json
The run passes when the loss on the [train] it lines falls and eval
writes smoke.md. In that report, bpb after (the student with the
adapter) must be below bpb before, since the slice is the training text.
align holds back about one row in fifty for validation, and train
never trains on those rows. train needs at least --batch-size training
rows, so 24 rows leave 23 for a batch of 4.
align also prints a= on its summary line: the share of the teacher's
likely tokens that map straight onto student tokens. An a of 1.000 is
ideal, and under 0.90 the student learns less. For a cross-family pair, run
this smoke corpus through cache with your teacher and align with your
student to see the figure before a real run.
Then check gen and filter on the same pair:
python3 -c 'import json; [print(json.dumps({"id": f"smoke-{i}", "messages": [{"role": "user", "content": f"Describe lighthouse number {i} in one sentence."}]})) for i in range(2)]' > smoke-prompts.jsonl
gmlx distill gen --model Qwen3-0.6B-Q8_0.gguf --prompts smoke-prompts.jsonl --max-tokens 96 \
--out smoke-replies.jsonl
gmlx distill filter --in smoke-replies.jsonl --out smoke-corpus.jsonl --min-words 1
Both pass when gen ends with a [gen] done: line that reports 0 failed
and filter prints [filter] kept 2. --model is another name for
--teacher. --min-words 1 keeps one-sentence replies, which the default
of 16 words drops.
The steps of a real run¶
A run on your own document takes these steps. The walkthrough shows each one with its commands.
- Write the prompts and the checker for your document.
- Check that the document matters before you spend hours on training.
- Round one: the teacher answers with the document in view, and the student trains on the right answers.
- Serve the adapter, and measure it on questions it did not train on.
- Round two, optional: the student trains on its own right answers, scored by the teacher.
The walkthrough's two rounds took about 11 hours, most of it in gen and
train. Distillation troubleshooting explains
the figures each step prints and what to do when one looks wrong.
Resume a stopped step¶
Each step that runs for hours can continue where it stopped:
gen: run the same command again. It skips the prompt ids already in its--outfile, so a new run, such as each measurement, needs its own--outname.cache: add--resume. It continues after the last shard it wrote.train: add--resume. It continues from the last checkpoint in--ckpt-dir,./ckptby default.
A step refuses to resume when its settings or inputs changed since the
first run, and says so. A new train run also refuses a --ckpt-dir
that already holds checkpoints, so give each run its own directory.
Train on plain text or a behavior¶
To make a small model behave like a larger one on ordinary text, cache the
teacher over the text. This needs neither gen nor filter:
gmlx distill cache --teacher teacher-Q6_K.gguf --corpus corpus.jsonl --out cache/ \
--top-k 256 --max-len 512 --max-disk-gb 20
gmlx distill align --cache cache/ --student student-Q4_K_M.gguf --out view/
gmlx distill train --view view/ --student student-Q4_K_M.gguf --adapter-out student-distill.gguf --iters 2000
gmlx distill eval --student student-Q4_K_M.gguf --adapter student-distill.gguf --before \
--cache cache/ --slice prose=heldout-prose.txt --slice code=heldout-code.txt \
--kld-cache cache/ --md eval.md --json eval.json
The corpus is one of these:
- A jsonl file with a
textfield in each row. - A folder. Each
.txt,.md,.pyor.jsonfile in it or its subfolders is one document, and each line of a.jsonlfile is one.cacheskips files with other extensions without a warning. - A Hugging Face dataset id, which needs the
datasetspackage.
The two slices are text files you kept out of the corpus.
eval --cache marks a slice that overlaps the corpus, so its score does
not count. --kld-cache adds a table of how far the student is from the
teacher's stored choices. It needs a teacher and student on the same
tokenizer.
--iters 2000 at the default batch of 8 is two passes over 8000 rows of
512 tokens, a starting size for general text. When val stops falling
while loss keeps falling, add text. When val still falls at the last
step, add steps.
A behavior, such as a fixed answer format, is
round one without a document. Write
prompts that call for the behavior, run gen without --context, and give
filter a checker for the format.
Disk use¶
A cache takes 6 x K + 22 bytes per position plus the text, where K is the
--top-k value, 256 by default. A corpus of 600 rows of about 1300 tokens
takes about 1.2 GB. --max-disk-gb refuses a cache whose estimate is
larger.
One cache serves any student, since align maps it onto each student's
tokenizer. A view reads its cache on every train, so keep the cache as
long as you use the view. When a run is done, keep the adapter and the reports. You can
delete the checkpoint folders and the .server.log files that gen writes.
Limitations¶
- The student is a GGUF file, and training writes a LoRA adapter for it. An adapter works only with the exact file it was trained on.
- Each training run starts from the base weights. A changed document needs a new round one, not a top-up of the old adapter.
gen --base-urlcan use a teacher on another server, butcacheruns the teacher itself and needs a local GGUF.evalreads its benchmark task files from disk and downloads nothing.- The adapter encodes the document. Sharing the adapter shares the document's content.