Глубокое практическое погружение в декларативное программирование ИИ
Делиться

Современный ландшафт GenAI построен вокруг подсказок. Мы обучаем LLM, таких как ChatGPT или Claude, используя длинные, очень подробные, пошаговые руководства для достижения желаемых результатов. Создание этих подсказок требует много времени и усилий, но мы готовы их потратить, поскольку лучшие подсказки обычно приводят к лучшим результатам.
Однако достижение оптимальной подсказки часто является сложной задачей. Это процесс проб и ошибок, в котором не всегда ясно, что лучше всего подойдет для вашей конкретной задачи или данного LLM. В результате может потребоваться много итераций, чтобы достичь удовлетворительного результата, особенно если ваша подсказка состоит из нескольких тысяч слов.
Для решения этих проблем DataBricks запустила фреймворк DSPy. DSPy означает Declarative Self-improving Python. Этот фреймворк позволяет вам создавать модульные приложения ИИ. Он основан на идее, что задачи LLM можно рассматривать как программирование, а не как ручное наведение. Используя стандартные строительные блоки, вы можете создавать широкий спектр приложений ИИ: от простых классификаторов до систем RAG (Retrieval Augmented Generation) или даже агентов.
Этот подход кажется многообещающим. Было бы интересно создавать приложения ИИ так же, как мы создаем традиционное ПО. Поэтому я решил попробовать DSPy.
В этой статье мы рассмотрим фреймворк DSPy и его возможности для построения LLM-конвейеров. Начнем с простой задачи по комбинаторике, чтобы охватить основы. Затем применим DSPy к реальной бизнес-задаче: классификации комментариев критиков NPS. На основе этого примера мы также протестируем одну из самых многообещающих функций фреймворка: автоматическую оптимизацию инструкций.
Основы DSPy
Начнем изучение фреймворка DSPy с установки пакета.
pip install -U dspy
Как упоминалось выше, DSPy определяет приложения LLM структурированным и модульным образом. Каждое приложение создается с использованием трех основных компонентов:
- языковая модель — LLM, которая ответит на наши вопросы,
- сигнатура — декларация входных и выходных данных программы (какую задачу мы хотим решить),
- модуль — методика подсказки (как мы хотим решить задачу).
Давайте попробуем на очень простом примере.
Как обычно, мы начнем с языковой модели — ядра любого приложения на базе LLM. Я буду использовать локальную модель (Llama by Meta), доступ к которой осуществляется через Ollama. Если у вас еще не установлен Ollama, вы можете следовать инструкциям в документации.
Чтобы создать языковую модель в приложении DSPy, нам нужно инициализировать объект dspy.LM и установить его как LLM по умолчанию для нашего приложения. Само собой разумеется, что DSPy поддерживает не только локальные модели, но и популярные API, такие как OpenAI и Anthropic.
импорт dspy llm = dspy.LM('ollama_chat/llama3.2', api_base='http://localhost:11434', api_key='', температура = 0,3) dspy.configure(lm=llm)
У нас настроена языковая модель. Следующий шаг — определить задачу, создав модуль и сигнатуру.
Сигнатура определяет входные и выходные данные для модели. Она сообщает модели, что мы ей даем и какой результат ожидаем получить в итоге. Сигнатура не указывает модели, как решать задачу, это всего лишь декларация.
Есть два способа определить сигнатуру в DSPy: встроенный или с использованием класса. Для нашего первого быстрого примера мы воспользуемся простым встроенным подходом, но мы рассмотрим определения на основе классов позже в статье.
Модули — это строительные блоки приложений DSPy. Они абстрагируют различные стратегии подсказок, такие как Chain-of-Thought или ReAct. Модули разработаны для работы с любой сигнатурой, поэтому вам не нужно беспокоиться о совместимости самостоятельно.
Вот некоторые из наиболее часто используемых модулей DSPy (полный список можно найти в документации):
- dspy.Predict — базовый предиктор;
- dspy.ChainOfThought — помогает LLM думать шаг за шагом, прежде чем дать окончательный ответ;
- dspy.ReAct — базовый агент, который может вызывать инструменты.
Начнем с простейшей версии — dspy.Predict и построим базовую модель, которая может отвечать на вопросы комбинаторики. Поскольку мы ожидаем, что ответ будет целым числом, я указал это в сигнатуре.
simple_model = dspy.Predict(«вопрос -> ответ: int»)
Это все, что нам нужно. Теперь мы можем начать задавать вопросы.
simple_model( question=»»»У меня есть 5 разных мячей, и я случайным образом выбираю 4. Сколько возможных комбинаций мячей я могу получить?»»» ) # Прогноз(answer=210)
Мы получили ответ, но, к сожалению, он неверный. Тем не менее, давайте посмотрим, как это работает под капотом. Мы можем увидеть полные логи с помощью команды dspy.inspect_history.
dspy.inspect_history(n = 1) # Системное сообщение: # # Ваши поля ввода: # 1. `question` (str): # Ваши поля вывода: # 1. `answer` (int): # Все взаимодействия будут структурированы следующим образом, с заполнением соответствующих значений. # # Входные данные будут иметь следующую структуру: # [[ ## question ## ]] # {question} # # Выходные данные будут представлять собой объект JSON со следующими полями. # { # «answer»: «{answer} # примечание: создаваемое вами значение должно быть одним целым числом» # } # Придерживаясь этой структуры, ваша цель: # учитывая поля `question`, создать поля `answer`. # # Сообщение пользователя: # [[ ## question ## ]] # У меня есть 5 разных мячей, и я случайным образом выбираю 4. Сколько возможных # комбинаций мячей я могу получить? # Ответить с объектом JSON в следующем порядке полей: `answer` # (должен быть отформатирован как допустимое целое число Python). # # Ответ: # {«answer»: 210}
Мы видим, что DSPy сгенерировал для нас подробную и хорошо структурированную подсказку. Это довольно удобно.
Последнее небольшое замечание, прежде чем мы перейдем к исправлению модели: я заметил, что DSPy по умолчанию включает кэширование для ответов LLM. Кэширование может быть полезным в некоторых случаях, например, для экономии затрат на отладку. Однако, если вы хотите отключить его, вы можете либо обновить конфигурацию, либо обойти его для определенного вызова.
# обновление конфигурации dspy.configure_cache(enable_memory_cache=False, enable_disk_cache=False) # не используется кэш для определенного модуля math = dspy.Predict(«question -> answer: float», cache = False)
Возвращаясь к нашей задаче, давайте попробуем добавить рассуждения, чтобы посмотреть, улучшит ли это результат. Это так же просто, как сменить модуль.
dspy.configure(adapter=dspy.JSONAdapter()) # Я также перешел на формат JSON, так как он лучше работает # для моделей со структурированным выводом cot_model = dspy.ChainOfThought(«question -> answer: int») cot_model(question=»»»У меня есть 5 разных мячей, и я случайным образом выбираю 4. Сколько возможных комбинаций мячей я могу получить?»»») # Prediction( # reasoning='Это задача на комбинацию, в которой нам нужно найти # количество способов выбрать 4 мяча из 5, не принимая во внимание # порядок. Формула для комбинаций: nCr = n! / (r!(nr)!), # где n — общее количество элементов, а r — количество выбираемых элементов. В этом случае n = 5 и r = 4.', # answer=5 # )
Ура! Рассуждение сработало, и на этот раз мы получили правильный результат. Давайте посмотрим, как изменилась подсказка. Поле рассуждения добавлено к выходным переменным.
dspy.inspect_history(n = 1) # Системное сообщение: # # Ваши поля ввода: # 1. `question` (str): # Ваши поля вывода: # 1. `reasoning` (str): # 2. `answer` (int): # Все взаимодействия будут структурированы следующим образом, # с заполнением соответствующих значений. # Входные данные будут иметь следующую структуру: # [[ ## question ## ]] # {question} # Выходные данные будут представлять собой объект JSON со следующими полями. # { # «reasoning»: «{reasoning}», # «answer»: «{answer} # примечание: создаваемое вами значение должно быть одним целым числом» # } # Придерживаясь этой структуры, ваша цель: # Учитывая поля `question`, создать поля `answer`.
Давайте проверим нашу систему, ответив на более сложный вопрос.
print(cot_model(question=»»»У меня есть 25 разных мячей, и я случайным образом выбираю 9. Сколько возможных комбинаций мячей я могу получить?»»»)) # Prediction( # reasoning='Это задача на комбинацию, в которой порядок выбора # не имеет значения. Количество комбинаций можно вычислить с помощью # формулы C(n, k) = n! / (k!(nk)!), где n — общее # количество предметов, а k — количество предметов для выбора.', # answer=55 # )
Ответ определенно неверный. LLM поделился правильной формулой, но выдал 55 вместо правильного результата (2 042 975). Это ожидаемо. Модель галлюцинировала, потому что не могла точно выполнить расчет. Так что это идеальный вариант использования агента. Мы снабдим нашего агента инструментом для выполнения расчетов, и, будем надеяться, он решит проблему.
Прежде чем перейти к созданию нашего первого агентского потока DSPy, давайте настроим наблюдаемость. Это поможет нам понять мыслительный процесс агента. DSPy интегрирован с MLFlow (инструментом наблюдаемости), что позволяет легко отслеживать все в удобном интерфейсе.
Для начала мы сделаем несколько первоначальных установочных звонков.
pip install -U mlflow # Настоятельно рекомендуется использовать хранилище SQL при использовании трассировки MLflow python3 -m mlflow server —backend-store-uri sqlite:///mydb.sqlite
Если вы не изменили настройки по умолчанию, MLFlow будет работать на порту 5000. Далее нам нужно добавить немного кода Python в нашу программу, чтобы начать отслеживание. Вот и все.
import mlflow # Сообщите MLflow об URI сервера. mlflow.set_tracking_uri(«http://127.0.0.1:5000») # Создайте уникальное имя для вашего эксперимента. mlflow.set_experiment(«DSPy») mlflow.dspy.autolog()
Затем давайте определим инструмент расчета. Мы дадим нашему агенту суперспособность выполнять код Python.
from dspy import PythonInterpreter def estimate_math(expr: str) -> str: # Выполняет Python и возвращает вывод в виде строки с PythonInterpreter() в качестве interp: return interp(expr)
Теперь у нас есть все необходимое для создания нашего агента. Как видите, определение агента DSPy лаконичное и простое.
react_model = dspy.ReAct( signature=»question -> answer: int», tools=[evaluate_math] ) response = react_model(question=»»»У меня есть 25 разных мячей, и я случайным образом выбираю 9. Сколько возможных комбинаций мячей я могу получить?»»») print(response.answer) # 2042975
Благодаря математическим возможностям мы получили правильный ответ. Давайте посмотрим, как агент пришел к этому ответу.
print(response.trajectory) # {'thought_0': 'Чтобы найти количество возможных комбинаций мячей, которые я могу получить, нам нужно вычислить количество комбинаций 9 мячей из набора из 25.', # 'tool_name_0': 'evaluate_math', # 'tool_args_0': {'expr': 'math.comb(25, 9)'}, # 'observation_0': 'Ошибка выполнения в estimate_math: nTraceback (most latest call last):n Файл «/Users/marie/Documents/github/llm_env/lib/python3.11/site-packages/dspy/predict/react.py», строка 89, в прямойn траектории[f»observation_{idx}»] = self.tools[pred.next_tool_name](**pred.next_tool_args)n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^n Файл «/Users/marie/Documents/github/llm_env/lib/python3.11/site-packages/dspy/utils/callback.py», строка 326, в sync_wrappern return fn(instance, *args, **kwargs)n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^n Файл «/Users/marie/Documents/github/llm_env/lib/python3.11/site-packages/dspy/adapters/types/tool.py», строка 166, в __call__n result = self.func(**parsed_kwargs)n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^n Файл «/var/folders/7v/1ln722x97kd8bchgxpmdkynw0000gn/T/ipykernel_84644/1271922619.py», строка 4, в estimate_mathn return interp(expr)n ^^^^^^^^^^^^^^n Файл «/Users/marie/Documents/github/llm_env/lib/python3.11/site-packages/dspy/primitives/python_interpreter.py», строка 149, в __call__n return self.execute(code, variables)n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ndspy.primitives.python_interpreter.InterpreterError: NameError: [«name 'math' is not defined»]', # 'thought_1': 'Функция math.comb не определена. Нам нужно импортировать модуль math.', # 'tool_name_1': 'evaluate_math', # 'tool_args_1': {'expr': 'import math; math.comb(25, 9)'}, # 'observation_1': 2042975, # 'thought_2': 'Нам нужно импортировать математический модуль перед использованием его функции comb.', # 'tool_name_2': 'evaluate_math', # 'tool_args_2': {'expr': 'import math; math.comb(25, 9)'}, # 'observation_2': 2042975, # 'thought_3': 'Нам нужно импортировать математический модуль перед использованием его функции comb.', # 'tool_name_3': 'evaluate_math', # 'tool_args_3': {'expr': 'import math; math.comb(25, 9)'}, # 'observation_3': 2042975, # 'thought_4': 'Нам нужно импортировать математический модуль перед использованием его функции comb.', # 'tool_name_4': 'evaluate_math', # 'tool_args_4': {'expr': 'import math; math.comb(25, 9)'}, # 'observation_4': 2042975}
В целом траектория имеет смысл. LLM правильно попытался вычислить количество комбинаций с помощью math.comb(25, 9) . Я не знал, что такая функция существует, так что это была победа. Однако изначально он забыл импортировать математический модуль, что привело к сбою выполнения. На следующей итерации он исправил код Python и получил результат. Однако по какой-то причине он повторил то же самое действие еще три раза. Не идеально, но мы все равно получили наш ответ.
Поскольку мы включили MLFlow, мы также можем просматривать полный журнал выполнения агента через пользовательский интерфейс. Это часто удобнее, чем читать траекторию в виде простого текста.

