Скрам ИИ

Скрам ИИ

[Журнал разработки Devlime] Представляем Scrum AI

1. Введение

При разработке с использованием Scrum написание элементов бэклога и пользовательских историй занимает больше времени, чем ожидаешь. Даже если встреча заканчивается словами: «Давайте добавим функцию бронирования в следующий спринт», разработчику всё равно предстоит ещё немало работы. Нужно привести в порядок заголовок, написать содержимое в формате As-A / I-Want / So-That, добавить критерии приёмки, а также указать story points и оценки ценности. Каждая из этих задач по отдельности кажется достаточно быстрой, но если повторять их около двадцати раз за каждый спринт, картина меняется.

Поэтому во время разработки Scrum-модуля Devlime мы решили сократить трудозатраты на этот процесс. Изначальная идея была простой: ввести требования, сгенерировать черновик UserStory и найти похожую выполненную ранее работу, чтобы использовать её в качестве основы для оценок. Мы ожидали, что это займёт около трёх недель.

На практике разработка пошла не так, как мы ожидали. Интеграция самого AI API не была сложной; большая часть проб и ошибок пришлась на решение о том, какие задачи следует делегировать AI. В этой статье я расскажу, с чего мы начали разработку функции, какие проблемы возникли и как мы изменили архитектуру.

2. Обоснование выбора технологий

2.1 Структурированный вывод

Первой функцией, которую мы разработали, была генерация черновика UserStory. Мы отправляли введённые пользователем требования в Claude и заполняли экран результатом, полученным в формате JSON. Сначала всё работало хорошо, но не всегда стабильно. Иногда перед ответом или после него добавлялись пояснительные предложения, вокруг JSON появлялись блоки кода Markdown или отсутствовали отдельные поля.

Изначально мы решали эту проблему, обрабатывая строку ответа напрямую. Мы постепенно добавляли код для удаления обратных кавычек, поиска места начала JSON, заполнения значений по умолчанию для отсутствующих полей и выброса исключения, если обработка всё ещё завершалась неудачно. В итоге код для восстановления ответа стал длиннее кода вызова AI. Мы пришли к выводу, что одного совершенствования промпта недостаточно, и нам нужен способ, позволяющий приложению самостоятельно контролировать формат вывода модели.

Поэтому мы применили структурированный вывод, предоставляемый Claude Java SDK. Вместо того чтобы получать строку и самостоятельно преобразовывать её, мы напрямую определяем DTO, используемый сервером, в качестве схемы ответа.

public class UserStoryDraftAiSchema {

    public boolean supported;

    public String rejectionReason;

    public List<TitleOption> titleOptions;

    public List<NarrativeOption> narrativeOptions;
}

Нам удалось удалить большую часть написанного ранее кода разбора и исправления ответов. Важнее сокращения объёма кода было то, что мы смогли управлять структурой данных, используемой сервером, и требуемой от модели структурой вывода как единым типом. Именно поэтому мы выбрали структурированный вывод для этой функции. Причиной было не то, что модель стала работать лучше, а то, что уменьшился объём ненужной обработки, которую приходилось выполнять приложению.

2.2 Выбор модели

Сначала мы использовали claude-opus-5, отдавая приоритет качеству. Сами результаты были хорошими, но при использовании модели на реальном экране проблемой стало время ответа. Каждый запрос занимал около 6–7 секунд, что было обременительно, учитывая, что функция должна была вызываться несколько раз во время планирования спринта.

Поэтому мы перешли на claude-haiku. Мы ожидали значительного снижения качества, но когда показали команде результаты обеих моделей и сравнили их, участники не смогли чётко различить разницу. Для этой функции требовалось упорядочить введённые требования в определённом формате, а не выполнять сложные рассуждения или творческую генерацию. В результате мы сократили и время ответа, и стоимость вызовов, подтвердив, что выбор модели, подходящей для конкретной функции, лучше простого выбора более мощной модели.

2.3 Подход к рекомендациям по оценкам

Именно в этой области направление изменилось сильнее всего. Изначально мы планировали просить Claude определить story points так же, как мы генерировали черновики. Результаты выглядели разумно, но мы не могли объяснить, почему задача получила 3 points. При повторной отправке одних и тех же требований результат мог составить 3 points в одном случае и 5 points — в другом. Даже снижение temperature для повышения стабильности результатов не давало оснований для такой оценки.

Оглядываясь назад, можно сказать, что это было очевидным результатом. Claude не знает, какую работу команда Devlime выполняла в течение последних нескольких месяцев и сколько points команда назначала этой работе. Story points — это относительные значения, оцениваемые по сравнению с выполненной командой работой в прошлом, а не значения с единственно правильным общепринятым ответом. Число без подтверждающих его данных бесполезно в качестве ориентира. Более того, поскольку оно отображается на экране первым, оно может стать основой для обсуждения.

