
Всем привет! На связи Александр и Артем — ML-инженеры из Т-Банка. Мы делаем copilot-инструмент для разработчиков.
Статья будет не про агентов. Расскажем, как отрезали лишние и бесполезные подсказки в Code Completion и усилили доверие пользователей.
Что мы называем Code Completion
Code Completion — это автодополнение кода в IDE.
Пользователь может принять подсказку нажатием Tab — или проигнорировать ее. С точки зрения продукта наша цель проста — показывать такие подсказки, которые уместны.
Система кодовых подсказок живет в проде уже два года, активно развивается и обрастает фичами. Но полгода назад стало очевидно, что каждый следующий процентный пункт качества стоит заметно дороже, чем предыдущий. Расти хотелось, но не получалось. Доля принятых подсказок не менялась, общий сгенерированный код не увеличивался.
У нас был рабочий Completion с таким флоу:
-
собираем контекст;
-
генерируем подсказку;
-
показываем ее пользователю.
Работает? Да. И уже дает хороший результат. Но мы видели, что можем сделать его еще точнее и полезнее для разработчиков.
Какая была проблема
Мы добились того, что нашими подсказками пользуется почти вся разработка внутри компании — 7,5 тысячи уникальных пользователей каждый день.
Acceptance Rate (доля принятых подсказок от всех предложенных, AR) стабильно держалась на уровне ~20%. Появилась проблема: новые улучшения не двигали эту цифру.
Мы пробовали увеличивать длину подсказки, меняли стратегию генерации, расширяли количество мест показа. Но чем больше подсказок мы показывали, тем больше среди них было шума, от которого хотелось избавиться. Новые фичи давали только точечные и нестабильные улучшения.
Объем принимаемого кода перестал расти — мы уперлись в потолок. В какой-то момент попробовали зайти с другой стороны. Что, если проблема не только в качестве генерации, но и в отсутствии решения на вопрос: показывать подсказку или нет?
Идея фильтра
Мы решили добавить отдельный слой, который оценивает сгенерированную подсказку и принимает финальное решение о ее показе. Фильтр не пытается что-то переписать или улучшить — он отвечает только на один вопрос: эта подсказка достаточно хороша, чтобы ее показать пользователю прямо сейчас?
Добавляя фильтр, мы сразу приняли важное ограничение: идеально фильтровать подсказки невозможно.
Любой фильтр — это компромисс:
-
слишком строгий → отбрасываем хорошие подсказки;
-
слишком мягкий → фильтр бесполезен.
Поэтому нам нужно было заранее ответить на вопрос: чем мы готовы пожертвовать ради улучшения пользовательского опыта?
До этого мы уже внедряли улучшения, которые поднимали долю принятых подсказок и наблюдали следующую картину. Пользователи со временем начинают принимать подсказки охотнее, потому что:
-
меньше мусора → выше доверие к подсказкам;
-
выше доверие → пользователи чаще нажимают Tab.
Acceptance Rate начинал расти не только мгновенно, но и постепенно — в течение нескольких недель после улучшений.
Проблема в том, что доверие напрямую измерить нельзя. У нас нет метрики trust_score. Но есть графики прошлых запусков, динамика поведения пользователей и понимание собственного продукта.
Из прошлых экспериментов мы видели закономерность: повышение AR на 1 п. п. вдолгую дает примерно +2% к абсолютному числу принятых подсказок.
Пользователь не только начинает принимать очевидно хорошие подсказки, но и чаще дает шанс новым — он уже не ожидает мусора по умолчанию.
Если принять нашу эмпирическую оценку (+1 п. п. AR ≈ +2% к числу принятий),
сначала нужно зафиксировать ограничение.
Мы понимали, что фильтр неизбежно будет что-то отбрасывать, а значит, часть текущих принятых подсказок мы потеряем. Больше 5% потерь в моменте мы позволить себе не можем: это верхняя граница, с которой согласился наш бизнес. Отсюда и минимальная цель — подняться с 20 до 22,5%.
22,5% — граница, где математика лишь сходится в ноль. Любой недобор, шум или ошибка оценки — и мы уже в минусе. Поэтому мы зафиксировали более жесткую цель — +3 п. п., то есть рост с 20 до 23%.
Мы начали проектировать фильтр с ограничениями:
-
не более 5% потерь принятых подсказок;
-
рост Acceptance Rate минимум на 3%, то есть с 20 до 23%.
Небольшой спойлер
Гипотеза подтвердилась. После внедрения фильтра количество принятых подсказок не только не просело на ожидаемые 5%, но со временем даже слегка подросло (порядка 1%). Пользователи стали больше доверять подсказкам и чаще их принимать.
Первый подход: быстрый Baseline
Мы решили не усложнять. Нужно было понять главное: имеет смысл крутить фильтр или нет? Для этого мы собрали самый простой Baseline:
-
обычный бинарный CatBoost Classifier (таргет: 1 — приняли подсказку, 0 — нет);
-
несколько часов Optuna;
-
только те признаки, которые доступны в real-time.
Важно: признаки зависели только от текущего запроса. Мы сознательно не использовали ничего, что требовало бы ждать отливки данных предыдущих событий. Например, приняли ли прошлую подсказку. Такие признаки могли дать значительное улучшение качества, но требовали существенных доработок со стороны сервиса. Нужно было бы добавлять логику просчета новых фич в Redis для хранения состояния прошлых запросов, а еще валидировать сходимость с историческими данными.
Иногда ошибка может быть там, где ее никто не ждал: в одном месте — длина в байтах, в другом —в символах. Или любимая ошибка обработки времени с таймзонами и без. Для первой пробы решили отложить такие эксперименты.
Мы остановились на таких табличных признаках:
-
IDE (JetBrains, VSCode, …);
-
язык (Python, TypeScript, …);
-
позиция курсора в строке (категориальная фича begin/middle/end);
-
тип подсказки (однострочная/многострочная);
-
размер префикса (текст файла до курсора);
-
размер суффикса (текст файла после курсора).
Результат baseline: оно завелось!
На офлайне мы увидели +2,3 п. п. — почти то, что нам было нужно: с 20 до 22,3%.Если честно, это было неожиданно. Мы не рассчитывали получить целевой прирост буквально с полпинка.
Но офлайн — только первый этап. Дальше — теневое тестирование, A/B и Prod.
Главный вывод мы сделали: фильтр — это рабочее направление. Сигнал в данных есть. Стало понятно: дальше надо идти глубже.
Эксперимент с LLM
CatBoost с табличными фичами дал хороший старт. Но основная информация содержится в тексте.
Подсказка — это текст. Контекст — это текст. Ошибки, нелепости, кринж — все живет внутри текста. Если фильтр не умеет по-настоящему понимать код, он всегда будет ограничен поверхностными сигналами. Поэтому следующим шагом мы сделали текстовый фильтр на базе LLM. Но это был не академический эксперимент, у нас были следующие требования:
-
30 запросов в секунду (RPS) на одной A100;
-
в проде максимум 1 GPU;
-
p90 latency ≤ 50 мс.
Модель должна быть маленькой, быстрой и при этом умной. Для инференса планировали использовать GPU Nvidia A100.
Чтобы оценить, какого размера модель следует выбрать, мы прогнали несколько вариантов моделей на наших нагрузочных тестах. Эти тесты составлялись из продовых данных, и для них использовался фреймворк Grafana K6 для нагрузочного тестирования.
Экспертно отобрали кандидатов: Bert размера < 0,5B (мы брали microsoft/unixcoder-base из семейства RoBERTa, так как уже поднимали ее в другом проекте), Qwen2.5-Coder 1.5B, Qwen2.5-Coder 3B. Решили сравнить их производительность в сыром виде и без оптимизаций.
Ожидаемый RPS в проде должен быть не более 30. По результатам:
|
Модель |
Максимальная нагрузка (RPS) |
Latency на 30 RPS |
|
Bert |
> 60 |
~20 мс |
|
Qwen 1.5B |
20 |
4 с (timeout) |
|
Qwen 3B |
10 |
4 с (timeout) |
На графике Latency (времени ответа) от нагрузки по времени видно, что модель при 20 RPS уже начинает таймаутить. Если посмотреть на график нагрузки GPU ниже, видно, что мы постепенно начинаем подходить к нагрузке 100%, где может начаться троттлинг.
На графике видно три зоны нагрузки, они соответствуют 10, 15 и 20 RPS. Видно, что при нагрузке на сервис 20 RPS GPU уже загружается практически на 100%. Здесь важно понимать разницу: нагрузка на GPU — процент времени, в течение которого устройство выполняет какие-то расчеты, а нагрузка на сервис — число запросов, приходящих на него в секунду (Requests Per Second).
Не самые оптимистичные результаты. Чтобы поддерживать необходимую нагрузку около 30 RPS, нам нужно будет два инстанса модели, а не один. Плюс время ответа на синтетическом тесте — порядка 80 мс, и это без учета сетевой задержки и профиля нагрузки прода (это в дальнейшем внесет свою лепту). Есть риск не уложиться в требование по времени ответа.
Мы хотели выбрать максимально емкую модель для фильтра. Мы учли потенциал оптимизации моделей с помощью различных фреймворков (ONNX или TensorRT) и остановили свой выбор на Qwen2.5-Coder 1.5B.
Что касается модели размера побольше (3B), после замеров стало понятно, что привести модель такого размера к требуемому времени ответа и нагрузке будет сложнее, чем аналогичную модель 1.5B. Поскольку кандидаты имеют схожую архитектуру, увеличение размера модели неизбежно приводит к увеличению времени ответа. Поэтому выбрали Qwen2.5-Coder 1.5B:
-
компактная модель;
-
ориентирована на код;
-
быстрая для продового инференса.
Дальше мы адаптировали эту модель под нашу задачу.
Адаптация модели
Вот четыре пункта плана адаптации.
Поменять голову: Мы не генерируем текст — мы принимаем решение. Первым делом мы поменяли голову модели: вместо генерационной поставили бинарную классификацию. Технически в Transformers это делается довольно легко, но по факту это был только самый первый шаг.