Наконец, мы успешно создали приложение, которое может точно отвечать на вопросы по комбинаторике, и попутно изучили основы DSPy. Теперь пришло время перейти к реальным бизнес-задачам.
Тематическое моделирование NPS
Поскольку мы рассмотрели основы, давайте рассмотрим реальный пример. Представьте, что вы аналитик продукта в компании, торгующей модной одеждой, и ваша задача — выявить наиболее существенные болевые точки клиентов. Компания регулярно проводит опрос NPS, поэтому вы решаете основывать свой анализ на комментариях критиков NPS.
Вместе с вашей командой по продукту вы рассмотрели кучу комментариев NPS, просмотрели предыдущие исследования клиентов и провели мозговой штурм списка ключевых проблем, с которыми могут столкнуться клиенты в продукте. В результате вы определили следующие ключевые темы:
- Медленная или ненадежная доставка,
- Неточное описание продукта или фотографии,
- Ограниченная доступность размера или оттенка,
- Неотзывчивая или стандартная служба поддержки клиентов,
- Ошибки веб-сайта или приложения,
- Запутанные системы лояльности или скидок,
- Сложные возвраты или обмены,
- Таможенные и импортные сборы,
- Трудное обнаружение продукта,
- Поврежденные или неправильные товары.
У нас есть список гипотез, и теперь нам просто нужно понять, какие проблемы чаще всего упоминают клиенты. К счастью, с LLM нет необходимости тратить часы на чтение комментариев NPS самостоятельно. Мы будем использовать DSPy для моделирования тем.
Давайте начнем с определения подписи. Модель получит комментарий NPS в качестве входных данных, и мы ожидаем, что она вернет одну или несколько тем в качестве выходных данных. С точки зрения объявления, выходными данными будет массив строк из предопределенного списка. Поскольку этот вариант использования немного сложнее, мы будем использовать для этой задачи подпись на основе класса.
Нам нужно создать класс, который наследует от dspy.Signature . Этот класс должен включать docstring, который будет использоваться совместно с моделью в качестве ее цели. Нам также нужно определить поля ввода и вывода, а также их соответствующие типы.
from typing import Literal, List class NPSTopic(dspy.Signature): «»»Классифицировать темы NPS»»» comment: str = dspy.InputField() answer: List[Literal['Медленная или ненадежная доставка', 'Неточные описания или фотографии продукта', 'Ограниченная доступность размера или оттенка', 'Сложный поиск продукта', 'Неотзывчивая или общая служба поддержки клиентов', 'Ошибки веб-сайта или приложения', 'Запутанные системы лояльности или скидок', 'Сложные возвраты или обмены', 'Таможенные и импортные сборы', 'Поврежденные или неправильные товары']] = dspy.OutputField()
Следующий шаг — определить модуль. Поскольку нам не нужны никакие инструменты, я буду использовать подход с подсказками в виде цепочки мыслей.
nps_topic_model = dspy.ЦепьМыслей(NPSTopic)
Вот и все. Можем попробовать. На основе одного примера модель работает довольно хорошо.
response = nps_topic_model( comment = «»»Полное разочарование! Каждый раз, когда я нахожу что-то, что мне нравится, оно распродается в моем размере. Какой смысл иметь список желаний, если ничего никогда не бывает в наличии?»»») print(response.answer) # [«Ограниченная доступность размера или оттенка»]
Вы можете удивиться, почему мы обсуждаем такую простую задачу. Нам потребовалось всего 2 минуты, чтобы построить прототип. Это правда, но цель здесь — увидеть, как оптимизация DSPy работает на практике, используя этот пример.
Оптимизация — одна из выдающихся особенностей фреймворка. DSPy может автоматически настраивать веса модели и корректировать инструкции для оптимизации в соответствии с указанными вами критериями оценки.
Доступно несколько оптимизаторов DSPy:
- Автоматическое обучение с несколькими попытками (например, BootstrapFewShot или BootstrapFewShotWithRandomSearch) автоматически выбирает лучшие примеры и добавляет их в подпись, реализуя подсказку для обучения с несколькими попытками.
- Автоматическая оптимизация инструкций ( например, MIPROv2) может одновременно корректировать инструкции и выбирать примеры для обучения за несколько попыток.
- Автоматическая тонкая настройка (например, BootstrapFinetune) корректирует веса языковой модели.
В этой статье я сосредоточусь исключительно на оптимизации инструкций. Я решил начать с оптимизатора MIPROv2 (что означает «Multiprompt Instruction Proposal Optimizer Version 2»), поскольку он может одновременно настраивать подсказки и добавлять примеры. Для получения более подробной информации ознакомьтесь со статьей «Optimizing Instructions and Demonstrations for Multi-Stage Language Model Programs» Опсахла-Онга и др.
Если вам интересен пример тонкой настройки, вы можете найти его в документации.
Для оптимизации нам понадобятся: программа DSPy (которая у нас уже есть), метрика и набор примеров (в идеале разделенный на обучающий и проверочный наборы).
Для обучающего набора я синтезировал 100 примеров комментариев NPS с метками. Давайте разделим их на обучающий и проверочный наборы.
trainset = [] valset = [] для rec в nps_data: если random.random() <= 0.5: trainset.append( dspy.Example( comment = rec['comment'], answer = rec['topics'] ).with_inputs('comment') ) иначе: valset.append( dspy.Example( comment = rec['comment'], answer = rec['topics'] ).with_inputs('comment') )
Теперь давайте определим функцию, которая будет вычислять метрику. Нам нужна пользовательская функция, поскольку функция по умолчанию dspy.evaluate.answer_exact_match не работает с массивами.
def list_exact_match(example, pred, trace=None): «»»Пользовательская метрика для сравнения списков тем»»» try: pred_answer = pred.answer expected_answer = example.answer # Преобразовать в наборы для сравнения, не зависящего от порядка if isinstance(pred_answer, list) and isinstance(expected_answer, list): return set(pred_answer) == set(expected_answer) else: return pred_answer == expected_answer except Exception as e: print(f»Ошибка в метрике: {e}») return False
Теперь у нас есть все необходимое для начала процесса оптимизации.
tp = dspy.MIPROv2(metric=list_exact_match, auto=»light», num_threads=24) opt_nps_topic_model = tp.compile(nps_topic_model, trainset=trainset, valset=valset, require_permission_to_run = False, provide_traceback=True)
Я выбрал auto = «light», чтобы сохранить небольшое количество итераций, но даже при такой настройке оптимизация может занять довольно много времени (60–90 минут).
Как только все будет готово, мы сможем запустить обе модели на проверочном наборе и сравнить их результаты.
tmp = [] для e в tqdm.tqdm(valset): comment = e.comment prev_resp = nps_topic_model(comment = comment) new_resp = opt_nps_topic_model(comment = comment) tmp.append( { 'comment': comment, 'sot_answer': e.answer, 'prev_answer': prev_resp.answer, 'new_answer': new_resp.answer } )
Мы добились значительного улучшения точности: с 62,3% до 82%. Это действительно крутой результат.
Давайте сравним подсказки, чтобы увидеть, что изменилось. Оптимизатор обновил цель с высокоуровневой «Классифицировать темы NPS», которую мы изначально определили, на более конкретную: «Классифицировать комментарии клиентов, связанные с проблемами онлайн-покупок, такими как ошибки веб-сайта, доступность продукта и неточные описания, в соответствующие темы NPS». Кроме того, алгоритм выбрал пять примеров для включения в подсказку.

