Prompt Optimization Loop
Technique source: Evidently AI — automated prompt optimization. The idea is borrowed, the implementation is local — do not import or depend on their library.
Instead of hand-tweaking wording, feed the LLM its own failures and ask it to generalize a fix.
When to use
- A prompt already in use produces output that doesn't match what's needed, and there are concrete bad outputs to learn from
- The user can paste or point to wrong-vs-expected output pairs and wants a revised prompt
- A prompt needs systematic improvement against an eval set, repeatable as requirements change
When NOT to use
- There's no way to tell a good output from a bad one, and no rubric or judge can be constructed — feedback has nothing to anchor on
- No failure examples exist at all and none can be produced — that's a cold rewrite, not this technique
Task types — this is not classification-only
The loop is identical across task types; only the scorer and the definition of "mistake" change.
| Task type |
Example |
Scorer |
A "mistake" is |
| Classification / LLM-judge |
sentiment, pass-fail, intent routing |
exact match vs label → accuracy |
output != target_label |
| Structured extraction |
pull fields into JSON |
per-field match, schema validity |
any wrong/missing/extra field |
| Query or code generation |
NL → analytics query, NL → SQL |
execute it: does it run, and does the result set match expected? plus schema/field-name validity |
fails to execute, or wrong result set, or hallucinated field |
| Summarization / free-text |
data story, narrative summary |
rubric LLM-judge scoring named criteria (coverage, no invented numbers, tone) |
any row scoring below the pass bar |
| Multi-constraint output |
must hit format + tone + length |
checklist scorer, one boolean per constraint |
any violated constraint |
For non-exact-match tasks, a mistake carries the scorer's reason, not just a wrong value — feed that reason into the feedback prompt. "Judge said: invented a revenue figure not present in input" improves a prompt far more than a bare low score.
Two modes
Quick/manual — default for a one-off "fix my prompt" ask. No dataset, no harness, no code. Take the current prompt plus whatever bad outputs exist (1–3 is enough), apply the feedback meta-prompt below, hand back the revised prompt, ask the user to re-test. Repeat once or twice. Still write the record files (below) — cheap, and it's what makes the next session resumable.
Full automated loop — when the user wants a repeatable optimizer, not a single fix. Needs an eval set (~10+ rows) and a programmatic scorer. Build the harness described below.
Persistent record — resume across sessions
Every session for a given prompt reads and writes one record directory, so a session months later with new failures resumes instead of re-litigating fixed problems.
Location: docs/prompt-records/<slug>/ at the repo root. <slug> is a kebab-case id for the prompt, derived from its purpose or source file (e.g. query-generation, d2c-mini-system-prompt).
Before creating it, confirm the path is not ignored:
git check-ignore -v docs/prompt-records/<slug>/current.md # must print nothing
Do not put records under .cursor/ — it is gitignored in many repos (including agent-service, via .cursor/*), which silently makes the record untracked and defeats the purpose. If the chosen path is ignored, pick a tracked one and say which was used.
Files (templates: reference-records.md):
| File |
Purpose |
When to write |
current.md |
The live prompt text right now |
Overwrite when a revision is accepted |
history.md |
Append-only: one entry per iteration — timestamp, trigger, what changed, score, accepted/rejected |
Append every iteration, never edit past entries |
failures.md |
Every failure case ever observed, dated, with which iteration it drove |
Append; never delete, even once fixed — this is the regression set |
learnings.md |
Distilled generalizable rules ("always pin the output schema or it drifts to prose") |
Only when something reusable is learned |
harness.py |
This prompt's actual run/score/next_prompt/optimize code (full-loop mode only) |
Once when built; update only if input/output shape changes |
Start of every session — check whether the record directory exists:
- Exists: read
current.md and learnings.md in full, plus the last 3–5 history.md entries. Treat learnings.md as hard constraints; don't reintroduce something it documents as fixed. Treat failures.md as a regression set — a new candidate must still handle the old cases, not just today's. If harness.py exists, reuse it and feed it the new rows; only modify it if the input/output shape itself changed.
- Doesn't exist: create the directory and seed
current.md with today's starting prompt; create history.md, failures.md, learnings.md empty; add harness.py only in full-loop mode.
Drift check (do this before iterating): compare current.md against the prompt actually live in the codebase. They diverge whenever someone hand-edited the prompt outside this loop. If they differ, treat the codebase as truth, append a history.md entry recording the out-of-band edit, overwrite current.md, and only then start iterating. Never silently optimize a stale prompt.
Every iteration: append to history.md as it happens (don't batch at the end), append new failures to failures.md, and update current.md only when a revision is actually accepted — not for every candidate generated.
Architecture
A pattern to implement per prompt, not a library to import. Write small plain functions sized to this prompt's shapes:
| Role |
Job |
run(prompt, rows) -> outputs |
Call the LLM once per row with the candidate prompt, parse to this task's output shape |
score(outputs, targets) -> (metric, per_row_results) |
Return the aggregate number and per-row pass/fail with reasons, since mistakes drive the next step |
next_prompt(prompt, mistakes, task, instructions) -> str |
Generate the next candidate via the feedback meta-prompt |
Loop:
1. outputs = run(current_prompt, rows)
2. metric, per_row = score(outputs, targets)
3. append iteration + new failures to the record
4. stop? (see Early stopping) -> yes: return best-scoring prompt so far
5. mistakes = rows failing in per_row -> next_prompt(...) -> back to 1
Keep a best_prompt/best_score pair across iterations and return that, never just the last candidate — candidates can regress.
Feedback meta-prompt
I ran LLM for some inputs to do {task} and it made some mistakes.
Here is my original prompt <prompt>
{prompt}
</prompt>
And here are rows where LLM made mistakes:
<rows>
{rows}
</rows>.
Please update my prompt to improve LLM quality.
Generalize examples to not overfit on them.
{instructions}
Return new prompt inside <new_prompt> tag
Placeholders:
{task} — one line describing the job ("generate an analytics query from a natural-language question").
{rows} — mistake examples: input, expected, what came out, and the scorer's reason. Cap at ~10–20; dumping everything undercuts the generalize instruction.
{instructions} — task-specific constraints the rewrite must not break: output contract ("must stay valid JSON matching this schema"), sections that must survive verbatim, length limits, tone. Leave empty only if there are genuinely no invariants.
- The "generalize, do not overfit" line is load-bearing. Without it, the rewrite hardcodes surface patterns from the examples ("if text contains 🚀, output 1").
Held-out scoring and small datasets
| Eval set size |
What to do |
| < 10 rows |
Skip formal splits — they'd be 2/2/1 and meaningless. Iterate on all rows, then sanity-check the final prompt on fresh examples the loop never saw. State plainly that the result is directional, not measured. |
| 10–30 rows |
Two-way split (e.g. 60/40 optimize/held-out), or leave-one-out if labels are expensive. Select on held-out, not on the rows being optimized against. |
| 30+ rows |
40/40/20 train/validation/test. Optimize on train, select the best prompt on validation, report the final number on test only. Never let the loop see test rows. |
The point of a held-out set: a prompt that memorized the examples scores well on them and visibly fails elsewhere, so it gets discarded.
Early stopping
Set all three; stop on whichever hits first.
| Param |
Meaning |
max_iterations |
Hard cap on attempts (also the cost ceiling) |
min_score_gain |
Minimum improvement to count as progress; below it, treat as converged |
target_score |
Stop once the held-out score reaches this (e.g. 0.97) |
Multiple starts
LLM generation is stochastic — the same starting prompt can converge differently. Repeat the whole run N times and keep the best held-out scorer. Worth it when a single run plateaus below target.
Edge cases and failure modes
Handle these explicitly; each one is a real way the loop breaks.
| Situation |
Handling |
Response has no <new_prompt> tag |
Don't regex-guess the whole response into a prompt. Retry once with the same input; if it fails again, fall back to the previous prompt, log it in history.md, and report the parse failure rather than shipping garbage. |
| Candidate scores worse than current |
Reject it, keep best_prompt, and log the rejection with its score. A rejected candidate is still useful history — it records what doesn't work. |
| Score moves but the change is noise |
Fix temperature low (0 where supported) for scoring runs. If scores still fluctuate across identical runs, average over 2–3 runs before comparing, and set min_score_gain above the observed noise band. |
| Zero mistakes on the first pass |
Nothing to optimize — stop and say so. Either the prompt is already fine or the eval set is too easy; suggest harder examples instead of iterating. |
| Every row fails |
Usually not a wording problem — check for a broken output contract, wrong model, or a parsing bug in run() before asking for a prompt rewrite. |
| Rate limits / cost blowup |
Throttle concurrency, cache per (prompt, row) so re-scored rows don't re-bill, and treat max_iterations as a budget. Route LLM calls through the existing client and cost-tracking wrapper (see the track-claude-cost skill), never a raw client. |
current.md disagrees with the codebase |
Drift check above — codebase wins, record the out-of-band edit, then iterate. |
| Slug already exists for a different prompt |
Don't append to someone else's record. Pick a more specific slug and note the sibling in history.md. |
| Prompt contains secrets or customer data in examples |
Redact before writing to failures.md — the record is committed to the repo. |
Implementation checklist (full loop only)
Before building new scaffolding, check whether this repo already has judge, eval, or prompt-harness code to extend instead (agent_utils/, pattern engine, eval harness) — extend it rather than adding a parallel implementation.
Other approaches (context)
| Approach |
Why not the default |
| Search-based (grid/random/genetic/Bayesian) |
Slow and eval-hungry; rarely worth it at prompt scale |
| Model-internals (gradients, soft prompts) |
Needs weight access — impossible against API-only models |
| Self-improvement with no failure context |
Works, but weaker: nothing grounds the rewrite in real errors |
| Example-based / few-shot selection |
Improves demonstrations, not instructions — complementary, not a replacement |
The approach above is a hybrid: self-improvement grounded in held-out evaluation and real mistake context.
1---2name: prompt-optimization-loop3description: Tune a user's own LLM prompt when its output doesn't match the required result — drive revisions from observed failures and feedback instead of rewording blind. Works for any prompt type: classification/LLM-judge, structured extraction, query or code generation, summarization, free-text generation. Covers a quick manual pass (a few bad outputs, revise now) and a full automated loop (eval set, held-out scoring, early stopping, multiple starts), and keeps a per-prompt record so a session months later resumes instead of restarting. Use when a prompt isn't producing the needed result, when wrong-vs-expected outputs are available for it, or when a prompt needs systematic improvement against an eval set.4---56# Prompt Optimization Loop78Technique source: [Evidently AI — automated prompt optimization](https://www.evidentlyai.com/blog/automated-prompt-optimization). The idea is borrowed, the implementation is local — do not import or depend on their library.910Instead of hand-tweaking wording, feed the LLM its own failures and ask it to generalize a fix.1112## When to use1314- A prompt already in use produces output that doesn't match what's needed, and there are concrete bad outputs to learn from15- The user can paste or point to wrong-vs-expected output pairs and wants a revised prompt16- A prompt needs systematic improvement against an eval set, repeatable as requirements change1718## When NOT to use1920- There's no way to tell a good output from a bad one, and no rubric or judge can be constructed — feedback has nothing to anchor on21- No failure examples exist at all and none can be produced — that's a cold rewrite, not this technique2223## Task types — this is not classification-only2425The loop is identical across task types; only the scorer and the definition of "mistake" change.2627| Task type | Example | Scorer | A "mistake" is |28|---|---|---|---|29| Classification / LLM-judge | sentiment, pass-fail, intent routing | exact match vs label → accuracy | `output != target_label` |30| Structured extraction | pull fields into JSON | per-field match, schema validity | any wrong/missing/extra field |31| Query or code generation | NL → analytics query, NL → SQL | execute it: does it run, and does the result set match expected? plus schema/field-name validity | fails to execute, or wrong result set, or hallucinated field |32| Summarization / free-text | data story, narrative summary | rubric LLM-judge scoring named criteria (coverage, no invented numbers, tone) | any row scoring below the pass bar |33| Multi-constraint output | must hit format + tone + length | checklist scorer, one boolean per constraint | any violated constraint |3435For non-exact-match tasks, a mistake carries the **scorer's reason**, not just a wrong value — feed that reason into the feedback prompt. "Judge said: invented a revenue figure not present in input" improves a prompt far more than a bare low score.3637## Two modes3839**Quick/manual — default for a one-off "fix my prompt" ask.** No dataset, no harness, no code. Take the current prompt plus whatever bad outputs exist (1–3 is enough), apply the feedback meta-prompt below, hand back the revised prompt, ask the user to re-test. Repeat once or twice. Still write the record files (below) — cheap, and it's what makes the next session resumable.4041**Full automated loop — when the user wants a repeatable optimizer, not a single fix.** Needs an eval set (~10+ rows) and a programmatic scorer. Build the harness described below.4243## Persistent record — resume across sessions4445Every session for a given prompt reads and writes one record directory, so a session months later with new failures resumes instead of re-litigating fixed problems.4647**Location**: `docs/prompt-records/<slug>/` at the repo root. `<slug>` is a kebab-case id for the prompt, derived from its purpose or source file (e.g. `query-generation`, `d2c-mini-system-prompt`).4849Before creating it, confirm the path is not ignored:50```bash51git check-ignore -v docs/prompt-records/<slug>/current.md # must print nothing52```53Do **not** put records under `.cursor/` — it is gitignored in many repos (including agent-service, via `.cursor/*`), which silently makes the record untracked and defeats the purpose. If the chosen path is ignored, pick a tracked one and say which was used.5455**Files** (templates: [reference-records.md](reference-records.md)):5657| File | Purpose | When to write |58|---|---|---|59| `current.md` | The live prompt text right now | Overwrite when a revision is accepted |60| `history.md` | Append-only: one entry per iteration — timestamp, trigger, what changed, score, accepted/rejected | Append every iteration, never edit past entries |61| `failures.md` | Every failure case ever observed, dated, with which iteration it drove | Append; never delete, even once fixed — this is the regression set |62| `learnings.md` | Distilled generalizable rules ("always pin the output schema or it drifts to prose") | Only when something reusable is learned |63| `harness.py` | This prompt's actual `run`/`score`/`next_prompt`/`optimize` code (full-loop mode only) | Once when built; update only if input/output shape changes |6465**Start of every session** — check whether the record directory exists:6667- **Exists**: read `current.md` and `learnings.md` in full, plus the last 3–5 `history.md` entries. Treat `learnings.md` as hard constraints; don't reintroduce something it documents as fixed. Treat `failures.md` as a regression set — a new candidate must still handle the old cases, not just today's. If `harness.py` exists, reuse it and feed it the new rows; only modify it if the input/output shape itself changed.68- **Doesn't exist**: create the directory and seed `current.md` with today's starting prompt; create `history.md`, `failures.md`, `learnings.md` empty; add `harness.py` only in full-loop mode.6970**Drift check (do this before iterating)**: compare `current.md` against the prompt actually live in the codebase. They diverge whenever someone hand-edited the prompt outside this loop. If they differ, treat the **codebase** as truth, append a `history.md` entry recording the out-of-band edit, overwrite `current.md`, and only then start iterating. Never silently optimize a stale prompt.7172**Every iteration**: append to `history.md` as it happens (don't batch at the end), append new failures to `failures.md`, and update `current.md` only when a revision is actually accepted — not for every candidate generated.7374## Architecture7576A pattern to implement per prompt, not a library to import. Write small plain functions sized to *this* prompt's shapes:7778| Role | Job |79|---|---|80| `run(prompt, rows) -> outputs` | Call the LLM once per row with the candidate prompt, parse to this task's output shape |81| `score(outputs, targets) -> (metric, per_row_results)` | Return the aggregate number **and** per-row pass/fail with reasons, since mistakes drive the next step |82| `next_prompt(prompt, mistakes, task, instructions) -> str` | Generate the next candidate via the feedback meta-prompt |8384Loop:85```861. outputs = run(current_prompt, rows)872. metric, per_row = score(outputs, targets)883. append iteration + new failures to the record894. stop? (see Early stopping) -> yes: return best-scoring prompt so far905. mistakes = rows failing in per_row -> next_prompt(...) -> back to 191```9293Keep a `best_prompt`/`best_score` pair across iterations and return that, never just the last candidate — candidates can regress.9495## Feedback meta-prompt9697```text98I ran LLM for some inputs to do {task} and it made some mistakes.99Here is my original prompt <prompt>100{prompt}101</prompt>102And here are rows where LLM made mistakes:103<rows>104{rows}105</rows>.106Please update my prompt to improve LLM quality.107Generalize examples to not overfit on them.108{instructions}109Return new prompt inside <new_prompt> tag110```111112Placeholders:113- `{task}` — one line describing the job ("generate an analytics query from a natural-language question").114- `{rows}` — mistake examples: input, expected, what came out, and the scorer's reason. Cap at ~10–20; dumping everything undercuts the generalize instruction.115- `{instructions}` — task-specific constraints the rewrite must not break: output contract ("must stay valid JSON matching this schema"), sections that must survive verbatim, length limits, tone. Leave empty only if there are genuinely no invariants.116- The "generalize, do not overfit" line is load-bearing. Without it, the rewrite hardcodes surface patterns from the examples ("if text contains 🚀, output 1").117118## Held-out scoring and small datasets119120| Eval set size | What to do |121|---|---|122| < 10 rows | Skip formal splits — they'd be 2/2/1 and meaningless. Iterate on all rows, then sanity-check the final prompt on fresh examples the loop never saw. State plainly that the result is directional, not measured. |123| 10–30 rows | Two-way split (e.g. 60/40 optimize/held-out), or leave-one-out if labels are expensive. Select on held-out, not on the rows being optimized against. |124| 30+ rows | 40/40/20 train/validation/test. Optimize on train, select the best prompt on validation, report the final number on test only. Never let the loop see test rows. |125126The point of a held-out set: a prompt that memorized the examples scores well on them and visibly fails elsewhere, so it gets discarded.127128## Early stopping129130Set all three; stop on whichever hits first.131132| Param | Meaning |133|---|---|134| `max_iterations` | Hard cap on attempts (also the cost ceiling) |135| `min_score_gain` | Minimum improvement to count as progress; below it, treat as converged |136| `target_score` | Stop once the held-out score reaches this (e.g. 0.97) |137138## Multiple starts139140LLM generation is stochastic — the same starting prompt can converge differently. Repeat the whole run N times and keep the best held-out scorer. Worth it when a single run plateaus below target.141142## Edge cases and failure modes143144Handle these explicitly; each one is a real way the loop breaks.145146| Situation | Handling |147|---|---|148| Response has no `<new_prompt>` tag | Don't regex-guess the whole response into a prompt. Retry once with the same input; if it fails again, fall back to the previous prompt, log it in `history.md`, and report the parse failure rather than shipping garbage. |149| Candidate scores **worse** than current | Reject it, keep `best_prompt`, and log the rejection with its score. A rejected candidate is still useful history — it records what doesn't work. |150| Score moves but the change is noise | Fix temperature low (0 where supported) for scoring runs. If scores still fluctuate across identical runs, average over 2–3 runs before comparing, and set `min_score_gain` above the observed noise band. |151| Zero mistakes on the first pass | Nothing to optimize — stop and say so. Either the prompt is already fine or the eval set is too easy; suggest harder examples instead of iterating. |152| Every row fails | Usually not a wording problem — check for a broken output contract, wrong model, or a parsing bug in `run()` before asking for a prompt rewrite. |153| Rate limits / cost blowup | Throttle concurrency, cache per `(prompt, row)` so re-scored rows don't re-bill, and treat `max_iterations` as a budget. Route LLM calls through the existing client and cost-tracking wrapper (see the `track-claude-cost` skill), never a raw client. |154| `current.md` disagrees with the codebase | Drift check above — codebase wins, record the out-of-band edit, then iterate. |155| Slug already exists for a different prompt | Don't append to someone else's record. Pick a more specific slug and note the sibling in `history.md`. |156| Prompt contains secrets or customer data in examples | Redact before writing to `failures.md` — the record is committed to the repo. |157158## Implementation checklist (full loop only)159160- [ ] Define the eval row schema for *this* task: `input`, expected value (`target_label` / `target_json` / `target_result_set` / rubric criteria), optional `reasoning`161- [ ] Choose the scorer from the task-type table; make it return per-row reasons, not just an aggregate162- [ ] Split per the size table above (or explicitly skip splits and say why)163- [ ] Write `run()`, parsing to this task's output shape; batch/async if the SDK supports it164- [ ] Write `score()` returning `(metric, per_row_results)`165- [ ] Write `next_prompt()` using the meta-prompt, with `{instructions}` filled from the prompt's real invariants166- [ ] Write `optimize()` wiring them with early stopping and best-so-far tracking167- [ ] Save the code as `harness.py` in the record directory so the next session reuses it168- [ ] Write every iteration to `history.md` / `failures.md` as it happens169- [ ] Add multiple starts as an outer loop if a single run plateaus170- [ ] Report the final score on held-out data only, and state which rows it was measured on171172Before building new scaffolding, check whether this repo already has judge, eval, or prompt-harness code to extend instead (`agent_utils/`, pattern engine, eval harness) — extend it rather than adding a parallel implementation.173174## Other approaches (context)175176| Approach | Why not the default |177|---|---|178| Search-based (grid/random/genetic/Bayesian) | Slow and eval-hungry; rarely worth it at prompt scale |179| Model-internals (gradients, soft prompts) | Needs weight access — impossible against API-only models |180| Self-improvement with no failure context | Works, but weaker: nothing grounds the rewrite in real errors |181| Example-based / few-shot selection | Improves demonstrations, not instructions — complementary, not a replacement |182183The approach above is a hybrid: self-improvement grounded in held-out evaluation and real mistake context.