Guides & Tutorials11 min read

Synthetic Dataset Generation with an Open LLM: 8,600 Labeled Examples an Hour on an OpenAI-Compatible API

We generated 990 labeled customer-support examples with Qwen 3.8 27B through a plain OpenAI-compatible endpoint, schema-enforced with a forced tool call. Here is the script, the throughput and latency we measured, the token cost per thousand examples at list price, what the model got right, and the two ways it quietly repeats itself.

The Paralon capybara pulling a glowing ribbon of index cards out of a server cube and stacking them into tall piles

Synthetic data is the unglamorous half of fine-tuning: before you can teach a small model to classify support tickets, you need a few thousand tickets with labels, and you usually do not have them. The standard move is to have a bigger model write them. This guide does exactly that, end to end, with an open-weight model behind an ordinary OpenAI-compatible API, and reports what we measured rather than what the brochure says: how many examples an hour, how many tokens each one costs, how often the output is malformed, and how often the model repeats itself when you are not looking.

Everything below ran on 13 September 2026 against qwen3.8-27b on the ParalonCloud inference API. Nothing in it is specific to us: the script uses the OpenAI request format, so it runs unchanged against any endpoint that speaks it, including a local vLLM.

What "synthetic dataset" means here

One request asks the model for a handful of examples in a fixed shape. For this guide the shape is a customer-support ticket for an online store:

{
  "customer_message": "my card got declined on order #77241 for the black yoga mat. tried twice. what now?",
  "intent": "payment_failed",
  "sentiment": "negative",
  "suggested_reply": "I'm sorry about the trouble. I've re-processed the payment for order #77241 …"
}

Twelve intents, three sentiments, a free-text message and a reply. That is a classifier's training set and a reply-suggestion model's training set in one row, which is why this shape is popular. Swap the fields for yours; the mechanics do not change.

Why an open 27B model for this

Three reasons, in order of how much they matter:

  1. Volume pricing. Synthetic data is measured in millions of output tokens. At list price on our API, Qwen 3.8 27B is $0.12 per million input tokens and $1.70 per million output tokens. The run below produced 990 examples for $0.45.
  2. License. Qwen 3 is Apache 2.0. What it generates can be used to train another model, commercially, without a clause to read first. Several closed APIs forbid exactly that in their terms.
  3. Structured output that is enforced, not requested. vLLM constrains the decoder to your JSON schema when you force a tool call, so the output parses. We will show the one failure mode that survives that.

What a 27B model is not: a judge of subtle quality. If you need every example reviewed for factual correctness, generate with the cheap model and verify with a bigger one, or with a human, on a sample.

Getting valid JSON every time: force a tool call

The tempting way is to write "respond in JSON" in the prompt and parse the reply. It works most of the time, which is the worst kind of working. The reliable way on a vLLM-backed endpoint is to define one tool whose parameters are your schema, and force the model to call it:

TOOL = {"type": "function", "function": {
  "name": "emit_examples",
  "parameters": {"type": "object", "properties": {"examples": {
    "type": "array", "minItems": 5, "maxItems": 5,
    "items": {"type": "object", "properties": {
      "customer_message": {"type": "string"},
      "intent": {"type": "string", "enum": INTENTS},
      "sentiment": {"type": "string", "enum": ["negative", "neutral", "positive"]},
      "suggested_reply": {"type": "string"}},
      "required": ["customer_message", "intent", "sentiment", "suggested_reply"]}}},
    "required": ["examples"]}}}

body = {
  "model": "qwen3.8-27b",
  "messages": [{"role": "system", "content": SYSTEM}, {"role": "user", "content": prompt}],
  "tools": [TOOL],
  "tool_choice": {"type": "function", "function": {"name": "emit_examples"}},
  "temperature": 0.9, "top_p": 0.95, "max_tokens": 2000,
}

With tool_choice naming the function, vLLM's guided decoding only emits tokens that keep the arguments valid against the schema. The enum on intent is doing real work: the label set is closed, so no "refund-status" variant sneaks in next to refund_status. The reply arrives as choices[0].message.tool_calls[0].function.arguments, a JSON string.

Two details that cost us a probe request each:

  • Turn thinking off. Qwen 3 models reason before answering by default. On a forced tool call that reasoning eats the token budget and the arguments come back as {}. Put /no_think at the start of the system prompt. It is a soft switch, and in the run below it failed to take once in 200 requests, so keep the parse-error branch.
  • Give it room. Five examples with replies are about 900 to 1,300 output tokens. max_tokens of 2,000 leaves headroom; a tight budget truncates the JSON and the guided decoder cannot save a string that was cut mid-word.