Поэтому мы переопределили задачу. Вместо генерации оценки мы изменили подход: теперь система находит похожую выполненную ранее работу и отображает оценки, назначенные этим элементам.

3. Процесс реализации

3.1 Поиск вместо генерации

Мы векторизовали и сохранили заголовки завершённых прошлых BacklogItems с помощью embedding-модели Voyage AI. Когда поступают новые требования, мы аналогичным образом создаём для них embedding, сравниваем косинусное сходство с существующими данными и используем три наиболее похожих элемента в качестве рекомендаций.

[저장] 완료된 BacklogItem 제목 → Voyage Embedding → Vector 저장
[검색] 새로운 요구사항 → Voyage Embedding → Cosine Similarity → 유사 항목 Top 3

На экране отображаются не только рекомендуемые оценки, но и заголовки прошлых элементов, послуживших основанием для рекомендации, а также оценки, которые они получили в тот момент.

추천 참고 항목

  1. 예약 화면에 사용자 검색 기능 추가 (Story Point 3)
  2. 예약 상세 조회 기능 추가 (Story Point 3)
  3. 예약 상태 변경 기능 추가 (Story Point 5)

В этой структуре AI не выдаёт окончательный ответ. Он лишь сообщает, что в прошлом существовала похожая работа и ей был присвоен определённый уровень оценки; окончательное решение принимает пользователь. Возможность показать подтверждающие данные стала главной причиной изменения нашего подхода.

3.2 Путь запроса

Мы могли бы напрямую вызывать Anthropic API из React. Однако в этом случае API Key оказался бы доступен клиенту, а контролировать вызовы было бы сложно.

React →  Scrum Backend  →  AI API

Frontend передаёт только значения, необходимые для запроса.

const draftUserStory = (params: {
    rawInput: string;
    productId: string;
    epicId?: string;
}) => {
    const query = { ...params };
    return axios.post(url('/draft-user-story/fetch'), query);
};

Все политики, связанные с AI, — такие как промпт, выбор модели, схема вывода и повторные попытки — управляются на сервере. При такой настройке даже последующая замена модели почти не потребует изменений во frontend.

3.3 Окружения без API Key

У разработчиков, которые не используют функции AI, в локальных окружениях нет Anthropic API Key. Однако если Anthropic Client всегда создаётся при запуске сервера, само приложение может не запуститься в окружениях без Key. Поэтому мы зарегистрировали Bean условно.

@Bean
@ConditionalOnProperty(name = "ANTHROPIC_API_KEY")
public AnthropicClient anthropicClient() {
    return AnthropicOkHttpClient.fromEnv();
}

Client создаётся только в окружениях, где существует API Key. В окружениях без него отключаются только функции AI, а остальные возможности работают нормально. Мы создали эту границу, чтобы одна функция AI не влияла на окружение разработки всей команды.

3.4 Порядок полей схемы

После перехода на структурированный вывод мы столкнулись с неожиданной проблемой: результаты менялись в зависимости от порядка полей схемы. Изначально мы объявили, что кандидаты на заголовок должны генерироваться первыми. В результате даже для ввода, который трудно считать требованием, например «Выбрать место для командного ужина на следующей неделе», сначала генерировались заголовки. Поскольку поле supported, определяющее, можно ли обработать ввод, проверялось позже, система иногда считала ввод поддерживаемым, чтобы соответствовать уже сгенерированному результату.

Поэтому мы переместили поля, необходимые для принятия решения, в начало.

public boolean supported;
public String rejectionReason;

public List<TitleOption> titleOptions;
public List<NarrativeOption> narrativeOptions;

Мы изменили порядок так, чтобы система сначала определяла, является ли ввод требованием, которое можно преобразовать в UserStory, и генерировала остальное содержимое только в том случае, если результат был true. В ходе этого процесса мы поняли, что схема — это не просто DTO; она также является частью промпта, управляющей процессом вывода модели.

4. Решение проблем

Проблемы продолжались даже после интеграции функции. Все следующие пять проблем были выявлены в процессе фактического использования после завершения реализации.

4.1 Ответ был успешным, но содержимое оказалось пустым

Возникла проблема: narrativeOptions в черновике UserStory возвращался как пустой массив. HTTP Status равнялся 200, а на сервере не возникало исключения, поэтому сначала мы решили, что проблема связана с frontend. Проверив фактический ответ, мы обнаружили, что он обрывался во время генерации.

Причиной был maxTokens. Мы установили его равным 2048, ориентируясь на BacklogItem, но UserStory должен был генерировать несколько заголовков, описания для каждого заголовка и критерии приёмки, поэтому требовался значительно больший объём вывода. Увеличение значения до 4096 решило проблему, но важнее было другое: мы поняли, что HTTP 200 не обязательно означает успешное завершение ответа AI. Теперь мы проверяем stop_reason ответа и записываем событие в журнал, когда достигнут максимальный лимит выходных токенов.

4.2 Количество рекомендаций стало равно нулю

