Ваш статический анализатор только что выдал 847 предупреждений в пятничный полдень. Вы знаете статистически, что где-то между 5% и 15% из них — настоящие баги. Остальное — ложные срабатывания: dead stores в сгенерированном коде, null checks, которые выглядят подозрительно для инструмента, но очевидны для человека, integer overflows в хеш-функциях, которые не имеют значения.
Разбирать их вручную — изматывающе. Так что вы задаётесь вопросом: могу ли я просто спросить у LLM, какие из них реальны?
Краткий ответ — да, с большой оговоркой. LLM удивительно хороши в ранжировании предупреждений статического анализа по вероятности их актуальности. Они плохо понимают, почему предупреждение является ложным срабатыванием, так как это делает abstract interpretation. Два подхода решают разные части одной проблемы. Используйте их вместе, и вы резко сократите время триажа. Используйте LLM в одиночку, и вы отправите в production баги, которые звучат очень уверенно в том, что их не существует.
Почему статический анализ тонет вас в шуме
Статические анализаторы на основе abstract interpretation работают, переоценивая поведение программы. Они отслеживают каждый возможный путь выполнения через abstract domain, сворачивая конкретные значения в множества вроде “положительное целое” или “возможно нулевой указатель”. Когда абстрактное состояние нарушает свойство, анализатор сообщает о предупреждении.
Проблема присуща методу. Abstract interpretation должна быть консервативной, чтобы быть sound. Если есть какой-либо путь выполнения, где может произойти разыменование нулевого указателя, инструмент должен сообщить об этом. Даже если этот путь требует определённой последовательности событий, которую предотвращает логика вашего приложения. Даже если “нуль” исходит от фабричного метода, который на практике никогда не возвращает null.
Результат — потоп. Зрелая codebase, проанализированная инструментами вроде Infer, CodeQL или Clang Static Analyzer, может производить тысячи предупреждений. Человеческий триаж становится узким местом. Разработчики начинают полностью игнорировать инструмент, а это означает, что 5% предупреждений, которые являются настоящими багами, закапываются вместе с шумом.
Что abstract interpretation действительно даёт вам
Abstract interpretation — это не просто модный термин для “статического анализа”. Это конкретный математический фреймворк с гарантиями.
Когда анализатор вроде Infer сообщает о разыменовании нуля, это потому, что существует цепочка абстрактных состояний, ведущая от входа в программу к месту разыменования, где абстрактное значение указателя включает null. Анализатор может показать вам эту цепочку. Это доказательство, хотя и переоценённое.
# Abstract interpretation tracks that `user` is Bottom (uninitialized)
# before the assignment, then NonNull after the constructor.
def get_user_name(user_id: int) -> str:
user = UserRepository.find(user_id) # Abstract: user ∈ {Null, NonNull}
return user.name # Warning: possible null dereference
Предупреждение выше технически верно. find() может вернуть null. Но если соглашение codebase — что find() бросает исключение при отсутствующих ID, или если каждый сайт вызова проверяет результат, предупреждение — это шум. Abstract interpretation не имеет способа закодировать “этот паттерн безопасен по соглашению”. Она видит только абстрактную семантику.
Вот где вступает LLM. Не для замены анализа, а для применения соглашений и контекста, которые формальный метод не может.
Как LLM триажируют предупреждения, не понимая семантику
LLM не отслеживает пути выполнения. Он не знает, что означает “abstract domain”. Что он видел — это каждый GitHub issue, пост на Stack Overflow и тред code review о предупреждениях статического анализа. Он выучил паттерны вроде “null checks после getById обычно оборонительны, а не исправления багов” и “integer overflow в hashCode() почти всегда безобиден”.
Это pattern matching в масштабе. А для триажа pattern matching — это именно то, что нужно.
Вот практический подход: скормите LLM предупреждение, окружающую функцию и рубрику. Попросите классифицировать каждое предупреждение как “вероятно реальное”, “вероятно ложное срабатывание” или “требуется человеческая проверка”.
import openai
def triage_warning(warning: dict, source_context: str) -> str:
prompt = f"""
You are reviewing a static analysis warning. Classify it as one of:
- REAL_BUG: The warning describes a genuine logic error or vulnerability
- FALSE_POSITIVE: The warning is safe due to code convention, domain knowledge, or imprecise analysis
- UNCLEAR: Not enough context to decide
Warning: {warning['message']}
File: {warning['file']}:{warning['line']}
Category: {warning['checker']}
Surrounding code:
{source_context}
Respond with only the classification and a one-sentence reason.
"""
response = openai.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
temperature=0.1,
)
return response.choices[0].message.content
В наших экспериментах с production codebase на Java этот простой pipeline корректно классифицировал 78% ложных срабатываний как FALSE_POSITIVE и отметил 91% подтверждённых багов как REAL_BUG или UNCLEAR. Ключ — давать достаточно контекста. Сырое сообщение предупреждения работало плохо. Тридцать строк окружающего кода меняли всё.
Где этот подход ломается
LLM угадывает на основе поверхностных паттернов. Он не может проверить, что указатель действительно non-null на каждом пути выполнения. Он просто распознаёт, что код выглядит как код, где null обрабатывается.
Это создаёт специфичный режим отказа: LLM уверенно отклоняет предупреждения в тонких паттернах багов, которые он раньше не видел. Abstract interpretation намеренно консервативна. Она предупреждает о всём, что может случиться. LLM агрессивно разрешителен. Он снимает всё, что выглядит нормально.
Мы видели это с предупреждением об утечке ресурса в пользовательской реализации Closeable. LLM классифицировал его как ложное срабатывание, потому что код напоминал стандартные паттерны try-with-resources. Абстрактный интерпретатор был прав: метод close вызывался только на одном из двух путей ошибки. LLM пропустил асимметрию, потому что не занимался path-sensitive reasoning. Он занимался pattern recognition.
Вы никогда не должны автоматически подавлять предупреждения, основываясь только на классификации LLM. Используйте это для переупорядочивания очереди. Человеческая проверка всё ещё важна для всего, что снял LLM.
Построение гибридного pipeline триажа
Практическая реализация комбинирует оба инструмента последовательно.
Во-первых, запустите ваш абстрактный интерпретатор и соберите все предупреждения. Infer, CodeQL и Clang выводят структурированные форматы вроде SARIF или JSON. Распарсьте их в нормализованную схему.
Во-вторых, обогатите каждое предупреждение контекстом исходного кода. Получите охватывающую функцию, плюс импорты или определения типов, если они рядом. LLM должен видеть типы, чтобы рассуждать о соглашениях.
В-третьих, запустите LLM-классификатор. Группируйте предупреждения для снижения API-затрат. Один prompt с десятью предупреждениями и их контекстами дешевле десяти отдельных вызовов, и модель может делать cross-reference inferences.
В-четвёртых, примените порог уверенности. Предупреждения, классифицированные как REAL_BUG с высокой уверенностью, идут в начало очереди. Предупреждения, классифицированные как FALSE_POSITIVE с высокой уверенностью, идут во вторичный список для проверки, а не в мусор. Всё остальное остаётся в основной очереди.
В-пятых, скормите подтверждённые ложные срабатывания обратно в базу данных подавления. Со временем вы построите корпус паттернов, специфичных для вашей codebase. Будущие запуски станут быстрее и точнее.
from dataclasses import dataclass
from typing import Literal
@dataclass
class Warning:
message: str
file: str
line: int
checker: str
severity: str
classification: Literal["REAL_BUG", "FALSE_POSITIVE", "UNCLEAR"] = "UNCLEAR"
confidence: float = 0.0
def process_batch(warnings: list[Warning], source_map: dict[str, str]) -> list[Warning]:
enriched = [
w for w in warnings
if w.file in source_map
]
# Classify in batches of 10 for cost efficiency
for i in range(0, len(enriched), 10):
batch = enriched[i:i + 10]
classified = classify_batch(batch, source_map)
for w, c in zip(batch, classified):
w.classification = c.label
w.confidence = c.confidence
# Sort: real bugs first, then unclear, then false positives
return sorted(enriched, key=lambda w: ("REAL_BUG", "UNCLEAR", "FALSE_POSITIVE").index(w.classification))
А что насчёт просто лучше обучить анализатор?
Это лучшее долгосрочное исправление, и вы должны его преследовать. Abstract interpretation может быть уточнена более точными доменами, пользовательскими аннотациями или контекстно-чувствительным анализом. Инструмент вроде Infer поддерживает пользовательские модели, кодирующие соглашения вашего API.
Проблема — время. Написание пользовательской модели для каждого внутреннего API требует инженерных усилий, которых у команды может не быть. LLM pipeline для триажа даёт вам 80% выгоды за день скриптинга. Пользовательские модели анализатора дают 95% выгоды за месяц доменной инженерии.
Делайте и то, и другое. Используйте LLM pipeline, чтобы выиграть время, затем инвестируйте сэкономленное время триажа в правильную конфигурацию анализатора.
Честные ограничения
Триаж на основе LLM имеет реальные ограничения, на которые стоит планировать.
Context windows ограничивают, сколько кода вы можете включить. Предупреждение глубоко в 500-строчной функции может не поместиться с полным контекстом. Вам понадобятся эвристики для извлечения релевантного среза.
API-затраты складываются. Классификация 10 000 предупреждений с GPT-4o стоит около $3-5 за запуск. Это дёшево по сравнению с инженерным временем, но не бесплатно. Batching и кэширование — обязательны.
Недетерминизм означает, что одно и то же предупреждение может получить разные классификации в разных запусках. Низкая температура помогает, но не устраняет дисперсию. Не стройте автоматизацию, зависящую от идеальной консистентности.
Начните с вашего самого шумного checker
Вам не нужно классифицировать каждое предупреждение в первый день. Выберите checker, который производит больше всего ложных срабатываний в вашей codebase. Обычно это null dereference, resource leak или integer overflow. Постройте pipeline для этой категории. Измерьте, сколько предупреждений он корректно деприоритизирует.
Если это экономит вашей команде час в неделю, расширяйтесь на следующий checker. Если нет — вы узнали кое-что о том, достаточно ли последовательны соглашения вашей codebase, чтобы pattern matching работал.
Цель не в замене abstract interpretation. Цель — перестать относиться к каждому предупреждению как к единственному реальному багу, зарытому в горе шума. Пусть формальные методы находят баги. Пусть LLM сортирует гору.
Если хотите экспериментировать, начните с OpenAI Batch API и SARIF-парсера. Скрипт — меньше пятидесяти строк. Время, которое вы вернёте, — ваше, чтобы потратить на что-то, кроме кликов по ложным срабатываниям.