Roman Kryvolapov Engineering Blog

DSPy — Автоматическая генерация промптов

Многие AI инженеры и не только, работающие с LLM и агентами, сталкиваются с задачами, когда корректность работы агента во многом зависит от промпта.

Для простых задач достаточно промпта в стиле "Ты умный агент, отвечай на вопросы пользователя". Но если агент должен делать сложные вызовы функций и обрабатывать нестандартные запросы, написать стабильный промпт для вызовов и для окончательного ответа непросто, а при смене LLM модели на другую ранее работавший промпт может перестать работать. Как писать такие промпты руками, разобрано в статье о промпт-инжиниринге, а как агент вызывает инструменты — в статье о RAG и вызове функций.

DSPy (GitHub) превращает это из сочинения текста в измеримый процесс. Что он делает простыми словами:

  • Вместо длинного текста вы описываете задачу как форму: какие есть входы и какие нужны выходы.
  • Правила вызова инструментов фиксируются кодом, а не «словами» в промпте.
  • Качество меряется метрикой, и DSPy подбирает формулировки под эту метрику.
  • При смене модели или функций вы запускаете настройку заново, а не переписываете всё руками.

Что это даёт:

  • Масштабирование: проще добавлять инструменты и новые форматы ответов.
  • Дешёвую смену модели: она превращается в повторный запуск настройки, с оценкой до и после.
  • Повторяемость: промпт хранится как артефакт и восстанавливается.
  • Контроль: поведение задаётся структурой и метрикой, а не «магическим текстом».

Цена — набор данных и время на настройку; о них ниже. Пример — агент, который ищет в локальной базе данных: так видно, что агенты могут быть довольно сложными.

Сигнатура и инструменты

Сигнатура говорит, какие поля приходят агенту и что он должен вернуть. Инструменты — обычные функции с чёткими входами и выходами:

import sqlite3
from contextlib import closing
import dspy
# A local model in LM Studio; any model LiteLLM can reach will do.
dspy.configure(
lm=dspy.LM(
"openai/google/gemma-3n-e4b",
api_base="http://localhost:1234/v1",
api_key="lm-studio",
temperature=0,
)
)
def search_kb(query: str) -> list[str]:
"""Searches the knowledge base and returns the matching fragments."""
with closing(sqlite3.connect("kb.db")) as db:
rows = db.execute(
"SELECT text FROM articles WHERE text LIKE ? LIMIT 5", (f"%{query}%",)
).fetchall()
return [row[0] for row in rows]
class AnswerFromKb(dspy.Signature):
"""Answer the question using only the 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)

Данные

Набор данных — это список вопросов с правильными ответами:

[
{
"question": "...",
"answer": "..."
}
]

По документации DSPy, 30 примеров часто уже дают заметную пользу, но ориентироваться стоит минимум на 300, а для MIPROv2 она советует от 200, чтобы избежать переобучения. Важно, чтобы в них было разнообразие.

Разбиение обратно привычному по нейросетям: 20 процентов на обучение и 80 на валидацию, потому что оптимизаторы промптов легко переобучаются на маленькой обучающей выборке. Сверху добавляется отложенная тестовая выборка, которую оптимизатор никогда не видит, и доверять стоит именно её оценке:

import json
import random
with open("dataset.json", encoding="utf-8") as f:
examples = [dspy.Example(**row).with_inputs("question") for row in json.load(f)]
random.Random(0).shuffle(examples)
holdout = len(examples) // 5
testset, rest = examples[:holdout], examples[holdout:]
split = len(rest) // 5
trainset, valset = rest[:split], rest[split:]

Метрика и настройка

MIPROv2 запускает агента, чтобы собрать удачные трассы, предлагает варианты инструкций и примеров few-shot и ищет среди них лучший по метрике:

def metric(example, prediction, trace=None):
return example.answer.lower() in prediction.answer.lower()
evaluate = dspy.Evaluate(devset=testset, metric=metric, num_threads=4)
print("before:", evaluate(agent))
optimizer = dspy.MIPROv2(metric=metric, auto="light", num_threads=4)
tuned = optimizer.compile(agent, trainset=trainset, valset=valset)
print("after:", evaluate(tuned))
tuned.save("agent.json")

Запуск не бесплатен. С auto="light" этот агент проходит 20 попыток на мини-батчах по 35 примеров, плюс каждые 5 попыток полный прогон по валидационной выборке, до 100 примеров, а каждый запуск агента — это несколько обращений к модели. На локальной модели это долгое ожидание, на облачной — счёт. Предложения вариантов можно отдать более сильной модели, чем та, на которой работает агент, — через prompt_model и task_model.

Использование результата

agent.json — это артефакт: он лежит в репозитории и загружается без всякой настройки. При изменении данных или модели запускаем настройку заново и сравниваем оценки на тестовой выборке.

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