Prompt optimization with textual gradients (ProTeGi): a complete guide
You know that loop where you tweak a prompt, run it, squint at the failures, and tweak again? ProTeGi hands that loop to an LLM. It reads your prompt's mistakes, writes a plain-English critique of what went wrong (the "gradient"), then rewrites the prompt to fix it, over and over, like gradient descent except every step happens in words instead of numbers.
The technique comes from Pryzant et al. (2023), "Automatic Prompt Optimization with 'Gradient Descent' and Beam Search," presented at EMNLP 2023 in Singapore. The headline: up to 31% accuracy improvement over the starting prompt across their classification benchmarks. The authors later renamed it Automatic Prompt Optimization (APO), so you'll see both names for the same method.
See it work
Say you're classifying support tickets as bug or feature_request. You start with the obvious prompt and it stumbles on tickets that describe a bug but politely ask for it to be fixed.
Initial prompt:
"Classify this ticket as 'bug' or 'feature_request'. Output only the label."
Error case:
Input: "Would love it if the export button stopped crashing on big files."
Predicted: feature_request
Correct: bug
--- ProTeGi reads the failure and writes a textual gradient ---
Gradient: "The prompt doesn't tell the model that polite, request-shaped
language can still describe broken behavior. It treats 'would love it if'
as a feature signal and ignores 'crashing'."
--- ProTeGi edits the prompt in the opposite direction of that gradient ---
Optimized prompt:
"Classify this ticket as 'bug' or 'feature_request'. A ticket is a 'bug'
if it describes existing behavior that is broken or crashing, even when
phrased as a polite request. It is a 'feature_request' only if it asks
for new behavior that does not exist yet. Output only the label."
Same model, same task, but the prompt now carries the decision rule the model kept missing. That edit was written by an LLM, not you, from one failed example.
The mental model
Think of prompt tuning as walking downhill on a foggy hillside, where lower ground means fewer errors. Numerical gradient descent reads the slope under your feet and steps the other way. ProTeGi can't feel a numeric slope, so it asks a guide who can see a little: "which way is wrong?" The critique is the direction; the rewrite is the step.
ProTeGi replaces the math
θ_new = θ_old - α·∇Lwith words:prompt_new = Edit(prompt_old, opposite_of(critique)). The LLM is both the thing being optimized and the optimizer.
That's the whole trick. The model that runs your task is also the model that diagnoses why the task failed and patches the instructions.
How it works
ProTeGi runs an iterative loop. Each round evaluates the current prompt on a batch, turns its mistakes into critiques, edits the prompt, then keeps only the best candidates for the next round.
- Initialize. Start from a human prompt or a simple task description, a labeled training set, a beam width, and a stopping rule.
- Evaluate a batch. Sample a minibatch, run the current prompt, compare predictions to ground truth, and keep the error cases.
- Generate textual gradients. For each error, ask the LLM what about the prompt caused this specific failure, in one or two sentences.
- Aggregate. Collect the gradients from several errors and optionally summarize them into one coherent critique.
- Edit the prompt. Feed the critique back and ask the LLM to rewrite the prompt to fix it while preserving intent. This is the descent step.
- Expand candidates. For each beam prompt, generate several edits from different errors, plus a couple of paraphrases as Monte Carlo samples for diversity.
- Select. Use a bandit algorithm, specifically Upper Confidence Bound (UCB), to spend evaluation budget on the most promising candidates and keep the top-k.
- Iterate. Repeat until you hit the iteration cap, plateau, or budget. Iterations usually run 3 to 10, with diminishing returns past about 5.
The canonical loop is short once the LLM does the heavy lifting:
def protegi_optimize(initial_prompt, train_data, iterations=5, beam_width=4):
beam = [initial_prompt]
for _ in range(iterations):
candidates = []
for prompt in beam:
errors = evaluate(prompt, sample_batch(train_data)) # step 2
gradients = [generate_gradient(prompt, e) for e in errors[:3]] # step 3
for g in gradients:
candidates.append(edit_prompt(prompt, g)) # step 5
beam = select_top_k(candidates, k=beam_width, data=train_data) # UCB select
if no_improvement(beam, threshold=0.01):
break
return best_prompt(beam, train_data)
The two LLM calls that make it tick are the gradient and the edit. Both are just prompts:
GRADIENT PROMPT
"The following prompt was used: {current_prompt}
On input '{input}' the model predicted '{prediction}' but the correct
answer was '{ground_truth}'. In 1-2 sentences, what flaw in the prompt
caused this error?"
EDIT PROMPT
"Current prompt: {current_prompt}
This prompt has the following problem: {aggregated_gradient}
Rewrite the prompt to fix this while preserving its core intent.
Output only the new prompt."
Why it works
The gains aren't magic; they trace to a few factors the original work and follow-ups consistently point at. Ranked by how much they move the needle:
| Factor | Weight | Why it matters |
|---|---|---|
| Training data quality | 35% | Representative, correctly labeled examples define what "wrong" even means |
| Initial prompt quality | 25% | A decent start converges faster; it sets speed, not the final ceiling |
| Gradient accuracy | 20% | If the LLM misdiagnoses the failure, the edit fixes the wrong thing |
| Beam width | 10% | Wider beams explore more directions and escape local optima |
| Iteration count | 10% | More rounds help up to a point, then plateau |
The deeper reason it beats random search: a critique gives direction. Instead of sampling arbitrary prompts, ProTeGi only edits toward the failure it just diagnosed, shrinking the search space to semantically nearby improvements. Edits also tend to be small, which acts like implicit regularization, so working parts of the prompt survive.
Where it shines
ProTeGi was built for tasks with a clear right answer and a metric to chase. The original paper evaluated four classification tasks with GPT-3.5 and GPT-4:
| Task | Initial accuracy | Optimized accuracy | Improvement |
|---|---|---|---|
| Jailbreak detection | ~65% | ~85% | +20% |
| Hate speech detection | ~70% | ~88% | +18% |
| Fake news detection | ~58% | ~76% | +18% |
| Sarcasm detection | ~62% | ~81% | +19% |
Against other prompt-optimization methods, it lands near the top on improvement while staying moderate on cost:
| Method | Avg. improvement | API calls | Time |
|---|---|---|---|
| Manual tuning | ~10-15% | N/A | Hours |
| Random search | ~8-12% | High | Variable |
| GRIPS | 2-10% | Moderate | Moderate |
| APE (one-shot) | ~15-20% | Low | Fast |
| ProTeGi | ~25-31% | Moderate | ~10 min/task |
Beyond the benchmarks, it carries over to content moderation (up to 20% accuracy improvement on jailbreak detection, enough to push borderline prompts into production), information extraction and named entity recognition, code classification and error detection, plus query reformulation prompts inside RAG pipelines. In research settings it's been applied to clinical entity extraction, legal clause classification, and financial risk detection. Results tend to transfer across similar tasks within the same domain.
When to use it (and when not)
Reach for it when: the task has definable right answers, you have a labeled set but the prompt isn't good enough, manual iteration has hit diminishing returns, you need reproducible optimization, or you have many similar prompts to tune.
Skip it when: you're doing open-ended or creative generation with no clear metric, you need real-time adaptation (this takes minutes, not milliseconds), the prompt already works well, evaluation is purely subjective, or you lack reliable labeled data.
The sweet spot by the numbers: 30 to 300 labeled examples (below 20 or above 1000 is a poor fit) and current accuracy in the moderate 50 to 80% band (below 30% or above 95% is a poor fit).
What it actually costs. A typical 5-iteration run is roughly $2 to $10 in API calls, broken down per iteration as ~$0.10-0.50 for evaluation, ~$0.20-1.00 for gradient generation, and ~$0.10-0.50 for editing. Figure ~10-50 API calls per iteration depending on beam width, ~2000-4000 tokens per gradient call, and 5 to 30 minutes total per task. Setup cost is minimal; the spend is all per-iteration.
Model fit. Quality scales with the model doing the gradients and edits:
| Tier | Examples | Notes |
|---|---|---|
| Minimum | GPT-3.5-turbo, Claude 3 Haiku | Works, but slower convergence |
| Recommended | GPT-4, Claude 3.5 Sonnet | Good quality-to-cost balance |
| Optimal | GPT-4o, Claude 3.5 Opus | Best gradient quality |
When to escalate to something else:
| Condition | Better choice |
|---|---|
| Fewer than 30 examples | Few-shot example selection (APE) |
| Need real-time adaptation | In-context learning |
| Very complex multi-step tasks | DSPy with MIPRO |
| Chasing maximum performance | Fine-tuning |
| Pure generation tasks | Human evaluation + iteration |
Picking a variant:
| Variant | Best for |
|---|---|
| Single-gradient ProTeGi | Quick optimization, limited budget |
| Full beam search ProTeGi | Maximum quality, sufficient budget |
| ProTeGi + paraphrasing | Diverse exploration, complex tasks |
| Momentum-aided (MAPO) | Faster convergence on established tasks |
Components and configuration
Six pieces do the work. The first five are required, the selector and beam manager are strongly recommended:
- Initial prompt — a human prompt, a task description, or output from another generator. Affects convergence speed, not the final ceiling.
- Training dataset — labeled input/output pairs. Minimum ~30, recommended 100 to 300, ideally covering edge cases.
- Gradient generator — the LLM that reads (prompt, input, prediction, ground truth) and emits a critique.
- Prompt editor — the LLM that applies a critique to produce a new prompt while keeping it coherent.
- Evaluation function — a scalar score: accuracy, F1, precision, recall for classification; BLEU, ROUGE, exact match, or semantic similarity for generation.
- Candidate selector + beam manager — UCB bandit selection over a beam of typically 3 to 8 candidates, so you don't pay to fully evaluate every weak edit.
The knobs you'll actually turn:
| Parameter | Default | Range | Effect |
|---|---|---|---|
iterations | 5 | 3-10 | More rounds = better results, higher cost |
beam_width | 4 | 2-8 | Wider beam = more exploration, higher cost |
errors_per_gradient | 3 | 1-5 | More errors = more diverse gradients |
temperature (gradient) | 0.7 | 0.5-1.0 | Higher = more creative critiques |
temperature (edit) | 0.7 | 0.5-1.0 | Higher = more varied edits |
temperature (eval) | 0 | 0 | Keep deterministic for stable scoring |
Tune for the scenario: high-stakes classification wants a wider beam (8 to 12) and a held-out validation set for final selection; low-data runs (below 50 examples) want beam width 2 to 3, cross-validation, and only 3 to 4 iterations to avoid overfitting; open-ended generation wants a semantic-similarity metric and more paraphrase variants.
Implementation and debugging
A practical workflow: collect 100 to 300 labeled examples with balanced classes, split 80/20 train/validation, write a clear initial prompt, record a baseline accuracy, run with defaults while watching the validation score for overfitting, then evaluate the winner on a held-out test set and A/B test it against the original before full rollout. Plan for periodic re-optimization as your data drifts.
The core mechanism is the gradient call. Here it is concretely, the function the loop leans on:
def generate_gradient(prompt, error, client):
"""Turn one error case into a textual gradient."""
g = f"""You are analyzing why a prompt produced an incorrect output.
PROMPT: "{prompt}"
INPUT: "{error['input']}"
MODEL OUTPUT: "{error['prediction']}"
CORRECT ANSWER: "{error['ground_truth']}"
What flaw in the prompt caused this error? Give a concise critique
(2-3 sentences) naming the specific problem."""
resp = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": g}],
temperature=0.7,
)
return resp.choices[0].message.content.strip()
The edit call is the mirror image: same shape, but it takes the prompt plus the aggregated critique and returns a rewritten prompt with temperature around 0.7. Frameworks wrap this for you. DSPy expresses the gradient and editor as Signature classes and runs the whole thing through MIPROv2; LangChain and Haystack wrap the same two calls around their prompt-template and pipeline objects.
Do: start from a reasonable prompt (garbage in, garbage out), use diverse examples, keep a validation set to catch overfitting, log every intermediate prompt and score, eyeball the gradients for quality, and review the final prompt by hand before shipping.
Don't: use fewer than 30 examples, skip validation, run iterations forever without a convergence check, trust low-quality gradients, or expect optimization to rescue a fundamentally broken task definition.
When it misbehaves, the symptom usually points at the cause:
| Symptom | Likely cause and fix |
|---|---|
| No improvement over iterations | Initial prompt already optimal, or data too small/unrepresentative; add diverse examples, check gradient quality |
| Performance degrades mid-run | Overfitting to specific errors or conflicting gradients; narrow the beam, aggregate gradients, validate every round |
| Inconsistent across runs | Temperature too high or samples too small; lower temperature, use fixed seeds, evaluate on the full set |
| Gradients vague or unhelpful | Error cases too similar or prompt too open-ended; sample diverse errors, add structure, use a stronger model |
| Optimized prompt incoherent | Too many iterations or edits too aggressive; stop earlier, emphasize minimal changes in the edit prompt |
A clean comparison is how you prove it worked, run both prompts on held-out data and check the gap is real:
def compare(original, optimized, test_data, client, trials=5):
import numpy as np
from scipy.stats import ttest_ind
a = [evaluate_prompt(original, test_data, client)[0] for _ in range(trials)]
b = [evaluate_prompt(optimized, test_data, client)[0] for _ in range(trials)]
t, p = ttest_ind(a, b)
return {"original": np.mean(a), "optimized": np.mean(b),
"significant": p < 0.05}
Stop optimizing when validation accuracy stalls for two consecutive iterations, you clear your target threshold, you exhaust the budget, or gradient quality visibly drops. Cover your test set with happy-path, edge, adversarial, and distribution-shift cases, not just easy examples.
Limitations
Some constraints are baked in and no amount of tuning removes them:
- Needs labeled data. No ground truth means no errors to learn from. Tasks without clear right answers can't be optimized this way.
- Only optimizes what you measure. Creativity, style, and nuance slip through standard metrics.
- First-order. It reacts to immediate per-iteration feedback, so it struggles with optimizations that need long-range, multi-step structure.
- Local optima. Like numerical gradient descent, it can settle into a prompt that's locally good but globally mediocre.
- Gradient-quality ceiling. If the model can't correctly diagnose a failure, it can't fix it. Effectiveness is bounded by the LLM's analytical skill.
Watch for the predictable failure modes: noisy labels make it optimize for noise (clean first), imbalanced data biases toward the majority class (balance or weight), small datasets overfit fast (fewer iterations, cross-validate), and genuinely ambiguous inputs make gradients conflict and oscillate (remove them or accept multi-label). Always keep a best-so-far fallback so a bad edit can't sink the run.
Optimization inherits your data's biases. ProTeGi chases the metric you give it. If the training set skews, the optimized prompt can amplify that skew, and aggressive accuracy-only optimization can even make a prompt more vulnerable to injection. Audit data for demographic balance, evaluate the result across subgroups, track fairness alongside accuracy, and keep a human in the loop before deployment.
How it compares
ProTeGi sits in a family of automatic prompt optimizers. The useful distinctions:
| Aspect | ProTeGi | APE | OPRO | DSPy MIPRO |
|---|---|---|---|---|
| Approach | Iterative gradient descent | One-shot generation | Trajectory optimization | Bayesian optimization |
| Iterations | 3-10 | 1 | 5-20 | 10-50 |
| Optimizes | Instructions | Instructions | Instructions | Instructions + examples |
| Search | Beam + bandit | Random sampling | Meta-prompting | TPE |
| Best for | Classification, extraction | Quick baseline | Complex reasoning | Multi-stage pipelines |
| API cost | Medium | Low | High | High |
| Improvement | 20-31% | 15-20% | 20-50% | 10-15% |
APE is its one-shot predecessor (generate then select); GRIPS uses heuristic edit operations rather than gradient-guided ones; OPRO is trajectory-based rather than error-focused; TextGrad is a direct extension that optimizes any text variable, not just prompts. It also combines well: ProTeGi + RAG, + CoT, + RLHF, and + Constitutional AI are all promising hybrids, and the gradient idea transfers to few-shot example selection, system-prompt tuning, and LLM-as-judge evaluation prompts.
ProTeGi's "textual gradient" idea also kicked off a research line. TextGrad (2024), published in Nature, generalized gradients to any text variable and reported 78% to 92% accuracy improvement on GPT-3.5-turbo benchmarks, with applications from code debugging to molecular structure and radiotherapy planning. MAPO (2024) added momentum to escape oscillation and hit higher F1 with fewer API calls. PO2G (2024) uses both a positive and negative gradient and reaches 89% accuracy in 3 iterations versus ProTeGi's 6 for comparable performance.
Tools to start from: the authors' reference implementation lives in Microsoft's LMOps repo (github.com/microsoft/LMOps/tree/main/prompt_optimization), TextGrad is at textgrad.com, DSPy at dspy.ai, and the original paper is aclanthology.org/2023.emnlp-main.494. For deeper reading, the MAPO paper is arxiv.org/abs/2410.19499 and there's a broader APO survey at arxiv.org/abs/2502.16923.
The headline, in context. On the original benchmarks ProTeGi turned a ~65%-accurate jailbreak-detection prompt into a ~85% one and delivered up to 31% improvement across tasks, all in roughly 10 minutes per task and a few dollars of API calls, with no model fine-tuning. That's the pitch: when manual prompt iteration has stalled, an LLM can often out-tune you on the parts of the prompt you keep missing.
Summary
- What: ProTeGi (also called APO) automates prompt tuning by simulating gradient descent in natural language, an LLM critiques its own failures and rewrites the prompt to fix them.
- Why: it replaces slow, subjective manual iteration with a systematic, reproducible loop, delivering up to 31% accuracy improvement over the starting prompt.
- How: evaluate on a batch, turn errors into textual gradients, edit the prompt in the opposite direction, expand candidates, and use UCB bandit selection over a beam, for 3 to 10 iterations.
- When: tasks with clear metrics and 30 to 300 labeled examples sitting at moderate (50 to 80%) accuracy, especially after manual tuning has plateaued.
- When not: open-ended generation, real-time needs, already-strong prompts, or subjective tasks without reliable labels.
- Where: classification, extraction, content moderation, code, and RAG query reformulation, with results that transfer across similar tasks in a domain.
- Which: prefer full beam search for quality, single-gradient for budget, MAPO for speed; escalate to APE, DSPy MIPRO, or fine-tuning when ProTeGi's assumptions don't hold.
- Cost and limits: roughly $2 to $10 and 5 to 30 minutes per task; bounded by label quality, metric coverage, gradient accuracy, and local optima, so keep a validation set and a human review gate.
Read Next
Start reading to get personalized recommendations
Explore Unread
Great job! You've read all available articles