Техники GenAI в Spring AI

от автора

Один сервис —  генератор хайку. Четыре проблемы. Четыре техники.
Путешествие от 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))}
@Serviceclass 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 выглядит так

@Overridepublic 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 с нужным типом.

@Overridepublic <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 ожидаемого ответа, но и текст, который будет добавлен в пользовательский промт.

@Overridepublic 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-заголовка:

@PostMappingfun 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)}

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

@Beanfun chatMemoryRepository() = InMemoryChatMemoryRepository()@Beanfun 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";//...@Overridepublic 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, который будет записывать метрику по токенам.

@Componentclass 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.

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

ссылка на оригинал статьи https://habr.com/ru/articles/1035232/