PunchHow to apply for Germany long-stay visas online – OfficialThe Jerusalem PostBuilding under reconstruction collapses in Tel Aviv, none injured as workers escape unharmedRTP DesportoMundial de Hóquei. Portugal defronta Itália após desaire com EspanhaInquirerGroup backs MILF’s Ebrahim as new BARMM gov’t headBollywood HungamaAdivi Sesh writes to Assam government over reported resort development near Kaziranga National ParkDaily MaverickPHOTO ESSAY: ‘Hunger has no party’ — candidates talk to Soweto residents ahead of electionsUOLInflação ao produtor da China sobe com aumento das commodities; IPC fica abaixo das previsõesObservador DesportoAs notícias das 10hEgypt IndependentBreaking Bad star RJ Mitte passes away at 34ZDF heuteEntdecken Sie das ZDF-NachrichtenstudioХабрЛичный ИИ-ассистент без платной подписки: как устроить прототип и посчитать пользу для бизнесаGolem.deBildagentur: Getty Images kündigt Insolvenzverfahren an
The Daily Newsstand · Free, Always
Wednesday, October 14, 2026

Первый RAG комом: как мы учились анализировать ошибки автотестов с помощью LLM

Translate

Привет! Меня зовут Алексей, я автотестер в hh.ru. У нас около 3000 запускаемых классов с e2e (UI) тестами на Java. Они прогоняются по несколько раз в день и служат основным quality gate. Но тесты периодически флакуют — становятся нестабильными, и с этим приходится работать.

Для борьбы с этим у нас работает карантин, а точнее автоматический карантин. Если тест становится нестабильным, крон исключает его из прогонов и создаёт задачу в Jira на владельца теста с просьбой разобраться в проблеме.

Подробнее о том, как мы работаем с flaky-тестами и устроили автоматический карантин, уже рассказывали на Хабре.

А теперь решили дополнить этот процесс LLM. Идея была простой: когда тест попадает в карантин, нейросеть анализирует ошибку и оставляет владельцу подсказку — несколько гипотез о причинах падения и возможные способы исправления, если проблема на стороне тестов.

Сегодня расскажу, как мы реализовали эту задачу, почему классический RAG с векторным поиском не дал нужного результата и как в итоге пришли к подходу, в котором LLM сама определяет, какой код ей нужен для анализа.

С чего начали: сервис для анализа ошибок

Начали с небольшого сервиса error-analyzer на FastAPI. Его задача — найти ошибку проблемного теста, передать её на анализ LLM и оставить результат в Jira, чтобы владелец теста получил подсказку о возможных причинах падения.

Весь процесс состоит из пяти шагов:

  1. Сервис получает задачу Jira и имя проблемного теста

  2. Обращается к базе данных со статистикой прогонов, находит наиболее частый stacktrace для проблемного класса и выбирает соответствующий прогон

  3. Переходит к результатам этого прогона в Jenkins и забирает полный stacktrace

  4. Передаёт stacktrace на анализ LLM

  5. Публикует ответ модели в исходной задаче Jira

Здесь есть пара нюансов. В БД мы храним статистику прогонов, в том числе начало stacktrace, а за полной версией идём в Jenkins. Для анализа используем локальную GLM, поэтому за количеством токенов особо не следим, но про размер контекстного окна всё же помним.

Высоких требований к производительности у сервиса нет: он обрабатывает всего 1–3 запроса в сутки.

Если упростить, основной цикл error-analyzer выглядит так:

while True:
 	task: ProcessTaskRequest = await queue.get()
 	try:
      	stacktrace = find_exceptions(task.class_name)
    	result = orchestrator.analyze(stacktrace)
     	try:
         	parsed_answer = json.loads(result["answer"], strict=False)
     	except json.JSONDecodeError:
         	parsed_answer = {"analysis": result["answer"], "solution": ""}
     	analysis = _format_analysis(stacktrace, parsed_answer)
     	post_jira_comment(task.jira_task, analysis)
    except Exception:
       	logger.exception("Error processing task jira=%s class=%s", task.jira_task, task.class_name,)
  	finally:
     	queue.task_done()

Нас здесь интересует прежде всего orchestrator.analyze(stacktrace) — именно в этой части сервис взаимодействует с LLM. На ней и остановимся подробнее: сначала расскажу, как мы реализовали анализ через классический RAG и почему результат нас не устроил.