Я попробовал более простую версию оптимизатора (BootstrapFewShotWithRandomSearch), которая только добавляет примеры в подсказку, и она достигла примерно тех же результатов, точности 77%. Это говорит о том, что подсказка с небольшим количеством подсказок является основным фактором повышения точности.
tp_v2 = dspy.BootstrapFewShotWithRandomSearch(list_exact_match, num_threads=24, max_bootstrapped_demos = 10) opt_v2_nps_topic_model = tp_v2.compile(nps_topic_model, trainset=trainset, valset=valset)
Вот и все по теме моделирования. Мы достигли замечательных результатов, используя небольшую локальную модель и всего несколько строк кода.
Полный код вы можете найти на GitHub.
Краткое содержание
В этой статье мы изучили фреймворк DSPy и его возможности. Теперь пришло время подвести итоги кратким резюме.
- DSPy (Declarative Self-improving Python) — это модульная декларативная структура для создания приложений ИИ, разработанная DataBricks.
- Его основная философия — «Программирование, а не подсказки — LM». Таким образом, фреймворк поощряет вас создавать приложения с использованием структурированных строительных блоков, таких как модули или подписи, а не вручную созданных подсказок. Хотя мне очень нравится идея создания приложений LLM больше похожих на традиционное программное обеспечение, я настолько привык к подсказкам, что мне становится немного некомфортно отказываться от этого уровня контроля.
- Самая впечатляющая функция фреймворка — это, безусловно, оптимизаторы. DSPy позволяет автоматически улучшать конвейеры либо путем настройки подсказок (как корректировки инструкций, так и добавления оптимальных примеров с несколькими выстрелами), либо путем тонкой настройки весов языковой модели.
Большое спасибо за прочтение этой статьи. Надеюсь, эта статья была для вас познавательной.
Ссылка
Эта статья написана по мотивам краткого курса «DSPy: Build and Optimise Agentic Apps» от DeepLearning.AI.
Источник: towardsdatascience.com


























