DSPy – Автоматично генериране на промптове
Тази статия е за тези, които правят агенти и вериги от извиквания на LLM и са се уморили да подбират промпта на ръка. За проста задача стига промпт от рода на „Ти си умен помощник, отговаряй на въпроси“. Но когато агентът извиква функции и разбира нестандартни заявки, промптът нараства до няколко страници правила, а след смяна на модела спира да работи и всичко трябва да се подбира наново. Как се пишат такива промптове на ръка, е разгледано в статията за промпт инженеринг, а как агентът извиква инструменти – в статията за RAG и извикване на функции.
DSPy (GitHub) предлага друг път. Задачата се описва с код: какво идва на входа, какво трябва да се получи и от какви стъпки се състои програмата. Текста на промпта 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:
pip install -U "dspy[optuna]"Добавката optuna е нужна на оптимизатора MIPROv2. От версия 3.2 тази библиотека не се инсталира по подразбиране и без нея MIPROv2 пада с грешка при импортиране. Оптимизаторът GEPA се инсталира заедно с DSPy, освен на Python 3.15: неговият пакет gepa засега поддържа само версии до 3.14.
Сега ще свържем модел. В примера това е Gemma 4 E4B, стартиран в LM Studio на същия компютър:
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.
Сигнатура
Какво е сигнатура
Сигнатурата описва задачата като функция: какво е на входа и какво на изхода. Най-краткият запис е низ, в който входовете и изходите са разделени със стрелка:
qa = dspy.Predict("question -> answer")result = qa(question="What is the capital of France?")print(result.answer) # ParisPredict е най-простият модул: едно извикване на модела без разсъждения и инструменти. Резултатът е обект Prediction, чиито полета носят имената на изходите на сигнатурата.
Какво DSPy изпраща на модела
Промпт не сме писали, но моделът го е получил. Какво точно е отишло към модела и какво е дошло обратно, може да се види с един ред:
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 (низът с документация) на класа става инструкция за модела:
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 Trueresult.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 FalseDocstring-ът – Classify a customer support ticket. – стана цел в системното съобщение вместо безличното „Given the fields...“. Именно тази инструкция оптимизаторът после ще пренаписва. Описанията на полетата (desc) не ги променя, затова в тях е добре да се пише това, което трябва да остане неизменно.
Docstring-овете и описанията на полетата ги пишем на английски: това е текст, който чете моделът, а не програмистът. За малките модели английският обикновено е по-надежден.
С какво Predict се различава от ChainOfThought
ChainOfThought приема същата сигнатура, но добавя пред изходните полета още едно – reasoning. Моделът първо пише разсъждение, после отговор:
think = dspy.ChainOfThought(ClassifyTicket)result = think(text="Can I pay with Apple Pay?")print(result.topic, result.urgent) # payment Falseprint(result.reasoning) # The customer is asking a question regarding accepted methods of payment...Сигнатурата не се наложи да се променя. В това е смисълът на модулите: задачата е описана веднъж, а начинът за решаването ѝ се сменя с един ред. Разсъждението помага при задачи, в които трябва нещо да се пресметне или съпостави, но удължава отговора и забавя извикването.
Агент с инструменти
Как се подготвя базата знания
На агента му трябва източник на факти. В примера това е базата знания на магазина в SQLite: дванадесет кратки статии за връщания, доставка, плащане, гаранция и така нататък. Ето първите четири и създаването на таблицата:
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 показва всичко това на модела и по тях той решава кога и как да извика функцията.
import refrom 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“ стоят поотделно или ги няма изобщо. Агентът би получил празен списък и би решил, че отговор в базата няма. Търсенето по думи с ранжиране прощава чужди формулировки. В истински проект тук обикновено стои векторно търсене, за него е разказано в статията за векторните бази данни, но за 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, за да може моделът да каже „информацията е достатъчна“.
Как се сглобява агентът
Сигнатурата на агента описва задачата изцяло, без да споменава инструменти:
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 е устроен така:
- Моделът получава въпроса, списъка с инструменти и историята на предишните стъпки. На първата стъпка историята е празна.
- Моделът пише мисъл (
next_thought), избира инструмент (next_tool_name) и аргументи за него (next_tool_args). - DSPy извиква функцията, а резултата ѝ (
observation) дописва в историята. - Стъпки 2 и 3 се повтарят, докато моделът не избере
finishили не свършиmax_iters. - Отделно извикване на модела,
extract, чете цялата история и попълва изходните полета на сигнатурата, при нас –answer. Устроено е катоChainOfThought, затова първо също разсъждава.
Излиза, че вътре в агента има два предиктора: react избира стъпките, extract пише отговора. Всеки има своя инструкция и свои примери и оптимизаторът настройва и двата. Това ще ни потрябва, когато разглеждаме резултата от оптимизацията.
Историята на стъпките може да се види в полето trajectory (дългите низове в изхода са съкратени):
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_kbtool_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: finishtool_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:
[ { "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:
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)) # 40print(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 даваше такива ориентири: тридесет примера често вече носят видима полза, но си струва да се стремите поне към триста, а за MIPROv2 – към двеста и повече, за да не се преобучи оптимизаторът. В документацията на 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:
import random
# Разбъркваме с фиксирано начално число (seed), за да се повтаря разделянето.random.Random(0).shuffle(examples)
# 1. Една пета я отделяме за крайната проверка.test_size = len(examples) // 5testset, rest = examples[:test_size], examples[test_size:]
# 2. Остатъкът за MIPROv2: 20 % за обучение, 80 % за валидация.train_size = len(rest) // 5trainset, valset = rest[:train_size], rest[train_size:]
print(len(trainset), len(valset), len(testset)) # 6 26 8Метрика
Какво е метрика
Метриката е функция, която получава пример с верен отговор и отговора на програмата и връща оценка: True/False или число от 0 до 1. Какво да смятаме за добър отговор, решаваме ние. Тук отговорът е добър, ако в него има верната стойност и е кратък, както поискахме в сигнатурата:
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. Тя прекарва програмата през всички примери, паралелно в няколко нишки, и смята средното:
evaluate = dspy.Evaluate(devset=testset, metric=metric, num_threads=4)report = evaluate(agent)print(report.score) # 50.0report.score е процентът успешни отговори. В report.results стоят тройки „пример, отговор, оценка“, по тях е удобно да се разглеждат грешките:
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, която сравнява отговора с еталона по смисъл.
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, втора версия) настройва инструкциите и примерите едновременно. Работи в три стъпки:
- Събиране на примери. Оптимизаторът стартира програмата върху обучаващата извадка и запомня прогоните, които метриката е признала за успешни. От тях се получават набори примери за всеки предиктор, заедно с всички междинни стъпки: кой инструмент е извикан, какво е върнал.
- Предлагане на инструкции. Моделът „автор“ получава кратко описание на данните, кода на програмата, събраните примери и случаен съвет от рода на „бъди кратък“ или „бъди креативен“ и пише няколко варианта на инструкцията за всеки предиктор.
- Търсене на най-добрата комбинация. Байесово търсене (алгоритъмът TPE от библиотеката Optuna) опитва съчетания „инструкция и набор примери“ за всички предиктори едновременно. Запомня кои варианти дават висок резултат и по-често опитва подобни.
Стартирането изглежда така:
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.5tuned.save("agent.json")compile не променя изходния агент, а връща настроено копие. Затова agent и tuned могат да се сравнят върху една и съща тестова извадка.
Върху тестовата извадка делът на успешните отговори нарасна от 50 на 87,5 %: от осемте въпроса агентът вече се справя със седем. Върху валидационната извадка най-добрият кандидат е събрал всичките 100 % срещу изходните 57,7 %. Разликата между 100 и 87,5 е същата завишена оценка, за която ставаше дума при разделянето на данните: кандидатът е избран по валидационната извадка, затова върху нея изглежда по-добре, отколкото върху въпросите, които не е виждал.
Отговорите станаха такива, каквито поискахме в сигнатурата:
How long is the refund period? -> 30 daysHow long after placing an order can I cancel it? -> Within 1 hourHow long does delivery to Canada take? -> 7 to 14 business daysWhen 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. „Авторът“ се извиква рядко, затова може да се вземе по-силен и по-скъп, а програмата да остане на евтин модел:
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: не прехвърля готови варианти, а чете как програмата е сбъркала и пренаписва инструкцията така, че тази грешка да я няма. Цикълът изглежда така:
- GEPA взема един от най-добрите кандидати и го стартира върху три примера от обучаващата извадка.
- Метриката оценява отговорите и обяснява с думи какво не е наред.
- Силен модел (
reflection_lm) чете хода на програмата, отговорите и обясненията и пише нова инструкция за един от предикторите. - Ако върху същите примери е станало по-добре, новият кандидат минава проверка върху цялата валидационна извадка и влиза в пула.
- В пула се държат кандидатите, които са най-добри поне върху един пример – това е „фронтът на Парето“ от името. Понякога GEPA кръстосва двама кандидати, които са силни върху различни примери.
Главната разлика е метриката. Може да ѝ се върне не само оценка, но и отзив, и той ще стигне дословно до модела, който пише инструкцията. Метриката за GEPA има пет аргумента: към обикновените се добавят името на предиктора, който в момента се настройва, и неговата част от историята на извикванията.
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. Всеки нов кандидат се проверява върху цялата валидационна извадка, така че голямата валидационна извадка изяжда бюджета. Документацията съветва да се прави възможно най-малка, стига да прилича на истинските въпроси, а всичко останало да се дава в обучаващата:
# Тестовата извадка е същата. Остатъка го делим по обратния начин:# три четвърти за обучение, една четвърт за валидация.val_size = len(rest) // 4gepa_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 examplesIteration 1: Selected program 0 score: 0.6875Iteration 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, skippingIteration 2: Selected program 0 score: 0.6875Iteration 2: All subsample scores perfect for parent 0. Skipping.Iteration 3: Selected program 0 score: 0.6875Iteration 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:
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, на които е протекла настройката. Кодът на програмата във файла не попада. Затова при зареждане агентът първо се създава така, както при настройката, а после върху него се налага запазеното състояние:
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]). Такъв файл съдържа изпълним код, затова може да се зарежда само от доверен източник.
Как да видим какво е променил оптимизаторът
Инструкциите и примерите на всеки предиктор са достъпни направо от кода:
for name, predictor in tuned.named_predictors(): print(name, "| demos:", len(predictor.demos)) print(predictor.signature.instructions)Ето какво се получи в нашия прогон на MIPROv2 (дългите места са пропуснати):
react | demos: 4You 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: 4You 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 се фиксира в зависимостите, а след обновяване оценката се стартира наново.