Если дедлайн плавающий или его нет, обучение и пет-проекты превращаются в вечный "черновик": сегодня читаешь доки, завтра переписываешь пример, послезавтра думаЕсли дедлайн плавающий или его нет, обучение и пет-проекты превращаются в вечный "черновик": сегодня читаешь доки, завтра переписываешь пример, послезавтра дума

28 дней со Spring AI: от простого чата до полноценного инструмента

2026/02/17 07:24
14м. чтение

Если дедлайн плавающий или его нет, обучение и пет-проекты превращаются в вечный "черновик": сегодня читаешь доки, завтра переписываешь пример, послезавтра думаешь про идеальную архитектуру. Это нормальный творческий процесс - пока не заметишь, что за месяц у тебя так и нет ничего, что можно запустить и показать.

Когда я проходил 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"

Spring AI хорошо ускоряет старт, но важно понимать границу: он закрывает слой интеграции, а "прод-поведение" почти всегда рождается из вашей инженерной обвязки.

Что дает 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),

  • дополнительные интеграции (граф, голос и т.п.) - их можно не включать.

Быстрый старт (docker-compose)

git clone https://github.com/GrinRus/ai_advent_challenge.git cd ai_advent_challenge # дальше - по README и docker-compose: поднять backend, backend-mcp и frontend


Практические блоки

Блок 1. ChatClient и режимы: sync, stream, structured

Проблема

В реальных системах почти всегда нужны три сценария:

  1. Интерактивный чат - нужен streaming (UX), устойчивость к разрывам, понятная деградация.

  2. Сервисные вызовы - удобнее sync: проще таймауты/ретраи/тестирование.

  3. Structured output - нужен контракт, валидация и предсказуемая обработка ошибок.

  4. Несколько моделей - под разные задачи (скорость/стоимость/контекст), и необходимость тюнить параметры запроса на лету - без релиза (например, overrides на temperature/top_p/max_tokens под конкретный запрос).

Что дает Spring AI

ChatClient - единый интерфейс, где и sync, и stream выглядят одинаково по стилю.

Что пришлось достроить

  • Развести режимы по контракту API: streaming-чат != sync-операция != structured вызов.

  • Устойчивый lifecycle стрима:

    • корректно закрывать SSE,

    • не оставлять подписки,

    • обрабатывать таймауты/ошибки.

  • Делать preflight до открытия стрима (чтобы не начинать SSE, если запрос заведомо "не влезет").

  • Пробрасывать conversationId везде, где есть сессия/память.

Пример (фрагмент): preflight + conversationId + tool callbacks

Полный файл: 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();


Блок 2. Advisors: точка сборки логики вокруг вызова модели

Проблема

Очень быстро выясняется, что обычный "вызов модели" - не самый сложный кусок. Интересное начинается вокруг вызовов:

  • подключить память,

  • добавить RAG,

  • подмешать системные инструкции/политики,

  • нормализовать и ограничить контекст,

  • включить наблюдаемость.

Если размазать это по контроллерам/сервисам - получится код, который сложно расширять и еще сложнее поддерживать.

Что дает Spring AI

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 начинают работать по одному и тому же ключу.


Блок 3. Память и контекст: как вообще устроена "память" у LLM

Важный факт

У LLM нет памяти в привычном смысле: модель не хранит состояние между запросами и отвечает только на то, что вы дали ей в контекст конкретного вызова. В Spring AI это прямо отражено в концепции "внешней памяти": Chat Memory.

Отсюда вытекает ключевая инженерная мысль:

Когда кто-то говорит "у нас есть память в llm", обычно это означает: мы где-то храним историю/факты и каждый раз решаем, какую часть вернуть в prompt.

Проблема

Контекстное окно у модели ограничено, а в запрос "лезут":

  • история диалога,

  • system prompt,

  • tool schemas и tool outputs,

  • RAG-документы,

  • сама задача пользователя.

