Справедливое желание автоматизировать всё подряд заставляет чуть иначе взглянуть на использование языковых моделей для написания кода.
В этой статье мы напишем ИИ-агент на основе Gradle и Ollama. Этот ИИ-агент должен будет генерировать работающий программный код, основываясь только на промптах и написанном ранее коде. В конце статьи мы немного поэкспериментируем с написанием промптов для создания простейшего приложения, и, ретроспективно взглянув на проделанную работу, сделаем вывод, что писать легче — код или промпты.
Промпты мы будем хранить в виде текстовых файлов том же проекте, где и код. ИИ-кодогенерация будет рассматриваться нами как потенциальный этап компиляции. Такой подход есть реализация концепции Prompt As Code (PAC).
Что такое ИИ-агент?
ИИ-агент (AI-agent) это механизм, позволяющий языковой модели взаимодействовать с "окружающим миром". В кавычках — потому что понятие "окружающего мира" определяется предметной областью и гибкостью этого самого механизма.
Если мы хотим создать ИИ-агента, то в общем случае нам нужно решить три задачи:
предоставить инструменты (tool) для работы с "окружающим миром"
определять момент, когда модель хочет вызвать эти инструменты
научить языковую модель сообщать о том, что она хочет вызвать определённый инструмент (tool call) с определёнными параметрами
Первая задача банальна и зависит только от предметной области.
Вторая задача сводится к парсингу текста и может быть решена регулярными выражениями, вызовами substring(..)
или чем-то подобным.
А вот третья задача намного интереснее. Собирать обучающую выборку и дообучать модель представляется слишком сложным и недостаточно гибким решением (придётся переобучать модель при добавлении или изменении вызываемых инструментов), поэтому мы попробуем договориться с моделью. Например вот так.
Первое сообщение чата. Пользовтаель описывает возможные команды и ставит модели задачу.
Тебе доступно несколько команд. Каждая команад начинаается с `@@@`. Вот исчерпывающий список команд:
`@@@read(file_name)` - чтение содержимого файла file_name
```@@@write_begin(file_name)
content
@@@write_end
``` - запись текста content в файл file_name
`@@@done` - все поставленные задачи выполнени.
Используя только эти команды, не давай никаких пояснений и комментариев.
При запуске Main.kt происходит NullPoinerException. Исправь ошибку в этом файле.
Второе сообщение чата. Модель вызывает инструмент чтения фала.
@@@read(Main.kt)
Третье сообщение чата. Инструмент отвечает модели.
fun main(){
val s : String? = null
println(s!!.length)
}
Четвёртое сообщение чата. Модель нашла ошибку и вызывает инструмент записи в файл и тут же сообщает что она закончила работу.
@@@write_begin(Main.kt)
fun main() {
val s : String? = null
if(s!=null) {
println(s.length)
} else {
println("String is null")
}
}
@@@write_end
@@@done
Да, это реальный диалог с моделью и именно так оно и работает (пусть и с оговорками). Но очевидно, что такие вызовы носят вероятностный характер. Модель может решить не вызывать функцию либо ошибиться в вызове. Об этом мы поговорим позже.
К счастью, существует более формализованный и стандартизированный формат для вызова инструментов - tool / function calling и модели, которые его поддерживают. Принцип работы такой же, и вызовы по-прежнему носят вероятностный характер, но мы будем придерживаться этого формата для уменьшения количества изобретаемых велосипедов.
Проблема и постановка цели
Использования ИИ для написания кода подразумевает два основных сценария.
Первый сценарий это умное автодополнение. Здесь ИИ на основе уже написанного кода делает попытку предположить, что мы собирается написать дальше. В итоге мы всё равно пишем код, разве что чуть быстрее.
Второй сценарий это режим чата. В этом случае ИИ имеет доступ к какой-то части проекта и выступает в качестве советчика. Программист может прислушаться к советам и скопипастить код из чата в среду разработки. Своего рода интерактивный вариант StackOwerflow. Так мы пишем кода чуть меньше, но зато пишем промпты, ещё раз пишем промпты, уточняем промпты, копипастим и адаптируем ответы.
А что если предложить ещё один сценарий? ИИ будет иметь доступ к проекту, сможет создавать и изменять файлы с исходным кодом, а действовать он будет на основе заранее поставленных задач и уже имеющего кода (прямо как настоящий, "мясной" программист). За разработчиком же останется задача контролировать действия ИИ и направлять его, ставя новые задачи и меняя формулировки существующих. Так мы чуть удалимся от непосредственного написания кода и приблизимся к постановке задач исполнителю, минуя тривиальные операции типа копипаста ответа ИИ из окна чата. Если максимально развить эту мысль, то ИИ-кодогенерация может стать одной из стадий компиляции, а в репозитории вместо исходных кодов будут храниться запросы к ИИ (промпты и сиды, например).
Таким образом, третьим сценарием будет итеративная ИИ-кодогенерация на основе цепочки промптов. Попробуем это реализовать.
Решение
Общая схема решения следующая.

