Разгоняем GateAI

Решил сделать свой Gate AI, который не гоняет просто сканеры (semgrep, osv‑scanner, syft), а отдает каждую находку на триаж Claude api (sonnet 5), прежде чем решить, валить билд или нет. Логика проста, semgrep репортит 200 находок — это не fail. Три из них выжили после триажа как реально эксплуатируемые — вот это fail
Делал это с супер крутой идеей, пока не посчитал токены. В этом посте я расскажу агентный цикл триажа, который быстро упирается в токены, и вот что из этого вышло — конкретная оптимизация с цифрами, 3 бага, которые я не заметил бы без телеметрии, и один момент, когда Claude в буквальном смысле впал в панику посреди tool‑call
Почему это вообще дорого?
Триаж одной находки — это не один запрос. Модель вызывает read_file, search_code, find_callers, list_entrypoints, читает результат, зовёт следующий инструмент — и так до вердикта или потолка в 14 итераций
Проблема в том, как устроен Messages API: на каждой итерации в запрос уходит вся история диалога целиком, включая всё, что модель уже прочитала на прошлых шагах. Итерация № 7 повторно отправляет содержимое итераций 1 — 6. Без кэша каждый шаг платит полную цену за один и тот же контекст заново.
Решение очень быстрое: сделать один плавающий breakpoint.
В коде уже был cache_control на системном промпте — это статика, общая для всех находок стадии. А вот растущая история диалога кэш‑точки не имела вовсе.
Anthropic разрешает до 4 breakpoint'ов на запрос, система уже занимает один. Я держу один плавающий breakpoint на последнем блоке последнего сообщения: перед каждым вызовом снимаю метку со старого места и ставлю на новое. Дешево и работает:)

296 982 из 371 337 входных токенов в финальном прогоне пришли из кэша. А обычного input, который не попал ни в cache read, ни в cache write, осталось всего 147 токенов — примерно 1.7–1.9 токена на API‑вызов. Похоже на служебный overhead Messages API, который breakpoint'ом уже не накрыть

Cache read стоит ≈ 10% от обычной цены input, cache write (TTL 5 минут) — ≈125%. Перевожу в токены‑эквиваленты полной цены — экономия по стоимости прогона получается ≈ 61%
Кэш держит один диапазон на трёх языках
Дальше я не остановился на одном прогоне — погонял GateAI на трёх совершенно разных кодовых базах: свой Go‑репозиторий, вырезанный кусок DVWA (PHP) и вырезанный кусок pyGoat (Python/Django)

80% / 80% / 82% — разные языки, разные прогоны, один и тот же диапазон. Свойство одного механизма все роляет
Три бага, которые нашла только телеметрия
Разбивка токенов по типам (base / cache read / cache write) была нужна мне для одного графика. По пути она случайно стала инструментом код‑ревью — заставила меня читать реальный вывод модели строка за строкой, а не верить агрегатным цифрам.
1. Формула hit‑rate в отчёте врала. Итоговая строка показывала 100%, хотя по стадиям — 75% и 81%. Делил cache_read на (in − cache_write) вместо полного in — из знаменателя случайно выпадали как раз ещё не закэшированные токены
2. Список «пустышек» не поспевал за моделью. В отчёте буквально reasoning: placeholder, потом reasoning: x, потом fix: >. Repair‑логика подставляла вытащенный из сломанного tool‑call текст только если текущее значение поля — известное слово‑пустышка. Модель писала что‑то своё, чего в словаре просто не было. Заменил словарь на проверку содержательности: reasoning короче 5 слов (схема просит «two to six sentences») — стоп, независимо от того, что там конкретно написано
3. Skeptic‑проход шёл в обход собственных guardrail'ов проекта. Второй, состязательный проход над каждым «не эксплуатируется» строил вердикт напрямую, минуя и repair, и проверку «нет evidence / confidence ниже порога → needs_human», которую первый проход проходит всегда. Теоретически эксплуатируемость без единой цитаты мог проскочить мимо защиты, которую я сам заявляю как гарантию проекта. Вынес обе проверки в общую функцию — оба прохода теперь идут через один путь
Проверка на настоящих уязвимостях: DVWA
Своего репозитория для проверки мало — там нет реальных дыр, только шум сканера. Нужны были данные на код с задокументированными уязвимостями, чтобы посчитать вторую половину — ловит ли реальное

Semgrep светит все четыре версии одинаково — паттерн‑матчинг не видит разницы между дырявым и пропатченным кодом. Claude — видит: все 6 находок на impossible.php ушли в not_exploitable, все 18 находок на low/medium/high — заблокированы. Ожидаемо для reasoning‑модели, но раньше это было утверждение на словах, а не число из реального прогона
Заодно вскрылась отдельная, ещё не починенная дыра: в категории exec/ (command injection) semgrep дал 24 находки, но реально уникальных уязвимых строк там всего 8. Три разных правила (exec-use, tainted-exec, injection.tainted-exec) сработали на одной и той же taint‑цепочке, и GateAI триажит каждое как отдельную находку — три независимых агентных цикла на один и тот же вопрос. 37% находок этого прогона — чистый дубль работы. В отличие от кэша, это не про переиспользование контекста, а про то, что вопрос физически задаётся трижды. Дедуп находок по file:line перед триажем — буду фиксить в следующем релизе
pyGoat: та же проверка, но на критике
Второй прогон — pyGoat, намеренно дырявое Django‑приложение. Вопрос тот же, но на более серьёзных находках: RCE и десериализация, не только «отличить low от impossible».
Поймал:
RCE через
eval(request.POST['val'])— прочитал вход, нашёл единственный gate (is_authenticated), процитировал обе строкиRCE через
pickle.loads()на значении, взятом прямо из cookie — классическая небезопасная десериализацияSQL‑инъекция через прямую конкатенацию в raw‑запрос
SSTI через динамическую запись пользовательского контента в файл, который потом рендерится как Django‑шаблон
yaml.Loaderвместоyaml.SafeLoaderна загруженном файле
Cache‑hit на этом прогоне — 82%, 58 находок, 8 минут
А теперь — экзистенциальный кризис Claude
На одной находке модель словила синтаксическую ошибку при вызове submit_verdict. Вместо того, чтобы просто попробовать ещё раз, она ушла в 40-строчный штопор прямо в поле reasoning:
Okay, clearly something is wrong with my output generation causing repeated broken pseudo‑tags.Stop meta‑commentary, call the tool.I'm going to stop apologizing and just do it.FINAL CALL:Note to self: just write the tool call block, nothing else.
и ещё несколько экранов в этом духе, прежде чем вердикт всё‑таки прилетел нормальный. Весь монолог утёк в финальный отчёт, потому что мой repair ловит утечки только с открывающей <parameter name="...">, а тут модель писала теги без скобки
Итог
Кэш агентного цикла — это не система, а один плавающий breakpoint. 80% входных токенов начинают читаться из кэша вместо пересчёта заново, ≈61% экономии по стоимости — и это держится на трёх разных языках, не случайность одного прогона
И неожиданно главным результатом этой оптимизации оказался даже не кэш. Телеметрия LLM‑агента, которую я добавил ради стоимости, заодно нашла три ошибки в логике самого продукта
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.