惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Threatpost
aimingoo的专栏
aimingoo的专栏
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
T
Tailwind CSS Blog
J
Java Code Geeks
博客园_首页
Google Online Security Blog
Google Online Security Blog
Hugging Face - Blog
Hugging Face - Blog
C
CXSECURITY Database RSS Feed - CXSecurity.com
I
Intezer
P
Palo Alto Networks Blog
V
Vulnerabilities – Threatpost
雷峰网
雷峰网
O
OpenAI News
SecWiki News
SecWiki News
小众软件
小众软件
酷 壳 – CoolShell
酷 壳 – CoolShell
美团技术团队
N
News | PayPal Newsroom
Project Zero
Project Zero
Forbes - Security
Forbes - Security
IT之家
IT之家
A
Arctic Wolf
WordPress大学
WordPress大学
Jina AI
Jina AI
T
Tor Project blog
博客园 - 三生石上(FineUI控件)
S
Secure Thoughts
Google DeepMind News
Google DeepMind News
Attack and Defense Labs
Attack and Defense Labs
博客园 - 聂微东
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
P
Privacy International News Feed
Cloudbric
Cloudbric
G
GRAHAM CLULEY
博客园 - 叶小钗
H
Hacker News: Front Page
腾讯CDC
量子位
Help Net Security
Help Net Security
人人都是产品经理
人人都是产品经理
C
Cyber Attacks, Cyber Crime and Cyber Security
月光博客
月光博客
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
宝玉的分享
宝玉的分享
爱范儿
爱范儿
L
Lohrmann on Cybersecurity
Hacker News - Newest:
Hacker News - Newest: "LLM"
Recorded Future
Recorded Future
C
CERT Recently Published Vulnerability Notes

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Техники GenAI в Spring AI
nectar92 (Т- · 2026-05-14 · via Все публикации подряд на Хабре

Один сервис — генератор хайку. Четыре проблемы. Четыре техники.
Путешествие от chatClient.prompt().call() до полноценного AI‑приложения и вызова Anthropic API

Привет, Хабр! Я Руслан Масгутов, архитектор в Т-Банке. Архитекторы не ограничиваются проектированием компонентов и связей между ними — важно глубоко понимать, как работают используемые технологии, и знать о готовых решениях, чтобы не разрабатывать то, что уже реализовано.

В статье рассмотрим ряд техник GenAI, реализованных в модуле Spring AI, и ответим на вопрос: является ChatClient лишь тонкой оберткой API провайдеров LLM или предоставляет функциональные возможности, которые имеет смысл применять в реальных проектах? В качестве примера будем итеративно разрабатывать приложение, интегрированное с Anthropic, и разбирать возникающие по ходу проблемы.

Пролог: три строчки, которые рождают хайку

Все начинается невинно. Мы, Spring-разработчики, слышали о LLM, использовали ChatGPT для быстрого получения ответов, знаем о возможности использовать LLM прямо из приложения и наконец решили попробовать сами. Задача простая и красивая — сервис, который генерирует хайку/хокку (японское стихотворение) по команде пользователя. Пользователь пишет «добавь строку про осень» — модель дописывает стихотворение.

Подключаем spring-ai-anthropic-spring-boot-starter, прописываем API-ключ и создаем первый сервис.

@RestController
@RequestMapping("/api/haiku")
class HaikuController(private val haikuService: HaikuService) {

  @PostMapping
  fun generate(@Valid @RequestBody request: HaikuRequest): HaikuResponse =
  HaikuResponse(haiku = haikuService.generate(request.prompt))

}
@Service
class HaikuService(chatClientBuilder: ChatClient.Builder) {

  private val chatClient: ChatClient = chatClientBuilder.build()
  
  fun generate(prompt: String): String =
    chatClient
    .prompt()
    .user(prompt)
    .call()
    .content()
    ?: error("ChatClient returned null content for prompt: $prompt")

}

Выполняем первый запрос:

curl --location --request POST 'localhost:8080/api/haiku' \
  --header 'Content-Type: application/json' \
  --data-raw '{
  "prompt": "Напиши стих про позднюю зиму"
  }'

Получаем ответ:

{ 
  "haiku": "# Февральское\n\nФевраль устал. Он дышит тяжело,\nСугробы осели, потемнели,\nИ солнце — робкое, несмелое —\nВсё дольше держит нас в тепло.\n\nКапель ещё не слышно, но уже\nВ воздухе что-то тонкое дрожит,\nИ ворон на берёзе не спешит —\nСидит и щурится в тиши.\n\nЗима стоит на шатком рубеже,\nЦепляется за ночи и метели,\nНо дни — длиннее. Птицы ошалели\nИ что-то вспомнили в душе.\n\nЕщё не март. Но что-то уже — март.\nЕщё мороз. Но что-то уже — талость.\nИ эта странная усталость\nЗимы как брошенный штандарт."
}

Вроде бы работает. Но иногда модель может начать писать по-японски. Иногда —  прозой. Иногда выдает четыре строки вместо трех. Иногда пишет эссе о природе хайку вместо самого стихотворения.

Модель не знает, чего мы хотим. Она просто отвечает на запрос пользователя. Все зависит от него.

На этом этапе мы начинаем наше путешествие.

Карта маршрута: четыре проблемы, четыре техники, один сервис.

  1. Модель пишет что попало — Prompt Engineering.

  2. Ответ представляет собой неструктурированную строку — Structured Output.

  3. Каждый запрос с чистого листа — Chat Memory.

  4. Система для нас черный ящик — Observability.

Prompt Engineering: научить модель писать хайку

Проблема. Запустим несколько запросов подряд с командой «Напиши строку про осенний лист».

