---
title: "DSPy – Автоматично генериране на промптове"
url: "https://romankryvolapov.com/bg/dspy/"
description: "DSPy 3.4 на примера на агент за поддръжка: сигнатури вместо текст на промпта, инструменти, метрика, оптимизаторите MIPROv2 и GEPA, запазване на резултата и какво ще се промени в DSPy 3.5."
language: bg
published: 2025-09-22
updated: 2026-11-09
---
Тази статия е за тези, които правят агенти и вериги от извиквания на LLM и са се уморили да подбират промпта на ръка. За проста задача стига промпт от рода на „Ти си умен помощник, отговаряй на въпроси“. Но когато агентът извиква функции и разбира нестандартни заявки, промптът нараства до няколко страници правила, а след смяна на модела спира да работи и всичко трябва да се подбира наново. Как се пишат такива промптове на ръка, е разгледано в [статията за промпт инженеринг](/bg/prompt-engineering/), а как агентът извиква инструменти – в [статията за RAG и извикване на функции](/bg/rag-and-function-calling/).

[DSPy](https://dspy.ai) ([GitHub](https://github.com/stanfordnlp/dspy)) предлага друг път. Задачата се описва с код: какво идва на входа, какво трябва да се получи и от какви стъпки се състои програмата. Текста на промпта DSPy го сглобява сам. А ако има примери с верни отговори и функция, която оценява отговора, оптимизаторът подбира за всяка стъпка инструкция и примери, при които оценката е по-висока. Сменили сте модела – стартирате подбора наново, вместо да пренаписвате текст.

Фреймуъркът се появи в Станфорд, в групата Stanford NLP. Негов автор е Омар Хатаб, а първата статия за него излезе през октомври 2023 г. Името се разшифрова като Declarative Self-improving Python, а девизът на проекта е „да се програмира, а не да се пишат промптове“.

В статията има един сквозен пример: агент за поддръжка на интернет магазин, който отговаря на въпроси на купувачи по база знания в SQLite. Първо ще разгледаме основните понятия, после ще свържем модела, ще опишем задачата, ще сглобим агента с инструмент, ще се научим да го оценяваме, ще го настроим с два оптимизатора, MIPROv2 и GEPA, и ще запазим резултата. Кодът е проверен на DSPy 3.4.1, последното издание към ноември 2026 г., с локалния модел Gemma 4 E4B в LM Studio. Изходът на програмите и измерванията в статията са от този прогон, а където цифрата е взета от документацията или е изчислена върху макетен модел, това е казано до нея.

**Плюсове:** промптът престава да бъде „магически текст“ – вместо него има код, метрика и възпроизводим резултат. Смяната на модела се превръща в повторно стартиране на настройката с оценка преди и след. Малкият модел след настройка нерядко настига по-големия, който никой не е настройвал.

**Минуси:** нужни са примери с верни отговори и начин да се оцени отговорът, а без тях няма какво да се настройва. Настройката отнема от десетки минути до часове и може да струва пари. API се променя бързо: между версиите 3.0 и 3.5 са преработени агентите, свързването на модели и адаптерите. Готовият промпт се пише от машина и ръчната му редакция е неудобна.

## От какво се състои DSPy

### Кои понятия трябва да знаете

DSPy има собствен речник и без него документацията се чете трудно. Ето думите, които ще срещнем нататък:

- **LM** (language model) – връзка с модела: кой сървър, кой модел, с какви параметри.
- **Сигнатура** – описание на задачата: какви полета идват на входа, какви трябва да се върнат и какво означават. Това е заместител на текста на промпта.
- **Модул** – начин за решаване на задачата по сигнатура. `Predict` просто пита модела, `ChainOfThought` го моли първо да разсъждава, `ReAct` превръща модела в агент, който извиква инструменти.
- **Предиктор** – едно извикване на модела вътре в модула със своя сигнатура, инструкция и примери. В простия модул е един, в агента са няколко. Оптимизаторът настройва именно предикторите.
- **Адаптер** – преводач между сигнатурата и модела. Превръща сигнатурата в съобщения на чата, а отговора на модела – обратно в полета на Python.
- **Пример** (`dspy.Example`) – един запис от набора данни: въпрос и верен отговор.
- **Метрика** – обикновена функция на Python, която казва колко добър е отговорът на програмата за един пример.
- **Оптимизатор** – алгоритъм, който многократно стартира програмата върху примери и променя инструкциите и примерите в промптовете (а някои – и теглата на модела), за да повиши метриката. В старите версии оптимизаторите се наричаха телепромптери и тази дума още се среща в кода.

Свързани са така. Модулът изпълнява сигнатурата. Адаптерът я превръща в съобщения за LM и разчита отговора. Метриката оценява резултата върху примерите. Оптимизаторът пренаписва инструкциите и подбира примери вътре в предикторите, докато метриката спре да расте.

## Свързване на модела

### Как се инсталира DSPy и се свързва модел

DSPy се инсталира като обикновен пакет на Python и работи на Python от 3.10 до 3.15:

```bash
pip install -U "dspy[optuna]"
```

Добавката `optuna` е нужна на оптимизатора MIPROv2. От версия 3.2 тази библиотека не се инсталира по подразбиране и без нея MIPROv2 пада с грешка при импортиране. Оптимизаторът GEPA се инсталира заедно с DSPy, освен на Python 3.15: неговият пакет `gepa` засега поддържа само версии до 3.14.

Сега ще свържем модел. В примера това е Gemma 4 E4B, стартиран в LM Studio на същия компютър:

```python
import dspy

# Префиксът openai/ означава „сървър със същото API като на OpenAI“,
# след него идва името на модела на този сървър.
lm = dspy.LM(
    "openai/google/gemma-4-e4b",
    api_base="http://localhost:1234/v1",
    api_key="lm-studio",  # LM Studio не проверява ключа, но полето е задължително
    temperature=0,
)
# Сега този модел се използва от всички модули, освен ако не е посочено друго.
dspy.configure(lm=lm)
```

Моделът се задава с низ „доставчик/модел“. За облачен модел се сменя само низът, например `openai/gpt-5.4-nano`, а адресът на сървъра не е нужно да се посочва. Ключът се взема от променливата на средата на доставчика, например `OPENAI_API_KEY`. Под капака DSPy се обръща към моделите през библиотеката LiteLLM, а от версия 3.4 там, където може, – през собствения си двигател lm15. Низът с модела е общ за двата двигателя, затова кодът не се променя от избора на двигател.

`temperature=0` почти премахва случайността: моделът избира най-вероятния отговор. Съвсем еднакви отговорите все пак няма да бъдат – в нашите прогони агентът при повторен въпрос понякога търсеше с други думи, – но разликите стават по-малки. За агента и за честно сравнение „преди и след“ това е удобно.

### Защо повторното стартиране е почти безплатно

DSPy запомня отговорите на модела. Кешът се пази в паметта и на диска, в папка `~/.dspy_cache`, и по подразбиране заема до 30 ГБ. Ако заявката се повтаря точно (същият промпт, същият модел, същите параметри), тя не отива към модела, а отговорът се взема от кеша.

За оптимизацията това е голяма икономия: прекъснат прогон може да се стартира отново и всичко, което вече е изчислено, се повтаря мигновено. Но кешът пречи при измерване на скоростта и при проверка колко е стабилен моделът: второто стартиране ще излезе подозрително бързо и точно същото като първото. За такива измервания кешът се изключва с параметъра `cache=False` в `dspy.LM`.

## Сигнатура

### Какво е сигнатура

Сигнатурата описва задачата като функция: какво е на входа и какво на изхода. Най-краткият запис е низ, в който входовете и изходите са разделени със стрелка:

```python
qa = dspy.Predict("question -> answer")
result = qa(question="What is the capital of France?")
print(result.answer)  # Paris
```

`Predict` е най-простият модул: едно извикване на модела без разсъждения и инструменти. Резултатът е обект `Prediction`, чиито полета носят имената на изходите на сигнатурата.

### Какво DSPy изпраща на модела

Промпт не сме писали, но моделът го е получил. Какво точно е отишло към модела и какво е дошло обратно, може да се види с един ред:

```python
dspy.inspect_history(n=1)  # n – колко последни извиквания да се покажат
```

```
System message:

Your input fields are:
1. `question` (str):
Your output fields are:
1. `answer` (str):
All interactions will be structured in the following way, with the appropriate values filled in.

[[ ## question ## ]]
{question}

[[ ## answer ## ]]
{answer}

[[ ## completed ## ]]
In adhering to this structure, your objective is: 
        Given the fields `question`, produce the fields `answer`.


User message:

[[ ## question ## ]]
What is the capital of France?

Respond with the corresponding output fields, starting with the field `[[ ## answer ## ]]`, and then ending with the marker for `[[ ## completed ## ]]`.


Response:

[[ ## answer ## ]]
Paris

[[ ## completed ## ]]
```

Това е работа на адаптера `ChatAdapter`, който DSPy използва по подразбиране. В системното съобщение той изброява полетата, показва формата и формулира целта. Засега целта е сглобена от имената на полетата, защото не сме дали инструкция. В съобщението на потребителя стоят стойностите на входните полета. Маркерите от вида `[[ ## answer ## ]]` са нужни, за да се намерят после полетата в отговора: адаптерът ги търси и разпределя текста по полетата.

Ако моделът наруши формата и адаптерът не може да разчете отговора, `ChatAdapter` сам повтаря заявката през `JSONAdapter`: той иска отговор във вид на JSON и, ако сървърът може, включва режим на строг JSON. Има и други адаптери, например `XMLAdapter`. Адаптерът за цялата програма може да се смени така: `dspy.configure(adapter=dspy.JSONAdapter())`.

Малките модели понякога нарушават формата незабелязано. В един от нашите прогони Gemma 4 E4B не постави нов ред пред финалния маркер и последното поле пристигна с опашка: `$15[[ ## completed ## ]]`. Грешка нямаше, просто в отговора се оказа боклук. Затова първото, което си струва да направите с нов модел, е да погледнете `inspect_history` със собствените си очи.

### Как се описва сигнатура с клас

За истинска задача сигнатурата се пише с клас. Тогава полетата получават типове и описания, а docstring (низът с документация) на класа става инструкция за модела:

```python
from typing import Literal


class ClassifyTicket(dspy.Signature):
    """Classify a customer support ticket."""

    text: str = dspy.InputField()
    # Literal ограничава отговора до списък със стойности, bool – до стойностите True и False.
    topic: Literal["delivery", "refund", "payment", "other"] = dspy.OutputField()
    urgent: bool = dspy.OutputField(desc="the customer is blocked or very upset")


classify = dspy.Predict(ClassifyTicket)
result = classify(text="My parcel is two weeks late and I need it for a birthday tomorrow!")
print(result.topic, result.urgent)  # delivery True
```

`result.urgent` тук е истински `bool`, а не низ `"True"`: адаптерът преобразува отговора в типа на полето. Подходящи са `int`, `float`, `list[str]`, `dict`, `Literal` и модели на Pydantic. Ако отговорът не може да се преобразува в типа, адаптерът ще съобщи за грешка, а няма да върне боклук.

За да знае моделът за ограниченията, адаптерът ги дописва в промпта. Ето как изглеждат полетата на тази сигнатура в системното съобщение:

```
[[ ## topic ## ]]
{topic}        # note: the value you produce must exactly match (no extra characters) one of: delivery; refund; payment; other

[[ ## urgent ## ]]
{urgent}        # note: the value you produce must be True or False
```

Docstring-ът – `Classify a customer support ticket.` – стана цел в системното съобщение вместо безличното „Given the fields...“. Именно тази инструкция оптимизаторът после ще пренаписва. Описанията на полетата (`desc`) не ги променя, затова в тях е добре да се пише това, което трябва да остане неизменно.

Docstring-овете и описанията на полетата ги пишем на английски: това е текст, който чете моделът, а не програмистът. За малките модели английският обикновено е по-надежден.

### С какво `Predict` се различава от `ChainOfThought`

`ChainOfThought` приема същата сигнатура, но добавя пред изходните полета още едно – `reasoning`. Моделът първо пише разсъждение, после отговор:

```python
think = dspy.ChainOfThought(ClassifyTicket)
result = think(text="Can I pay with Apple Pay?")
print(result.topic, result.urgent)  # payment False
print(result.reasoning)  # The customer is asking a question regarding accepted methods of payment...
```

Сигнатурата не се наложи да се променя. В това е смисълът на модулите: задачата е описана веднъж, а начинът за решаването ѝ се сменя с един ред. Разсъждението помага при задачи, в които трябва нещо да се пресметне или съпостави, но удължава отговора и забавя извикването.

## Агент с инструменти

### Как се подготвя базата знания

На агента му трябва източник на факти. В примера това е базата знания на магазина в SQLite: дванадесет кратки статии за връщания, доставка, плащане, гаранция и така нататък. Ето първите четири и създаването на таблицата:

```python
import sqlite3

ARTICLES = [
    ("Refunds",
     "You can return an item within 30 days of delivery for a full refund. "
     "The money goes back to the original payment method within 5 business days "
     "after the warehouse receives the item."),
    ("Return shipping",
     "Return shipping is free for defective items. For any other return, "
     "a flat fee of $6 is deducted from the refund."),
    ("Delivery times",
     "Standard delivery takes 3 to 5 business days. Express delivery takes "
     "1 to 2 business days and costs $12."),
    ("Payment methods",
     "We accept Visa, Mastercard, PayPal and Apple Pay. "
     "Cash on delivery is not available."),
    # Още осем статии в същия вид: безплатна доставка, доставка в чужбина,
    # гаранция, промяна на поръчка, подаръчни карти, бонуси, гаранция за най-добра цена и поддръжка.
]

# FTS5 е вграденото в SQLite пълнотекстово търсене. Такава таблица
# съхранява текста и веднага строи по него индекс на думите.
db = sqlite3.connect("kb.db")
db.execute("DROP TABLE IF EXISTS articles")
db.execute("CREATE VIRTUAL TABLE articles USING fts5(title, text)")
db.executemany("INSERT INTO articles VALUES (?, ?)", ARTICLES)
db.commit()
db.close()
```

FTS5 обикновено вече го има в SQLite, който се доставя с Python, и не е нужно да се инсталира нищо. Пълнотекстово търсене е нужно, защото заявката към базата я пише моделът, а той я формулира по свой начин.

### Как се описва инструмент

Инструментът за DSPy е обикновена функция на Python. Важно е само да има ясно име, подсказки за типовете на аргументите и docstring: DSPy показва всичко това на модела и по тях той решава кога и как да извика функцията.

```python
import re
from contextlib import closing


def search_kb(query: str) -> list[str]:
    """Search the shop's knowledge base and return up to 3 matching articles."""
    # 1. Оставяме от заявката само думите: кавичките и звездичките
    #    FTS5 би взел за свой синтаксис.
    words = re.findall(r"\w+", query)
    if not words:
        return []
    # 2. OR: подходяща е статия, в която има поне една дума от заявката.
    #    bm25 поставя по-горе статиите с повече съвпадения.
    match = " OR ".join(f'"{word}"' for word in words)
    # closing затваря връзката: обикновеният with в sqlite3 управлява само транзакцията.
    with closing(sqlite3.connect("kb.db")) as db:
        rows = db.execute(
            "SELECT title, text FROM articles WHERE articles MATCH ? "
            "ORDER BY bm25(articles) LIMIT 3",
            (match,),
        ).fetchall()
    # 3. Моделът ще получи списък от низове от вида „заглавие: текст“.
    return [f"{title}: {text}" for title, text in rows]


print(search_kb("cash on delivery")[0])  # Payment methods: We accept Visa, Mastercard, PayPal and Apple Pay. ...
```

Защо не `LIKE '%...%'`. Моделът сам измисля заявката, например `refund period`. `LIKE` търси тази фраза цялата, а в текста на статията думите „refund“ и „period“ стоят поотделно или ги няма изобщо. Агентът би получил празен списък и би решил, че отговор в базата няма. Търсенето по думи с ранжиране прощава чужди формулировки. В истински проект тук обикновено стои векторно търсене, за него е разказано в [статията за векторните бази данни](/bg/vector-databases/), но за DSPy това нищо не променя: инструментът си остава функция.

Ето как DSPy описва този инструмент на модела. Това е фрагмент от промпта на агента, за който ще говорим по-нататък:

```
(1) search_kb, whose description is <desc>Search the shop's knowledge base and return up to 3 matching articles.</desc>. It takes arguments {'query': {'type': 'string'}}.
(2) finish, whose description is <desc>Marks the task as complete. That is, signals that all information for producing the outputs, i.e. `answer`, are now available to be extracted.</desc>. It takes arguments {}.
```

Името на функцията, docstring и схемата на аргументите са взети от кода. Затова неподходящо име като `f1` или празен docstring влошават работата на агента също като лошо написан промпт. Инструментът `finish` не сме го декларирали: добавя го самият `ReAct`, за да може моделът да каже „информацията е достатъчна“.

### Как се сглобява агентът

Сигнатурата на агента описва задачата изцяло, без да споменава инструменти:

```python
class AnswerFromKb(dspy.Signature):
    """Answer the customer's question using only the shop's knowledge base."""

    question: str = dspy.InputField()
    answer: str = dspy.OutputField(desc="a short answer, no explanations")


agent = dspy.ReAct(AnswerFromKb, tools=[search_kb], max_iters=5)
result = agent(question="How long is the refund period?")
print(result.answer)  # You can return an item within 30 days of delivery for a full refund.
```

`max_iters` ограничава броя на стъпките: ако моделът така и не каже `finish`, агентът ще спре след петото извикване на инструмент. По подразбиране стъпките са двадесет.

Отговорът е верен, но дълъг, макар в описанието на полето да поискахме кратко и без обяснения. Малкият модел често пропуска такива молби. Ще се върнем към това, когато пишем метриката: именно такива неща оптимизацията поправя.

### Какво става вътре в `ReAct`

ReAct (от reasoning and acting – „разсъждавай и действай“) е цикъл, в който моделът последователно мисли и извиква инструменти. В DSPy 3.4 е устроен така:

1. Моделът получава въпроса, списъка с инструменти и историята на предишните стъпки. На първата стъпка историята е празна.
2. Моделът пише мисъл (`next_thought`), избира инструмент (`next_tool_name`) и аргументи за него (`next_tool_args`).
3. DSPy извиква функцията, а резултата ѝ (`observation`) дописва в историята.
4. Стъпки 2 и 3 се повтарят, докато моделът не избере `finish` или не свърши `max_iters`.
5. Отделно извикване на модела, `extract`, чете цялата история и попълва изходните полета на сигнатурата, при нас – `answer`. Устроено е като `ChainOfThought`, затова първо също разсъждава.

Излиза, че вътре в агента има два предиктора: `react` избира стъпките, `extract` пише отговора. Всеки има своя инструкция и свои примери и оптимизаторът настройва и двата. Това ще ни потрябва, когато разглеждаме резултата от оптимизацията.

Историята на стъпките може да се види в полето `trajectory` (дългите низове в изхода са съкратени):

```python
for key, value in result.trajectory.items():
    print(f"{key}: {value}")
```

```
thought_0: The user wants to know the duration of the refund period. I need to search the knowledge base using relevant keywords like "refund period" or "return policy" to find this information.
tool_name_0: search_kb
tool_args_0: {'query': 'refund period'}
observation_0: ['Price match: If you find a lower price at another official retailer within 14 days of purchase, we refund the difference.', 'Return shipping: ...', 'Refunds: You can return an item within 30 days of delivery for a full refund. ...']
thought_1: The previous search provided a clear answer in observation [3]. It states: "You can return an item within 30 days of delivery for a full refund." ... I will finish the task.
tool_name_1: finish
tool_args_1: {}
observation_1: Completed.
```

Моделът търсеше по думите `refund period` и нужната статия се оказа трета: думата „refund“ се среща в три статии, а „period“ – в нито една. Моделът все пак намери отговора и приключи работата си. За трите извиквания на модела отидоха около девет секунди.

Още две неща, които `ReAct` прави сам. Ако инструментът е хвърлил изключение, агентът не пада: текстът на грешката попада в историята вместо резултат и моделът може да опита по друг начин. Ако историята е престанала да се събира в контекста на модела, `ReAct` изхвърля най-старите стъпки и опитва пак, до три пъти.

### Какво ще се промени в DSPy 3.5

В основния клон на DSPy, от който ще излезе версия 3.5, `ReAct` вече е пренаписан. Новата реализация я има и в 3.4, под името `dspy.ReActV2`, с бележка „експериментална“. Разликите са следните:

- Предишните стъпки се предават на модела като истински диалог с извиквания на функции, а ако адаптерът го поддържа, се използва вграденото в API на доставчика извикване на функции. За една стъпка могат да се извикат няколко инструмента.
- Вместо `finish` и отделна стъпка `extract` моделът извиква инструмента `submit` и веднага в него предава готовите изходни полета. Предикторът вътре в агента остава един.
- Вместо `trajectory` в резултата ще има полета `history` и `termination_reason`, причината за спиране.

Кодът в тази статия ще преживее обновяването, освен цикъла по `trajectory` по-горе. А запазеният резултат от оптимизацията – не: новият агент има други предиктори и настройката ще трябва да се стартира наново. Документацията на 3.4 съветва за нови агенти още сега да се взема `dspy.ReActV2`, ако не ви смущава експерименталното API и сте фиксирали версията на DSPy в зависимостите. В 3.5 името `ReActV2` ще остане като остарял псевдоним, а в 3.6 ще го махнат.

## Данни

### Как изглежда наборът от данни

Наборът от данни е списък от въпроси с верни отговори. Най-лесно се съхранява в JSON:

```json
[
  {
    "question": "How long is the refund period?",
    "answer": "30 days"
  },
  {
    "question": "How much does it cost to return a jacket that I just didn't like?",
    "answer": "$6"
  }
]
```

DSPy работи не с речници, а с обекти `dspy.Example`:

```python
import json

with open("dataset.json", encoding="utf-8") as f:
    examples = [dspy.Example(**row).with_inputs("question") for row in json.load(f)]

print(len(examples))  # 40
print(examples[0].inputs())  # Example({'question': 'How long is the refund period?'}) (input_keys={'question'})
print(examples[0].labels())  # Example({'answer': '30 days'}) (input_keys=None)
```

`with_inputs("question")` казва кои полета на примера са входове на програмата. Само те ще попаднат в агента при стартиране. Останалите полета, тук `answer`, остават верните отговори, които ще види метриката. Ако забравите `with_inputs`, DSPy няма да разбере какво да подава на входа: всяко стартиране ще завърши с грешка „Inputs have not been set for this example“. При набор от по-малко от десет примера оценката просто ще покаже нула. Ако примерите са десет или повече, `dspy.Evaluate` на десетата грешка се предава и хвърля изключение „Execution cancelled due to errors or interruption“, а истинската причина се вижда само в редовете с ERROR над него.

### Колко примера са нужни

Старата документация на DSPy даваше такива ориентири: тридесет примера често вече носят видима полза, но си струва да се стремите поне към [триста](https://github.com/stanfordnlp/dspy/blob/3.4.1/docs/docs/learn/optimization/overview.md), а за MIPROv2 – към [двеста и повече](https://github.com/stanfordnlp/dspy/blob/3.4.1/docs/docs/learn/optimization/optimizers.md), за да не се преобучи оптимизаторът. В документацията на 3.4, преработена около GEPA, тези страници ги няма, текстът им е останал само в хранилището. Универсални числа новата документация не дава.

По-важно от количеството е разнообразието. В нашия набор има четиридесет въпроса и в него има:

- едни и същи факти, зададени с различни думи: „How long is a gift card valid?“ и „When does a gift card expire?“;
- въпроси в две стъпки, където трябва да се намери и да се пресметне: „If I return a sweater I don't like, how much of its $40 price do I get back?“ – верният отговор е $34;
- въпроси, при които моделът трябва да се досети коя статия е подходяща, макар думите във въпроса да са други.

В истински набор си струва да се добавят въпроси, на които в базата няма отговор: агентът трябва честно да каже „не знам“, а не да измисли правило. Четиридесет примера са малко за истинска настройка, но за учебен прогон на локален модел са тъкмо толкова: един прогон на MIPROv2 върху тях отне малко повече от час.

### Как се разделят данните

Данните се делят на три части и всяка има своя роля:

- **обучаваща** (train): от нея оптимизаторът взема примери за промпта и материал за нови инструкции;
- **валидационна** (val): върху нея оптимизаторът сравнява кандидатите и избира най-добрия;
- **тестова** (test): нея оптимизаторът не я вижда и крайната оценка се смята върху нея.

Без тестова извадка резултатът лъже. Оптимизаторът е избрал промпта, който най-добре се е справил с валидационната извадка, затова оценката върху нея винаги е завишена. Честна картина дава само извадката, която той не е виждал.

Пропорциите зависят от оптимизатора. За повечето оптимизатори на промптове, включително MIPROv2, старата документация съветваше необичайно разделение: двадесет процента за обучение и осемдесет за валидация. Оптимизаторите на промптове лесно се нагаждат към малка обучаваща извадка и устойчивата оценка върху голяма валидационна е по-важна. За GEPA е тъкмо обратното, към него ще се върнем по-долу. Разделението за MIPROv2:

```python
import random

# Разбъркваме с фиксирано начално число (seed), за да се повтаря разделянето.
random.Random(0).shuffle(examples)

# 1. Една пета я отделяме за крайната проверка.
test_size = len(examples) // 5
testset, rest = examples[:test_size], examples[test_size:]

# 2. Остатъкът за MIPROv2: 20 % за обучение, 80 % за валидация.
train_size = len(rest) // 5
trainset, valset = rest[:train_size], rest[train_size:]

print(len(trainset), len(valset), len(testset))  # 6 26 8
```

## Метрика

### Какво е метрика

Метриката е функция, която получава пример с верен отговор и отговора на програмата и връща оценка: `True`/`False` или число от 0 до 1. Какво да смятаме за добър отговор, решаваме ние. Тук отговорът е добър, ако в него има верната стойност и е кратък, както поискахме в сигнатурата:

```python
def metric(example, prediction, trace=None):
    answer = prediction.answer.lower()
    # 1. В отговора има верна стойност, например „30 days“.
    correct = example.answer.lower() in answer
    # 2. Отговорът е кратък: не по-дълъг от десет думи.
    short = len(answer.split()) <= 10
    return correct and short
```

Третият аргумент, `trace`, е историята на извикванията вътре в програмата. При обикновена оценка е равен на `None`. Оптимизаторът го подава, когато решава дали един успешен прогон става за пример в промпта, и в този момент му трябва строг отговор „да“ или „не“. Нашата метрика и без това връща `bool`, затова `trace` може да не се използва. Ако метриката връща дробна оценка, по `trace` тя се превръща в да или не: например `return score >= 0.8 if trace is not None else score`.

### Как се оценява агентът

Оценката върху набора данни я стартира `dspy.Evaluate`. Тя прекарва програмата през всички примери, паралелно в няколко нишки, и смята средното:

```python
evaluate = dspy.Evaluate(devset=testset, metric=metric, num_threads=4)
report = evaluate(agent)
print(report.score)  # 50.0
```

`report.score` е процентът успешни отговори. В `report.results` стоят тройки „пример, отговор, оценка“, по тях е удобно да се разглеждат грешките:

```python
for example, prediction, score in report.results:
    if not score:
        print(example.question, "->", prediction.answer)
```

Върху тестовата извадка агентът се справи с половината въпроси. Върху всичките четиридесет резултатът е почти същият – 57,5 %, а пълният прогон отне осем минути. Неуспехите там са от три вида:

```
How long is a gift card valid? -> Gift cards are valid for 3 years from the date of purchase.
What is the price of express shipping? -> The knowledge base confirms that express delivery is never free, but it does not provide the specific price.
How many loyalty points do I earn per dollar? -> One point per dollar
```

Първият е най-честият: отговорът е верен, но по-дълъг от десет думи. Вторият е истинска грешка: статията с цената е била в базата, но моделът не я е намерил. Третият е грешка на метриката: „One point“ значи същото като „1 point“, но подънизът не съвпадна. Такива фалшиви провали развалят оптимизацията: оптимизаторът старателно ще учи модела да пише с цифри онова, което и с думи е било правилно. Затова, преди да настройвате програмата, си струва да прегледате неуспехите с очи и да се уверите, че метриката мъмри точно това, което трябва.

### Кога е нужен съдия на LLM

Подънизът работи добре за кратки факти. За свободни отговори, където една мисъл може да се каже по сто начина, той се заменя със съдия: още една програма на DSPy, която сравнява отговора с еталона по смисъл.

```python
class JudgeAnswer(dspy.Signature):
    """Decide whether the answer gives the same facts as the reference answer."""

    question: str = dspy.InputField()
    reference: str = dspy.InputField()
    answer: str = dspy.InputField()
    is_correct: bool = dspy.OutputField()


judge = dspy.ChainOfThought(JudgeAnswer)


def judge_metric(example, prediction, trace=None):
    verdict = judge(question=example.question, reference=example.answer, answer=prediction.answer)
    return verdict.is_correct
```

Съдията е скъп: всяка оценка вече е още едно извикване на модела, а за оптимизацията оценките са хиляди. Той също греши, затова за съдия се взема модел, по-силен от оценявания. Отделен модел за съдията се задава през `dspy.context(lm=...)` около извикването. В DSPy има и готови метрики: `dspy.evaluate.answer_exact_match` сравнява отговорите след нормализиране на текста, а `dspy.evaluate.SemanticF1` е съдия, който по смисъл смята колко пълно отговорът покрива еталона и няма ли в него излишно. Те изискват полета с определени имена, затова често е по-просто да се напише собствена метрика от десет реда.

## Оптимизация

### Какви оптимизатори има

Оптимизаторът може да променя три неща: инструкциите, примерите в промпта (наричат се демонстрации, или few-shot) и теглата на модела. Всеки работи с едно-две от тях:

| Оптимизатор | Какво променя | Кога да се вземе |
|---|---|---|
| `LabeledFewShot` | примери от данните както са | честна отправна точка, не прави извиквания на модела |
| `BootstrapFewShot` | примери от успешните прогони на програмата | първа разумна стъпка, почти винаги по-добре, отколкото без примери |
| `BootstrapFewShotWithRandomSearch` | същото, много набора, най-добрият по валидация | когато един набор примери дава нестабилен резултат |
| `MIPROv2` | инструкции и примери заедно | когато трябва да се настроят и формулировките, и примерите |
| `GEPA` | само инструкции, по текстови отзиви на метриката | когато метриката може да обясни грешката с думи и има силен модел за разбор |
| `SIMBA` | правила в инструкцията и примери за най-трудните случаи | когато програмата греши на отделни типове въпроси |
| `COPRO` | само инструкции, чрез пребор | когато примерите вече са добри и пречат само формулировките |
| `BootstrapFinetune` | теглата на модела | последен лост: нужен е модел, който може да се дообучава |
| `BetterTogether` | верига от оптимизатори на промптове и дообучаване | когато промптовете и дообучаването поотделно вече са опитвани |

Самият DSPy не избира оптимизатор вместо вас. Документацията съветва да се започва от простото: ако `LabeledFewShot` или `BootstrapFewShot` вече дават нужния резултат, тежката оптимизация е излишен разход. Дообучаването на теглата е последното средство: струва много по-скъпо, а прибавката в сравнение с GEPA или MIPROv2 често е малка. Имайте предвид и цената: според документацията един прогон на GEPA или MIPROv2 върху облачни модели може да струва стотици долара.

### Как работи MIPROv2

MIPROv2 (Multiprompt Instruction Proposal Optimizer, втора версия) настройва инструкциите и примерите едновременно. Работи в три стъпки:

1. **Събиране на примери.** Оптимизаторът стартира програмата върху обучаващата извадка и запомня прогоните, които метриката е признала за успешни. От тях се получават набори примери за всеки предиктор, заедно с всички междинни стъпки: кой инструмент е извикан, какво е върнал.
2. **Предлагане на инструкции.** Моделът „автор“ получава кратко описание на данните, кода на програмата, събраните примери и случаен съвет от рода на „бъди кратък“ или „бъди креативен“ и пише няколко варианта на инструкцията за всеки предиктор.
3. **Търсене на най-добрата комбинация.** Байесово търсене (алгоритъмът TPE от библиотеката Optuna) опитва съчетания „инструкция и набор примери“ за всички предиктори едновременно. Запомня кои варианти дават висок резултат и по-често опитва подобни.

Стартирането изглежда така:

```python
evaluate = dspy.Evaluate(devset=testset, metric=metric, num_threads=4)
print("before:", evaluate(agent).score)  # before: 50.0

optimizer = dspy.MIPROv2(metric=metric, auto="light", num_threads=4)
tuned = optimizer.compile(agent, trainset=trainset, valset=valset)

print("after:", evaluate(tuned).score)  # after: 87.5
tuned.save("agent.json")
```

`compile` не променя изходния агент, а връща настроено копие. Затова `agent` и `tuned` могат да се сравнят върху една и съща тестова извадка.

Върху тестовата извадка делът на успешните отговори нарасна от 50 на 87,5 %: от осемте въпроса агентът вече се справя със седем. Върху валидационната извадка най-добрият кандидат е събрал всичките 100 % срещу изходните 57,7 %. Разликата между 100 и 87,5 е същата завишена оценка, за която ставаше дума при разделянето на данните: кандидатът е избран по валидационната извадка, затова върху нея изглежда по-добре, отколкото върху въпросите, които не е виждал.

Отговорите станаха такива, каквито поискахме в сигнатурата:

```
How long is the refund period? -> 30 days
How long after placing an order can I cancel it? -> Within 1 hour
How long does delivery to Canada take? -> 7 to 14 business days
When can I reach customer support? -> By chat, from 9:00 to 21:00 CET, seven days a week.
```

Последният отговор е верен, но в него има единадесет думи и метриката не го зачете.

Какво точно се е променило, ще разгледаме по-долу, в раздела за резултата. Накратко: и двата предиктора получиха нови инструкции, над две хиляди знака всяка. За стъпката `extract` това е скок от един ред, а инструкцията на `react` и преди беше дълга, около 1 200 знака, защото `ReAct` слага в нея описанието на инструментите; оптимизаторът я е пренаписал изцяло. Освен това всеки предиктор получи по четири примера. Първият е взет от записан успешен прогон на агента: за `extract` това е цялата история на търсенето с крайния отговор, за `react` – само първата стъпка, въпросът и първото извикване на инструмент. Останалите три са въпроси от обучаващата извадка с верни отговори като „$6“ и „free“. Вижда ги само `extract`: в тях няма нито едно поле, което извежда стъпката `react`, затова адаптерът не ги слага в нейния промпт и `react` на практика работи с един пример.

Журналът на опитите показва какво е сработило. В два от опитите стъпката `react` е настроена еднакво, а при стъпката `extract` няма примери и се променя само инструкцията. Със старата инструкция оценката на валидацията е 53,8 %, с новата – 88,5 %. Най-добрият резултат даде съчетанието на новата инструкция с примери.

Прогонът отне 65 минути на видеокартата на лаптопа (RTX 5070 Ti): 14 минути за събиране на примери и инструкции и около 50 за двадесетте опита. Агентът за това време е стартиран 555 пъти. Времето не е еталонно, видеокартата беше заета и с други задачи. Вариантите на инструкциите ги писа същата Gemma 4 E4B: отделен модел „автор“ не зададохме.

### Колко струва прогон на MIPROv2

Параметърът `auto` задава бюджета: `light`, `medium` или `heavy`. В режим `light` MIPROv2 за нашия агент събира шест набора примери, подготвя по три варианта на инструкцията за всеки от двата предиктора – изходната и две нови – и прави двадесет опита. Броят на опитите се смята по формула: две променливи на предиктор (инструкция и примери), общо четири, и опитите излизат 2 × 4 × log₂ 6 ≈ 20.

Как се оценява всеки опит, зависи от размера на валидационната извадка. Режим `light` взема от нея не повече от сто примера. Ако те са повече от петдесет, всеки опит се проверява върху случайна партида от 35 примера, а най-добрият кандидат веднъж на пет опита минава пълна проверка. Ако са петдесет или по-малко, както при нас, всеки опит се проверява върху цялата извадка.

Нататък е проста аритметика. При набор от триста примера (60 тестови, 48 обучаващи, 192 валидационни) MIPROv2 в режим `light` стартира агента около 1 230 пъти. Изчислихме го върху макетен модел, който отговаря мигновено и нищо не кешира. Всяко стартиране на агента са минимум три извиквания на модела: търсене, `finish` и отговор. Излизат около четири хиляди извиквания. С истински модел част от повторенията ще се вземат от кеша, но при облачен модел останалото са директни пари.

Две неща забележимо намаляват цената. Първото е да се разделят моделите. Вариантите на инструкциите ги пише `prompt_model`, а програмата я изпълнява `task_model`. „Авторът“ се извиква рядко, затова може да се вземе по-силен и по-скъп, а програмата да остане на евтин модел:

```python
optimizer = dspy.MIPROv2(
    metric=metric,
    auto="light",
    prompt_model=dspy.LM("openai/gpt-5.4"),  # пише вариантите на инструкциите
    task_model=lm,  # изпълнява агента
    num_threads=4,
)
```

Второто е кешът, за който говорихме по-горе. Прекъснат прогон при повторно стартиране минава вече изчислената част мигновено и безплатно.

### Как работи GEPA

GEPA (от Genetic-Pareto) е оптимизаторът, около който сега е построено въведението в документацията на DSPy. Той променя само инструкциите и го прави по различен начин от MIPROv2: не прехвърля готови варианти, а чете как програмата е сбъркала и пренаписва инструкцията така, че тази грешка да я няма. Цикълът изглежда така:

1. GEPA взема един от най-добрите кандидати и го стартира върху три примера от обучаващата извадка.
2. Метриката оценява отговорите и обяснява с думи какво не е наред.
3. Силен модел (`reflection_lm`) чете хода на програмата, отговорите и обясненията и пише нова инструкция за един от предикторите.
4. Ако върху същите примери е станало по-добре, новият кандидат минава проверка върху цялата валидационна извадка и влиза в пула.
5. В пула се държат кандидатите, които са най-добри поне върху един пример – това е „фронтът на Парето“ от името. Понякога GEPA кръстосва двама кандидати, които са силни върху различни примери.

Главната разлика е метриката. Може да ѝ се върне не само оценка, но и отзив, и той ще стигне дословно до модела, който пише инструкцията. Метриката за GEPA има пет аргумента: към обикновените се добавят името на предиктора, който в момента се настройва, и неговата част от историята на извикванията.

```python
def gepa_metric(gold, pred, trace=None, pred_name=None, pred_trace=None):
    answer = pred.answer.lower()
    if gold.answer.lower() not in answer:
        return dspy.Prediction(score=0.0, feedback=f"Wrong. The correct answer is {gold.answer}.")
    words = len(answer.split())
    if words > 10:
        return dspy.Prediction(score=0.5, feedback=f"Correct, but {words} words long. Use at most 10.")
    return dspy.Prediction(score=1.0, feedback="Correct and short.")
```

Метриката може да върне и просто число, но тогава GEPA ще види само „This trajectory got a score of 0.5“ и главното му предимство изчезва. Отзивът го пишем на английски по същата причина като инструкциите: чете го моделът.

Разделението за GEPA е обратно на разделението за MIPROv2. Всеки нов кандидат се проверява върху цялата валидационна извадка, така че голямата валидационна извадка изяжда бюджета. Документацията съветва да се прави възможно най-малка, стига да прилича на истинските въпроси, а всичко останало да се дава в обучаващата:

```python
# Тестовата извадка е същата. Остатъка го делим по обратния начин:
# три четвърти за обучение, една четвърт за валидация.
val_size = len(rest) // 4
gepa_train, gepa_val = rest[:-val_size], rest[-val_size:]

optimizer = dspy.GEPA(
    metric=gepa_metric,
    reflection_lm=dspy.LM("openai/gpt-5.4", temperature=1.0, max_tokens=32000),
    auto="light",
    num_threads=4,
)
tuned_gepa = optimizer.compile(agent, trainset=gepa_train, valset=gepa_val)
print("after GEPA:", evaluate(tuned_gepa).score)
```

Как изглежда това на живо, показва нашият съкратен прогон върху същите четиридесет въпроса: 24 в обучаващата извадка, 8 във валидационната, бюджет `max_metric_calls=200` вместо `auto="light"`, а в ролята на `reflection_lm` – същата Gemma 4 E4B вместо силен облачен модел. Прогонът прекъсна на седмата итерация и крайна оценка няма, но механиката първите минути от журнала показват добре:

```
Iteration 0: Base program full valset score: 0.6875 over 8 / 8 examples
Iteration 1: Selected program 0 score: 0.6875
Iteration 1: Proposed new text for react: You are an intelligent Customer Service Agent whose sole purpose is to answer customer queries using information exclusively sourced from the shop's Knowledge Base (KB). ...
Iteration 1: New subsample score 2.0 is not better than old score 2.5, skipping
Iteration 2: Selected program 0 score: 0.6875
Iteration 2: All subsample scores perfect for parent 0. Skipping.
Iteration 3: Selected program 0 score: 0.6875
Iteration 3: Proposed new text for extract.predict: You are an expert Customer Service AI agent tasked with answering customer inquiries using only the provided shop Knowledge Base (KB). ...
Iteration 3: New subsample score 1.5 is not better than old score 2.0, skipping
```

Изходният агент набра на валидацията 0,6875: това е средната оценка на нашата метрика, в която дългият верен отговор струва 0,5. На първата итерация моделът-рефлектор пренаписа инструкцията на стъпката `react`. Върху същите три примера новият кандидат набра общо 2,0 срещу предишните 2,5 и GEPA го изхвърли, без да харчи проверка върху валидационната извадка. На втората итерация и трите взети примера вече бяха решени идеално, нямаше от какво да се учи и GEPA пропусна хода. До спирането той четири пъти предложи нова инструкция, по два пъти за всеки предиктор, и нито една не мина проверката върху трите примера. Така GEPA пести бюджета: скъпата проверка върху цялата валидационна извадка минават само кандидатите, които вече са се показали върху малката партида. Много зависи и от модела-рефлектор. При нас неговата роля играеше същата малка Gemma и нито една от нейните инструкции не се оказа по-добра от изходната. Документацията не случайно съветва да се взема силен модел за тази роля.

И GEPA има `auto="light"` и печата бюджета в началото на работата: „approx N metric calls of the program“. Въпреки името това са стартирания на програмата, по едно за всеки проверен пример. За нашия агент и триста примера, разделени както в кода по-горе (60 тестови, 180 обучаващи, 60 валидационни), това са около 1 090 стартирания. Самата функция на метриката той я извиква по-често, около 1 550 пъти: за отзива по отделен предиктор – втори път, и тези извиквания не влизат в бюджета. Вместо `auto` бюджетът може да се зададе направо: `max_metric_calls` – в същите стартирания на програмата, `max_full_evals` – с брой пълни преминавания през извадките.

Какво е добре да знаете за GEPA преди стартиране:

- `reflection_lm` е задължителен. От версия 3.4.1 това трябва да е модел на DSPy, който умее да отговаря с JSON. Документацията съветва силен модел: извиква се рядко, по няколко десетки пъти за прогон.
- GEPA още е отбелязан като експериментален и на Python 3.15 не се инсталира.
- Примерите в промпта GEPA не ги променя. Ако трябват и те, GEPA се съчетава с `BootstrapFewShot` през `BetterTogether`.
- Ако се посочи `log_dir`, GEPA запазва там всички кандидати и може да продължи прекъснат прогон от същото място.

### Как да разберем колко е изразходвано

DSPy умее да брои токени. Отчитането се включва за времето на едно извикване през `dspy.context`:

```python
with dspy.context(track_usage=True):
    result = agent(question="Do you ship to Canada?")

print(result.get_lm_usage())  # {'openai/google/gemma-4-e4b': {'prompt_tokens': 2019, 'completion_tokens': 605, ...}}
```

Агентът направи три извиквания на модела: 2 019 токена са отишли в заявките и 605 са дошли в отговорите. 351 от тези 605 са скрити разсъждения: Gemma 4 първо мисли наум и чак после отговаря, затова всяка нейна стъпка е забележимо по-бавна, отколкото може да се очаква от толкова кратък отговор. Отговорите от кеша в отчета не влизат: ако този въпрос вече е бил задаван, речникът ще е празен.

Цифрите се събират за всеки модел поотделно. Ако моделът е облачен, цената на всяко извикване е в историята на модела, в `lm.history`. Подробният ход на оптимизацията – кандидатите, оценките, промптовете – може да се изпраща в MLflow: `mlflow.dspy.autolog(log_compiles=True, log_evals=True, log_traces_from_compile=True)` преди стартиране. Извикан без аргументи, той няма да запише нито самата оптимизация, нито резултатите от оценките: тези три флага по подразбиране са изключени.

## Резултат

### Как се запазва и зарежда настроеният агент

`tuned.save("agent.json")` записва това, което е подбрал оптимизаторът: инструкциите и примерите на всеки предиктор, плюс версиите на Python и DSPy, на които е протекла настройката. Кодът на програмата във файла не попада. Затова при зареждане агентът първо се създава така, както при настройката, а после върху него се налага запазеното състояние:

```python
agent = dspy.ReAct(AnswerFromKb, tools=[search_kb], max_iters=5)
agent.load("agent.json")
print(agent(question="How long is the refund period?").answer)  # 30 days
```

Файлът е малък, четим за човек и е удобно да се съхранява в хранилището до кода: промените в инструкциите се виждат в обикновения diff. API ключове в него няма никога. Адресите на сървърите DSPy при зареждане също ги отхвърля, освен ако изрично не се подаде `allow_unsafe_lm_state=True`. Моделът, на който ще работи агентът, се задава както обикновено, през `dspy.configure`. Ако файлът е запазен от друга версия на DSPy, при зареждане ще се появи предупреждение, но агентът ще се зареди.

Ако там, където се зарежда агентът, няма вашия код, има втори начин: `tuned.save("agent/", save_program=True)` запазва програмата изцяло през cloudpickle, а `dspy.load("agent/", allow_pickle=True)` я възстановява. Без вашия код това работи само за класове, обявени в самия запазващ скрипт: cloudpickle ги запазва изцяло, а за клас от импортируем модул – само референция, и зареждането без този модул пада с `ModuleNotFoundError`. Ако сигнатурата и инструментите са във ваш модул, изброете го при запазването: `tuned.save("agent/", save_program=True, modules_to_serialize=[my_module])`. Такъв файл съдържа изпълним код, затова може да се зарежда само от доверен източник.

### Как да видим какво е променил оптимизаторът

Инструкциите и примерите на всеки предиктор са достъпни направо от кода:

```python
for name, predictor in tuned.named_predictors():
    print(name, "| demos:", len(predictor.demos))
    print(predictor.signature.instructions)
```

Ето какво се получи в нашия прогон на MIPROv2 (дългите места са пропуснати):

```
react | demos: 4
You are a highly specialized AI Agent Planner (Reasoning Engine). Your sole function is to analyze a customer's question and the entire conversation history (`trajectory`) accumulated so far to determine the single, most logical next step needed to find the answer using only the available tools.
...
**Tool Definitions:**
1. **`search_kb`**: Use this when you need to actively retrieve new information from the knowledge base. The argument must be a specific, focused search query (e.g., `'return period'`). Do not use general terms; focus on the core concepts of the question or observations.
2. **`finish`**: Use this only when your reasoning confirms that you have gathered sufficient evidence across all parts of the trajectory to formulate a definitive answer, and no further tool calls are required.
...
extract.predict | demos: 4
You are a sophisticated AI knowledge synthesis engine responsible for compiling the final answer to a customer query based *exclusively* on an agent's complete investigative history (the 'trajectory'). ...
...
2. **Answer (Final Output):**
    *   Provide a final answer that is extremely concise, direct, and factual.
    *   The `answer` must directly address the initial `Question`.
    *   Do not include explanations, filler words, or unnecessary introductory phrases in the final `answer`. ...
```

Инструкциите ги е писал моделът и това си личи: те са дълги, със заглавия и повторения. Но пък в инструкцията на стъпката `extract` направо е казано, че отговорът трябва да е изключително кратък и без въведения – точно за това метриката сваляше точки.

Обърнете внимание на стъпката `react`. Нейната изходна инструкция съдържаше списък с инструменти, който `ReAct` е събрал от кода: имена, docstring, схеми на аргументите. Оптимизаторът е пренаписал инструкцията изцяло и сега инструментите са описани с неговите думи. Агентът работи, но ако добавите в кода нов инструмент или преименувате аргумент, запазената инструкция няма да научи за това и настройката ще трябва да се повтори.

Същото се намира и в `agent.json`. Полезно е да се чете това след всяка настройка. Оптимизаторът нагажда промпта към метриката, а не към вашите намерения: ако метриката някъде греши, това най-напред ще се прояви в инструкцията.

### Кога да се стартира оптимизацията наново

Настройката е свързана с модела, данните и кода. Стартира се отново, когато:

- се е сменил моделът или неговата версия при доставчика;
- са се променили инструментите, сигнатурата или устройството на програмата;
- в базата знания или във въпросите на потребителите се е появило нещо, което го е нямало в данните;
- DSPy се е обновил така, че са се променили предикторите, както ще бъде с `ReAct` в 3.5.

Всеки път се сравнява оценката върху тестовата извадка преди и след. Ако новият модел след настройка не е по-добър от стария, преминаването към него може да не се прави: и това решение се взема по цифри, а не по впечатление.

## Какво още има в DSPy

Извън примера остана много полезно:

- `dspy.Refine` и `dspy.BestOfN` стартират модула няколко пъти и избират отговора, който най-добре е минал проверката.
- `dspy.RLM` е модул за задачи с огромен контекст. Данните се съхраняват като променливи в интерпретатора на Python, а моделът пише код, за да ги разглежда, и сам извиква модел върху нужните парчета. Той заменя `CodeAct` и `ProgramOfThought`, които ще бъдат премахнати в 3.5.
- Модулите могат да се извикват асинхронно през `acall`, отговорът да се получава на парчета през `dspy.streamify` и да се свързват инструменти от MCP сървъри през `dspy.Tool.from_mcp_tool`.
- Модулите се подреждат в програми, като слоевете в PyTorch: собствен клас, наследник на `dspy.Module`, извиква във `forward` няколко модула и оптимизаторът ги настройва всичките наведнъж.

## Кога DSPy не е нужен

DSPy се изплаща, когато задачата се повтаря, може да се измери и моделът ще се сменя. Излишен е, ако:

- няма примери с верни отговори и начин да се оцени отговорът. Без тях остава само удобният запис на извикванията, а главното – оптимизацията – е недостъпно;
- задачата е еднократна или промптът и така работи;
- текстът на промпта трябва да е точно такъв, какъвто го е написал човек, например заради юридически формулировки;
- нужен е пълен контрол над всеки токен на заявката: промптът, който сглобява адаптерът, е по-дълъг и по-шаблонен от ръчния.

Ограниченията си струва да се помнят и когато DSPy е подходящ. Промпт, настроен за един модел, не се пренася на друг. При малък набор от данни оптимизаторът се преобучава и без отделна тестова извадка това не се вижда. API се променя от версия на версия, затова версията на DSPy се фиксира в зависимостите, а след обновяване оценката се стартира наново.