Здесь "Исходный код" следует рассматривать как артефакт, а не как выполняемые инструкции.
Для начала кодогенерации запускается гредловая таска. Она берёт следующий промпт из хранилища промптом (это может быть, например, отдельный каталог в проекте), преобразует его в запрос и отправляет в Ollama. Ollama, при необходимости загрузив языковую модель, предлагает ей запрос, оборачивает ответ и возвращает его в гредловую таску. В зависимости от решения модели, в этом ответе может быть какое-то количество вызовов инструментов (чтение из файла, запись в файл и др). Если такой вызов есть, таска выполняет его и, вместе с историей запросов и ответов, снова отправляет в Ollama. Если вызовов инструментов нет, промпт считается обработанным и можно переходить к следующему или завершить работу.
Теперь рассмотрим каждый из компонентов решения подробнее.
Gradle
Система сборки Gradle хорошо знакома многим разработчикам, поэтому нет смысла на ней подробно останавливаться.
Отмечу только что она позволяет создавать и гибко настраивать таски (task). Таска гредла - это атомарная единица сборки проекта. Таски можно связывать между собой, устанавливать последовательности вызовов и т.п. Входными парамтерами таски могут быть как переменные, так и объекты файловой системы (файлы и каталоги). Выходными - только файлы и каталоги.
Ollama
Ollama - инструмент для локального запуска языковых моделей. В этом инструменте привлекают три момента. Во-первых, есть сборки под разные ОС. Во-вторых, есть собственный репозиторий моделей. В-третьих, Ollama предоставляет простое (хотя и не до конца документированное, но когда это было проблемой?) API.
Именно то, что нужно для решения нашей задачи. Нулевым пунктом отмечу что на моём маке Ollama запустилась буквально в пару кликов и не потребовала дополнительных настроек.
Языковая модель
Языковую модель я выбирал на основе таких требований:
Она должна из коробки работать через Ollama, без дополнитиельных настроек
Это должна быть специальная модель для работы с кодом.
В репозитории Ollama должно быть достаточно много загрузок, ведь миллионы пользоватаелей не могут ошибаться
Запросы должны выполняться за разумное время на моём личном маке (i7 2.6GHz, 32 MB RAM)
Поэтому, не долго думая, я выбрал qwen2.5-coder:7b
Реализация
Реализация достаточно простая. Здесь я остановлюсь только на основных моментах.
Мы разрабатываем кастомную таску гредла, поэтому исходный код лежит в каталоге buildSrc
. Там же файл build.gradle.kts
, в котором определены зависимости. Для http-запросов используется ktor, а для сериализации и десериализации json - kotlinx-serialization. Подключим эти зависимости:
implementation("io.ktor:ktor-client-core:$ktor_version")
implementation("io.ktor:ktor-client-cio:$ktor_version")
implementation("io.ktor:ktor-client-content-negotiation:$ktor_version")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
Кастомная таска у нас будет иметь следующие входные параметры:
файл с промптом
имя модели
сид
максимальное число запросов модели
Файл с промптом представляет собой простой текстовый файл, содержимое которого передаётся в модель как есть.
Сид это целое число. Он указывается явно для воспроизводимости результата.
Максимальное число запросов нужно указать чтобы при неожиданном ответе модели таска не попала в бесконечный цикл.
Выходным параметром будет каталог для генерации кода.
abstract class AiCodegenTask : DefaultTask() {
@get:InputFile
abstract val promptFile: RegularFileProperty
@get:Input
abstract val model: Property<String>
@get:Input
abstract val seed: Property<Int>
@get:Input
abstract val maxSteps: Property<Int>
@get:OutputDirectory
abstract val workingDirectory: DirectoryProperty
@TaskAction
fun execute() {
...
}
}
В методе execute
такски запускаем цикл while
. Условие выхода — либо превысили максимальное число запросов модели, либо в следующем ответе модели нет вызовов инструментов (модель сделала всё что хотела).
Накапливаем историю сообщений чата с моделью. Начальное состояние — в истории только промпт. Каждый новый ответ модели и каждый ответ инструмента сохраняем в историю.
var working = true
while (working) {
val response = ollamaService.send(history)
history.add(response)
val toolCalls = response.toolCalls
if (toolCalls.isNotEmpty()) {
val content = toolCalls.joinToString(separator = "\n\n") { toolCall ->
toolService.process(toolCall)
}
history.add(CodegenMessage.ToolMessage(content))
} else {
working = false
}
step++
if (step > maxSteps.get()) {
working = false
}
}
Теперь самое интересное. Функции для вызова инструментов описаны следующим образом:
private val TOOL_READ_FILE = OllamaRequest.Tool(
type = TYPE_FUNCTION,
function = OllamaRequest.Tool.Function(
name = NAME_READ_FILE,
description = "Прочитать содержимое файла",
parameters = OllamaRequest.Tool.Function.Parameters(
type = TYPE_OBJECT,
properties = mapOf(
PROPERTY_FILE_NAME to OllamaRequest.Tool.Function.Parameters.Property(
type = TYPE_STRING,
description = "Имя файла, который следует прочитать"
)
),
required = listOf(
PROPERTY_FILE_NAME
)
)
)
)
private val TOOL_WRITE_FILE = OllamaRequest.Tool(
type = TYPE_FUNCTION,
function = OllamaRequest.Tool.Function(
name = NAME_WRITE_FILE,
description = "Записать содержимое в файл",
parameters = OllamaRequest.Tool.Function.Parameters(
type = TYPE_OBJECT,
properties = mapOf(
PROPERTY_FILE_NAME to OllamaRequest.Tool.Function.Parameters.Property(
type = TYPE_STRING,
description = "Имя файла, в который следует выполнить записать",
),
PROPERTY_CONTENT to OllamaRequest.Tool.Function.Parameters.Property(
type = TYPE_STRING,
description = "Содержимое, которое следует записать в файл"
)
),
required = listOf(
PROPERTY_FILE_NAME,
PROPERTY_CONTENT
)
)
)
)
Самое важно здесь это значение полей description
. Да, такого текстового описания для модели достаточно (с оговорками, конечно, но об этом ниже) для того чтобы понять какую функцию, с какими параметрами и в какой момент вызывать. Да, выглядит непривычно, но это работает.
Для запуска в build.gradle.kts
приложения (обычно это app
, не путать с buildSrc/build.gradle.kts
или с build.gradle.kts
в корне проекта) зарегистрируем таску:
tasks.register("codegen_01_view"){
promptFile = layout.projectDirectory.file("prompts/step_01_view.txt")
model = "qwen2.5-coder:7b"
seed = 123
maxSteps = 5
workingDirectory = layout.projectDirectory.dir("src/ai-gen/kotlin")
}
Файл prompts/step_01_view.txt
— текстовый файл с нашим промптом.
В каталог src/ai-gen/kotlin
будут складываться сгенерированные файлы. Этот каталог зарегестрирован как каталог с исходниками в том же build.gradle.kts
:
java.sourceSets["main"].java {
srcDir("src/ai-gen/kotlin")
}
qwen2.5-coder:7b
- наша модель. Предварительно её нужно установить, выполнив в терминале
ollama pull qwen2.5-coder:7b
После этого кодогенерацию можно запустить, выполнив
./gradlew app:codegen_01_view
Остальной код достаточно банален и состоит из http-запросов, парсинга json и маппинга моедлей. Ознакомиться с ним можно на GitVerse.
Использование и особенности написания промтов
Не хотелось бы так говорить, но в поведении рассмотренной здесь языковой модели чувствуется некоторая логика. Состоит она в буквальном понимании языковых конструкций.
Например, для "мясного" программиста просьба "напиши класс" означает что нужно создать файл и уже в нём написать нужный класс. Понятия "напиши класс" и "создай файл" для нас неразрывно связаны нашим профессиональным опытом.
Моделью же промпт "напиши класс" понимается буквально и приводит к выводу листинга класса в сообщении чата, но не вызовет инструмент записи в файл. Промпт "Напиши программу и запиши её в файл" будет понят как два действия, и не факт что они будут одним сообщением. И только "Запиши в файл исходный код класса" приведёт к вызову инструмента записи в файл с нужными параметрами.
Не всегда модель соглашается читать файлы. Возможно срабатывает какая-то внутреняя цензура. Например на запрос
При запуске файла `ai-gen/kotlin/Main.kt` я получаю ошибку `NullPointerException`.
Исправь этот файл так, чтобы не было ошибки.
я получил ответ
Я не могу просматривать внешние файлы.
Однако запрос
При запуске файла `Main.kt` я получаю ошибку `NullPointerException`.
Этот файл лежит в каталоге `ai-gen/kotlin/`
Исправь этот файл так, чтобы не было ошибки.
сработал как ожидается. Сначала был вызван инструмент чтения из файла, потом инструмент записи в файл. При этом модель корреткно конкатенировала имя файла и путь к нему.
Иногда ответ модели приходит как бы корреткный, но в виде json или xml. Не понятно, то ли не сработал прасер Ollama, то ли модель добавила в ответ какие-то символы, так что ответ перестал быть валидным json или xml. А может быть модель придумала такой формат только что.
В случае неподходящего ответа часто смена сида помогала получить ответ правильный. Так же помогала переформулировка запроса.
После многих попыток мне удалось составить цепочку промптов, которая привела к написанию простого приложения: сначала запрос имени пользователыя, а затем вывод на экран приветствия с этим именем. Программа состоит из четырёх файлов:
View.kt
- интерфейс представления. Есть метод вывода привествия по данному имени пользвоватя и метод запроса имени пользователяViewImpl.kt
- реализация представленияPresenter.kt
- презентер. В конструктор инжектится интерфейс представленияMain.kt
- основной класс с методом main
. Здесь происходит создание инстансов представления и презентера, инжект предсталвения в презентер и вызов метода презентера.
Имена файлов я задал в промптах, а всё их содержимое полностью создано моделью.
Заключение
Можно сказать что создать ИИ-агент получилось. Он может читать файлы, создавать новые и редактировать существующие. Код, создаваемый ИИ-агентом, обычно компилируется и соотвествует запросу.
В принципе, писать код с помощью одних только промптов можно, но есть пара существенных оговорок.
Языковая модель понимает запросы очень буквально. Если о чём-то не сказано явно, то она попытается это сделать, руководствуясь какой-то своей логикой, и скорее всего это не будет соответствовать цели пользователя.
Исходя из п.1, промпты приходится писать очень подробно. Если результат не совпадает с ожиданиями, то промпт приходится переделывать "на ощупь", без понимания как именно изменениня повлияют на результат.
Выполнение ИИ-кодогенерации на локальной машине занимает заметное время. Но вряд ли это будте проблемой через два-три года, когда и вычислительные мощности вырастут и модели станут быстрее.
Самым неприятным мне показался пункт 2. Запросы приходится писать так подробно, что это уже почти что пересказ кода своими словами (что, очевидно, более трудоёмко, чем просто написать код). Возможно, имеет смысл задействовать оркестр моделей. Чтобы первая модель получала слабо формализованную задачу и разбивала её на подзадачи; вторая разбивала подзадачу на классы; третья писала каждый отдельный класс и т.п. Но это уже тема для отдельной статьи.
Исходные коды к статье доступны на GitVerse https://gitverse.ru/ilichme/CodegenAiAgent