Написание промптов — это искусство общения с генеративной ИИ-моделью. В этой статье мы расскажем о том, как мы в GitHub подходим к составлению промптов и как можно использовать эти принципы для создания собственного приложения на основе LLM.
В 2011 году в одной своей публикации Марк Андриссен предупредил: «Программы поглощают мир». Спустя более десятка лет мы наблюдаем появление нового типа технологий, которые поглощают мир с ещё большей скоростью: генеративный искусственный интеллект. Этот инновационный искусственный интеллект включает в себя уникальный класс больших языковых моделей (англ. large language models, LLM), созданных в результате десятилетия новаторских исследований, которые способны превзойти человека в решении определённых задач. И вам не нужно иметь докторскую степерь в области машинного обучения, чтобы создавать программы с использованием LLM. Разработчики уже создают программы с LLM, используя базовые HTTP-запросы и промпты на естественном языке.
В этой статье мы расскажем о работе GitHub с LLM, чтобы помочь другим разработчикам узнать, как лучше использовать эту технологию. Статья состоит из двух основных частей: в первой мы высокоуровнево расскажем о том, как функционируют LLM и как создавать приложения на основе LLM. Во второй части мы рассмотрим пример такого приложения: автодополнение кода от GitHub Copilot.
Не можем не поделиться, что была проделана впечатляющая работа по каталогизации нашей работы. Теперь мы расскажем о некоторых идеях, которые привели GitHub Copilot к успеху.
Давайте начнём.
Всё, что вам нужно знать о составлении промптов, в 1600 токенах или меньше
Знаете, когда вы набираете текстовое сообщение на смартфоне, а в центре экрана, прямо над клавиатурой, есть кнопка, нажав на которую вы можете принять предложенное следующее слово? Именно это и делает LLM — но гораздо в больших масштабах.
LLM работает над предсказанием следующей лучшей группы букв, которые называются токенами. И точно так же, как вы можете принимать предложенные варианты слов, чтобы завершить текстовое сообщение, LLM завершает документ, предсказывая следующее слово. Он будет продолжать делать это снова и снова, и остановится только тогда, когда достигнет максимального порога токенов или когда встретит специальный токен, сигнализирующий «Стоп! Это конец документа».
Однако здесь есть важное отличие. Языковая модель в вашем смартфоне довольно проста — по сути, она задаётся вопросом: «Основываясь на двух последних введённых словах, какое следующее слово будет наиболее вероятным?». В отличие от этого, LLM выдаёт результат, который больше похож на запрос потипу «Основываясь на полном содержании всех документов, когда-либо существовавших в открытом доступе, какой токен вероятнее всего будет следующим в документе?». Благодаря обучению такой большой и хорошо построенной модели на огромном наборе данных, может казаться, что LLM почти обладает здравым смыслом.
Пример осведомлённости или «здравого смысла» LLM, обусловленного его подготовкой.
Но будьте внимательны: LLM также иногда уверенно выдаёт информацию, которая не является реальной или правдивой, что обычно называется «галлюцинациями». Также может показаться, что LLM учатся делать то, чему их изначально не обучали. Исторически модели естественного языка создавались для решения разовых задач, таких как классификация тона текстового сообщения, извлечение бизнес-сущностей из электронного письма или выявление похожих документов. Теперь же вы можете попросить AI-инструменты выполнить какую-то задачу, которой они никогда не обучались.
Джон беседует с ChatGPT о серьёзных вещах.
Создание приложений с использованием LLM
Каждый день появляется большое количество приложений, использующих LLM — от диаологового поиска, ассистентов по написанию текстов, автоматизированной ИТ-поддержки до инструментов для автодополнения кода, таких как GitHub Copilot. Но как возможно, чтобы все эти инструменты появились на основе того, что фактически является инструментом для автодополнения текстов? Секрет в том, что любое приложение, использующее LLM, на самом деле сопоставляет две области: область пользователя и область документа.
Диаграмма пути пользователя при взаимодействии с LLM, в данном случае — путь пользователя Дэйва.
Слева находится пользователь. Его зовут Дэйв, и у него есть проблема. Сегодня у него запланирован совместный просмотр по случаю чемпионата мира по футболу, а Wi-Fi не работает. И если его не починят в ближайшее время, он станет объектом шуток своих друзей на долгие годы. Дэйв звонит своему интернет-провайдеру, а ему отвечает голосовой помощник. Уфф! Но представьте, что мы реализовываем автоматизированного помощника как LLM приложение. Сможем ли мы ему помочь?
Главное здесь — понять, как преобразовать область пользователя в область документа. Во-первых, нам нужно будет транскрибировать речь пользователя в текст. Как только агент автоматизированной службы поддержки сказал: «Пожалуйста, опишите характер вашей чрезвычайной ситуации, связанной с кабелем», Дэйв пролепетал:
«Это ужасно! Финал Кубка мира. Мой телевизор был подключен к Wi-Fi, я внезапно ударился о стойку, и роутер упал и сломался! Теперь мы не можем посмотреть игру».
На данный момент у нас есть текст, но толку от него мало. Возможно, вы представите, что это часть истории, и продолжите её: «Наверное, я позвоню брату и узнаю, сможем ли мы посмотреть игру у него». LLM без контекста точно так же создаст продолжение истории Дэйва. Итак, давайте дадим LLM некоторый контекст и определим, что это за документ:
### Запись беседы ИТ-поддержки интернет-провайдера:
Ниже приводится запись разговора между клиентом интернет-провайдера Дэйвом Андерсоном и Джулией Джонс, специалистом по ИТ-поддержке. Эта расшифровка служит примером отличной поддержки, предоставляемой Comcrash своим клиентам.
*Дэйв: Это ужасно! Финал Кубка мира. Мой телевизор был подключен к Wi-Fi, я внезапно ударился о стойку, и роутер упал и сломался! Теперь мы не можем посмотреть игру.
*Джулия:
Теперь, если бы вы нашли этот псевдодокумент, как бы вы его дополнили? Исходя из дополнительного контекста, вы бы знали, что Джулия — специалист ИТ-поддержки, и, судя по всему, очень хороший. Вы бы ожидали, что вслед за запросом Дейва последует мудрый совет, который поможет ему решить проблему. Неважно, что Джулии не существует, и это не записанный разговор — важно то, что эти дополнительные слова дают больше контекста для того, чтобы понять, как может выглядеть завершение разговора. LLM делает то же самое. Прочитав этот неполный документ, он сделает всё возможное, чтобы завершить реплику Джулии наилучшим образом.
Но мы можем сделать ещё больше, чтобы создать лучший документ для LLM. LLM не так уж много знает об устранении неисправностей кабельного телевидения. (Хоть он и прочитал все руководства и документы по информационным технологиям, когда-либо опубликованные в Интернете). Давайте предположим, что ему не зватает знаний именно в этой области. Мы можем найти дополнительный контент, который может помочь Дэйву, и поместить его в документ. Представим, что у нас есть поисковая система, позволяющая находить документацию, которая уже помогала в подобных ситуациях в прошлом. Теперь нам остаётся только вплести эту информацию в наш псевдодокумент в естественном месте.
Продолжение разговора:
*Джулия: (роется в своем портфеле и достаёт идеальную документацию для запроса Дэйва)
Общие проблемы с подключением к Интернету ...
<...здесь мы вставляем 1 страницу текста, полученного в результате поиска по нашей базе данных истории поддержки клиентов...
(Прочитав документ, Джулия даёт следующую рекомендацию)
* Джулия:
Теперь, получив этот полный текст, LLM обуславливается использовать документацию, и в контексте «отзывчивого эксперта по информационным технологиям» модель генерирует ответ. Этот ответ учитывает документацию, а также конкретный запрос Дэйва.
Последний шаг — переход из области документов в область проблем пользователя. Для данного примера это означает просто преобразование текста в голос. А поскольку это фактически чат-приложение, мы будем несколько раз перемещаться назад и вперёд между пользователем и областью документов.
В основе примера лежит написание промптов. В данном случае мы создали промпт с достаточным контекстом, чтобы искусственный интеллект мог выдать наилучший результат, который в данном случае заключался в предоставлении Дэйву полезной информации для возобновления работы его Wi-Fi. В следующем разделе мы рассмотрим, как мы в GitHub усовершенствовали методы разработки промптов для GitHub Copilot.
Искусство и наука написания промптов
Мы работаем над GitHub Copilot уже более двух лет, поэтому успели выявить некоторые закономерности в этом процессе.
Эти закономерности помогли нам формализовать пайплайн. Мы считаем, что это вполне применимый шаблон, который поможет другим лучше подойти к составлению промптов для своих собственных приложений. Сейчас мы продемонстрируем, как работает этот пайплайн, рассмотрев его в контексте GitHub Copilot.
Пайплайн написания промптов для GitHub Copilot
С самого начала LLM GitHub Copilot строились на основе ИИ-моделей от OpenAI, которые постоянно становились всё лучше и лучше. Но что оставалось неизменным, так это ответ на главный вопрос: какой документ пытается дописать модель?
Используемые нами модели OpenAI были обучены на полных файлах кода на GitHub. Если не принимать во внимание некоторые шаги по фильтрации и стратификации, которые не сильно меняют игру по разработке промптов, это распределение соответствует содержимому отдельных файлов в соответствии с последним коммитом в main на момент сбора данных.
Проблема дописывания документов, которую решает LLM, связана с кодом, а задача GitHub Copilot — с завершением кода. Но эти две задачи очень разные.
Вот несколько примеров:
- Большинство файлов, зафиксированных в main, являются завершёнными. Во-первых, они обычно компилируются. Большую часть времени, пока пользователь набирает текст, код не компилируется из-за недоработок, которые будут исправлены до push’а коммита.
- Пользователь может даже писать код в иерархическом порядке: сначала сигнатуры методов, затем тело; а не построчно или в смешанном стиле.
- Написание кода означает перемещение туда-сюда. В частности, правки часто требуют перемещения по документу в верхнюю часть для внесения там изменений — например, для добавления параметра в функцию. Строго говоря, если Codex предлагает использовать функцию, которая еще не была импортирована, независимо от того, сколько смысла в ней может быть, это ошибка. Но как предложение (рекомендация) от GitHub Copilot это было бы полезно.
Проблема в том, что простое предсказание наиболее вероятного продолжения на основе текста перед курсором для создания предложения GitHub Copilot будет напрасной тратой времени. Потому что при этом игнорируется невероятное богатство контекста. Мы можем использовать этот контекст, чтобы направить предложение, например — метаданные, код, содержимое импорта, остальная часть репозитория или ошибки — и создать сильный промпт для ИИ-помощника.
Разработка программного обеспечения — это комплексная, мультимодальная задача, и чем больше этой сложности мы сможем передать модели, тем лучше будут результаты её работы.
Шаг 1: Сбор контекста
GitHub Copilot живёт в контексте IDE, такой как Visual Studio Code (VS Code), и он может использовать всё, что IDE может ему сообщить, — но только если IDE не торопится с этим. В такой интерактивной среде, как GitHub Copilot, важна каждая миллисекунда. GitHub Copilot обещает позаботиться об обычных задачах кодирования, и если он хочет это сделать, ему нужно показать разработчику своё решение до того, как он начнёт писать код в IDE. Наша грубая эвристика говорит, что за каждые дополнительные 10 миллисекунд, которые мы тратим на то, чтобы придумать предложение, вероятность того, что оно придёт вовремя, уменьшается на один процент.
Итак, что же мы можем сказать быстро? Вот пример. Рассмотрим это предложение для простого фрагмента кода на Python:
Неправильно! Оказалось, что на самом деле пользователь хотел написать на языке Ruby вот так:
Синтаксис этих двух языков достаточно схож, поэтому всего пара строк может быть неоднозначной, особенно если они находятся в начале файла, где мы сталкиваемся в основном с шаблонными комментариями. Но современные IDE, такие как VS Code, обычно знают, на каком языке пишет пользователь. Это делает языковые путаницы особенно раздражающими для пользователя, поскольку они нарушают неявное ожидание того, что «компьютер должен знать» (в конце концов, большинство IDE имеют подсветку синтаксиса языка).
Итак, давайте поместим метаданные о языке в нашу кучу контекста, который мы, возможно, захотим включить. На самом деле, давайте добавим и всё имя файла. Если оно доступно, то обычно подразумевает язык через расширение, а также задаёт тон тому, что можно ожидать от этого файла — небольшие, простые кусочки информации, которые не перевернут ход событий, но которые полезно включить.
На другом конце спектра находится остальная часть репозитория. Скажем, у вас есть файл, определяющий абстрактный класс
DataReader
. В другом файле определён подкласс CsvReader
. И теперь вы пишете новый файл, определяющий ещё один подкласс SqlReader
. Скорее всего, чтобы написать новый файл, вы захотите ознакомиться с обоими существующими файлами, потому что в них содержится полезная информация о том, что и как вам нужно реализовать. Обычно разработчики держат такие файлы открытыми на разных вкладках и переключаются, чтобы напоминать себе об определениях, примерах, похожих паттернах или тестах.Если содержимое этих двух файлов полезно для вас, есть шанс, что оно будет полезно и для искусственного интеллекта. Так давайте добавим его в качестве контекста! В конце концов, IDE знает, какие ещё файлы из репозитория открыты как вкладки в том же окне. В репозитории могут быть сотни или даже тысячи файлов, но только некоторые из них будут открыты, и это намёк на то, что они могут быть полезны для того, что он делает в данный момент. Конечно, слово «некоторые» может означать очень многое, поэтому мы не рассматриваем больше 20 последних вкладок.
Шаг 2: Сниппетинг
Нерелевантная информация в контексте LLM снижает его точность. Кроме того, исходный код имеет тенденцию быть длинным, поэтому нет гарантии, что даже один файл полностью поместится в контекстное окно LLM (эта проблема возникает примерно в одном из пяти случаев). Поэтому, если только пользователь бережно не относится к использованию вкладок, мы просто не сможем включить все вкладки.
Важно избирательно подходить к выбору кода для включения из других файлов. Поэтому мы нарезаем файлы на (надеемся) естественные, перекрывающиеся сниппеты длиной не более 60 строк. Конечно, мы не хотим включать все перекрывающиеся сниппеты, поэтому мы их оцениваем и берём только лучшие. В данном случае «балл» должен отражать релевантность. Чтобы определить балл сниппета, мы используем сходство Жаккара — статистику, которую можно использовать для оценки сходства или разнообразия наборов образцов. (Также он быстро вычисляется, что удобно для сокращения времени ожидания).
Шаг 3: Одеваем их
Теперь у нас есть контекст, который мы хотели бы передать модели. Но как это сделать? Codex и другие модели не предлагают API, с помощью которого можно добавить другие файлы, указать язык документа и имя файла. Они представляют собой единый документ. Как уже говорилось ранее, вам нужно будет естественным путём внедрить свой контекст в этот документ.
Путь и имя могут быть самыми простыми. Многие файлы начинаются с преамбулы, в которой указываются некоторые метаданные, например — автор, название проекта или имя файла. Представим, что так и здесь, и добавим строку в самом начале, которая будет содержать что-то вроде
# filepath: foo/bar.py
или // filepath: foo.bar.js
, в зависимости от синтаксиса.Иногда путь неизвестен, например, в новых файлах, которые ещё не были сохранены. Но даже в этом случае мы можем попытаться хотя бы указать язык, если IDE об этом знает. Для многих языков у нас есть возможность включить строки shebang, например
#!/usr/bin/python
или #!/usr/bin/node
. Это хороший трюк, который защищает от ошибочной идентификации языка. Но он также немного опасен, поскольку файлы с shebang-строками являются необъективной подгруппой всего кода. Так что давайте использовать его для коротких файлов, где опасность ошибочной языковой идентификации высока, и избегать его для больших или именованных файлов.Если комментарии работают как система доставки крупиц информации, таких как путь или язык, мы также можем заставить их работать как системы доставки больших глубоких погружений, которые представляют собой 60 строк связанного кода.
Комментарии универсальны, и прокомментированный код существует по всему GitHub. Давайте рассмотрим некоторые из наиболее распространённых примеров:
- Старый код, который больше не применяется
- Удалённые функции
- Ранние версии текущего кода
- Пример кода, оставленный специально для документации
- Код, взятый из других частей кодовой базы
Давайте черпать вдохновение из последней группы примеров. Знакомство с группами (1) — (3) немного упрощает модель, но наши фрагменты направлены на эмуляцию групп (4) и (5):
# сравните этот сниппет из utils/concatenate.py:
# def crazy_concat(a, b):
# return str(a) + str(b)[::-1]
Обратите внимание, что включение имени файла и пути к источнику сниппета может быть полезным. В сочетании с путём к текущему файлу это может помочь в завершении работы над ссылками на импорт.
Шаг 4: Расстановка приоритетов
До сих пор мы брали множество фрагментов контекста из разных источников: текст непосредственно над курсором, текст под курсором, текст в других файлах, а также метаданные, такие как язык и путь к файлу.
В подавляющем большинстве случаев (около 95 %) нам приходится делать сложный выбор — что включать, а что нет.
Мы делаем этот выбор, подразумевая элементы, которые мы можем включить, как желаемое. Каждый раз, когда мы обнаруживаем фрагмент контекста, например закомментированный сниппет из открытой вкладки, мы загадываем желание. Желания имеют определённый приоритет, например, строки shebang имеют низкий приоритет. Сниппеты с низкой оценкой сходства едва ли выше. Напротив, строки непосредственно над курсором имеют максимально высокий приоритет. Пожелания также сопровождаются желаемым положением в документе. Строка shebang должна быть самой первой, а текст над курсором — последним, он должен непосредственно предшествовать завершению LLM.
Самый быстрый способ выбрать, какие желания нужно исполнить, а какие отбросить, — это отсортировать список желаний по приоритету. Затем мы можем продолжать удалять желания с наименьшим приоритетом, пока оставшееся не поместится в контекстное окно. Затем мы снова сортируем их по порядку в документе и вставляем всё вместе.
Шаг 5: Искуственный интеллект делает своё дело
Теперь, когда мы собрали информативный промпт, настало время для искусственного интеллекта придумать завершение. Здесь мы всегда сталкивались с очень деликатным компромиссом — GitHub Copilot должен использовать высокоэффективную модель, потому что качество делает разницу между полезным предложением и отвлекающим фактором. Но в то же время это должна быть модель, способная работать быстро, потому что задержка делает разницу между полезным предложением и невозможностью предоставить предложение вообще.
Так какой же искусственный интеллект выбрать для выполнения задания: самый быстрый или самый точный? Сложно сказать заранее, поэтому OpenAI совместно с GitHub разработал целый парк моделей. Мы предложили разработчикам две разные модели, но обнаружили, что наибольший эффект (в плане принятых и сохраненных завершений) получили от более быстрой модели. С тех пор дальнейшие оптимизации значительно ускорили работу модели, так что текущая версия GitHub Copilot опирается на ещё более мощную модель.
Шаг 6: Теперь к вам!
Генеративный искусственный интеллект создаёт строку, и если его не остановить, он продолжит её создавать и будет продолжать до тех пор, пока не предскажет конец файла. Это приведёт к потере времени и вычислительных ресурсов, поэтому нужно установить критерии остановки.
Наиболее распространённым критерием остановки является поиск первого разрыва строки. Во многих ситуациях кажется вероятным, что разработчик программного обеспечения хочет закончить текущую строку, но не более того. Но некоторые из самых волшебных действий GitHub Copilot — это когда он предлагает сразу несколько строк кода.
Многострочные завершения выглядят естественно, когда речь идёт об одном семантическом блоке, таком как тело функции, ветвь if или класс. GitHub Copilot ищет случаи, когда такой блок запускается, либо потому, что разработчик только что написал начало, например заголовок, if guard или объявление класса, либо пишет начало в данный момент. Если тело блока окажется пустым, он попытается сделать для него предложение и остановится только тогда, когда блок окажется завершённым.