DEV Community

Cover image for Generate a training set with DeepSeek, then fine-tune a small model on a rented GPU
CloudGPU
CloudGPU

Posted on

Generate a training set with DeepSeek, then fine-tune a small model on a rented GPU

Short answer: write a few documentation pages yourself, have DeepSeek turn them into question-and-answer pairs through an OpenAI-compatible endpoint, filter the pairs with a short script, then train a LoRA adapter on a small base model with LLaMA-Factory on a rented GPU. Generate and filter on your own machine; rent the GPU only for training; download the adapter, then destroy the instance.

Disclosure: I work on CloudGPU. The endpoint (https://cloudgpu.app/v1) and the GPU rentals below are ours. Each step links the official documentation it follows and ends with a "What to check" line, because the details (versions, template names, model access) change.

Why this pattern? AI labs describe using models to write or judge training data from written rules. This is the small, single-developer version of that idea. It is our own analogy, not a claim from those papers.

Read these three things first

  1. DeepSeek's terms. This tutorial generates data with DeepSeek only. DeepSeek's Terms of Use (Last Update: March 27, 2026) say in section 4.2(3) that you may apply the Outputs of the Services to "a wide range of use cases, including personal use, academic research, derivative product development, training other models (such as model distillation), etc., as long as such usage is legal and adhere to these Terms." Section 3.1 adds that if you publish or disseminate outputs, you must "clearly indicate that the output content is generated by artificial intelligence". So if you publish the dataset, a model card or examples from it, label them as AI-generated. Read the terms yourself, and read CloudGPU's terms and acceptable use policy, which apply to the gateway.
  2. Rights to your source text. The acceptable use policy lists "Training models on datasets you do not have rights to use" under large-scale copyright infringement (section 1.8). In this tutorial the generator only sees documentation you wrote yourself.
  3. The base model's license. Each base model below has its own license. Open the link, read it, and confirm for yourself that your use is allowed.

What you need

  • Python 3 and the openai package on your own machine: pip install openai.
  • A CloudGPU account with balance, and an API key (the API page has a "Get your API key" link). Keep it in an environment variable, never in code:
export CLOUDGPU_API_KEY="paste-your-key-here"
Enter fullscreen mode Exit fullscreen mode
  • Three to ten Markdown files of your own documentation, with ## section headings, in a docs/ folder. A README of your own project works.
  • A Hugging Face account and a read token if you choose Llama or Gemma (both are gated, see the table below).

What to check: python3 -c "import os; print(bool(os.environ.get('CLOUDGPU_API_KEY')))" prints True in the shell where you will run the scripts, and python3 -c "import openai" raises no error.

API calls are billed per token from the same balance as GPU rentals; the current rate for every model is in the table on the API page. This post does not repeat rates.

Step 1: generate question-and-answer pairs with DeepSeek

The script splits each Markdown file at ## headings, asks deepseek-v4.1-flash for a handful of pairs per section, and writes one JSON object per line. The model ID comes from the live model table on the API page (checked 2026-10-27); the same table lists deepseek-v4-flash and deepseek-v4-pro. The OpenAI SDK only needs base_url and the key changed.

# gen_data.py
import json, os, re, sys, time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from openai import OpenAI

client = OpenAI(base_url="https://cloudgpu.app/v1", api_key=os.environ["CLOUDGPU_API_KEY"])
MODEL = "deepseek-v4.1-flash"
PAIRS_PER_SECTION = 6

PROMPT = """You write training examples for a support assistant.
Use ONLY the documentation below. Do not invent features or settings.
Write {n} varied question-and-answer pairs that a user might ask about it.
Return ONLY a JSON array. Each item has the keys "instruction" (the question),
"input" (extra context, or an empty string) and "output" (the answer, 1-4 sentences).

Documentation:
{chunk}
"""

def sections(folder):
    for path in sorted(Path(folder).glob("*.md")):
        for part in re.split(r"\n(?=## )", path.read_text(encoding="utf-8")):
            if len(part.strip()) > 200:
                yield path.name, part.strip()

def parse(text):
    fence = "`" * 3  # the model may wrap its JSON in a code fence
    text = text.strip().removeprefix(fence + "json").removeprefix(fence).removesuffix(fence).strip()
    rows = json.loads(text)
    keys = ("instruction", "input", "output")
    return [r for r in rows if isinstance(r, dict) and all(isinstance(r.get(k), str) for k in keys)]

def ask(item):
    name, chunk = item
    for attempt in range(3):
        try:
            resp = client.chat.completions.create(
                model=MODEL,
                temperature=0.8,
                messages=[{"role": "user", "content": PROMPT.format(n=PAIRS_PER_SECTION, chunk=chunk)}],
            )
            return parse(resp.choices[0].message.content)
        except Exception as err:  # network error, rate limit, bad JSON
            print(f"{name}: attempt {attempt + 1} failed: {err}", file=sys.stderr)
            time.sleep(2 ** attempt)
    return []

if __name__ == "__main__":
    with ThreadPoolExecutor(max_workers=4) as pool:
        results = list(pool.map(ask, sections("docs")))
    with open("raw.jsonl", "w", encoding="utf-8") as f:
        for rows in results:
            for row in rows:
                f.write(json.dumps(row, ensure_ascii=False) + "\n")
    print("sections:", len(results), "pairs:", sum(len(r) for r in results))
Enter fullscreen mode Exit fullscreen mode

Run python3 gen_data.py. The four-worker pool and the retry loop keep you inside the default rate limits listed on the API page; check those limits there and lower max_workers if you see rate-limit errors.

What to check: raw.jsonl exists and has lines; the script prints how many sections and pairs it got. If a section failed three times it is skipped and the error is on stderr. A 401 means the key variable is not set in this shell.

Step 2: filter and de-duplicate

Model-written data needs a filter before training. This script drops empty rows, answers that are very short or very long, boilerplate such as "as an AI", and near-duplicate questions (token overlap of 0.8 or more). Tune the numbers for your data.

# filter_data.py
import json, re
from pathlib import Path

def tokens(s):
    return set(re.sub(r"\W+", " ", s.lower()).split())

def jaccard(a, b):
    union = a | b
    return len(a & b) / len(union) if union else 0.0

kept, seen = [], []
dropped = {"empty": 0, "length": 0, "boilerplate": 0, "duplicate": 0}

for line in Path("raw.jsonl").read_text(encoding="utf-8").splitlines():
    row = json.loads(line)
    ins, inp, out = (row[k].strip() for k in ("instruction", "input", "output"))
    if not ins or not out:
        dropped["empty"] += 1
    elif not 20 <= len(out) <= 1200:
        dropped["length"] += 1
    elif re.search(r"\bas an ai\b|\blanguage model\b", out, re.I):
        dropped["boilerplate"] += 1
    elif any(jaccard(tokens(ins), t) >= 0.8 for t in seen):
        dropped["duplicate"] += 1
    else:
        seen.append(tokens(ins))
        kept.append({"instruction": ins, "input": inp, "output": out})

Path("synth_docs_qa.json").write_text(json.dumps(kept, ensure_ascii=False, indent=2), encoding="utf-8")
Path("DATASET_NOTE.md").write_text(
    "AI-generated data: the examples in synth_docs_qa.json were generated with deepseek-v4.1-flash "
    "from documentation written by the dataset author. Review before use.\n", encoding="utf-8")
print("kept", len(kept), "dropped", dropped)
Enter fullscreen mode Exit fullscreen mode

The output is in LLaMA-Factory's Alpaca format: instruction, optional input, and output, per the data preparation docs. A row looks like this (illustrative, written by hand, not model output):

{
  "instruction": "How do I change the output folder?",
  "input": "",
  "output": "Pass --out followed by a path, for example --out ./build."
}
Enter fullscreen mode Exit fullscreen mode

Then read a sample yourself. Filters catch format problems, not wrong answers:

python3 -c "import json,random; [print(r['instruction'],'->',r['output'],'\n') for r in random.sample(json.load(open('synth_docs_qa.json')), 10)]"
Enter fullscreen mode Exit fullscreen mode

What to check: DATASET_NOTE.md is the label DeepSeek's terms ask for if you publish the data: keep it next to the file. Compare ten sampled answers with your documentation; delete rows that are wrong, or tighten the prompt and regenerate. If the filter drops most rows, the model is probably not returning clean JSON: print one raw response and adjust parse.

If your data is multi-turn, LLaMA-Factory also reads a conversation format; the same docs page shows the dataset_info.json entry for it.

Step 3: rent a GPU

On Deploy, choose a card. The CloudGPU docs say two things that matter here: every template is an image with root SSH, and for anything a template does not cover you can rent a raw GPU (Deploy → Raw GPU) and install what you need over SSH. For this tutorial, pick a template that includes PyTorch, or choose Raw GPU and install it yourself. Pick a card with enough memory for your base model: the LLaMA-Factory README has a rough memory table (marked as estimates), and nvidia-smi shows real use during the first training steps.

GPU instances are billed per minute, rounded up, with a 1-minute minimum, and billing stops when you destroy the instance (terms). Rates are on the pricing page.

Connect with the SSH details of your running instance. Then upload the dataset from your own machine (scp takes the port with a capital -P):

scp -P <ssh-port> synth_docs_qa.json <ssh-user>@<ssh-host>:/workspace/
Enter fullscreen mode Exit fullscreen mode

What to check: connect with ssh -p <ssh-port> <ssh-user>@<ssh-host>, then run nvidia-smi (a GPU is listed) and python3 -c "import torch; print(torch.cuda.is_available())" (prints True). If torch is missing, install PyTorch with CUDA from the PyTorch install page first.

Step 4: install LLaMA-Factory and register the dataset

These are the install commands from the LLaMA-Factory README (read 2026-10-27; the repository name is spelled LlamaFactory there). The docs FAQ lists a LLaMA-Factory template, but don't count on it being installed or current: check, and install if needed.

cd /workspace
git clone --depth 1 https://github.com/hiyouga/LlamaFactory.git
cd LlamaFactory
pip install -e .
pip install -r requirements/metrics.txt
Enter fullscreen mode Exit fullscreen mode

Copy the dataset into data/ and add an entry to data/dataset_info.json, which is how LLaMA-Factory finds custom datasets:

cp /workspace/synth_docs_qa.json data/
python3 - <<'EOF'
import json
p = "data/dataset_info.json"
info = json.load(open(p, encoding="utf-8"))
info["synth_docs_qa"] = {
    "file_name": "synth_docs_qa.json",
    "columns": {"prompt": "instruction", "query": "input", "response": "output"},
}
json.dump(info, open(p, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
EOF
Enter fullscreen mode Exit fullscreen mode

What to check: llamafactory-cli help prints the command list, and grep synth_docs_qa data/dataset_info.json finds your entry.

Step 5: choose a base model and train a LoRA adapter

Small models are enough to see the pipeline work. Pick one row; the template name must match the model.

Base model LLaMA-Factory template License (read it yourself) Access
Llama 3.2 3B Instruct (meta-llama/Llama-3.2-3B-Instruct) llama3 Llama 3.2 Community License Gated on Hugging Face: accept the license, use a token
Gemma 3 4B IT (google/gemma-3-4b-it) gemma3 Gemma Terms of Use Gated on Hugging Face: accept the terms, use a token
Mistral 7B Instruct v0.3 (mistralai/Mistral-7B-Instruct-v0.3) mistral License shown on the model card The model card listed no gate when we looked (2026-10-27)

Template names are from the LLaMA-Factory README's model table; license and access information was read on 2026-10-27 and can change. The Llama license has conditions that apply if you distribute or make available a model built on it. Confirm the license for your use yourself before you train or share anything.

For a gated model, request access on its Hugging Face page, then give the instance a fine-grained read token for just this run (you will revoke it at the end):

export HF_TOKEN="paste-a-read-token-here"
Enter fullscreen mode Exit fullscreen mode

Save this as synth_lora.yaml in the LlamaFactory folder. The keys follow the official LoRA example in the repo's examples/train_lora/ folder; the model, template and dataset lines are the parts you change:

### model
model_name_or_path: meta-llama/Llama-3.2-3B-Instruct

### method
stage: sft
do_train: true
finetuning_type: lora
lora_rank: 8
lora_target: all

### dataset
dataset: synth_docs_qa
template: llama3
cutoff_len: 2048
preprocessing_num_workers: 4

### output
output_dir: saves/llama32-3b/lora/synth
logging_steps: 10
save_steps: 100
plot_loss: true
overwrite_output_dir: true

### train
per_device_train_batch_size: 1
gradient_accumulation_steps: 8
learning_rate: 1.0e-4
num_train_epochs: 3.0
lr_scheduler_type: cosine
warmup_ratio: 0.1
bf16: true

### eval
val_size: 0.1
per_device_eval_batch_size: 1
eval_strategy: steps
eval_steps: 50
Enter fullscreen mode Exit fullscreen mode

Train:

llamafactory-cli train synth_lora.yaml
Enter fullscreen mode Exit fullscreen mode

Open the LLaMA-Factory examples folder and compare your YAML with the current LoRA example: key names change between releases.

What to check: the log prints a training loss every 10 steps and, with val_size set, an evaluation loss every 50; saves/llama32-3b/lora/synth contains adapter_model.safetensors and adapter_config.json. If the base model download fails with a 401 or 403, the Hugging Face access request or token is the problem. An out-of-memory error means a smaller cutoff_len, a smaller model or a card with more memory; nvidia-smi shows the real use.

Unsloth (optional). The LLaMA-Factory README lists Unsloth among its supported add-ons and describes a use_unsloth: true argument for LoRA. Unsloth has its own documentation and install steps, and supports a specific list of models, so check that your base model is on its list before you add it.

Step 6: try the adapter, copy it off, destroy the instance

A quick test in the terminal (no server is started). Save as infer.yaml:

model_name_or_path: meta-llama/Llama-3.2-3B-Instruct
adapter_name_or_path: saves/llama32-3b/lora/synth
template: llama3
infer_backend: huggingface
Enter fullscreen mode Exit fullscreen mode
llamafactory-cli chat infer.yaml
Enter fullscreen mode Exit fullscreen mode

Ask ten questions that are not in your training data and that you wrote yourself, and compare the answers with your documentation. That is your real test; the evaluation loss is not.

Now copy the adapter to your own machine before you destroy anything: destroying the instance permanently deletes its local storage. From your own machine:

rsync -avP -e "ssh -p <ssh-port>" \
  <ssh-user>@<ssh-host>:/workspace/LlamaFactory/saves/llama32-3b/lora/synth/ ./adapter-synth/
Enter fullscreen mode Exit fullscreen mode

Check that adapter_model.safetensors is in ./adapter-synth/, revoke the Hugging Face token you pasted on the instance, then destroy the instance from your Dashboard (the docs: "You can destroy an instance at any time"). The minutes you spent copying are billed like any other minute, and billing stops once the instance is destroyed.

What to check: the instance no longer appears as running in your dashboard, and ./adapter-synth/ still opens on your machine.

Privacy and access, in plain terms

  • Generation (step 1) sends the documentation sections you give it to a model API. Don't send text you would not want processed by a model service, and read the privacy policy for what the service handles.
  • You keep ownership of what you put on the GPU instance. CloudGPU's terms (section 5) say it does not access the contents of your instance except where needed to investigate a credible report of a policy violation, where compelled by valid legal process, or to protect the safety of users or third parties. Local instance storage is deleted when you destroy the instance.
  • This tutorial does not start a server. If you later serve your model from an instance, use your own endpoint with your own API key: the terms (section 6) say instance service URLs are not authenticated by default, so add authentication yourself before you share the URL.

Sources

All read on 2026-10-27 (UTC+8).

Top comments (0)