Первая попытка: классический RAG с векторным поиском

Чтобы LLM могла разобраться в причинах падения теста, одного stacktrace мало. Нужен дополнительный контекст, и первое, что приходит на ум, — исходный код тестов. Но отправлять модели весь проект не вариант, поэтому решили выбирать только те фрагменты, которые наиболее релевантны ошибке.

Так мы пришли к классическому RAG с векторным индексом.

Как устроили поиск по коду

Для начала построили индекс исходников автотестов с помощью LlamaIndex. Для эмбеддингов взяли модель BAAI/bge-m3, которая работает в нашем внутреннем контуре. Индекс перестраиваем каждую ночь.

Для его построения читаем файлы с кодом тестов, настраиваем параметры индексации, такие как CodeSplitter и модель для эмбеддингов. После этого запускаем построение:

def _setup_code_splitter(self):
 	self.code_splitter = CodeSplitter(
     	language="java",
     	max_chars=4000,
 	)
 	Settings.node_parser = self.code_splitter

def load_documents(self) -> List:
 	logger.info("Загрузка файлов из %s...", self.java_dir)
 	reader = SimpleDirectoryReader(
     	input_dir=self.java_dir,
     	required_exts=[".java"],
     	recursive=True,
     	filename_as_id=True,
     	exclude_hidden=True,
 	)
 	documents = reader.load_data(show_progress=True)
 	logger.info("Загружено %d файлов", len(documents))
 	return documents

def _setup_embedding_model(self):
   logger.info("Подключение к удалённому BGE-M3 (%s)...", BGE_M3_BASE_URL)
   self.embed_model = BgeM3RemoteEmbedding(
       api_base=BGE_M3_BASE_URL,
       api_key=BGE_M3_API_KEY,
       embed_batch_size=self.batch_size,
   )
   Settings.embed_model = self.embed_model


def create_index(self, documents: List) -> VectorStoreIndex:
   logger.info("Создание индекса для %d документов (batch=%d)...", len(documents), self.batch_size)
   index = VectorStoreIndex.from_documents(
            documents,
            show_progress=True,
        )
   logger.info("Сохранение индекса в %s...", self.persist_dir)
   os.makedirs(self.persist_dir, exist_ok=True)
   index.storage_context.persist(persist_dir=self.persist_dir)
   logger.info("Индексирование завершено")
   return index

Следующий шаг — поиск по готовому индексу. Для каждого запроса получаем top_k наиболее близких чанков вместе с путём к файлу, содержимым и оценкой релевантности:

def search(self, query: str, top_k: int):
 	logger.debug("Поиск: '%s'", query)
 	retriever = self.index.as_retriever(similarity_top_k=top_k)
 	nodes = retriever.retrieve(query)
 	results = []
 	for i, node in enumerate(nodes, 1):
     	result = {
         	"rank": i,
         	"file": node.node.metadata.get("file_path", "unknown"),
         	"content": node.node.text,
         	"score": node.score
     	}
     	results.append(result)
 	return results

В итоге схема получилась довольно простой: берём stacktrace, находим по нему N наиболее релевантных чанков кода, добавляем их в промпт и отправляем всё на анализ LLM.

Технически RAG заработал. Вот только поиск часто возвращал совсем не тот код, который был нужен для анализа.

Почему векторный поиск не сработал

Проблема обнаружилась, когда мы посмотрели, какие именно фрагменты кода возвращает поиск.

Например, тест падал с ошибкой:

java.lang.AssertionError: Модальное окно не закрылось после отправки заявки expected [true] but found [false]
 at class1
 at class2

Так, вместо классов из stacktrace поиск мог вернуть те, в логах или комментариях которых встречались слова «модальное» и «заявки». Совпадения по тексту ошибки находились, а нужных для анализа классов среди результатов не оказывалось.

И это касалось не только assertion-ошибок. С тестами, которые падали внутри page-классов, ситуация была похожей.

В итоге LLM получала фрагменты кода, связанные с текстом ошибки, но далёкие от реального падения. И вместо того чтобы разбираться в проблемных классах, анализировала то, что ей передал поиск.

Стало понятно, что stacktrace плохо подходит для векторного поиска по исходникам. Результаты часто оказывались нерелевантными, поэтому нужно было по-другому организовать получение контекста.

Вторая попытка: пусть LLM сама решает, какой код ей нужен