Ответы каждый раз будут разными. Модель то соблюдает правило 5—7—5 слогов, то игнорирует его. То добавляет объяснение, то нет. То пишет по-русски, то вдруг переключается на английский.

Причина в том, что мы ничего не сказали модели о том, кто она и что должна делать. Мы отправили голый текст и получили голый текст обратно.

Справка: что такое промт как программа. Если смотреть на LLM как на черный ящик, который принимает текст и возвращает текст, промт кажется просто вопросом. Но точнее думать о промте как о программе для языковой модели.

Модель обучена на огромном корпусе текстов. Она видела тысячи хайку, тысячи объяснений правил жанра, тысячи примеров мастеров. Все это знание в ней есть, но по умолчанию она не знает, какое именно знание активировать для вашего запроса.

Промт — это инструкция, которая направляет ее в нужную часть пространства ответов.

Существует несколько фундаментальных техник.

Zero-shot prompting — спросить без контекста. Работает для простых, однозначных задач. Для творческих задач с правилами — ненадежно.

Few-shot prompting — показать примеры правильных ответов прямо в промте. Модель понимает паттерн и воспроизводит его. Для хайку это значит показать три-пять примеров с правильной структурой.

Role prompting — дать модели роль, которую она должна играть. «Ты мастер хайку в традиции Мацуо Басе» работает лучше, чем «Напиши хайку». Роль активирует релевантное знание и задает стиль. Это не магия — это то, как работает in-context learning.

Chain-of-Thought (CoT) — попросить модель думать пошагово. Для хайку это «Сначала определи образ, затем посчитай слоги, затем напиши строку». Промежуточные шаги рассуждения улучшают качество финального ответа —  особенно для задач с жесткими ограничениями вроде метрики стиха.

Промт как данные: почему нельзя держать его в коде. Первый порыв — написать промт прямо в Kotlin-коде:

val systemPrompt = """
  Ты мастер хайку. Пиши в традиции Мацуо Басё.
  Соблюдай правило 5—7—5 слогов.
  Отвечай только строкой хайку, без пояснений.
  """

Это работает. Но давайте подумаем, что происходит дальше.

Промт — это не логика приложения, это конфигурация. Он будет меняться чаще, чем меняется код: вы будете экспериментировать с формулировками, добавлять примеры, менять тон. Каждое изменение промта в .kt-файле — коммит, ревью, сборка, деплой.

Кроме того, интерполяция строк Kotlin для сложных промтов быстро превращается в проблему:

  • Нет валидации. Если забыли передать переменную $theme, получим строку с литеральным $theme в промте. Без ошибки компиляции, без исключения.

  • Экранирование. Когда промт содержит примеры в формате JSON или фигурные скобки, нужно экранировать их от интерполяции.

  • Переиспользование. Один шаблон промта для разных контекстов невозможен без рефакторинга кода.

Читаемость diff. Изменение одной строки промта в длинной строке Kotlin создает нечитаемый diff в Git.

Решение: вынести промты в файлы ресурсов. Это обычные текстовые файлы — их можно версионировать отдельно, передавать продуктовой команде, менять без перекомпиляции.

Мы добавим для нашего приложения 2 промта: системный и пользовательский.

Системный промт: роль, правила, примеры. Применим техники Role prompting и Few-shot prompting. Создадим файл src/main/resources/prompts/system.st.

Ты — Мацуо Басе, величайший мастер хайку эпохи Эдо.
Ты пишешь только в строгой форме хайку: три строки с ритмической структурой 5-7-5 слогов.
Ты отвечаешь только самим стихотворением — без заголовков, пояснений и знаков препинания, кроме переносов строк.

Примеры:
Команда: Напиши, Тема: осенний дождь

Листья тихо льнут
к мокрым камням у тропы —
дождь смывает день

Команда: Сочини, Тема: рассвет над морем
Алый край небес
будит спящие волны —
чайка первой встает

Команда: Создай, Тема: одиночество зимой
Снег засыпал сад
нет ни следа, ни голоса —
только я и ночь

Пользовательский шаблон у нас будет довольно банальным, но его тоже поместим в каталог ресурсов src/main/resources/prompts/user.st

{!

Подстановки (заполняются через PromptTemplate#createMessage, в LLM не попадают):
{command} — глагол-команда от пользователя (например: «Напиши», «Сочини», «Создай»); max 200 символов
{theme} — тема или образ хайку (например: «осенний дождь», «закат над рекой»); max 200 символов

!}

{command}: {theme}

{! и !} экранируют комментарии, которые библиотека StringTemplate уберет при рендеринге.

Мотивация для StringTemplate (ST4). Spring AI использует для рендеринга шаблонов библиотеку StringTemplate 4 (ST4), созданную Теренсом Парром, автором ANTLR. Синтаксис ST4: <variableName> вместо $variableName в Kotlin. В Spring AI дефолтные токены подстановок <> заменены на {}.

Почему ST4, а не простая подстановка строк:

  • Именованные параметры и валидация. ST4 бросает исключение, если обязательная переменная не передана. Такой подход обнаруживает ошибки в runtime немедленно, а не отправляет промт с <theme> буквально.

  • Условные блоки прямо в шаблоне. Промт может содержать секцию с примерами, только если они переданы: <if(examples)>Примеры:\n<examples><endif>. Логика отображения живет в шаблоне, а не в Java/Kotlin-коде.

  • Разделение ответственности. Шаблон описывает структуру промта. Код передает данные. Это реализация паттерна MVC, но при взаимодействии с LLM.

  • Переиспользование. Один .st-файл может использоваться в нескольких местах приложения с разными параметрами.

Я не смог найти обоснованного ответа, почему выбор пал именно на ST4, а не на другие библиотеки шаблонизации, более привычные Java/Kotlin-разработчикам. Вероятно, это связано с возможностью поменять токены, используемые для подстановки. Если у кого-то есть ответ, напишите в комментариях.

В Spring AI: путь до AnthropicChatModel.call(). Посмотрим, как .st-файл превращается в Prompt перед уходом в модель.

В HaikuService.kt добавили загрузку промтов из файлов ресурсов.

class HaikuService(
  chatClientBuilder: ChatClient.Builder,
  @Value("classpath:prompts/system.st") private val systemResource: Resource,
  @Value("classpath:prompts/user.st") private val userResource: Resource,
) {

  private val chatClient: ChatClient = chatClientBuilder.build()
  
  fun generate(command: String, theme: String): String {
    val systemMessage = SystemPromptTemplate(systemResource).createMessage()
    val userMessage = PromptTemplate(userResource)
    .createMessage(mapOf("command" to command, "theme" to theme))
    return chatClient
      .prompt(Prompt(listOf(systemMessage, userMessage)))
      .call()
      .content()
      ?: error("ChatClient returned null content for command='$command', theme='$theme'")
  }

}

Наше приложение получает запрос

curl --location --request POST 'localhost:8080/api/haiku' \
–-header 'Content-Type: application/json' \
--data-raw '{
"command": "Напиши стих про позднюю зиму",
"theme": "Киберпанк"
}'