The script

The full runner is short. It rotates intents, products and writing styles per request so the model does not receive the same prompt twice, runs six requests in flight under a 58-per-minute ceiling, validates every example against the label sets, de-duplicates on the normalized message text, and prints the numbers you will see in the next section. Replace the constants at the top and it is yours.

#!/usr/bin/env python3
import json, os, re, time, threading, hashlib, random, queue, requests

API   = "https://paraloncloud.com/v1/chat/completions"
MODEL = "qwen3.8-27b"
KEY   = os.environ["PRL_KEY"]
N_REQ, PER_REQ, IN_FLIGHT, RPM = 200, 5, 6, 58

INTENTS = ["order_status","cancel_order","return_request","refund_status","damaged_item","wrong_item",
           "payment_failed","discount_code","product_question","shipping_address_change","account_login","complaint"]
CATEGORIES = ["running shoes","espresso machine","phone case","winter jacket","board game","desk lamp","yoga mat",
              "headphones","kids bicycle","cookware set","gaming mouse","garden hose","perfume","laptop bag","smartwatch"]
STYLES = ["short and irritated","polite and detailed","confused, with a typo","terse, all lowercase","formal",
          "friendly with an emoji","in a hurry, one line","asking two things at once"]

SYSTEM = ("/no_think You generate training data for a customer-support classifier of an online store. "
          "Each example is a realistic customer message, its intent label, its sentiment, and a good agent reply. "
          "Vary names, order numbers, products and phrasing; never repeat a message from your own examples.")

def prompt(i):
    rnd = random.Random(i)
    rows = zip(rnd.sample(INTENTS, PER_REQ), rnd.sample(CATEGORIES, PER_REQ), rnd.sample(STYLES, PER_REQ))
    lines = [f"- intent `{it}`, about a {c}, written {s}" for it, c, s in rows]
    return (f"Generate exactly {PER_REQ} examples, one per line below, in this order:\n" + "\n".join(lines)
            + "\nReplies: 1-3 sentences, specific, no placeholders like [name].")

TOOL = {...}  # the schema from the previous section

lock, rows, stats = threading.Lock(), [], {"req": 0, "ok": 0, "err": 0, "in": 0, "out": 0}

def one(i):
    body = {"model": MODEL, "messages": [{"role": "system", "content": SYSTEM}, {"role": "user", "content": prompt(i)}],
            "tools": [TOOL], "tool_choice": {"type": "function", "function": {"name": "emit_examples"}},
            "temperature": 0.9, "top_p": 0.95, "max_tokens": 2000}
    r = requests.post(API, headers={"Authorization": f"Bearer {KEY}"}, json=body, timeout=180)
    with lock: stats["req"] += 1
    if r.status_code != 200:
        with lock: stats["err"] += 1
        return
    d = r.json(); u = d.get("usage", {})
    try:
        exs = json.loads(d["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"])["examples"]
    except Exception:
        with lock: stats["err"] += 1   # the /no_think switch did not take: arguments came back empty
        return
    good = [e for e in exs if e.get("intent") in INTENTS and e.get("sentiment") in ("negative","neutral","positive")]
    with lock:
        stats["ok"] += 1; stats["in"] += u.get("prompt_tokens", 0); stats["out"] += u.get("completion_tokens", 0)
        rows.extend(good)

q = queue.Queue(); [q.put(i) for i in range(N_REQ)]; start = time.time()
def worker():
    while not q.empty():
        i = q.get()
        allowed = (time.time() - start) / 60 * RPM + IN_FLIGHT      # stay under the key's requests/min
        if stats["req"] > allowed: time.sleep(60 / RPM)
        one(i)
threads = [threading.Thread(target=worker) for _ in range(IN_FLIGHT)]
[t.start() for t in threads]; [t.join() for t in threads]

norm = lambda s: re.sub(r"[^a-z0-9 ]", "", s.lower()).strip()
seen, uniq = set(), []
for r_ in rows:
    k = hashlib.md5(norm(r_["customer_message"]).encode()).hexdigest()
    if k not in seen: seen.add(k); uniq.append(r_)
with open("dataset.jsonl", "w") as f:
    for r_ in uniq: f.write(json.dumps(r_, ensure_ascii=False) + "\n")

wall = time.time() - start
print(f"{len(uniq)} unique examples in {wall:.0f}s = {len(uniq)/wall*3600:.0f}/hour; "
      f"{stats['out']/max(1,len(rows)):.0f} output tokens/example; "
      f"${stats['in']/1e6*0.12 + stats['out']/1e6*1.70:.2f} at list price; errors {stats['err']}/{stats['req']}")