Что может излишне "раздуть" контекст после чего модель может начать теряться в переданных токенах.

Что дает Spring AI

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());

Пример (фрагмент): summarization очередь + деградация

Полный файл: ChatMemorySummarizerService.java

if (!enqueueSummarization(result)) { log.warn("Summarisation queue is full (session={}), skipping request", result.sessionId()); recordFailure(result.sessionId(), "chat", "Summarisation queue saturated"); }

Пример (фрагмент): summary + tail без дублей

Полный файл: 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);


Блок 4. Structured output: когда нужен объект, а не текст

Structured output - это подход, где вы просите модель вернуть ответ в строго заданном формате (чаще всего JSON), чтобы затем:

  • распарсить в DTO,

  • провалидировать,

  • и безопасно встроить в бизнес-логику (пайплайны, оркестрации, автоматические решения).

Где это реально полезно

  • классификация (например, "определи тип запроса"),

  • извлечение структурированных данных из текста (сущности, поля формы),

  • построение плана действий (список шагов, параметры инструмента),

  • генерация настроек/конфига,

  • машиночитаемые промежуточные результаты между шагами оркестрации.

Что дает Spring AI

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);


Блок 5. Tools: почему tool calling стал стандартом

Если упростить до одной фразы: LLM хороша в рассуждении и планировании, но "действия" она совершает через инструменты.

Tool calling (оно же function calling / tool use) - де-факто стандартный способ "подключить внешний мир" к модели:

  • OpenAI: Function calling

  • Anthropic: Tool use

На практике это означает: поиск по данным, вызовы внутренних сервисов, работа с репозиторием, запуск тестов - все это оформляется как инструменты с четким контрактом.

Что дает Spring AI

Tools: ToolCallback, @Tool-аннотации и регистрация tool'ов.

Что пришлось достроить

В системе tools - это не "магия модели", а API-контракты + контроль исполнения:

  • allow-list инструментов под режим/запрос,

  • классификация по риску: read / write / execute / external,

  • таймауты и лимиты на все (включая внешние API),

  • "dry-run first" для любых операций, меняющих состояние,

  • ограничение размера результатов инструментов (чтобы не "съесть" контекст),

  • предсказуемые ошибки "почему нельзя" вместо непойманных исключений.

Пример (фрагмент): GitHub tool как контракт + лимиты

Полный файл: GitHubTools.java

@Tool( name = "github.list_pull_requests", description = "Список PR с фильтрами, лимитами и пагинацией. Возвращает truncated=true если обрезано.") GitHubPullRequestsResponse listPullRequests(GitHubListPullRequestsRequest request) { // ... }

Пример (фрагмент): preview/dry-run как обязательный шаг перед опасными действиями

Полный файл: CodingTools.java

@Tool( name = "coding.apply_patch_preview", description = "Preview патча (dryRun) + опциональный запуск тестов. Таймаут в ISO-8601.") ApplyPatchPreviewResponse applyPatchPreview(ApplyPatchPreviewRequest request) { return codingAssistantService.applyPatchPreview(request); }


Блок 6. Sandbox и live-окружение: контроль обязателен

Как только у модели появляются инструменты уровня "выполни команду" / "запусти тесты", появляется новый класс рисков:

  • таймауты,

  • ресурсы (CPU/RAM/disk),

  • изоляция workspace,

  • очистка после выполнения,

  • безопасность параметров и команд.

И тут важен практический момент:

Что дает Spring AI

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;


Блок 7. RAG по коду, как один из инструментов: что это и зачем

RAG (Retrieval-Augmented Generation) - подход, где вы не надеетесь на "память модели", а подключаете к ответу вашу базу знаний:

  1. заранее индексируете источники (код/доки/вики),

  2. на запрос находите релевантные фрагменты (retrieval),

  3. добавляете их в контекст,

  4. модель отвечает с опорой на реальные куски данных.

Это дает:

  • меньше галлюцинаций по вашему проекту,

  • возможность отвечать "по факту кода", а не "как обычно принято",

  • проверяемость через цитаты (file_path/чанк).

Spring AI описывает RAG как паттерн: RAG.

Немного о ETL-пайплайне

Если данных много (репозитории, монорепы, доки), "просто закинуть в 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.

  • Набор "отладочных сигналов": что реально попало в контекст, сколько, почему.

Пример (фрагмент): skip binary + skip unchanged

Полный файл: 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; }

