Если дедлайн плавающий или его нет, обучение и пет-проекты превращаются в вечный "черновик": сегодня читаешь доки, завтра переписываешь пример, послезавтра думаешь про идеальную архитектуру. Это нормальный творческий процесс - пока не заметишь, что за месяц у тебя так и нет ничего, что можно запустить и показать.
Когда я проходил AI Advent Challenge этот режим прокрастинации сломался: 28 дней подряд у тебя есть ровно сутки. В 10:00 приходит задание, а в 10:00 следующего дня - дедлайн. Поэтому каждый день заканчивается одной из двух вещей: либо у тебя есть работающий кусок, либо ты точно понимаешь, где решение не выдержало и почему.
Разработка шла в стиле "vibe coding": главный артефакт - backlog "что сделать", минимум upfront-дизайна, максимум итераций и постоянная проверка, что базовая работоспособность не сломалась.
Самое принципиальное решение было не про "какую модель взять", а про стек. Вместо привычного "Python + что-то вокруг LLM" я остался в своем прод-стеке: Java + Spring Boot, а AI-часть собрал на Spring AI. Цель была прикладная: понять, насколько LLM-интеграции нормально живут в Java-стеке, если делать не "демо-чатик", а инструмент, который реально помогает в задачах и умеет:
sync / stream / structured режимы,
"память" и управление контекстом,
tool calling с ограничениями,
RAG по коду с цитатами,
sandbox для тестов и команд,
интеграции с GitHub/workspace,
немного orchestration поверх этого.
Началось все с базового чата. Но по мере того как задания усложнялись, получилась связка "retrieval + инструменты + окружение" - когда модель не просто отвечает, а помогает искать по коду, проверять гипотезы и воспроизводимо выполнять сценарии вокруг проекта.
Spring AI хорошо ускоряет старт, но важно понимать границу: он закрывает слой интеграции, а "прод-поведение" почти всегда рождается из вашей инженерной обвязки.
ChatClient - единый API к моделям: prompt -> call()/stream() -> response.
Advisors - прослойки вокруг вызова модели (как middleware).
Chat Memory - хранение истории/фактов вне модели и подмешивание их в запрос по conversationId.
Tools - tool calling / function calling внутри приложения.
RAG - базовые паттерны retrieval + интеграции для vector store.
Observability - точки интеграции в метрики/трейсы вокруг вызовов.
Token budget + preflight: оценить заранее "влезает ли запрос" и что резать, если не влезает.
Контекст-менеджмент: summarization/pruning/pinned facts + лимиты на tool outputs и RAG-контент.
Безопасность инструментов: allow-list, таймауты, лимиты ресурсов, dry-run.
RAG как пайплайн: multi-query, дедуп, пост-обработка + цитаты.
Sandbox для команд/тестов: изоляция, ограничения, cleanup.
Два коротких "продовых" примера, почему это нужно:
Стриминг может оборваться на середине - а вы все равно должны корректно закрыть соединение, не оставить подписки и сохранить "что успели сгенерировать".
Инструмент может вернуть огромный вывод (логи/дифф/тесты) - и если не ограничить размер, он "съест" весь контекст и ухудшит качество следующего шага.
Ниже - минимум, чтобы воспроизвести проект локально и пройти сценарии из статьи.
Что потребуется:
ключ/доступ к LLM-провайдеру (или локальная модель),
Docker,
базовое понимание Spring Boot,
GitHub token (если хотите сценарии интеграции с GitHub),
дополнительные интеграции (граф, голос и т.п.) - их можно не включать.
git clone https://github.com/GrinRus/ai_advent_challenge.git cd ai_advent_challenge # дальше - по README и docker-compose: поднять backend, backend-mcp и frontend
В реальных системах почти всегда нужны три сценария:
Интерактивный чат - нужен streaming (UX), устойчивость к разрывам, понятная деградация.
Сервисные вызовы - удобнее sync: проще таймауты/ретраи/тестирование.
Structured output - нужен контракт, валидация и предсказуемая обработка ошибок.
Несколько моделей - под разные задачи (скорость/стоимость/контекст), и необходимость тюнить параметры запроса на лету - без релиза (например, overrides на temperature/top_p/max_tokens под конкретный запрос).
ChatClient - единый интерфейс, где и sync, и stream выглядят одинаково по стилю.
Развести режимы по контракту API: streaming-чат != sync-операция != structured вызов.
Устойчивый lifecycle стрима:
корректно закрывать SSE,
не оставлять подписки,
обрабатывать таймауты/ошибки.
Делать preflight до открытия стрима (чтобы не начинать SSE, если запрос заведомо "не влезет").
Пробрасывать conversationId везде, где есть сессия/память.
Полный файл: ChatStreamController.java
preflightManager.run(context.sessionId(), selection, sanitizedMessage, "stream-chat"); SseEmitter emitter = new SseEmitter(0L); var promptSpec = chatProviderService.chatClient(selection.providerId()).prompt(); promptSpec = promptSpec .user(sanitizedMessage) .advisors(advisors -> advisors.param(ChatMemory.CONVERSATION_ID, context.sessionId().toString())); if (researchContext.hasCallbacks()) { promptSpec = promptSpec.toolCallbacks(researchContext.callbacks()); } Flux<ChatResponse> responseFlux = promptSpec.options(chatOptions).stream().chatResponse();
Очень быстро выясняется, что обычный "вызов модели" - не самый сложный кусок. Интересное начинается вокруг вызовов:
подключить память,
добавить RAG,
подмешать системные инструкции/политики,
нормализовать и ограничить контекст,
включить наблюдаемость.
Если размазать это по контроллерам/сервисам - получится код, который сложно расширять и еще сложнее поддерживать.
Advisors - middleware-слои вокруг вызова модели, подключаемые через .advisors(...).
Практически полезная мысль: advisors - это место, где удобно держать "стандартные" части поведения, например:
memory-подмешивание,
retrieval-подмешивание,
trimming/budget-политики,
safety/policy-правила,
observability-обвязку.
Политики: что добавлять в контекст всегда, что - только для отдельных режимов, сколько токенов выделять под память/tools/RAG.
Ограничение размеров tool outputs и retrieval документов (иначе они "съедают" окно).
Вокруг advisor-цепочки - сервисные "рамки": preflight, лимиты, allow-list tools.
Ключевой "якорь", с которого все начинает работать согласованно - привязка conversationId:
ChatStreamController.java
StructuredSyncService.java
Именно там видно: .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, ...)), после чего память и любые другие advisors начинают работать по одному и тому же ключу.
У LLM нет памяти в привычном смысле: модель не хранит состояние между запросами и отвечает только на то, что вы дали ей в контекст конкретного вызова. В Spring AI это прямо отражено в концепции "внешней памяти": Chat Memory.
Отсюда вытекает ключевая инженерная мысль:
Когда кто-то говорит "у нас есть память в llm", обычно это означает: мы где-то храним историю/факты и каждый раз решаем, какую часть вернуть в prompt.
Контекстное окно у модели ограничено, а в запрос "лезут":
история диалога,
system prompt,
tool schemas и tool outputs,
RAG-документы,
сама задача пользователя.
Что может излишне "раздуть" контекст после чего модель может начать теряться в переданных токенах.
ChatMemory и ChatMemoryRepository + типовые стратегии вроде "окна сообщений".
Token budget + preflight: оценка токенов и решение "что резать/сжимать" до вызова модели.
Summarization истории, но контролируемо (очередь + backpressure + деградации).
Хранение памяти в БД, чтобы переживать рестарты и масштабирование.
Паттерн "summary + tail": краткая сводка + хвост последних сообщений.
Полезная практическая стратегия деградации (в общих чертах):
сначала ограничивать tool outputs -> потом ограничивать RAG -> потом сжимать историю -> и только потом "не выполняем запрос".
Полный файл: DefaultTokenUsageEstimator.java
TokenComputation promptComputation = computeTokenCount(encoding, tokenizerName, PROMPT_SEGMENT, request.prompt()); TokenComputation completionComputation = computeTokenCount(encoding, tokenizerName, COMPLETION_SEGMENT, request.completion()); int promptTokens = promptComputation.tokens(); int completionTokens = completionComputation.tokens(); int totalTokens = promptTokens + completionTokens; return new Estimate( promptTokens, completionTokens, totalTokens, promptComputation.cacheHit(), completionComputation.cacheHit());
Полный файл: ChatMemorySummarizerService.java
if (!enqueueSummarization(result)) { log.warn("Summarisation queue is full (session={}), skipping request", result.sessionId()); recordFailure(result.sessionId(), "chat", "Summarisation queue saturated"); }
Полный файл: DatabaseChatMemoryRepository.java
int summarizedOrder = summaries.stream().mapToInt(SummaryRow::sourceEndOrder).max().orElse(0); // summaries -> вперед for (SummaryRow summary : summaries) { result.add(summary.asMessage()); } // tail -> после, без дублей storedMessages.stream() .filter(entry -> entry.messageOrder() > summarizedOrder) .map(StoredMessage::message) .forEach(result::add);
Structured output - это подход, где вы просите модель вернуть ответ в строго заданном формате (чаще всего JSON), чтобы затем:
распарсить в DTO,
провалидировать,
и безопасно встроить в бизнес-логику (пайплайны, оркестрации, автоматические решения).
классификация (например, "определи тип запроса"),
извлечение структурированных данных из текста (сущности, поля формы),
построение плана действий (список шагов, параметры инструмента),
генерация настроек/конфига,
машиночитаемые промежуточные результаты между шагами оркестрации.
Structured Output Converter и связанные механики.
У крупных провайдеров есть механики, которые помогают добиваться более предсказуемого структурированного формата, но это не отменяет необходимости валидации на вашей стороне:
OpenAI: Structured Outputs
Anthropic: Structured Outputs
Даже при "строгих" подсказках/схемах в реальности могут появлятся типовые сбои:
"почти JSON" (лишний текст до/после),
сломанные кавычки/экранирование,
несоответствие типов,
недостающие обязательные поля или "лишние" поля,
частичный ответ из-за лимита токенов,
сложности со streaming-парсингом, если пытаться разбирать JSON "на лету".
Поэтому structured output в проде - это контролируемая, но все равно best-effort интеграция, где валидация и fallback - обязательны.
Отдельный плюс structured-подхода: он хорошо тестируется. Можно делать контрактные/"golden"-тесты на JSON и на DTO-валидацию, а не пытаться тестировать "красоту текста".
Пример (фрагмент): защита от пустого ответаПолный файл: StructuredSyncService.java
preflightManager.run(conversation.sessionId(), selection, userPrompt, "structured-sync"); ChatResponse response = prompt.options(options).call().chatResponse(); String content = extractContent(response); if (!StringUtils.hasText(content)) { throw new SchemaValidationException("Model returned empty response for structured sync"); } StructuredSyncResponse payload = convert(content);
Если упростить до одной фразы: LLM хороша в рассуждении и планировании, но "действия" она совершает через инструменты.
Tool calling (оно же function calling / tool use) - де-факто стандартный способ "подключить внешний мир" к модели:
OpenAI: Function calling
Anthropic: Tool use
На практике это означает: поиск по данным, вызовы внутренних сервисов, работа с репозиторием, запуск тестов - все это оформляется как инструменты с четким контрактом.
Tools: ToolCallback, @Tool-аннотации и регистрация tool'ов.
В системе tools - это не "магия модели", а API-контракты + контроль исполнения:
allow-list инструментов под режим/запрос,
классификация по риску: read / write / execute / external,
таймауты и лимиты на все (включая внешние API),
"dry-run first" для любых операций, меняющих состояние,
ограничение размера результатов инструментов (чтобы не "съесть" контекст),
предсказуемые ошибки "почему нельзя" вместо непойманных исключений.
Полный файл: GitHubTools.java
@Tool( name = "github.list_pull_requests", description = "Список PR с фильтрами, лимитами и пагинацией. Возвращает truncated=true если обрезано.") GitHubPullRequestsResponse listPullRequests(GitHubListPullRequestsRequest request) { // ... }
Полный файл: CodingTools.java
@Tool( name = "coding.apply_patch_preview", description = "Preview патча (dryRun) + опциональный запуск тестов. Таймаут в ISO-8601.") ApplyPatchPreviewResponse applyPatchPreview(ApplyPatchPreviewRequest request) { return codingAssistantService.applyPatchPreview(request); }
Как только у модели появляются инструменты уровня "выполни команду" / "запусти тесты", появляется новый класс рисков:
таймауты,
ресурсы (CPU/RAM/disk),
изоляция workspace,
очистка после выполнения,
безопасность параметров и команд.
И тут важен практический момент:
Spring AI не "сделает sandbox за вас" - исполнение остается зоной ответственности приложения.
Docker runner с профилями (gradle/maven/npm/…),
таймауты и лимиты ресурсов,
ограничения команд (лучше allow-list/шаблоны, чем произвольные строки),
изоляция и cleanup.
Полный файл: DockerRunnerService.java
if (Files.exists(projectAbsolute.resolve("pom.xml"))) return RunnerProfile.MAVEN; if (Files.exists(projectAbsolute.resolve("package.json"))) return RunnerProfile.NPM;
RAG (Retrieval-Augmented Generation) - подход, где вы не надеетесь на "память модели", а подключаете к ответу вашу базу знаний:
заранее индексируете источники (код/доки/вики),
на запрос находите релевантные фрагменты (retrieval),
добавляете их в контекст,
модель отвечает с опорой на реальные куски данных.
Это дает:
меньше галлюцинаций по вашему проекту,
возможность отвечать "по факту кода", а не "как обычно принято",
проверяемость через цитаты (file_path/чанк).
Spring AI описывает RAG как паттерн: RAG.
Если данных много (репозитории, монорепы, доки), "просто закинуть в vector store" быстро не получится. Почти всегда нужна обертка в виде ETL:
Extract: собрать источники, обновления, диффы,
Transform: декодирование, фильтры, чанкинг, метаданные, дедуп,
Load: embeddings -> vector store, контроль версий, пересборка.
В Spring AI это выделено отдельным понятием: ETL Pipeline.
Индексация с защитой от мусора: skip binary, skip unchanged.
Retrieval как пайплайн:
multi-query,
дедуп,
topK после merge,
пост-обработка/реранкинг.
"Цитаты" (file_path/чанк) как обязательный формат.
Бюджеты: ограничивать число документов и размер контента в prompt.
Набор "отладочных сигналов": что реально попало в контекст, сколько, почему.
Полный файл: RepoRagIndexService.java
if (isBinaryFile(file)) { appendWarning(warnings, "Skipped binary file " + relativePath); return FileVisitResult.CONTINUE; } byte[] rawBytes = Files.readAllBytes(file); String fileHash = hashBytes(rawBytes); RepoRagFileStateEntity existingState = stateByPath.get(relativePath); if (existingState != null && fileHash.equals(existingState.getFileHash())) { filesSkipped.incrementAndGet(); return FileVisitResult.CONTINUE; }
Полный файл: RepoRagRetrievalPipeline.java
List<Query> queries = expandQueries(transformedQuery, input, appliedModules); Map<String, AggregatedDocument> dedup = new LinkedHashMap<>(); List<QueryRetrievalResult> retrievalResults = retrieveAll(queries, input); for (QueryRetrievalResult result : retrievalResults) { for (Document document : result.documents()) { accumulateDocument(dedup, document, result.query(), result.index()); } } List<Document> merged = dedup.values().stream().map(AggregatedDocument::toDocument).toList(); List<Document> top = merged.size() > input.topK() ? merged.subList(0, input.topK()) : merged;
Полный файл: RepoRagGenerationService.java
int limit = Math.min(documents.size(), 5); for (int i = 0; i < limit; i++) { Document document = documents.get(i); Map<String, Object> metadata = document.getMetadata(); String path = metadata != null ? (String) metadata.getOrDefault("file_path", "") : ""; builder.append(i + 1).append(". ").append(path).append("\n"); builder.append(document.getText()).append("\n\n"); }
LLM-часть легко превратить в "черный ящик", который сожрет весь ваш бюджет, если не мерить:
latency по режимам (stream/sync/structured),
ошибки (где именно: модель, tools, retrieval, sandbox),
токены/стоимость.
Spring AI поддерживает интеграцию с наблюдаемостью через Observability.
latency p50/p95 по режимам,
tokens in/out (и "fallback-оценка", если провайдер не отдает usage),
tool success/error rate + latency,
retrieval empty rate,
cost estimate.
Идея - не просто "почитать про подходы", а включить инструменты, дать ассистенту репозиторий и прогнать реальные сценарии: анализ кода, поиск, проверка сборки/тестов, объяснение архитектуры, ревью изменений.
Поднимите проект по README (backend + backend-mcp + frontend).
Убедитесь, что настроены ключи, которые нужны сценарию (LLM-провайдер, GitHub).
Во фронтенде включите доступные MCP-инструменты (в проекте есть каталог инструментов, который UI использует, чтобы показать "что доступно").
Скачай репозиторий https://github.com/GrinRus/ai_advent_challenge в workspace. Дальше: 1) Коротко объясни, что делает проект и из каких компонентов он состоит. 2) Опиши, как связаны backend, backend-mcp и frontend. 3) Если информации не хватает - используй поиск по коду и приводи цитаты (пути файлов + фрагменты). Формат ответа: сначала summary на 5-7 строк, затем список "компонент -> роль".
Найди все gradle подпроекты (settings.gradle/settings.gradle.kts) и перечисли их. Затем запусти гредл тесты Если есть падения: - перечисли упавшие тесты - покажи ключевые строки стека - предположи 2-3 причины - предложи варианты исправления (без применения изменений)
Найди в проекте, где реализован preflight и оценка токенов. Дай ссылки на файлы и коротко опиши, как устроен процесс и какие решения принимаются (что режется/сжимается).
Ответь на вопрос: "Где в проекте реализованы инструменты GitHub и sandbox?" Требования: - приведи цитаты с путями файлов - покажи по 1-2 ключевых фрагмента кода (коротко) - если результатов слишком много - объясни, как ты ограничивал поиск/дедуп/выбор topK
Если GitHub-интеграция доступна: 1) Выведи список открытых PR (или последних PR, если открытых нет). 2) Выбери один PR и сделай ревью: - что меняется - потенциальные риски - что стоит протестировать - что можно упростить Формат: краткое резюме + список замечаний.
Самый полезный эффект оказался не в "количестве фич", а в том, что ежедневная работа с LLM быстро калибрует ожидания и вырабатывает понимание что это такое и с чем его едят.
За этот месяц я намного четче понял:
где LLM реально ускоряет работу (обзор кода, сводки, поиск связей, генерация черновиков, классификация/роутинг);
где она стабильно ошибается без "обвязки" (контекст раздувается, structured "плывет", инструменты без лимитов начинают вести себя непредсказуемо);
что качество ответа часто определяется не "умностью модели", а качеством контекста (RAG, цитаты, лимиты, preflight);
что "инструменты" - не дополнение, а центр практического применения: модель рассуждает, а действия делает через tool-слой;
что без наблюдаемости и дисциплины логов вы слишком поздно узнаете, где именно "дорого", "долго" и "ломается".
LLM - это не "магический мозг, который все помнит и все знает", а скорее это еще один инструмент в стеке, но со своими особенностями: модель статична, контекст ограничен, ошибки выглядят непривычно, а "интеллект" проявляется не всегда как ты это ожидаешь.
Поэтому рабочая интеграция выглядит не как "мы прикрутили чат", а как полноценная инженерная система, где LLM играет роль оркестратора:
RAG дает модели факты из ваших данных, а не "догадки",
tools/MCP дают модели действия (прочитать код, запустить тесты, сходить в GitHub),
контекст-менеджмент делает все это возможным в рамках ограниченного окна,
sandbox и ограничения делают действия безопасными и воспроизводимыми,
observability объясняет, что происходит и сколько это стоит.
А дальше уже вы выбираете режим работы процесса:
human-in-the-loop, когда важны контроль и подтверждения,
human-out-of-the-loop, когда готовы автоматизировать целиком отдельные шаги.
Если относиться к LLM именно так - как к компоненту, вокруг которого нужна дисциплина, ограничения и измеримость - тогда "AI-часть" начинает жить в Java/Spring так же естественно, как любые другие части Spring зоопарка.
GrinRus/ai_advent_challenge
Источник