PRL_KEY is a premium key from the Console; a free key works too, at 20 requests a minute, and its 250,000-token trial covers roughly the first 900 examples of this shape.

What we measured

200 requests, five examples each, six in flight, on 13 September 2026:

requests succeeded198 of 200
examples produced990, all schema-valid
exact duplicates (normalized message)0
output tokens per example259
input tokens per request621
latency, median / p9011.7 s / 16.1 s per request
wall time6 min 53 s
throughput8,624 examples per hour
cost at list price$0.45 for the run, $0.455 per 1,000 examples

The two failures were different animals. One was the /no_think switch not taking: the model reasoned for 2,000 tokens and returned {}. One was an HTTP 400 from the server, a UTF-8 encoding error on an emoji the model produced as a lone surrogate half; the request is simply retried. Budget for about one percent of requests to need a retry and the pipeline is boring, which is what you want.

The label distribution came out flat, 73 to 90 examples per intent, because the prompt assigns intents explicitly rather than letting the model choose. Sentiment was 48% negative, 38% neutral, 14% positive; support tickets skew that way in real life too, and if you need balance you assign sentiment in the prompt the same way.

Message length ranged from 6 to 173 words, median 37, which is the styles list doing its job: "terse, all lowercase" and "polite and detailed" produce genuinely different tickets. Product names were specific (a Garmin Venu 3, a Logitech G502, a Voile Noir 50 ml) without being asked for brands, and the replies read like a competent agent's: an order number, a concrete action, one sentence of apology, done.

The two ways it repeats itself

Zero exact duplicates is the easy number. Two softer ones matter more.

Openings. 138 of 990 messages share their first eight words with another message: "Hello, I hope this message finds you well" and "hey so i tried to use the code" are the model's favourite ways to start. A classifier trained on this learns openings, not intents. Either strip the first sentence before training, or add an instruction like "no two messages in this batch may start with the same three words" and check it in the validator.

Order numbers. The 990 messages contain 210 distinct order numbers, and #48291 appears 35 times. The model has a few numbers it likes. If your downstream task ever sees order numbers, generate them yourself and put them in the prompt: "use order number #58213" costs nothing and removes the tell.

Both are worth a line in the validator rather than a shrug: on a bigger run the shared openings would compound.

Scaling the run

Throughput scales with requests in flight until the rate limit or the model's own capacity caps it. On the premium key (60 requests a minute, 8 in flight) that is roughly 10,000 examples an hour for this shape; asking for 10 examples per request instead of 5 roughly doubles it, at the cost of longer responses and a slightly higher chance of a truncated one. For a 100,000-example dataset that is a working day and about $45 at list price, which is the number to compare against labeling it by hand.

If you generate in several sessions, seed the random.Random(i) with a global counter rather than restarting at 0, or you will regenerate the same prompts and the de-duplicator will throw half the second run away.

Quality control that is worth doing

  1. Validate the closed fields in code, not by trusting the schema alone. The schema guarantees intent is one of the twelve; it cannot guarantee the message actually expresses that intent. A second pass with the same model in judge mode ("does this message express refund_status? yes/no") costs a few input tokens per example and catches the mislabeled ones.
  2. Hold out the seed products. If your real tickets mention products the model never saw, evaluate on those.
  3. Read fifty rows. No metric replaces it. Ours were good; the tell was that too many customers were unusually eloquent for people whose parcel had just arrived broken.

Frequently asked questions

Does this work with the OpenAI Python SDK? Yes. Point OpenAI(base_url= "https://paraloncloud.com/v1", api_key=KEY) at the endpoint and pass tools and tool_choice to chat.completions.create exactly as above.

Why not response_format with a JSON schema? It works too: the same request with "response_format": {"type": "json_schema", "json_schema": {…}} returns schema-valid JSON in content, and passing "chat_template_kwargs": {"enable_thinking": false} turns Qwen 3's thinking off more reliably than the /no_think switch. Forced tool calling is the more widely supported of the two across endpoints, which is why the script uses it; pick whichever your client library makes easier.

Can I use the generated data commercially? Qwen 3 is Apache 2.0, so the outputs carry no license restriction from the model. Your own prompt inputs are yours.

How many examples does the free key cover? About 900 of this shape: 259 output tokens each plus the prompt, against the 250,000-token trial.

Is a bigger model worth it for generation? For volume, rarely. For the judge pass on a sample, often. Generate cheap, verify selectively.

Keep reading

Related Articles