Пример (фрагмент): multi-query + дедуп + topK после merge

Полный файл: 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;

Пример (фрагмент): контекст с citations

Полный файл: 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"); }


Блок 8. Observability и дисциплина логов

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.


Практикум: сценарий, который можно пройти руками

Идея - не просто "почитать про подходы", а включить инструменты, дать ассистенту репозиторий и прогнать реальные сценарии: анализ кода, поиск, проверка сборки/тестов, объяснение архитектуры, ревью изменений.

Подготовка

  1. Поднимите проект по README (backend + backend-mcp + frontend).

  2. Убедитесь, что настроены ключи, которые нужны сценарию (LLM-провайдер, GitHub).

  3. Во фронтенде включите доступные MCP-инструменты (в проекте есть каталог инструментов, который UI использует, чтобы показать "что доступно").

Промпты для копирования: анализ, сборка, поиск, ревью

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

1) Подключить репозиторий как workspace и понять, что это за проект

Скачай репозиторий https://github.com/GrinRus/ai_advent_challenge в workspace. Дальше: 1) Коротко объясни, что делает проект и из каких компонентов он состоит. 2) Опиши, как связаны backend, backend-mcp и frontend. 3) Если информации не хватает - используй поиск по коду и приводи цитаты (пути файлов + фрагменты). Формат ответа: сначала summary на 5-7 строк, затем список "компонент -> роль".

2) Найти Gradle-подпроекты и проверить тесты в sandbox

Найди все gradle подпроекты (settings.gradle/settings.gradle.kts) и перечисли их. Затем запусти гредл тесты Если есть падения: - перечисли упавшие тесты - покажи ключевые строки стека - предположи 2-3 причины - предложи варианты исправления (без применения изменений)

3) Найти в проекте preflight и оценку токенов

Найди в проекте, где реализован preflight и оценка токенов. Дай ссылки на файлы и коротко опиши, как устроен процесс и какие решения принимаются (что режется/сжимается).

4) Проверить RAG по коду: вопрос, цитаты, отладка

Ответь на вопрос: "Где в проекте реализованы инструменты GitHub и sandbox?" Требования: - приведи цитаты с путями файлов - покажи по 1-2 ключевых фрагмента кода (коротко) - если результатов слишком много - объясни, как ты ограничивал поиск/дедуп/выбор topK

5) Ревью существующего MR/PR или изменений в ветке

Если GitHub-интеграция доступна: 1) Выведи список открытых PR (или последних PR, если открытых нет). 2) Выбери один PR и сделай ревью: - что меняется - потенциальные риски - что стоит протестировать - что можно упростить Формат: краткое резюме + список замечаний.


Что удалось достичь за 28 дней

Самый полезный эффект оказался не в "количестве фич", а в том, что ежедневная работа с 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

Источник

Отказ от ответственности: Статьи, размещенные на этом веб-сайте, взяты из общедоступных источников и предоставляются исключительно в информационных целях. Они не обязательно отражают точку зрения MEXC. Все права принадлежат первоисточникам. Если вы считаете, что какой-либо контент нарушает права третьих лиц, пожалуйста, обратитесь по адресу [email protected] для его удаления. MEXC не дает никаких гарантий в отношении точности, полноты или своевременности контента и не несет ответственности за любые действия, предпринятые на основе предоставленной информации. Контент не является финансовой, юридической или иной профессиональной консультацией и не должен рассматриваться как рекомендация или одобрение со стороны MEXC.