diff --git a/guides/README.md b/guides/README.md index 12605ba..ff57e4a 100644 --- a/guides/README.md +++ b/guides/README.md @@ -6,6 +6,7 @@ Task-oriented examples for doing something specific with Opik — combining Opik |---|---| | [annotation_queues_with_context/](./annotation_queues_with_context/) | Structure RAG traces for Opik annotation queues — clean answer in output, context in metadata, full detail in child spans | | [multimodal_online_evaluation/](./multimodal_online_evaluation/) | Run an online LLM-as-judge eval over multimodal (text + image) traces — create the rule in the UI and with the SDK | +| [prompt_agent_optimization/](./prompt_agent_optimization/) | A-to-Z guide to prompt & agent optimization with Opik — one escalating RAG example, from an exact-match metric to LLM-judge, multi-objective, and agent/tool optimization; doubles as a live workshop (Part 1) | | [tracing_finetuned_models/](./tracing_finetuned_models/) | Fine-tune a model, register it to the CometML Model Registry, then fetch and trace inference in Opik | [Contribute one](../CONTRIBUTING.md). diff --git a/guides/prompt_agent_optimization/.gitignore b/guides/prompt_agent_optimization/.gitignore new file mode 100644 index 0000000..ffb4101 --- /dev/null +++ b/guides/prompt_agent_optimization/.gitignore @@ -0,0 +1,5 @@ +.venv/ +chroma_db/ +__pycache__/ +*.pyc +.ipynb_checkpoints/ diff --git a/guides/prompt_agent_optimization/README.md b/guides/prompt_agent_optimization/README.md new file mode 100644 index 0000000..155b39c --- /dev/null +++ b/guides/prompt_agent_optimization/README.md @@ -0,0 +1,65 @@ +# Prompt & Agent Optimization with Opik — an A-to-Z guide + +A single, **self-contained** notebook that teaches prompt and agent optimization +end-to-end, over one escalating RAG-over-docs example (a documentation assistant +for a fictional product, **Ledgerline**). It doubles as: + +- a **live workshop** — run **Part 1** (~20 min) to optimize a prompt against an + exact-match metric and see it in Opik; and +- a **take-home guide** — Parts 2–5 cover LLM-judge metrics (and how to *trust* + them), multi-objective optimization, agent/tool optimization, and choosing an + optimizer. + +Every optimization logs to Opik under **Evaluation → Optimization runs**, so each +step is a comparable run. + +## What it covers + +- **Part 0** — how to think about prompt optimization (prompt + dataset + metric). +- **Part 1** ⭐ — your first optimization: exact-match metric + `MetaPromptOptimizer`. +- **Part 2** — LLM-judge metrics, *how to trust a judge*, and multi-objective + optimization with `MultiMetricObjective`. +- **Part 3** — from prompt to agent: a tool-calling `search_docs` agent optimized + end-to-end, then `FewShotBayesianOptimizer` on the same agent (with a pointer to + `ParameterOptimizer`). +- **Part 4** — choosing an optimizer (selection table + how to choose + chaining). +- **Part 5** — promote the winner to the Prompt Library; pointers to Optimization + Studio and the docs. + +## Running it + +The notebook is self-contained — it installs its dependencies and configures its +credentials in the first few cells, and defines its corpus + RAG app inline. Run +the cells top to bottom; for the workshop, stop at the end of Part 1. + +- **Google Colab** — upload/open the notebook and run it; the first cell + `%pip install`s everything. +- **Locally** — `uv sync` then `uv run jupyter lab` (or open the notebook in your + editor's Jupyter). `uv` and the `pyproject.toml` are here for convenience; the + notebook's own `%pip install` cell means it also runs in a bare environment. + +## Credentials + +The **Credentials** cell walks you through setup — no external environment dance +required: + +- **Opik** — it calls `opik.configure()`, which prompts for your API key and + workspace (get them free at [comet.com/opik](https://www.comet.com/opik)). +- **A model provider key** — the guide calls models through litellm. It defaults + to a small Anthropic Claude model and prompts for your `ANTHROPIC_API_KEY`. To + use another provider, set `OPIK_EXAMPLES_MODEL` (e.g. `openai/gpt-4o-mini`) and + you'll be prompted for that provider's key instead. + +If the relevant variables are already set in your environment (`OPIK_API_KEY`, +`OPIK_WORKSPACE`, `OPIK_EXAMPLES_MODEL`, the provider key, and optional +`OPIK_PROJECT_NAME`), the cell skips the prompts — which is how it runs +non-interactively in CI. There is **no dry-run**: optimization runs real +evaluations against your Opik workspace. + +## How the code is organized + +Everything lives **in the notebook** — the corpus, the tiny RAG app (a ChromaDB +retriever + an `answer()` function), the metrics, and every optimizer call. That's +deliberate: you can read it top to bottom, run it anywhere, and share it as a +single file with no external dependencies. Lifting the inline retriever/answer +helpers into a module to back a repeatable CLI is a natural next step. diff --git a/guides/prompt_agent_optimization/prompt_agent_optimization.ipynb b/guides/prompt_agent_optimization/prompt_agent_optimization.ipynb new file mode 100644 index 0000000..b34a840 --- /dev/null +++ b/guides/prompt_agent_optimization/prompt_agent_optimization.ipynb @@ -0,0 +1,813 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "4da2e303", + "metadata": {}, + "source": [ + "# Prompt & Agent Optimization with Opik — an A-to-Z Guide\n", + "\n", + "This notebook takes you from *\"I have a prompt that works okay\"* to *\"my prompt and my agent are measurably better, and every improvement is a comparable run in Opik.\"*\n", + "\n", + "**Two ways to use it:**\n", + "- **Live workshop (≈20 min):** run **Part 1** top to bottom. You'll optimize a RAG answer prompt against an exact-match metric and see the improvement in Opik.\n", + "- **Take-home guide:** continue through Parts 2–5 — LLM-judge metrics (and how to *trust* them), multi-objective optimization, and optimizing an agent's tool use.\n", + "\n", + "We optimize a **documentation assistant** for a fictional product, **Ledgerline** (a task-queue API), so the corpus is clean and the lesson is about *optimization*, not about parsing messy docs.\n", + "\n", + "**Runs anywhere.** The notebook installs its own dependencies and configures credentials in the first few cells and defines its tiny RAG app inline, so you can run it top-to-bottom in **Google Colab** or locally — nothing else to set up." + ] + }, + { + "cell_type": "markdown", + "id": "41618004", + "metadata": {}, + "source": [ + "## Part 0 — How to think about prompt optimization\n", + "\n", + "**When do you start?** When you have (1) a prompt that works *okay*, (2) a dataset of representative inputs, and (3) a metric that says how good an output is — and hand-tuning has plateaued.\n", + "\n", + "**The mental shift.** Classic optimization gives you an objective and a gradient. Prompt optimization is different: the **search space is prompt text**, and the **objective is a metric computed over a dataset**. You can't differentiate it, so optimizers *propose* candidate prompts, *evaluate* them on your dataset, keep the best, and repeat.\n", + "\n", + "The three ingredients map exactly to three objects you'll build:\n", + "\n", + "| Ingredient | Opik object |\n", + "|---|---|\n", + "| The prompt | `ChatPrompt` |\n", + "| The dataset | Opik `Dataset` |\n", + "| The metric | a callable `(dataset_item, llm_output) -> float` |\n", + "\n", + "The loop, once, looks like: **propose candidate → evaluate on dataset → keep best → repeat.** Everything below is that loop, escalating in complexity." + ] + }, + { + "cell_type": "markdown", + "id": "8f9dc65b", + "metadata": {}, + "source": [ + "## Setup — run these first\n", + "\n", + "The next few cells make the notebook self-contained: install dependencies, configure your credentials, and define a tiny RAG app over the Ledgerline docs. Run them once, top to bottom." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4a88ae85", + "metadata": {}, + "outputs": [], + "source": [ + "# Install dependencies. In Colab this installs them; locally (after `uv sync`)\n", + "# they're already present. --upgrade keeps you on current SDKs.\n", + "%pip install --quiet --upgrade opik opik-optimizer chromadb litellm" + ] + }, + { + "cell_type": "markdown", + "id": "f2d2833c", + "metadata": {}, + "source": [ + "### Credentials\n", + "\n", + "Run the cell below. `opik.configure()` reads your **Opik API key** and **workspace** from the environment, or prompts for them (get them free at [comet.com/opik](https://www.comet.com/opik)) — the same one-call setup used across these guides. `install_mcp=False` keeps it non-interactive, so the same cell also runs unattended in CI. `project_name=` pins the **project** every trace and optimization run logs to.\n", + "\n", + "You also need a **model provider key**: the guide calls models through litellm and defaults to a small Anthropic Claude model, so it'll use (or prompt for) your `ANTHROPIC_API_KEY`. To use another provider, set `OPIK_EXAMPLES_MODEL` (e.g. `openai/gpt-4o-mini`) and it'll use that provider's key instead." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2ddf88c4", + "metadata": {}, + "outputs": [], + "source": [ + "import getpass\n", + "import os\n", + "\n", + "import opik\n", + "\n", + "OPIK_PROJECT_NAME = \"prompt-agent-optimization\"\n", + "\n", + "# Reads OPIK_API_KEY / OPIK_WORKSPACE from the environment, or prompts for them.\n", + "# install_mcp=False keeps it non-interactive so it also runs unattended in CI.\n", + "opik.configure(project_name=OPIK_PROJECT_NAME, install_mcp=False)\n", + "\n", + "# The model, called via litellm. Default is a small Anthropic Claude model; set\n", + "# OPIK_EXAMPLES_MODEL to switch providers (e.g. \"openai/gpt-4o-mini\").\n", + "MODEL = os.environ.get(\"OPIK_EXAMPLES_MODEL\", \"anthropic/claude-haiku-4-5-20251001\")\n", + "\n", + "# Its provider key (ANTHROPIC_API_KEY / OPENAI_API_KEY / ...): from env, or prompted.\n", + "key_var = f\"{MODEL.split('/')[0].upper()}_API_KEY\"\n", + "if not os.environ.get(key_var):\n", + " os.environ[key_var] = getpass.getpass(f\"Enter {key_var}: \")\n", + "\n", + "print(\"Using model:\", MODEL, \"| logging to project:\", OPIK_PROJECT_NAME)" + ] + }, + { + "cell_type": "markdown", + "id": "07025541", + "metadata": {}, + "source": [ + "### The app: a tiny RAG over the Ledgerline docs\n", + "\n", + "Everything the guide needs is defined right here in the notebook — no external files. First the corpus and evaluation cases (for a fictional task-queue product, **Ledgerline**), then a small RAG app: a ChromaDB retriever and an `answer()` function. Swap this corpus for your own product's docs and the rest of the guide still applies." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dc121bb4", + "metadata": {}, + "outputs": [], + "source": [ + "# --- Corpus: ~12 short Ledgerline doc snippets. ---\n", + "DOCS = [\n", + " {\"id\": \"timeouts\", \"title\": \"Job timeouts\", \"text\": \"Every Ledgerline job has a default timeout of 30 seconds. Jobs exceeding the timeout are marked failed and eligible for retry. The maximum configurable timeout is 15 minutes.\"},\n", + " {\"id\": \"retries\", \"title\": \"Retries\", \"text\": \"Failed jobs are retried automatically. The default maximum number of retries is 3, using exponential backoff starting at 2 seconds. Set max_retries to 0 to disable retries.\"},\n", + " {\"id\": \"rate-limits\", \"title\": \"Rate limits\", \"text\": \"The API allows 1000 requests per minute per API key. Exceeding the limit returns HTTP 429. Rate limit headers are included on every response.\"},\n", + " {\"id\": \"auth\", \"title\": \"Authentication\", \"text\": \"Authenticate by sending your API key in the Authorization header as a Bearer token: 'Authorization: Bearer '. Keys are created in the dashboard.\"},\n", + " {\"id\": \"priorities\", \"title\": \"Queue priorities\", \"text\": \"Ledgerline supports three queue priorities: low, default, and high. High-priority jobs are dequeued before default and low. Priority is set per job at enqueue time.\"},\n", + " {\"id\": \"dead-letter\", \"title\": \"Dead-letter queue\", \"text\": \"After a job exhausts all retries it is moved to the dead-letter queue, where it is retained for 7 days before permanent deletion. Dead-letter jobs can be replayed from the dashboard.\"},\n", + " {\"id\": \"webhooks\", \"title\": \"Webhooks\", \"text\": \"When a job completes, Ledgerline POSTs a webhook to your configured URL. The payload includes job_id, status, and result fields. Webhook deliveries are signed with the X-Ledgerline-Signature header.\"},\n", + " {\"id\": \"install\", \"title\": \"SDK installation\", \"text\": \"Install the Python SDK with 'pip install ledgerline'. The SDK requires Python 3.9 or newer. Import it as 'import ledgerline'.\"},\n", + " {\"id\": \"concurrency\", \"title\": \"Concurrency\", \"text\": \"Each project runs up to 50 concurrent jobs by default. Contact support to raise the concurrency limit for your plan.\"},\n", + " {\"id\": \"regions\", \"title\": \"Regions\", \"text\": \"Ledgerline is available in three regions: us-east, eu-west, and ap-south. The default region is us-east. Set the region when initializing the client.\"},\n", + " {\"id\": \"batch\", \"title\": \"Batch enqueue\", \"text\": \"You can enqueue up to 500 jobs in a single batch request. Larger batches must be split. Each job in a batch is billed individually.\"},\n", + " {\"id\": \"idempotency\", \"title\": \"Idempotency\", \"text\": \"Pass an Idempotency-Key header to safely retry enqueue requests. Ledgerline deduplicates requests with the same key for 24 hours.\"},\n", + "]\n", + "\n", + "# --- Part 1 eval: exact-match cases. Each expected_substring appears verbatim in a doc. ---\n", + "EXACT_CASES = [\n", + " {\"query\": \"What is the default job timeout?\", \"expected_substring\": \"30 seconds\"},\n", + " {\"query\": \"What is the maximum configurable timeout?\", \"expected_substring\": \"15 minutes\"},\n", + " {\"query\": \"How many times are failed jobs retried by default?\", \"expected_substring\": \"3\"},\n", + " {\"query\": \"How do I disable retries?\", \"expected_substring\": \"max_retries to 0\"},\n", + " {\"query\": \"What backoff does retry use, and starting at what delay?\", \"expected_substring\": \"2 seconds\"},\n", + " {\"query\": \"How many requests per minute per API key are allowed?\", \"expected_substring\": \"1000 requests per minute\"},\n", + " {\"query\": \"What HTTP status is returned when the rate limit is exceeded?\", \"expected_substring\": \"429\"},\n", + " {\"query\": \"Which header carries the API key?\", \"expected_substring\": \"Authorization\"},\n", + " {\"query\": \"What token scheme is used for auth?\", \"expected_substring\": \"Bearer\"},\n", + " {\"query\": \"What queue priorities are supported?\", \"expected_substring\": \"low, default, and high\"},\n", + " {\"query\": \"How long are dead-letter jobs retained?\", \"expected_substring\": \"7 days\"},\n", + " {\"query\": \"Which header signs webhook deliveries?\", \"expected_substring\": \"X-Ledgerline-Signature\"},\n", + " {\"query\": \"How do I install the Python SDK?\", \"expected_substring\": \"pip install ledgerline\"},\n", + " {\"query\": \"What Python version does the SDK require?\", \"expected_substring\": \"3.9\"},\n", + " {\"query\": \"How many concurrent jobs run per project by default?\", \"expected_substring\": \"50 concurrent jobs\"},\n", + " {\"query\": \"What is the default region?\", \"expected_substring\": \"us-east\"},\n", + " {\"query\": \"How many jobs can I enqueue in one batch?\", \"expected_substring\": \"500 jobs\"},\n", + " {\"query\": \"How long are idempotency keys deduplicated?\", \"expected_substring\": \"24 hours\"},\n", + "]\n", + "\n", + "# --- Part 2 eval: open-ended cases with a reference answer (for an LLM judge). ---\n", + "JUDGE_CASES = [\n", + " {\"query\": \"How should I handle a job that keeps failing?\", \"reference\": \"Explain retries with exponential backoff, the default of 3 retries, and that exhausted jobs move to the dead-letter queue (retained 7 days, replayable from the dashboard).\"},\n", + " {\"query\": \"How do I make sure I don't enqueue the same job twice if my request retries?\", \"reference\": \"Use an Idempotency-Key header; Ledgerline deduplicates same-key requests for 24 hours.\"},\n", + " {\"query\": \"What's the best way to authenticate my requests?\", \"reference\": \"Send the API key as a Bearer token in the Authorization header; create keys in the dashboard.\"},\n", + " {\"query\": \"How do I get notified when a job finishes?\", \"reference\": \"Configure a webhook URL; Ledgerline POSTs job_id, status, and result, signed with X-Ledgerline-Signature.\"},\n", + " {\"query\": \"How can I prioritise urgent work?\", \"reference\": \"Set the job priority to high at enqueue time; high-priority jobs are dequeued before default and low.\"},\n", + " {\"query\": \"How do I run more jobs at the same time?\", \"reference\": \"Default concurrency is 50 concurrent jobs per project; contact support to raise the limit.\"},\n", + " {\"query\": \"How do I choose where my jobs run?\", \"reference\": \"Set the region (us-east, eu-west, ap-south) when initializing the client; default is us-east.\"},\n", + " {\"query\": \"What happens when I hit the rate limit?\", \"reference\": \"Requests over 1000/min per key return HTTP 429; rate-limit headers are on every response.\"},\n", + " {\"query\": \"How do I submit many jobs efficiently?\", \"reference\": \"Use batch enqueue, up to 500 jobs per request; split larger batches; each job billed individually.\"},\n", + " {\"query\": \"How long do I have to recover a permanently failing job?\", \"reference\": \"Dead-letter jobs are retained 7 days before permanent deletion and can be replayed from the dashboard.\"},\n", + " {\"query\": \"Can I make jobs run longer than the default?\", \"reference\": \"Yes; the default timeout is 30 seconds and the maximum configurable timeout is 15 minutes.\"},\n", + " {\"query\": \"How do I start using the SDK in Python?\", \"reference\": \"Install with pip install ledgerline (Python 3.9+), then import ledgerline.\"},\n", + "]\n", + "\n", + "print(f\"{len(DOCS)} docs, {len(EXACT_CASES)} exact cases, {len(JUDGE_CASES)} judge cases\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f971cba4", + "metadata": {}, + "outputs": [], + "source": [ + "import threading\n", + "\n", + "import chromadb\n", + "import litellm\n", + "\n", + "client = opik.Opik()\n", + "\n", + "# --- A tiny RAG app: an in-memory ChromaDB retriever + an answer function. ---\n", + "_collection = chromadb.Client().get_or_create_collection(\n", + " \"ledgerline_docs\", metadata={\"hnsw:space\": \"cosine\"}\n", + ")\n", + "# The optimizer calls retrieve() across worker threads; serialize reads for safety.\n", + "_retrieve_lock = threading.Lock()\n", + "\n", + "\n", + "def ingest(docs):\n", + " _collection.upsert(\n", + " ids=[d[\"id\"] for d in docs],\n", + " documents=[d[\"text\"] for d in docs],\n", + " metadatas=[{\"title\": d[\"title\"]} for d in docs],\n", + " )\n", + " return _collection.count()\n", + "\n", + "\n", + "def retrieve(query, n_results=3):\n", + " with _retrieve_lock:\n", + " result = _collection.query(query_texts=[query], n_results=n_results)\n", + " return result[\"documents\"][0]\n", + "\n", + "\n", + "@opik.track(project_name=OPIK_PROJECT_NAME)\n", + "def answer(query, system_prompt, model=None):\n", + " context = \"\\n\\n\".join(retrieve(query))\n", + " messages = [\n", + " {\"role\": \"system\", \"content\": system_prompt},\n", + " {\"role\": \"user\", \"content\": f\"Context:\\n{context}\\n\\nQuestion: {query}\"},\n", + " ]\n", + " response = litellm.completion(model=model or MODEL, messages=messages)\n", + " return response.choices[0].message.content\n", + "\n", + "\n", + "def build_dataset(name, cases):\n", + " dataset = client.get_or_create_dataset(name)\n", + " dataset.insert(cases)\n", + " return dataset\n", + "\n", + "\n", + "def optimized_system(result):\n", + " \"\"\"The optimized system text. An optimizer-returned ChatPrompt stores its text\n", + " in messages (get_messages()), not the .system scalar (which stays None).\"\"\"\n", + " for message in result.prompt.get_messages():\n", + " if message[\"role\"] == \"system\":\n", + " return message[\"content\"]\n", + " return None" + ] + }, + { + "cell_type": "markdown", + "id": "5474ff73", + "metadata": {}, + "source": [ + "### Part 1 — Your first optimization ⭐ (workshop)\n", + "\n", + "We'll ingest the Ledgerline docs, build an evaluation dataset with **checkable answers**, score a baseline prompt with an **exact-match metric** (no LLM judge needed), then let an optimizer improve the prompt." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e0b47f79", + "metadata": {}, + "outputs": [], + "source": [ + "count = ingest(DOCS)\n", + "print(f\"Ingested {count} doc snippets into ChromaDB\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6a5bb899", + "metadata": {}, + "outputs": [], + "source": [ + "# RAG step — retrieve. For each question, pull the most relevant docs with our\n", + "# retriever and attach them as `context`. A production RAG system retrieves per\n", + "# query at answer time; we do it once here so every optimizer trial answers the\n", + "# SAME question from the SAME context. What we optimize is the *prompt*, not the\n", + "# retriever.\n", + "for case in EXACT_CASES:\n", + " case[\"context\"] = \"\\n\\n\".join(retrieve(case[\"query\"]))\n", + "\n", + "exact_dataset = build_dataset(\"ledgerline-exact\", EXACT_CASES)\n", + "print(f\"Dataset 'ledgerline-exact' has {len(EXACT_CASES)} cases (each with retrieved context)\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fc73beb9", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "\n", + "# Peek at one dataset item. The optimizer fills {query}/{context} in the prompt\n", + "# from these fields; the exact-match metric checks that expected_substring appears.\n", + "print(json.dumps(EXACT_CASES[0], indent=2))" + ] + }, + { + "cell_type": "markdown", + "id": "fcd354dd", + "metadata": {}, + "source": [ + "A dataset item looks like this — the question, the fact we check for, and the docs our retriever pulled for it (context abridged; the cell above prints it in full):\n", + "\n", + "```json\n", + "{\n", + " \"query\": \"What is the default job timeout?\",\n", + " \"expected_substring\": \"30 seconds\",\n", + " \"context\": \"Every Ledgerline job has a default timeout of 30 seconds. ... The maximum configurable timeout is 15 minutes.\\n\\nFailed jobs are retried automatically. The default maximum number of retries is 3 ...\\n\\nAfter a job exhausts all retries it is moved to the dead-letter queue, ... retained for 7 days ...\"\n", + "}\n", + "```\n", + "\n", + "`{query}` and `{context}` are filled into the prompt from these fields; the metric checks that `expected_substring` shows up in the answer." + ] + }, + { + "cell_type": "markdown", + "id": "7493d07b", + "metadata": {}, + "source": [ + "#### The metric: exact-match, no judge\n", + "\n", + "Our first metric is deterministic and cheap: **does the answer contain the expected fact?** Opik ships `Contains` for exactly this. Optimizer metrics are plain callables `(dataset_item, llm_output) -> float`, so we wrap `Contains` in one. *Not every metric needs an LLM.*" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b441d305", + "metadata": {}, + "outputs": [], + "source": [ + "from opik.evaluation.metrics import Contains\n", + "\n", + "\n", + "def exact_match(dataset_item: dict, llm_output: str) -> float:\n", + " # Contains returns 1.0 if expected_substring is in the output, else 0.0.\n", + " result = Contains(case_sensitive=False).score(\n", + " output=llm_output,\n", + " reference=dataset_item[\"expected_substring\"],\n", + " )\n", + " return result.value\n", + "\n", + "\n", + "exact_match.__name__ = \"exact_match\"" + ] + }, + { + "cell_type": "markdown", + "id": "8dbbc319", + "metadata": {}, + "source": [ + "#### The starting prompt\n", + "\n", + "Here is our baseline system prompt — deliberately mediocre, so there's room to improve. This is the `ChatPrompt` the optimizer will rewrite.\n", + "\n", + "Look at the **user template**: `{context}` is filled with the docs we just retrieved and `{query}` with the question — both come from each dataset row. That's the **retrieve-then-generate** shape of a real RAG system. Retrieval is held fixed; what we optimize is how the **system prompt** tells the model to turn that context into a correct, concise answer." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a3dbb793", + "metadata": {}, + "outputs": [], + "source": [ + "from opik_optimizer import ChatPrompt\n", + "\n", + "BASELINE_SYSTEM = \"You are a support bot. Answer the question.\"\n", + "\n", + "prompt = ChatPrompt(\n", + " name=\"ledgerline-answer\",\n", + " system=BASELINE_SYSTEM,\n", + " user=\"Context:\\n{context}\\n\\nQuestion: {query}\",\n", + " model=MODEL,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "3f64ec73", + "metadata": {}, + "source": [ + "#### Run the optimizer\n", + "\n", + "We use **`MetaPromptOptimizer`** — it uses a reasoning LLM to critique and rewrite the prompt. It's the docs' recommended general-purpose starting point for prompt wording. Watch the params:\n", + "- `max_trials` — how many candidate prompts to try.\n", + "- `n_samples` — dataset rows evaluated per candidate (smaller = cheaper/faster for a live run).\n", + "- `skip_perfect_score=False` — keep optimizing even if the baseline already scores high." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d46de3a0", + "metadata": {}, + "outputs": [], + "source": [ + "from opik_optimizer import MetaPromptOptimizer\n", + "\n", + "optimizer = MetaPromptOptimizer(\n", + " model=MODEL,\n", + " n_threads=4,\n", + " skip_perfect_score=False,\n", + ")\n", + "\n", + "result = optimizer.optimize_prompt(\n", + " prompt=prompt,\n", + " dataset=exact_dataset,\n", + " metric=exact_match,\n", + " max_trials=8,\n", + " n_samples=8,\n", + ")\n", + "\n", + "print(\"Baseline score:\", result.initial_score)\n", + "print(\"Best score: \", result.score)\n", + "\n", + "# See HOW the prompt was refined: the optimizer rewrote the *system* instructions.\n", + "print(\"\\n--- Baseline system prompt ---\")\n", + "print(BASELINE_SYSTEM)\n", + "print(\"\\n--- Optimized system prompt ---\")\n", + "print(optimized_system(result))" + ] + }, + { + "cell_type": "markdown", + "id": "2009c5ae", + "metadata": {}, + "source": [ + "#### What does an optimized prompt look like?\n", + "\n", + "The cell above prints *your* run's actual result. To set expectations, here's the kind of rewrite `MetaPromptOptimizer` typically produces — turning the terse baseline into explicit, context-grounded instructions (**illustrative; your exact wording will differ**):\n", + "\n", + "> **Baseline:** `You are a support bot. Answer the question.`\n", + ">\n", + "> **Optimized (illustrative):** `You are a Ledgerline support assistant. Answer using only the provided context. Quote exact values — durations, limits, header names — verbatim, and keep the answer to one or two sentences. If the context doesn't contain the answer, say so.`\n", + "\n", + "Notice what optimization *found*: lean on the context, quote exact values (exactly what the exact-match metric rewards), and stay concise." + ] + }, + { + "cell_type": "markdown", + "id": "e3a37eb5", + "metadata": {}, + "source": [ + "#### See it in Opik\n", + "\n", + "The cell above printed the **baseline vs optimized system prompt** side by side — that rewrite is the concrete refinement the optimizer found. Now open **Evaluation → Optimization runs** in your Opik workspace: you'll see this run with every candidate prompt, its score, and the trace for each trial. Compare the baseline row to the best row — that delta is your improvement.\n", + "\n", + "🎓 **This is where the live workshop ends.** You've run a real optimization and improved a prompt, measured against a dataset, stored in Opik. Everything below builds on exactly this loop." + ] + }, + { + "cell_type": "markdown", + "id": "9e7c1ee4", + "metadata": {}, + "source": [ + "## Part 2 — Metrics done right\n", + "\n", + "Exact-match got us far because our questions had crisp answers. But real docs questions are open-ended — *\"How should I handle a job that keeps failing?\"* has no single substring. For those you need a metric that judges **meaning**: an **LLM-as-judge**." + ] + }, + { + "cell_type": "markdown", + "id": "214c46a7", + "metadata": {}, + "source": [ + "#### The LLM-judge metric\n", + "\n", + "Opik ships judge metrics like `AnswerRelevance` (is the answer relevant to the question, given context?) and `Hallucination` (is it unsupported by context?). We wrap `AnswerRelevance` as an optimizer metric, exactly like we wrapped `Contains` — same callable shape, different scorer." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4a271870", + "metadata": {}, + "outputs": [], + "source": [ + "from opik.evaluation.metrics import AnswerRelevance\n", + "\n", + "\n", + "def answer_relevance(dataset_item: dict, llm_output: str) -> float:\n", + " result = AnswerRelevance(model=MODEL).score(\n", + " input=dataset_item[\"query\"],\n", + " output=llm_output,\n", + " context=[dataset_item[\"reference\"]],\n", + " )\n", + " return result.value\n", + "\n", + "\n", + "answer_relevance.__name__ = \"answer_relevance\"" + ] + }, + { + "cell_type": "markdown", + "id": "6fe27303", + "metadata": {}, + "source": [ + "#### How do we *trust* a judge?\n", + "\n", + "An LLM-judge is itself a prompt — it can be wrong. Before you optimize *against* it, sanity-check it:\n", + "\n", + "1. **Spot-check against your own labels.** Take 3–5 rows, decide the score yourself, and compare. If you and the judge disagree wildly, fix the judge before trusting its numbers.\n", + "2. **Read the *reason*, not just the number.** Opik judge metrics return a `reason`. A right score for the wrong reason is a red flag.\n", + "3. **Watch for drift and bias.** Judges favor longer, confident-sounding answers. If your metric rewards verbosity, your \"optimized\" prompt may just be wordier — which is exactly why Part 2 ends with a *cost/length* objective.\n", + "\n", + "Run the cell below to inspect a judge score **and its reasoning** on one example." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a96a5df9", + "metadata": {}, + "outputs": [], + "source": [ + "sample = JUDGE_CASES[0]\n", + "sample_output = answer(sample[\"query\"], system_prompt=optimized_system(result))\n", + "judged = AnswerRelevance(model=MODEL).score(\n", + " input=sample[\"query\"],\n", + " output=sample_output,\n", + " context=[sample[\"reference\"]],\n", + ")\n", + "print(\"Question:\", sample[\"query\"])\n", + "print(\"Answer: \", sample_output)\n", + "print(\"Score: \", judged.value)\n", + "print(\"Reason: \", judged.reason)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0416e070", + "metadata": {}, + "outputs": [], + "source": [ + "for case in JUDGE_CASES:\n", + " case[\"context\"] = \"\\n\\n\".join(retrieve(case[\"query\"]))\n", + "judge_dataset = build_dataset(\"ledgerline-judge\", JUDGE_CASES)\n", + "\n", + "judge_prompt = ChatPrompt(\n", + " name=\"ledgerline-answer-judge\",\n", + " system=BASELINE_SYSTEM,\n", + " user=\"Context:\\n{context}\\n\\nQuestion: {query}\",\n", + " model=MODEL,\n", + ")\n", + "\n", + "judge_result = optimizer.optimize_prompt(\n", + " prompt=judge_prompt,\n", + " dataset=judge_dataset,\n", + " metric=answer_relevance,\n", + " max_trials=8,\n", + " n_samples=8,\n", + ")\n", + "print(\"Judge-metric baseline:\", judge_result.initial_score, \"-> best:\", judge_result.score)" + ] + }, + { + "cell_type": "markdown", + "id": "7350cf6b", + "metadata": {}, + "source": [ + "#### Multi-objective: quality *and* cost\n", + "\n", + "Optimizing purely for a judge can inflate answer length. Often you want **quality high *and* answers short**. `MultiMetricObjective` combines metrics into one weighted composite the optimizer maximizes — this is how Opik does multi-objective optimization.\n", + "\n", + "Below we combine `answer_relevance` (weight 0.7) with a length penalty (weight 0.3). The length metric is a plain callable that returns a normalized \"shorter is better\" score." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dea15376", + "metadata": {}, + "outputs": [], + "source": [ + "from opik_optimizer import MultiMetricObjective\n", + "\n", + "\n", + "def brevity(dataset_item: dict, llm_output: str) -> float:\n", + " # Normalized \"shorter is better\": 1.0 for <=200 chars, decaying to 0 at 1000 chars.\n", + " length = len(llm_output)\n", + " return max(0.0, min(1.0, (1000 - length) / 800))\n", + "\n", + "\n", + "brevity.__name__ = \"brevity\"\n", + "\n", + "composite = MultiMetricObjective(\n", + " metrics=[answer_relevance, brevity],\n", + " weights=[0.7, 0.3],\n", + " name=\"relevance_and_brevity\",\n", + ")\n", + "\n", + "multi_result = optimizer.optimize_prompt(\n", + " prompt=judge_prompt,\n", + " dataset=judge_dataset,\n", + " metric=composite,\n", + " max_trials=8,\n", + " n_samples=8,\n", + ")\n", + "print(\"Multi-objective best score:\", multi_result.score)\n", + "print(\"\\nOptimized system prompt:\\n\", optimized_system(multi_result))" + ] + }, + { + "cell_type": "markdown", + "id": "cc9c9c42", + "metadata": {}, + "source": [ + "#### Compare your runs\n", + "\n", + "You now have three optimization runs in Opik: exact-match, judge, and multi-objective. In **Evaluation → Optimization runs**, put them side by side. Notice how the multi-objective prompt trades a little relevance for much shorter answers — that trade-off is the whole point of naming your objectives explicitly." + ] + }, + { + "cell_type": "markdown", + "id": "2ad67d28", + "metadata": {}, + "source": [ + "## Part 3 — From prompt to agent\n", + "\n", + "So far retrieval was **fixed**: we retrieved once, put the docs in the prompt, and optimized the wording. Real systems are agents — they *decide* what to do. Here we hand the model a **`search_docs` tool** wired to our retriever and let it choose when to call it. That turns the prompt into an **agent**, and the same optimizer loop tunes it.\n", + "\n", + "**What \"optimizing an agent\" means:** not rewriting the tool's code — the retriever is fixed. It means optimizing the natural-language surface the agent reasons over: its **system prompt** (when to search, how to answer from results) and, optionally, its **tool descriptions** (`optimize_tools=True`) so it calls the tool at the right moments.\n", + "\n", + "*(A lighter alternative to a tool is a yes/no retrieval gate — a small prompt that decides whether to look up docs at all — optimized with the same loop. We use a real tool here because it better reflects a production agent.)*" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5387f4e6", + "metadata": {}, + "outputs": [], + "source": [ + "# The tool the agent may call. It wraps our retriever; the *agent* decides when to\n", + "# call it. Returning one string keeps the tool result clean.\n", + "def search_docs(query: str) -> str:\n", + " \"\"\"Search the Ledgerline documentation and return the most relevant snippets.\"\"\"\n", + " return \"\\n\\n\".join(retrieve(query))\n", + "\n", + "\n", + "SEARCH_DOCS_TOOL = {\n", + " \"type\": \"function\",\n", + " \"function\": {\n", + " \"name\": \"search_docs\",\n", + " \"description\": \"Search the Ledgerline product documentation for relevant snippets.\",\n", + " \"parameters\": {\n", + " \"type\": \"object\",\n", + " \"properties\": {\n", + " \"query\": {\"type\": \"string\", \"description\": \"What to look up in the docs.\"},\n", + " },\n", + " \"required\": [\"query\"],\n", + " },\n", + " },\n", + "}\n", + "\n", + "AGENT_SYSTEM = \"You are a Ledgerline support agent. Use tools when they help.\"\n", + "\n", + "# tools + function_map make this ChatPrompt an agent: on a tool call, the optimizer\n", + "# executes search_docs and feeds the result back to the model.\n", + "agent_prompt = ChatPrompt(\n", + " name=\"ledgerline-agent\",\n", + " system=AGENT_SYSTEM,\n", + " user=\"{query}\",\n", + " tools=[SEARCH_DOCS_TOOL],\n", + " function_map={\"search_docs\": search_docs},\n", + " model=MODEL,\n", + ")\n", + "\n", + "# optimize_prompts defaults to \"system\": we tune the agent's instructions.\n", + "# (Flip optimize_tools=True to ALSO let the optimizer refine the tool description.)\n", + "agent_result = optimizer.optimize_prompt(\n", + " prompt=agent_prompt,\n", + " dataset=judge_dataset,\n", + " metric=answer_relevance,\n", + " max_trials=8,\n", + " n_samples=8,\n", + " allow_tool_use=True,\n", + ")\n", + "print(\"Agent baseline:\", agent_result.initial_score, \"-> best:\", agent_result.score)\n", + "print(\"\\n--- Optimized agent system prompt ---\")\n", + "print(optimized_system(agent_result))" + ] + }, + { + "cell_type": "markdown", + "id": "da6d814d", + "metadata": {}, + "source": [ + "#### When demonstrations matter: Few-Shot Bayesian\n", + "\n", + "If the win comes from *showing examples* rather than rewording instructions, reach for `FewShotBayesianOptimizer` — it uses Bayesian search (Optuna) to pick the best set and order of few-shot demonstrations to attach. We point it at the **same agent**, so it tunes the agent's examples rather than a fresh prompt." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b4245ab8", + "metadata": {}, + "outputs": [], + "source": [ + "from opik_optimizer import FewShotBayesianOptimizer\n", + "\n", + "fewshot_optimizer = FewShotBayesianOptimizer(model=MODEL, n_threads=4)\n", + "\n", + "fewshot_result = fewshot_optimizer.optimize_prompt(\n", + " prompt=agent_prompt,\n", + " dataset=judge_dataset,\n", + " metric=answer_relevance,\n", + " n_samples=8,\n", + ")\n", + "print(\"Few-shot best score:\", fewshot_result.score)" + ] + }, + { + "cell_type": "markdown", + "id": "0bb4ec29", + "metadata": {}, + "source": [ + "#### Tuning the model, not the prompt: Parameter optimizer\n", + "\n", + "Sometimes the prompt is fine and you just need better sampling settings. `ParameterOptimizer` leaves the prompt alone and searches temperature / top_p with Bayesian optimization. It's the right reach when behavior — not wording — is the problem. See the [Parameter optimizer docs](https://www.comet.com/docs/opik/agent_optimization/algorithms/parameter_optimizer) for the search-space API." + ] + }, + { + "cell_type": "markdown", + "id": "643a2c1c", + "metadata": {}, + "source": [ + "## Part 4 — Choosing an optimizer\n", + "\n", + "You've now *used* several optimizers at the moment each was the right tool. Here's the consolidated map:\n", + "\n", + "| Optimizer | Best for | You saw it in |\n", + "|---|---|---|\n", + "| **MetaPrompt** | General prompt rewording & clarity | Part 1 |\n", + "| **HRPO** | Systematic fixes from *why* prompts fail (failure-mode analysis) | (try on your own) |\n", + "| **Few-Shot Bayesian** | Picking the best demonstrations | Part 3 |\n", + "| **Evolutionary** | Exploring diverse structures; multi-objective | (see multi-objective, Part 2) |\n", + "| **GEPA** | Single-turn, reflection-heavy tasks (`pip install gepa`) | (try on your own) |\n", + "| **Parameter** | Temperature / top_p, prompt unchanged | Part 3 (described) |\n", + "\n", + "**How to choose, in four questions:**\n", + "1. **What's the constraint** — wording, examples, tool use, or sampling params?\n", + "2. **Is the dataset ready** — reflective optimizers (HRPO) need metrics with detailed *reasons*. Split train/validation to avoid overfitting.\n", + "3. **What's the budget** — Evolutionary/GEPA burn more tokens than MetaPrompt.\n", + "4. **Can you chain?** — e.g. MetaPrompt to fix wording, then Parameter to tune sampling.\n", + "\n", + "The docs' own advice: **start with GEPA or HRPO** for a new task, then specialize." + ] + }, + { + "cell_type": "markdown", + "id": "c3dc750c", + "metadata": {}, + "source": [ + "#### Chaining optimizers\n", + "\n", + "Because every optimizer shares the same API and returns an `OptimizationResult` whose `.prompt` you can feed into the next, you can chain them: optimize wording, then feed the winner into a Parameter run. See [Chaining optimizers](https://www.comet.com/docs/opik/agent_optimization/advanced/chaining_optimizers)." + ] + }, + { + "cell_type": "markdown", + "id": "9d19f6d7", + "metadata": {}, + "source": [ + "## Part 5 — Take it further\n", + "\n", + "**Version the winner.** Promote your best prompt to the Opik **Prompt Library** so it's versioned and reusable." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2d189ce0", + "metadata": {}, + "outputs": [], + "source": [ + "best_prompt = opik.Prompt(name=\"ledgerline-answer\", prompt=optimized_system(multi_result))\n", + "print(\"Saved prompt version:\", best_prompt.commit)" + ] + }, + { + "cell_type": "markdown", + "id": "2c4399cd", + "metadata": {}, + "source": [ + "**Where to go next:**\n", + "- **[Optimization Studio](https://www.comet.com/docs/opik/agent_optimization/optimization_studio)** — run all of this from the Opik UI, no code.\n", + "- **[Optimizer benchmarks](https://www.comet.com/docs/opik/agent_optimization/algorithms/benchmarks)** — numbers per algorithm.\n", + "- **[Agent optimization overview](https://www.comet.com/docs/opik/agent_optimization/overview)** — the full reference.\n", + "- **Wrap this in a CLI** — the retriever + `answer()` helpers are defined inline above; lifting them into a module to back a repeatable CLI is a natural next project (out of scope here).\n", + "\n", + "You've gone A-to-Z: framing → first optimization → trustworthy judge metrics → multi-objective → agent tuning → optimizer selection → versioned prompt. Every step is a comparable run in Opik." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "opik-examples (3.12.13)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/guides/prompt_agent_optimization/pyproject.toml b/guides/prompt_agent_optimization/pyproject.toml new file mode 100644 index 0000000..07f3bdb --- /dev/null +++ b/guides/prompt_agent_optimization/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "prompt-agent-optimization" +version = "0.1.0" +description = "Prompt & agent optimization with Opik, end-to-end over an escalating RAG-over-docs example." +readme = "README.md" +requires-python = ">=3.12,<3.14" +dependencies = [ + "opik>=2.0", + "opik-optimizer", + "chromadb", + "litellm", + # WHY: this guide's deliverable is a notebook. Jupyter lets `uv run jupyter lab` + # open it locally, and nbconvert/nbformat let it be validated and executed + # headlessly (CI). In Colab (or any bare env) the notebook's own %pip cell + # installs the runtime deps instead. + "jupyterlab", + "nbconvert", + "nbformat", + "ipykernel", +] + +# WHY: notebook-only example — uv manages the env, no installable package. +[tool.uv] +package = false