Возвращает ответ

{
  "haiku": "Неон сквозь туман\nгреет мертвые провода —\nснег не тает здесь"
}

Теперь посмотрим на классы Spring AI, которые участвуют в этом процессе. PromptTemplate — центральный класс для работы с шаблонами.

public class PromptTemplate implements PromptTemplateActions, PromptTemplateMessageActions {

  private static final Logger log = LoggerFactory.getLogger(PromptTemplate.class);
  
  private static final TemplateRenderer DEFAULT_TEMPLATE_RENDERER = StTemplateRenderer.builder().build();
  
    /**
  * If you're subclassing this class, re-consider using the built-in implementation
  * together with the new PromptTemplateRenderer interface, designed to give you more
  * flexibility and control over the rendering process.
  */
  private final String template;
  
  private final Map<String, Object> variables = new HashMap<>();
  
  private final TemplateRenderer renderer;
  
  public PromptTemplate(Resource resource) {
    this(resource, new HashMap<>(), DEFAULT_TEMPLATE_RENDERER);
  }
  
  //.......
  
  @Override
  public String render(Map<String, Object> additionalVariables) {
    Map<String, @Nullable Object> combinedVariables = new HashMap<>();
    Map<String, Object> mergedVariables = new HashMap<>(this.variables);
    // variables + additionalVariables => mergedVariables
    if (additionalVariables != null && !additionalVariables.isEmpty()) {
      mergedVariables.putAll(additionalVariables);
    }
  
    for (Entry<String, Object> entry : mergedVariables.entrySet()) {
      if (entry.getValue() instanceof Resource resource) {
        combinedVariables.put(entry.getKey(), renderResource(resource));
      }
      else {
        combinedVariables.put(entry.getKey(), entry.getValue());
      }
      
    }
    
    return this.renderer.apply(this.template, combinedVariables);
  }
  
  //.......
  
  @Override
  public Message createMessage(Map<String, Object> additionalVariables) {
    return new UserMessage(render(additionalVariables));
  }
  	
  //.......
}

Класс PromptTemplate представляет объединение объектов — шаблона, значений переменных и класс для рендеринга строки TemplateRenderer, с некоторым количеством удобных методов. Передать переменные для подстановки в шаблон мы можем как в конструкторе, так и через дополнительные методы, которые и использовали в примере.

SystemMessage и UserMessage — типизированные обертки над ролями. Взглянем на диаграмму доступных классов из документации Spring AI.

Диаграмма классов из документации Spring AI

Диаграмма классов из документации Spring AI

Семейство классов Message используется для корректного разложения контекста на контракт провайдера AI. Разберем несколько типов сообщений:

SystemMessage — используется для передачи системного промта при запросе.

UserMessage — пользовательский промт при запросе

ToolResponseMessage — ответ, полученный через вызов инструмента

AssistantMessage — используется для передачи предыдущих сообщений текущего диалога.

Prompt — финальный контейнер перед вызовом модели, который целиком передается в AnthropicChatModel.call(prompt). Разберем какой путь он проходит и из чего состоит.

public class Prompt implements ModelRequest<List<Message>> {
  private final List<Message> messages;
  
  private @Nullable ChatOptions chatOptions;
  
  //......

ChatOptions представлен наследником AnthropicChatOptions. Класс содержит параметры модели с двумя уровнями конфигурации — дефолтные опции, которые были заданы в ChatClient, и опций per‑request, которые можно указать в классе Prompt. 

Список основных опций генерации для наглядности собрал в таблицу.

Опция

Тип

Описание

model

String

Модель Claude (например, claude-3-7-sonnet‑latest) 

temperature

Double

Температура (случайность генерации)

maxTokens

Double

Максимальное количество токенов в ответе

topP

Double

Nucleus sampling — порог вероятности токенов

topK

Integer

Top‑K sampling — количество топ‑токенов

stopSequences

List<String>

Последовательности, при которых генерация останавливается

Помимо опций для генерации есть ряд опций по вызову инструментов (Tool Calling), выводу (Structure Output), кэшированию и расширенному мышлению (Thinking). Для подробного изучения можно почитать документацию.

Разберем наши три строчки кода.

.prompt(Prompt(listOf(systemMessage, userMessage)))
.call()
.content()

Метод.prompt() получает объект Prompt и возвращает объект для запроса — DefaultChatClientRequestSpec, копируя в него настройки и сообщения из промта.

Метод DefaultChatClientRequestSpec.call() возвращает DefaultCallResponseSpec, обертка, через которую можно выполнить запрос. 

Посмотрим на часть метода DefaultCallResponseSpec.doGetObservableChatClientResponse

var chatClientResponse = observation.observe(() -> {
  // Apply the advisor chain that terminates with the ChatModelCallAdvisor.
  var response = this.advisorChain.nextCall(chatClientRequest);
  observationContext.setResponse(response);
  return response;
});

Из кода видим, что ответ мы получаем от самого последнего advisor в цепочке — ChatModelCallAdvisor. AdvisorChain реализует паттерн Chain of Responsibility. Мы с ним могли взаимодействовать уже в контексте Filter Chain в Servlet.

Ключевой метод ChatModelCallAdvisor выглядит так

@Override
public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
  Assert.notNull(chatClientRequest, "the chatClientRequest cannot be null");
  