После развёртывания функции рекомендаций по оценкам мы получили отзывы о том, что некоторые проекты не получают никаких рекомендаций. Мы обнаружили, что в наборе данных для поиска сходства не было ни одного элемента. Изначально мы ограничили поиск завершёнными элементами с тем же типом задачи, что и у выбранной в данный момент задачи. Однако в фактических данных было мало завершённых элементов некоторых типов, поэтому после фильтрации не оставалось данных для сравнения.

Поэтому мы удалили фильтр по типу. Сначала мы считали, что использование оценок работ других типов будет неточным, но оценки, показываемые этой функцией, являются ориентирами, а не окончательными значениями. Даже при различии типов похожие заголовки и содержание работы могут сделать элемент полезным примером из прошлого. В итоге мы решили обеспечить наличие результатов поиска, даже ценой небольшого снижения точности.

4.3 Ожидание ответа

Даже после перехода на Haiku от запроса до получения ответа проходило около 2–4 секунд. Сначала мы отображали только Spinner, но при реальном использовании это время ощущалось дольше, чем ожидалось. Поэтому во время генерации мы стали поэтапно менять сообщение о состоянии.

요구사항을 분석하고 있습니다.
UserStory 초안을 작성하고 있습니다.
인수 조건을 정리하고 있습니다.

Эти сообщения не связаны с фактическим прогрессом модели. Однако они помогли сократить время, которое пользователи проводили, глядя только на Spinner. Пользовательский опыт при работе с функцией AI определяется не только качеством ответа модели; решение о том, что пользователи видят, пока модель формирует ответ, также является частью функции.

5. Итоговая архитектура

В процессе создания этой функции в итоге была реализована следующая архитектура.

사용자 → BFF → Scrum API
                  ├─ Claude : UserStory 초안 생성
                  └─ Voyage : 과거 유사 항목 검색

Мы не объединили две функции в одну общую задачу AI. Мы разделили задачу генерации предложений и задачу поиска подтверждающих данных в наших данных: Claude отвечает за черновики UserStory, а embedding-модели Voyage — за поиск выполненной ранее работы. Окончательное решение принимает пользователь. После разделения обязанностей архитектура фактически стала проще, чем была изначально.

6. Извлечённые уроки

Главный урок, который мы вынесли за три недели разработки, заключается в том, что сложная часть функции AI — не сам вызов API. Код, вызывающий Claude API, можно создать быстрее, чем ожидаешь. Время потребовалось на последующую работу. Нам пришлось решить, какие задачи делегировать генеративной модели, а какие искать в собственных данных. Также требовалось проверять, что фактическое содержимое действительно корректно, даже если мы получили успешный HTTP-ответ, и учитывать, как пользователи воспринимают время ожидания ответа модели.

Эту разницу я особенно остро ощущал в рекомендациях по оценкам. Если попросить модель вынести суждение без каких-либо подтверждающих данных, она может дать правдоподобный ответ, но вопрос о том, соответствует ли этот ответ стандартам нашей команды, — отдельный. И наоборот, если у команды есть исторические данные, может быть уместнее найти их и показать пользователю, а не просить модель сгенерировать ответ. В конечном счёте было важно сначала определить, действительно ли это то, что должен генерировать ИИ, а не сосредотачиваться только на том, что заставить ИИ делать.

7. Заключение

Сначала я думал, что это будет простая функция. Я предполагал, что нам нужно лишь ввести требования, позволить системе создать UserStory, найти похожие задачи и показать их оценки. Однако после того, как мы действительно начали её создавать, мы дважды существенно меняли направление. Сначала мы рассматривали рекомендации по оценкам как задачу генерации, а затем слишком сильно ограничили круг кандидатов, пытаясь повысить точность поиска. Ни одна из этих проблем не была вызвана ошибками в написании кода; обе возникли из-за того, что исходная задача была сформулирована неправильно.

В рамках этой работы в будущем, при создании функций на основе ИИ, я планирую всегда проверять три вещи. Во-первых, выбирать модель, подходящую для функции. Во-вторых, контролировать на сервере все вызовы, которые влекут расходы. В-третьих, после создания функции просить членов команды пользоваться ею самостоятельно.

Я считаю, что последний пункт особенно важен. Большинство проблем, которые мы обнаружили на этот раз, невозможно было выявить во время разработки. Проблема с пустым массивом, проблема с вводом на корейском языке и проблема, при которой в итоге не находилось ни одного результата поиска, стали очевидны только после того, как мы действительно начали пользоваться функцией.

В конечном счёте сильнее всего в процессе этой разработки Scrum AI изменился не код, а наш взгляд на проблему. В будущем, добавляя функции на основе ИИ в Devlime, я планирую не начинать с выбора модели, а сначала определить, в чём заключается проблема, и решить, какой подход — генерация, поиск или правила — лучше всего для неё подходит.

Спасибо, что прочитали этот длинный пост.

jun

Site footer