Ввести строгую структуру промпта. Самая большая проблема — структура входа. Префикс может быть на тысячи строк, суффикс — пустым, а ответ модели — из двух символов.
Если просто склеить prefix + response + suffix, получится каша. Модель не поймет, где контекст, где предложение и что именно нужно оценивать. Поэтому мы ввели строгую структуру:
<PREF>...</PREF>
<LINE>...</LINE>
<RESP>...</RESP>
<SUFF>...</SUFF>
А еще мы ограничили общую длину. Если контекст не помещался полностью, мы отдавали приоритет префиксу в соотношении примерно 3:1.
В нашем домене префикс почти всегда важнее суффикса. Это наблюдение многократно подтверждалось еще на этапе обучения основной модели генерации.
После ввода строгой структуры изменился офлайн-прирост — вырос с +2,3 до +3,9 п. п.
Закодировать категориальные признаки как токены. На этом шаге случился интересный инсайт. Если мы используем специальные токены для структуры, почему бы не закодировать в токены и категориальные признаки?
Мы добавили:
<SOURCE_vscode>
<SOURCE_jetbrains>
<LANG_python>
<LANG_sql>
<TYPE_begin>
<TYPE_middle>
<TYPE_end>
Мы буквально научили модель видеть источник, язык и позицию. Получили ощутимый прирост — с +3,9 до +5,1 п. п. на офлайне.
Fine-tuning: идем до конца. На последнем этапе мы пошли в полноценный Fine-tuning самой модели. Сначала попробовали частично разморозить веса, потом добавили LoRA, а в финале — комбинированный метод разморозки и LoRA.
Мы уже не просто учили новую голову поверх готовой модели, а аккуратно адаптировали внутренние представления самой сети под нашу задачу фильтрации. Это был самый дорогой и трудоемкий этап: между итерациями были переобучения, отладка пайплайна, новые запуски, эксперименты с конфигами и много инженерной рутины. По сути, между этими пунктами лежало несколько дней работы и довольно много написанного кода.
Дополнительно мы подключили focal loss, потому что в данных был выраженный дисбаланс классов примерно 1:4 (на 5 показанных подсказок — 1 принятая), и обычная функция потерь хуже фокусировалась на действительно сложных примерах. В итоге именно на этой финальной стадии мы получили лучший офлайн-результат — с +5,1 до +6,8 п. п. Это выглядело слишком хорошо, чтобы сразу радоваться, поэтому следующим шагом мы пошли проверять все в теневом режиме.
Теневое тестирование
Прежде чем включать фильтр на пользователей, нужно убедиться в его корректности. Хочется быть уверенным, что мы не будем убивать весь трафик или, наоборот, ничего не будем фильтровать вообще.
Поэтому мы провели теневое тестирование — прием, когда какая-то модель или правило работает в реальных условиях, на проде, но результат этой работы видим только мы, а на пользователей это никак не влияет. Чтобы запустить такой тест, нужно сначала занести сам фильтр на бэкенд-сервис.
Сервис на диаграмме — это сервис нашего бэкенда, на нем происходит предобработка, формирование промпта и прочие действия. Оттуда запрос отправляется в сервис с нашей моделью для генерации (ByteDance SeedCoder-8b), развернутый на кластере с GPU.
Наш бэк необходимо модифицировать.
Сначала LLM-фильтр был просто кодом модели с логикой формирования промпта, завернутым в FastAPI. Но такой подход означал, что нам пришлось бы в перспективе с нуля реализовывать батчинг, мониторинг и управление нагрузкой. Поэтому в дальнейшем мы решили использовать Triton Inference Server.
При выборе модели мы учли потенциал оптимизации, и теперь пришло время им воспользоваться. Первая мысль про оптимизацию — ONNX. Это известный фреймворк оптимизации инференса. Умеет компилировать и сжимать вычислительный граф модели, например делать сжатие (Fusion) некоторых слоев типа Multi-Head Attention или LayerNorm в одну операцию.
Оказалось, что простая оптимизация из коробки через конвертацию в ONNX показала себя достаточно неплохо.
По графикам видно, что результаты уже сильно лучше: максимальная нагрузка возросла с ~15 RPS практически до 40. А время ответа — около 30 мс.
Простая конвертация в ONNX и запуск сессии со стандартными параметрами уже дали хороший результат, который вполне удовлетворяет нашу потребность, не влияя на качество самой модели.
Казалось бы, мы нашли шикарное средство оптимизации, запустим в прод, но, конечно же, был подвох.
Видно, что в пиках время ответа доходит до 90 мс, что уже сильно выходит за ограничения.
Если посмотреть на график загрузки GPU, увидим, что среднее значение в прайм-тайм (с 10:00 до 19:00) — всего 34%, но при этом видны скачки до 80% и в целом профиль загрузки GPU сильно отличается от теста. При аналогичных нагрузках (порядка 25—30 RPS) на тесте мы получали загрузку GPU максимум 60%.
Видно, что на проде Latency начал прыгать. Стандартная гипотеза в таких случаях: паттерн нагрузки не совпадает с тестами.
Дело в том, что в реальных условиях могут приходить пачки почти одновременных запросов, чего мы не видели в тестах. Эта проблема лечится добавлением динамического батчинга через Triton: схлопываем близкие по времени запросы, стараясь не заставлять долго ожидать в очереди одиночные. Для этого берем максимальный размер батча — 4, и максимальное время ожидания в очереди — 10 мс.
Не будем утомлять графиками с тестов и покажем сразу с прода.
Получили стабилизацию графика и подтверждение гипотезы, а также снижение Latency в пиках до требуемых значений. На этом мы остановились с оптимизацией.
Конечно, есть потенциал дальнейшей оптимизации (например, квантование модели) и альтернативы ONNX типа TensorRT, но мы решили остановиться на текущем результате. Он уже отвечает поставленным требованиям, а на дальнейшее углубление времени не было.
Финальные цифры в теневом нас остудили. Качество оказалось ниже офлайна: +5,5 вместо +6,8 п. п. Да, +5,5 п. п. — это все еще очень круто, но «ваши ожидания — это ваши проблемы».
Фильтр стал слишком агрессивным: вместо целевых 5% трафика мы отбрасывали 6—7%, а в некоторых языках доходило до 11%. Это уже перебор: пользователи начнут замечать пропуски, а бизнес — задавать вопросы.
Вариантов, почему офлайн разошелся с продом, было несколько, но главный: мы слишком оптимистично подбирали порог для LLM. Изначально порог тюнили на паре дней данных: казалось, этого достаточно. Но нет, у комплишена очень выраженная периодичность по дням недели, рабочим и нерабочим часам, языкам и IDE.
Мы увеличили окно подбора до 7 дней, чтобы захватить недельный цикл. Параллельно усилили валидацию: больше данных отправили в Holdout, чтобы меньше переобучаться на случайных всплесках.
И еще одно неприятное решение: в тюнинге мы стали ориентироваться не на 5% отфильтрованных принятых подсказок, а на 4,5% как страховочную границу, чтобы в реальном трафике не улететь выше 5%. Это, конечно, ударило по количеству отфильтрованного шума.
После переобучения получили +5,9 п. п. на офлайне, а в теневом — +4,8 п. п. при выбросе ровно 5% трафика.
Главный и вечный вывод после этой итерации: нужно всегда валидироваться на проде!
Офлайн нужен, но теневое — единственное место, где начинается реальность.
Последняя идея: второй слой на CatBoost
Фильтр был почти готов к A/B, но после потери качества на пороге мы решили добить задачу завершающей идеей.
Проблема LLM-фильтра простая: он классно понимает текст, но ему трудно использовать числовые признаки.
А у нас как раз оставались сложные признаки, от которых мы отказались в бейзлайне, но в которые мы верили. Они опираются на предыдущие запросы, самый яркий — время между запросами. Такие фичи в онлайне доставать сложно, но, если получится, они должны дать прирост.
Здесь хорошо ложится двухслойная схема:
-
LLM выдает оценку уместности подсказки на основе текстовых фич;
-
CatBoost дожимает финальное решение, добавляя табличные и исторические признаки. А еще снимает необходимость вручную тюнить порог LLM.
Результат: вместо +5,9 получили +6,5 п. п. на офлайне. Как и ожидалось, скор LLM оказался самой сильной фичей, а сразу за ним — сложные признаки, на которые мы делали ставку. Немного? Да. Но это CatBoost — старый добрый дожиматель процентов.
Теневое тестирование ансамбля моделей
Пришло время проверить этот ансамбль в проде по уже известной схеме теневого тестирования. Но для его проведения нужно добавить модель CatBoost на прод и завести логику расчета вспомогательных фич для него.
CatBoost интегрировали прямо в бэкенд (через фреймворк Catboost — CGO, поскольку бэкенд реализован на Go). Модель легкая и быстрая, поэтому выносить ее в отдельный сервис не имело смысла: сетевая задержка была бы дороже инференса.
Фичи собираются просто: логпробы из LLM-фильтра и базовые признаки вроде response_length, lang, source либо приходят в запросе, либо считаются по ходу пайплайна.
Две самые интересные фичи, которыми мы расширили контекст: request_interval и prefix_len_diff — время между запросами одного пользователя и изменение длины префикса, то есть объем дописанного кода. И вот с ними в офлайне все выглядит просто, но в проде существует проблема. Такие признаки нельзя брать из DWH: между запросами проходят секунды, а данные доезжают туда с задержкой в часы. Поэтому нужно хранить состояние в каком-то горячем хранилище, например в Redis: ключ — ID пользователя, значение — время последнего запроса и длина префикса. Такой подход позволял сохранять состояние пользователя между запросами и быстро его получать.
Часть этой логики уже была реализована нашим коллегой в другой задаче, и это сэкономило нам кучу времени.
С первого раза все, конечно, заработало, но качество оказалось провальным: модель фильтровала слишком много. Причина быстро нашлась: часть фич в проде считалась не так, как в обучении. Например, где-то длина префикса считалась в байтах, а где-то — в символах.
После нескольких итераций выровняли логику, и в итоге значения фич в проде и офлайне совпадали уже в 99% запросов.
Наконец-то увидели заветные 5% отброшенных данных и +6 п. п.
В A/B все подтвердилось: рост был сильным, картина — стабильной. Мы не боялись развала, потому что доверяли теневому.
Но при детализации по языкам всплыл неприятный перекос: SQL фильтровался заметно жестче остальных — порядка 12%. Со временем целевые 5% потерь принятых подсказок начинали распухать до 6% — тревожный звоночек, поэтому мы сделали еще одну итерацию:
-
переобучили CatBoost на большем окне данных;
-
ослабили фильтрацию.
В обновленном A/B получили 4,7% отброшенного трафика и +5,2 п. п. к AR. А главное, без диких перекосов по языкам и источникам.
На этом мы решили остановиться и поехали в прод.
Выводы
Мы начинали с простой идеи: возможно, проблема не только в генерации, но и в отсутствии решения, показывать подсказку или нет.
В итоге получился двухслойный фильтр:
-
LLM понимает смысл и контекст кода;
-
CatBoost аккуратно дожимает решение табличными и историческими признаками.
В этом кейсе самым важным оказалось то, что качество — это не только модель генерации. Иногда отдельный слой принятия решения дает больший эффект, чем очередной fine-tune основной модели.
Офлайн почти всегда оптимистичен. Настоящая жизнь начинается в теневом и A/B.
Порог — это не просто число, это баланс между качеством, доверием и бизнес-ограничениями.
Мелкие инженерные детали решают. Байты vs символы, TTL в Redis, недельная периодичность — все это может стоить нескольких процентных пунктов.
Общие результаты в цифрах:
-
+5,2 п. п. к Acceptance Rate;
-
≤ 5% отброшенного трафика;
-
отсутствие перекосов по языкам и IDE.
А спустя некоторое время мы увидели, что пользователи действительно стали чаще принимать подсказки. То самое доверие, которое нельзя измерить напрямую, все-таки проявилось в метриках. На масштабе 7,5 тысячи разработчиков такой прирост по метрикам дал существенный буст.
Фильтр не сделал модель умнее. Он просто перестал показывать лишнее. Иногда этого достаточно, чтобы система начала работать лучше.
Автор: makler322