  ChatClientRequest formattedChatClientRequest = augmentWithFormatInstructions(chatClientRequest);
  
  ChatResponse chatResponse = this.chatModel.call(formattedChatClientRequest.prompt());
  return ChatClientResponse.builder()
    .chatResponse(chatResponse)
    .context(Map.copyOf(formattedChatClientRequest.context()))
    .build();

}

chatModel.call и есть AnthropicChatModel, подключаемый через стандартный механизм Spring поиска доступных бинов. Здесь заканчивается зона ответственности ChatClient и начинается адаптер провайдера.

К концу этого этапа наш сервис умеет генерировать настоящие хайку с соблюдением структуры. Промт живет в файлах‑ресурсах и управляется независимо от кода. Модель знает свою роль, знает правила, видит примеры.

Но ответ — строка. Одна строка, в которой смешано стихотворение и, возможно, объяснение. Нам нужно отдельно получить текст каждой строки хайку и отдельно — ее смысл. Парсить это вручную ненадежно.

Structured Output — три строки и их смысл

Проблема: владелец продукта хочет показывать рядом с каждой строкой хайку ее поэтическое объяснение. Пользователь видит строку и понимает, какой образ за ней стоит. Значит нам нужен не просто текст, а структура: список из трех элементов, каждый из которых содержит line (текст строки) и meaning (объяснение образа). Подобный подход реализуется техникой reasoning.

Первая идея — попросить модель отвечать в определенном формате и парсить строку вручную. Это хрупко: достаточно чуть иной формулировки в ответе, и парсер ломается. Нужно что‑то надежнее.

Справка: Output Parsing и JSON Schema как контракт. Языковые модели по своей природе генерируют токены, а не объекты. Это авторегрессионный процесс: каждый следующий токен предсказывается на основе всего предыдущего контекста. Никакой структуры данных здесь нет — только вероятностное распределение над словарем.

Но модели очень хорошо умеют следовать инструкциям. Если добавить в промт точное описание ожидаемого формата — JSON Schema, модель с высокой вероятностью вернет валидный JSON.

Паттерн Output Parsing выглядит так:

[Ваш запрос] + [JSON Schema целевого объекта] ->

LLM генерирует текст в формате JSON ->

Десериализация JSON → Java/Kotlin объект

Ключевой вопрос: откуда берется JSON Schema? Писать ее вручную — долго, высокая вероятность ошибки и хрупкость при изменениях. Более адекватный подход: генерировать схему автоматически из Java/Kotlin‑класса через Jackson. Именно это делает Spring AI.

JSON Schema лучше, чем просто «отвечай в JSON» потому что:

  1. Схема точно описывает поля, их типы и ограничения — модель понимает, что именно нужно заполнить. 

  2. Схема содержит описания полей через description — это дополнительный контекст для модели. 

  3. Десериализатор может валидировать ответ против схемы и обнаруживать отклонения.

В Spring AI: путь до AnthropicChatModel.call() Смотрим, как data class превращается в JSON Schema и попадает в промт. Для этого внесем небольшие правки в наше приложение.

HaikuService.kt

return chatClient
  .prompt(Prompt(listOf(systemMessage, userMessage)))
  .call()
  .entity(object : ParameterizedTypeReference<List<HaikuLine>>() {})
  ?: error("ChatClient returned null content for command='$command', theme='$theme'")

В ответе от вызова клиента будем ожидать десериализованный объект HaikuLine.

data class HaikuLine @JsonCreator constructor(
  @JsonProperty("line")
  @JsonPropertyDescription("Одна строка хайку (сам стих на целевом языке)")
  val line: String,
  
  @JsonProperty("meaning")
  @JsonPropertyDescription("Краткое пояснение образа или смысла этой строки")
  val meaning: String,

)

Само DTO тоже необходимо изменить. У нас data class kotlin, поэтому важно пометить конструктор через аннотацию JsonCreator для корректной десериализации. Для решения проблем с дата‑классами возможно подключить KotlinModule в используемый JsonMapper. А еще мы укажем описание полей через JsonPropertyDescription для формирования json schema, которую мы передадим в LLM.

Внедрив такие изменения, получим ответ на наш http‑запрос к приложению:

{
  "lines": [
    {
      "line": "Неон тает в снег",
      "meaning": "Яркий свет рекламных вывесок растворяется в поздней зиме — технология бессильна перед природой"
    },
    {
      "line": "провода в инее молчат —",
      "meaning": "Сети и кабели скованы льдом, связь замерзает, мир цифрового шума умолкает"
    },
    {
      "line": "март пахнет озоном",
      "meaning": "Запах грядущей оттепели смешан с запахом электричества — граница двух миров"
    }
  ]
}