Во время работы мы заметили одну особенность: если передать модели stacktrace, она, как правило, может определить, с какого класса стоит начать анализ.

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

Так мы пришли к iterative retrieval — итеративному получению контекста.

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

Собираем цикл анализа на LangGraph

Для реализации выбрали LangGraph и построили граф состояний с четырьмя узлами: инициализация, получение контекста, анализ через LLM и завершение.

# Строим граф состояний
def _build_graph(self):
 	workflow = StateGraph(AnalysisState)
 
 	# Узлы графа
 	workflow.add_node("initialize", self._initialize)
 	workflow.add_node("fetch_context", self._fetch_context)
 	workflow.add_node("LLM_analyze", self._LLM_analyze)
 	workflow.add_node("finalize", self._finalize)
 
 	# Рёбра
 	workflow.set_entry_point("initialize")
 	workflow.add_edge("initialize", "fetch_context")
 	workflow.add_edge("fetch_context", "LLM_analyze")
 
 	# Условное ребро: нужен ли ещё контекст?
 	workflow.add_conditional_edges(
     	"LLM_analyze",
     	self._should_continue,
     	{
         	"LLM_analyze": "LLM_analyze",
         	"finalize": "finalize"
           	}
 	)
 
 	workflow.add_edge("finalize", END)
 
 	return workflow.compile()

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

В начале задаём роль и контекст:

«Ты эксперт в области анализа Java-исключений, e2e и api тестов.
 ...Здесь мы даём немного контекста о технической составляющей продукта который тестируется
 ...Добавляем stacktrace и просим проанализировать
Если тебе недостаточно контекста, чтобы проанализировать stacktrace, составь ПЛАН ПОИСКА в структурированном JSON:
 {
   "needs_context": true,
   "plan": [
 	 { "class": "<полное имя класса>",
 	   "reason": "<краткое обоснование>"
           	}
  ]
}
...
- Когда контекста достаточно, верни:
 {
   "needs_context": false,
   "analysis": "анализ - от 1 до 5 предположений почему произошла ошибка",
   "solution": "что можно сделать чтобы исправить ошибку"
 }»

Получается, что теперь модель не ограничена фрагментами, которые заранее подобрал поиск. Она сама определяет, какие исходники ей понадобятся для следующего шага.

Как теперь проходит анализ

В итоге схема стала такой:

  1. Передаём LLM stacktrace и просим составить план поиска — указать исходники файлов, которые нужны для анализа

  2. Получаем необходимые классы, добавляем их исходный код в промпт и снова обращаемся к модели

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

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

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

Заодно изменилась и роль индекса. Теперь он служит просто хранилищем исходных файлов: искать релевантные фрагменты через него больше не нужно. А значит, при желании его можно заменить любым другим подходящим решением.

Что получилось в итоге

Со второй попытки результаты стали заметно лучше: ответы LLM оказались более предсказуемыми, а вместо нерелевантных фрагментов кода модель анализировала классы, которые действительно участвовали в падении теста.

Качество оценивали по двум источникам: моей экспертной оценке и оценкам пользователей.

А главное, после внедрения подсказок от LLM медианное время пребывания тестов в карантине сократилось примерно на 15%.

Какие выводы мы сделали

За время работы успели кое-что попробовать, ошибиться и пересмотреть подход. И теперь можем поделиться выводами, которые пригодятся, если вы тоже решите подключить LLM к анализу ошибок:

  1. Не спешите строить векторный поиск. У нас классический RAG технически работал, но находил не тот код. То, что фрагмент похож на текст ошибки, ещё не значит, что он поможет разобраться в её причине.

  2. Попробуйте доверить выбор контекста самой LLM. Мы заметили, что по stacktrace модель обычно может определить, с какого класса начать анализ. Когда дали ей возможность запрашивать исходники, результаты стали более предсказуемыми.

  3. Не забывайте про лимит итераций. С каждой итерацией промпт растёт, поэтому мы установили лимит. Если он достигнут, модель анализирует ошибку на основе уже собранного контекста.

Что мы вынесли из этой истории: не усложнять и использовать подходящие инструменты. Мы использовали неподходящие данные для векторного поиска и получили плохой результат, но пересмотрели подход на простое итеративное получение исходников и всё заработало.

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

А как у вас обстоят дела с LLM для анализа падений тестов? Рассказывайте в комментариях — сравним опыт.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.