Fine-tuning GPT-4o lets you customize the model for specific tasks, formats, or domains without the latency and cost of few-shot prompting at scale. The process is straightforward but has sharp edges around data formatting, validation, and cost control. This guide walks through the complete workflow from raw data to a production-ready fine-tuned model.
Step 1: Decide if fine-tuning is the right tool
Before you spend money and time, verify that fine-tuning actually solves your problem. Fine-tune GPT-4o when you need consistent output formatting, domain-specific terminology, or behavior that few-shot prompting can’t reliably produce. Skip it if your task is purely knowledge retrieval (use RAG instead), if you have fewer than 50 high-quality examples, or if prompt engineering gets you 90% of the way there.
OpenAI recommends at least 50–100 training examples for meaningful results, though 500+ is where you see diminishing returns. Each example should demonstrate the exact input-output pattern you want the model to learn.
Step 2: Prepare your training data
The API expects JSONL (newline-delimited JSON) where each line is a chat conversation in the same format as the Chat Completions API. Each example must have a messages array with system, user, and assistant roles.
{"messages": [{"role": "system", "content": "You are a SQL expert that writes PostgreSQL queries."}, {"role": "user", "content": "Find all users who signed up in the last 30 days and have made at least one purchase."}, {"role": "assistant", "content": "SELECT u.* FROM users u JOIN purchases p ON u.id = p.user_id WHERE u.created_at >= NOW() - INTERVAL '30 days' GROUP BY u.id HAVING COUNT(p.id) >= 1;"}]}
{"messages": [{"role": "system", "content": "You are a SQL expert that writes PostgreSQL queries."}, {"role": "user", "content": "Show me the top 5 products by revenue last quarter."}, {"role": "assistant", "content": "SELECT p.name, SUM(oi.quantity * oi.unit_price) AS revenue FROM products p JOIN order_items oi ON p.id = oi.product_id JOIN orders o ON oi.order_id = o.id WHERE o.created_at >= DATE_TRUNC('quarter', NOW()) - INTERVAL '3 months' AND o.created_at < DATE_TRUNC('quarter', NOW()) GROUP BY p.name ORDER BY revenue DESC LIMIT 5;"}]}
Save this as train.jsonl. A few rules that will save you failed jobs:
- Every example must have at least one
userand oneassistantmessage - The
systemmessage is optional but recommended for consistency — include it in every example if you use it - Keep total tokens per example under the model’s context window (128k for GPT-4o)
- Strip PII and secrets before uploading
Create a validation file (val.jsonl) with the same format using 10–20% of your data. The API uses this to compute validation loss during training, which is your primary signal for overfitting.
Step 3: Validate your data locally
Don’t discover formatting errors after uploading. Run the OpenAI CLI validation tool first:
pip install openai
openai api fine_tunes.prepare_data -f train.jsonl
This checks format, counts tokens, estimates cost, and suggests fixes. For a 1,000-example dataset with ~500 tokens each, expect roughly $15–25 for a GPT-4o fine-tune job at current pricing. The tool outputs a cleaned file if it finds issues — use that version.
Step 4: Upload training and validation files
Use the Files API to upload both files with purpose: "fine-tune":
from openai import OpenAI
client = OpenAI()
train_file = client.files.create(
file=open("train.jsonl", "rb"),
purpose="fine-tune"
)
val_file = client.files.create(
file=open("val.jsonl", "rb"),
purpose="fine-tune"
)
print(f"Training file: {train_file.id}")
print(f"Validation file: {val_file.id}")
Store the returned file IDs. They expire after 30 days if not used.
Step 5: Create the fine-tuning job
Kick off the job with the file IDs and your hyperparameters. The key parameters:
model:"gpt-4o-2024-08-06"(or the current snapshot identifier)training_file: your training file IDvalidation_file: your validation file ID (optional but recommended)hyperparameters:n_epochs,batch_size,learning_rate_multiplier
job = client.fine_tuning.jobs.create(
model="gpt-4o-2024-08-06",
training_file=train_file.id,
validation_file=val_file.id,
hyperparameters={
"n_epochs": 3,
"batch_size": "auto",
"learning_rate_multiplier": "auto"
},
suffix="sql-expert"
)
print(f"Job ID: {job.id}")
print(f"Status: {job.status}")
The suffix becomes part of your fine-tuned model name (e.g., ft:gpt-4o-2024-08-06:my-org:sql-expert:abc123). Choose something descriptive.
Hyperparameter guidance
- n_epochs: Start with 3. Increase if validation loss is still decreasing; decrease if it plateaus or rises. Most tasks converge between 2–4 epochs.
- batch_size:
"auto"scales with dataset size. Manual values (1, 2, 4, 8, 16, 32, 64) are rarely needed. - learning_rate_multiplier:
"auto"works for most cases. Lower (0.1–0.5) for small datasets or when overfitting; higher (1.5–2) for large datasets needing faster convergence.
Step 6: Monitor training progress
Poll the job status or stream events. The job moves through validating_files → queued → running → succeeded (or failed).
import time
while True:
job = client.fine_tuning.jobs.retrieve(job.id)
print(f"Status: {job.status} | Trained tokens: {job.trained_tokens}")
if job.status in ("succeeded", "failed", "cancelled"):
break
time.sleep(30)
# Stream events for detailed logs
for event in client.fine_tuning.jobs.list_events(fine_tuning_job_id=job.id):
print(f"[{event.created_at}] {event.message}")
Watch the validation loss in the events. A healthy run shows training and validation loss decreasing together, then validation loss flattening or slightly rising (early stopping kicks in). If validation loss diverges sharply from training loss, you’re overfitting — reduce epochs or learning rate.
Step 7: Retrieve the fine-tuned model name
On success, the job object contains fine_tuned_model:
job = client.fine_tuning.jobs.retrieve(job.id)
if job.status == "succeeded":
model_name = job.fine_tuned_model
print(f"Fine-tuned model: {model_name}")
else:
print(f"Job failed: {job.error}")
The model name looks like ft:gpt-4o-2024-08-06:my-org:sql-expert:abc123. This is the identifier you’ll use in Chat Completions calls.
Step 8: Test the model
Run a few inference calls to verify behavior matches expectations:
completion = client.chat.completions.create(
model=model_name,
messages=[
{"role": "system", "content": "You are a SQL expert that writes PostgreSQL queries."},
{"role": "user", "content": "List all customers who haven't placed an order in 90 days."}
],
temperature=0,
max_tokens=500
)
print(completion.choices[0].message.content)
Test edge cases: ambiguous prompts, out-of-domain questions, formatting stress tests. The fine-tuned model should follow your training patterns more consistently than the base model with the same system prompt.
Step 9: Compare against baseline
Quantify the improvement. Run the same test suite against both the base model and your fine-tuned model:
def evaluate(model_name, test_cases):
results = []
for case in test_cases:
resp = client.chat.completions.create(
model=model_name,
messages=case["messages"],
temperature=0
)
results.append({
"input": case["messages"][-1]["content"],
"expected": case["expected"],
"actual": resp.choices[0].message.content
})
return results
# Compare base vs fine-tuned
base_results = evaluate("gpt-4o-2024-08-06", test_cases)
ft_results = evaluate(model_name, test_cases)
Score outputs programmatically (exact match, semantic similarity, regex for format compliance) or with human eval. Fine-tuning typically improves format adherence by 20–40 percentage points over few-shot prompting, with smaller gains on reasoning quality.
Step 10: Deploy and monitor in production
Use the fine-tuned model name exactly like any other model in your inference pipeline. A few operational notes:
- Rate limits: Fine-tuned models share your organization’s rate limits with the base model. They don’t get separate quotas.
- Latency: Expect similar latency to the base model — fine-tuning doesn’t change model size.
- Cost: Inference pricing matches the base GPT-4o model. Training cost is one-time.
- Versioning: Each fine-tuning job produces a new model ID. Pin the specific model ID in production; don’t rely on the suffix alone.
# Production call
response = client.chat.completions.create(
model="ft:gpt-4o-2024-08-06:my-org:sql-expert:abc123", # pinned model ID
messages=[...],
temperature=0.1
)
Log inputs, outputs, and latency. Set up alerts for error rate spikes or latency degradation. If you route traffic through a gateway like n4n.ai, you can enforce per-model budgets, fallback to the base model on errors, and collect per-token usage without instrumenting every call site.
Step 11: Iterate or retire
Fine-tuning is rarely one-and-done. Common iteration triggers:
- New edge cases appear in production logs → add examples, retrain
- Base model updates (new snapshot) → re-fine-tune on the new base
- Requirements change → adjust system prompt or training data, retrain
Keep your training data in version control alongside the code that generates it. Tag the dataset commit that produced each model ID. This makes rollbacks and audits straightforward.
When a model is superseded, you can delete it to clean up your model list:
client.models.delete(model_name)
Deletion is permanent and doesn’t refund training costs.
Common failure modes and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
Job fails in validating_files |
Malformed JSONL, missing roles, token overflow | Run prepare_data locally first |
| Validation loss flatlines immediately | Learning rate too low, data too easy | Increase learning_rate_multiplier or check data diversity |
| Validation loss spikes after epoch 1 | Overfitting, too many epochs | Reduce n_epochs, add more data, lower learning rate |
| Model ignores system prompt at inference | System prompt omitted from training examples | Include system message in every training example |
| Output format drifts | Inconsistent formatting in training data | Audit and normalize assistant messages |
Cost estimation cheat sheet
At current pricing (subject to change):
- Training: ~$25 per 1M tokens (input + output combined across epochs)
- Inference: Same as base GPT-4o ($2.50/1M input, $10/1M output tokens)
A 1,000-example dataset at 500 tokens/example × 3 epochs = 1.5M tokens ≈ $37.50 training cost. Inference cost depends entirely on your traffic.
You now have a repeatable process: prepare data → validate → upload → train → evaluate → deploy → monitor → iterate. The first run takes an afternoon. Subsequent iterations are faster because your data pipeline and eval harness are already built.