Теперь посмотрим на классы Spring AI, которые выполняют всю работу. В методе DefaultChatClient.entity() увидим создание экземпляра BeanOutputConverter с нужным типом.

@Override
public <T> @Nullable T entity(ParameterizedTypeReference<T> type) {
  Assert.notNull(type, "type cannot be null");
  return doSingleWithBeanOutputConverter(new BeanOutputConverter<>(type));
}

BeanOutputConverter<T> — сердце Structured Output. Класс выполняет две функции: предоставляет информацию о схеме ответа в LLM и конвертирует ответ от LLM.

Важно, что метод getFormat возвращает не только json schema ожидаемого ответа, но и текст, который будет добавлен в пользовательский промт.

@Override
public String getFormat() {
  String template = """
  Your response should be in JSON format.
  Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
  Do not include markdown code blocks in your response.
  Remove the ```json markdown from the output.
  Here is the JSON Schema instance your output must adhere to:
  ```%s```
  """;
  
  return String.format(template, this.jsonSchema);
}

Выше мы адаптировали data class под десериализацию через аннотации, но это также возможно сделать заменой JsonMapper. Поскольку метод protected, это возможно сделать через создание наследника, который переопределит возвращаемые JsonMapper.

protected JsonMapper getJsonMapper() {
  return JsonMapper.builder()
  .addModules(JacksonUtils.instantiateAvailableModules())
  .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
  .build();
}

Теперь посмотрим как .entity() связывает конвертер с промтом внутри ChatClient.

if (StringUtils.hasText(outputConverter.getFormat())) {
  // Used for default structured output format support, based on prompt
  // instructions.
  this.request.context().put(ChatClientAttributes.OUTPUT_FORMAT.getKey(), outputConverter.getFormat());
}

if (this.request.context().containsKey(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey()) && outputConverter instanceof BeanOutputConverter beanOutputConverter) {
  // Used for native structured output support, e.g. AI model API should
  // provide structured output support.
  this.request.context()
  .put(ChatClientAttributes.STRUCTURED_OUTPUT_SCHEMA.getKey(), beanOutputConverter.getJsonSchema());
}

Видим, что в объект запроса мы добавляем указание возвращать ответ в формате json и описание схемы объекта, в который будет десериализовывать ответ. При включенном логировании увидим json schema в контексте запроса:

spring.ai.chat.client.output.format=Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
Do not include markdown code blocks in your response.
Remove the ```json markdown from the output.
Here is the JSON Schema instance your output must adhere to:
```{
"$schema" : "https://json-schema.org/draft/2020-12/schema",
"type" : "array",
"items" : {
"type" : "object",
"properties" : {
"line" : {
"type" : "string",
"description" : "Одна строка хайку (сам стих на целевом языке)"
},
"meaning" : {
"type" : "string",
"description" : "Краткое пояснение образа или смысла этой строки"
}
},
"required" : [ "line", "meaning" ],
"additionalProperties" : false
}
}```

Сервис возвращает типизированный HaikuResponse со списком из трех HaikuLine, каждая с полями line и meaning. Контроллер отдает структурированный JSON — фронтенд может рендерить стихотворение и объяснения независимо.

Но если попробуем пообщаться с сервисом как с живым автором: «хорошо, теперь добавь строку про ветер, который подхватывает этот лист». Модель начнет заново. Она не знает, о каком листе мы говорим — каждый запрос для нее первый.

Chat Memory — хайку как живой диалог

Проблема: хайку — не одноразовый акт. Это диалог между поэтом и образом, который разворачивается во времени. Пользователь хочет вести этот диалог: написать первую строку, потом попросить «добавь контраст», потом «сделай финал более открытым».

Сейчас это невозможно: каждый вызов chatClient.prompt() создает новый запрос с нуля. Предыдущие строки хайку, обсуждение образов, направление разговора — все исчезает.

Справка: почему LLM не помнит ничего. Языковая модель — математическая функция. На входе последовательность токенов, на выходе — следующий токен. Никакого состояния. Никакой памяти между вызовами. Каждый HTTP‑запрос к Anthropic API абсолютно независим.

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

Но для диалоговых приложений нужна иллюзия памяти. Реализуется она просто: передавать историю диалога в каждом новом запросе как часть промта.

Первый запрос: messages: [SystemMessage, UserMessage("напиши строку про лист")]  

Второй запрос:

messages: [

SystemMessage,

UserMessage("напиши строку про лист"), <- первый запрос(история)

AssistantMessage("{lines:[{line:'...'}]}"), <- ответ на первый запрос(история)

UserMessage("добавь строку про ветер")] <- новый запрос

]

Модель «видит» весь диалог и отвечает в контексте. Это называется Conversation History — одна из техник для stateful AI‑приложений.

Ограничения очевидны: контекстное окно конечно. У Anthropic оно большое, но бесконечным не бывает. Для длинных диалогов нужна стратегия усечения: хранить последние N сообщений, или сжимать историю через суммаризацию.

Два подхода к добавлению истории в промт:

  1. MessageChatMemoryAdvisor добавляет историю как отдельные Message объекты в список messages[]. Модель видит реальный диалог со всеми ролями. Это предпочтительный подход — модели обучены понимать структуру диалога.

  1. PromptChatMemoryAdvisor сжимает историю в текст и добавляет в системный промт. Полезно когда история большая и нужно контролировать ее представление.

В Spring AI: путь до AnthropicChatModel.call() Посмотрим как MessageChatMemoryAdvisor трансформирует запрос до того, как он попадет в модель. Для этого улучшим наше приложение следующим образом.

В наш метод контроллера добавим получение http‑заголовка:

@PostMapping
fun generate(
  @Valid @RequestBody request: HaikuRequest,
  @RequestHeader(name = "X-Conversation-Id", required = false)
  @Size(max = 128) rawConversationId: String?,
): HaikuResponse {
  val conversationId = rawConversationId?.takeIf { it.isNotBlank() }
  ?: UUID.randomUUID().toString()
  val lines = haikuService.generate(request.command, request.theme, conversationId)
  return HaikuResponse(lines = lines, conversationId = conversationId)
}

Добавим создание бинов, которые обеспечивают сохранение истории сообщений.

@Bean
fun chatMemoryRepository() = InMemoryChatMemoryRepository()

@Bean
fun chatMemory(
  repository: InMemoryChatMemoryRepository,
  @Value("\${haiku.memory.max-messages:20}") maxMessages: Int,
): ChatMemory = MessageWindowChatMemory.builder()
  .chatMemoryRepository(repository)
  .maxMessages(maxMessages)
  .build()

Подключим ChatMemory к ChatClient.

private val chatClient: ChatClient = chatClientBuilder
  .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
  .build()

Ну и конечно же при вызове LLM необходимо передать параметр в advisor с ID текущего диалога, чтобы ChatClient добавил в запрос историю сообщений из памяти. Добавление происходит через метод advisors в стиле Fluent API.

chatClient
  .prompt()
  .system(systemText)
  .user { it.text(userText).param("command", command).param("theme", theme) }
  .advisors { it.param(ChatMemory.CONVERSATION_ID, conversationId) }
  .call()
  .entity(object : ParameterizedTypeReference<List<HaikuLine>>() {})

Для простоты в примере используем in‑memory подход, но для хранения в production можно подключить внешнее хранилище.

Память представлена интерфейсом ChatMemory и реализацией MessageWindowChatMemory. Выбранная нами реализация позволит контролировать размер контекстного окна, который будем передавать в LLM.

В нашем случае, каждая пара user+assistant равна примерно 200–500 токенов для нашего хайку. 20 последних сообщений ~10 ходов диалога, ~4000 токенов на историю

Рассмотрим подробно MessageChatMemoryAdvisor, где происходит магия добавления истории. Spring AI предоставляет интерфейс BaseAdvisor c методом adviseCall, который декорирует вызов цепочки adviser и LLM методами before() и after()

ChatClientRequest processedChatClientRequest = before(chatClientRequest, callAdvisorChain);
ChatClientResponse chatClientResponse = callAdvisorChain.nextCall(processedChatClientRequest);
return after(chatClientResponse, callAdvisorChain);

Методы before() и after() реализуются в MessageChatMemoryAdvisor

До вызова LLM выполняем:

  1. Получение истории из памяти по conversationId

  2. К полученной истории сообщений добавляем новое сообщение от пользователя

  3. Системное сообщение делаем самым первым в истории

  4. Создаем новый ChatClientRequest с историей сообщений

  5. Добавляем в память последнее новое сообщение пользователя

После вызова LLM добавляем в память ответ — AssistantMessage.

MessageChatMemoryAdvisor имеет самый высокий приоритет среди всех advisor, чтобы вся последующая цепочка работала с актуальным запросом к LLM.

К концу этой главы сервис помнит историю каждого пользователя. Хайку создается в диалоге: пользователь направляет, модель продолжает, каждая новая строка знает о предыдущих.

Но система все еще непрозрачна. Когда ответ вдруг стал хуже — почему? Что реально ушло в модель? Сколько токенов потратили? История правильно добавляется или нет? Понять это без специальных инструментов невозможно.

Observability — видеть хайку изнутри

Проблема: прошла неделя в production. Пользователи жалуются: иногда модель перестает соблюдать структуру хайку, иногда JSON приходит с лишними полями, иногда ответы стали длиннее и медленнее.

Смотрим в логи — там нет ничего полезного. Видно только HTTP 200 и время ответа. Что реально ушло в модель неизвестно. Добавилась ли история из памяти? Вставилась ли JSON Schema? Не превысили ли лимит контекстного окна?

Вероятностная природа является принципиальной проблемой AI‑систем: они сложнее для отладки, чем обычные сервисы. Детерминированный код при одинаковом вводе дает одинаковый вывод. LLM — нет. Плюс несколько слоев Advisor'ов незаметно трансформируют промт, и вы не знаете, что именно получила модель на входе.

Справка: observability в AI‑системах.

Уровень 1: логирование промтов. Самый важный инструмент. Нужно видеть финальный промт с историей, с JSON Schema, с системными инструкциями именно таким, каким его получила модель. Не то, что мы написали в коде, а то, что собрал pipeline после всех трансформаций.

Уровень 2: метрики. Количество токенов на входе и выходе, время ответа, стоимость вызова. Токены — это деньги и latency. Внезапный рост input_tokens часто означает, что история диалога разрослась сверх ожидаемого.

В Spring AI: путь до AnthropicChatModel.call() Посмотрим как работает SimpleLoggerAdvisor и откуда берутся Micrometer‑метрики.

SimpleLoggerAdvisor — самый простой и самый полезный advisor. Подключается в ChatClient он также просто, в метод дефолтных advisors передаем инстанс этого класса.

private val chatClient: ChatClient = chatClientBuilder
  .defaultAdvisors(
    MessageChatMemoryAdvisor.builder(chatMemory).build(),
    SimpleLoggerAdvisor(),
    tokenMetricsAdvisor,
  )
  .build()

SimpleLoggerAdvisor реализован максимально просто на объектах запроса и ответа вызывается метод .toString и записывается с уровнем DEBUG в логгер.

public static final Function<@Nullable ChatClientRequest, String> DEFAULT_REQUEST_TO_STRING = chatClientRequest -> chatClientRequest != null
? chatClientRequest.toString() : "null";

//...

@Override
public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
  logRequest(chatClientRequest);
  ChatClientResponse chatClientResponse = callAdvisorChain.nextCall(chatClientRequest);
  logResponse(chatClientResponse);
  return chatClientResponse;
}

//....

protected void logRequest(ChatClientRequest request) {
  logger.debug("request: {}", this.requestToString.apply(request));
}

protected void logResponse(ChatClientResponse chatClientResponse) {
  logger.debug("response: {}", this.responseToString.apply(chatClientResponse.chatResponse()));
}

При включение адвизора, чтобы эффект логирования вступил в силу, необходимо в application.properties задать уровень логирования DEBUG для логгера.

logging.level.org.springframework.ai.chat.client.advisor=DEBUG

Один advisor, одна строка в конфиге — и видим полный контекст каждого вызова. Теперь посмотрим на вывод в лог при запросе. Для полноты лога посмотрим на запрос с историей.

[haiku-generator] [nio-8080-exec-5] o.s.a.c.c.advisor.SimpleLoggerAdvisor : request: ChatClientRequest[prompt=Prompt{messages=[SystemMessage{textContent='Ты — Мацуо Басе, величайший мастер хайку эпохи Эдо.
Ты пишешь только в строгой форме хайку: три строки с ритмической структурой 5-7-5 слогов.
Для каждой строки объясняй ее образный смысл.

Примеры:

Команда: Напиши, Тема: осенний дождь
Строки:
- «Листья тихо льнут» — Образ мягкого прилипания, покорность неизбежному
- «к мокрым камням у тропы —» — Земля как свидетель, путь и время сливаются
- «дождь смывает день» — Дождь как очищение, конец и начало одновременно

Команда: Сочини, Тема: рассвет над морем
Строки:
- «Алый край небес» — Заря как граница между мирами
- «будит спящие волны —» — Море как живое существо, пробуждающееся от прикосновения света
- «чайка первой встает» — Птица как вестник нового дня

Команда: Создай, Тема: одиночество зимой
Строки:
- «Снег засыпал сад» — Белое безмолвие как покров забвения
- «нет ни следа, ни голоса —» — Отсутствие как присутствие пустоты
- «только я и ночь» — Слияние наблюдателя с тишиной
', messageType=SYSTEM, metadata={messageType=SYSTEM}}, UserMessage{content='Напиши стих про позднюю зиму: Киберпанк', metadata={messageType=USER}, messageType=USER}, AssistantMessage [messageType=ASSISTANT, toolCalls=[], textContent=[
{
"line": "Неон тает в снег",
"meaning": "Яркий свет рекламных вывесок растворяется в поздней зиме — технология уступает природе"
},
{
"line": "провода в инее дрожат —",
"meaning": "Сети и коммуникации скованы холодом, хрупкость цифрового мира перед стихией"
},
{
"line": "март пахнет золой",
"meaning": "Конец зимы в мире киберпанка — не свежесть, а запах сгоревших схем и усталости"
}
], metadata={messageType=ASSISTANT}], UserMessage{content='Придумай эпичный финал: Исконно русский', metadata={messageType=USER}, messageType=USER}], modelOptions=org.springframework.ai.anthropic.AnthropicChatOptions@c9dc1723}, context={spring.ai.chat.client.output.format=Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
Do not include markdown code blocks in your response.
Remove the ```json markdown from the output.
Here is the JSON Schema instance your output must adhere to:
```{
"$schema" : "https://json-schema.org/draft/2020-12/schema",
"type" : "array",
"items" : {
"type" : "object",
"properties" : {
"line" : {
"type" : "string",
"description" : "Одна строка хайку (сам стих на целевом языке)"
},
"meaning" : {
"type" : "string",
"description" : "Краткое пояснение образа или смысла этой строки"
}
},
"required" : [ "line", "meaning" ],
"additionalProperties" : false
}
}```
, chat_memory_conversation_id=22711f38-f796-45d4-b9c6-4c3a3c19e5c8}]

В логе мы видим первое сообщение — SystemMessage с ролью и few‑shot примерами. Затем предыдущие UserMessage, AssistantMessage(ответ от LLM) и id диалога. Присутствует контекст, в котором описывается формат ответа с автоматически сгенерированной json schema(structured output).

В логе ответа кроме самого текста от LLM присутствует объект metadata, который содержит провайдер‑зависимый состав атрибутов.

[haiku-generator] [nio-8080-exec-5] o.s.a.c.c.advisor.SimpleLoggerAdvisor : response: {
"result" : {
"metadata" : {
"finishReason" : "end_turn",
"contentFilters" : [ ],
"empty" : true
},
"output" : {
"messageType" : "ASSISTANT",
"metadata" : {
"messageType" : "ASSISTANT"
},
"toolCalls" : [ ],
"media" : [ ],
"text" : "[\n {\n \"line\": \"Русь встает из льда\",\n \"meaning\": \"Пробуждение древней земли — зима отступает, как отступали враги, не сломив духа\"\n },\n {\n \"line\": \"колокол гудит в веках —\",\n \"meaning\": \"Звон как память поколений, голос предков, пронизывающий время и пространство\"\n },\n {\n \"line\": \"медведь видит сон\",\n \"meaning\": \"Исконный символ России еще дремлет, но пробуждение его — и есть сам финал истории\"\n }\n]"
}
},
"metadata" : {
"id" : "msg_01FfKdyKrVMHDdGjyhcxXPNZ",
"model" : "claude-sonnet-4-6",
"rateLimit" : {
"requestsLimit" : 0,
"requestsRemaining" : 0,
"requestsReset" : 0.0,
"tokensLimit" : 0,
"tokensRemaining" : 0,
"tokensReset" : 0.0
},
"usage" : {
"promptTokens" : 930,
"completionTokens" : 180,
"totalTokens" : 1110,
"nativeUsage" : {
"input_tokens" : 930,
"output_tokens" : 180,
"cache_creation_input_tokens" : 0,
"cache_read_input_tokens" : 0
}
},
"promptMetadata" : [ ],
"empty" : false
},
"results" : [ {
"metadata" : {
"finishReason" : "end_turn",
"contentFilters" : [ ],
"empty" : true
},
"output" : {
"messageType" : "ASSISTANT",
"metadata" : {
"messageType" : "ASSISTANT"
},
"toolCalls" : [ ],
"media" : [ ],
"text" : "[\n {\n \"line\": \"Русь встает из льда\",\n \"meaning\": \"Пробуждение древней земли — зима отступает, как отступали враги, не сломив духа\"\n },\n {\n \"line\": \"колокол гудит в веках —\",\n \"meaning\": \"Звон как память поколений, голос предков, пронизывающий время и пространство\"\n },\n {\n \"line\": \"медведь видит сон\",\n \"meaning\": \"Исконный символ России еще дремлет, но пробуждение его — и есть сам финал истории\"\n }\n]"
}
} ]
}

В ответе провайдера Anthropic есть статистика по токенам. Но это не то что мы увидели в логе выше. В логе отображается объект класса DefaultUsage от Spring AI для провайдер‑независимого отображения. Его создание происходит при обработке ответа в AnthropicChatModel

private DefaultUsage getDefaultUsage(AnthropicApi.@Nullable Usage usage) {
  Integer inputTokens = usage != null && usage.inputTokens() != null ? usage.inputTokens() : 0;
  Integer outputTokens = usage != null && usage.outputTokens() != null ? usage.outputTokens() : 0;
  return new DefaultUsage(inputTokens, outputTokens, inputTokens + outputTokens, usage);
}

Вызов API оборачивается в observation micrometer и записывается в метрику gen_ai.client.operation. Поскольку вызовы API платные, то полезной возможностью будет собирать метрику по токенам. Для этого мы напишем свой advisor, который будет записывать метрику по токенам.

@Component
class TokenMetricsAdvisor(meterRegistry: MeterRegistry) : CallAdvisor {
  private val inputSummary = DistributionSummary.builder("gen-ai.token.usage")
    .tag("token-type", "input")
    .register(meterRegistry)
  
  private val outputSummary = DistributionSummary.builder("gen-ai.token.usage")
    .tag("token-type", "output")
    .register(meterRegistry)
    
  override fun getName() = "TokenMetricsAdvisor"
  
  override fun getOrder() = Int.MIN_VALUE
  
  override fun adviseCall(request: ChatClientRequest, chain: CallAdvisorChain): ChatClientResponse {
    val response = chain.nextCall(request)
    val usage = response.chatResponse()?.metadata?.usage
    if (usage != null) {
      inputSummary.record(usage.promptTokens.toDouble())
      outputSummary.record(usage.completionTokens.toDouble())
    }
    return response
  }
}

Необходимо его добавить в ChatClient в цепочку advisor

private val chatClient: ChatClient = chatClientBuilder
  .defaultAdvisors(
    MessageChatMemoryAdvisor.builder(chatMemory).build(),
    SimpleLoggerAdvisor(),
    tokenMetricsAdvisor,
  )
  .build()

При запросе метрики через эндпоинт актуатора будет возможно получить значения

curl --location --request GET 'localhost:8080/actuator/metrics/gen-ai.token.usage'
{
  "availableTags": [
    {
      "tag": "token-type",
      "values": [
        "output",
        "input"
      ]
    }
  ],
  "measurements": [
    {
      "statistic": "COUNT",
      "value": 2.0
    },
    {
      "statistic": "TOTAL",
      "value": 931.0
    }
  ],
  "name": "gen-ai.token.usage"
}

К концу этой главы система стала прозрачной. Каждый вызов оставляет след: в логах — финальный промт со всеми слоями, в метриках — количество токенов и время ответа. Когда что‑то идет не так, знаем где искать.

Эпилог: одно хайку, четыре техники

Каждая техника появилась, чтобы закрыть конкретную проблему:

  • Prompt Engineering закрыл проблему качества. Без роли и правил модель генерирует случайный текст. С.st‑файлами промт стал данными — его можно менять без деплоя.

  • Structured Output закрыл проблему типобезопасности. Вместо хрупкого парсинга строки — типизированный Kotlin‑объект, который компилятор проверяет.

  • Chat Memory закрыл проблему недостатка контекста. Из серии изолированных вызовов получился диалог, в котором каждая строка хайку знает о предыдущих.

  • Observability закрыл проблему прозрачности. Система перестала быть черным ящиком — каждый вызов оставляет след.

Ключевой принцип: Spring AI — не магия и не монолитный фреймворк, который нужно учить целиком. Это совокупность классов, каждый из которых решает одну задачу:

  • ChatOptions — управление параметрами модели

  • PromptTemplate — шаблонизация промтов

  • BeanOutputConverter — типобезопасные ответы

  • ChatMemory + Advisor — контекст диалога

  • SimpleLoggerAdvisor + Micrometer — observability

Добавляйте слои по мере роста требований. Начните chatClient.prompt().call().content(). Добавьте PromptTemplate когда промт усложнился. Добавьте.entity() когда нужна структура. Добавьте ChatMemory когда нужен диалог. Добавьте SimpleLoggerAdvisor когда что‑то пошло не так.

Архитектура Spring AI устроена так, что каждый следующий слой не ломает предыдущий и обеспечивает расширяемость и переопределение базовой логики. Это и есть хорошее API.

*Исходный код сервиса‑генератора хайку доступен в репозитории.