1. Visión general
Las aplicaciones impulsadas por IA son nuestra nueva realidad. Estamos implementando ampliamente diversas aplicaciones RAG, APIs de prompts y creando proyectos impresionantes utilizando LLMs. Con Spring AI, podemos realizar estas tareas más rápido y de manera más consistente.
En este artículo, revisaremos una característica valiosa llamada Spring AI Advisors, que puede manejar diversas tareas rutinarias por nosotros.
2. ¿Qué es el Advisor de Spring AI?
Los Advisors son interceptores que manejan las solicitudes y respuestas en nuestras aplicaciones de IA. Podemos usarlos para configurar funcionalidades adicionales en nuestros procesos de prompting. Por ejemplo, podríamos establecer un historial de chat, excluir palabras sensibles o añadir contexto extra a cada solicitud.
El componente principal de esta característica es la interfaz CallAroundAdvisor. Implementamos esta interfaz para crear una cadena de Advisors que afectarán nuestras solicitudes o respuestas. El flujo de los advisors se describe en este diagrama:

Enviamos el prompt a un modelo de chat adjunto a una cadena de advisors. Antes de que se entregue el prompt, cada advisor de la cadena realiza su acción before. De manera similar, antes de obtener la respuesta del modelo de chat, cada advisor llama a su propia acción after.
3. Advisors de Memoria de Chat
Los Advisors de Memoria de Chat son un conjunto bastante útil de implementaciones de Advisor. Podemos usar estos Advisors para proporcionar un historial de comunicación con nuestro prompt de chat, mejorando la precisión de las respuestas de chat.
3.1. MessageChatMemoryAdvisor
Usando MessageChatMemoryAdvisor podemos proporcionar un historial de chat con llamadas del cliente de chat usando la propiedad messages. Guardamos todos los mensajes en una implementación de ChatMemory y podemos controlar el tamaño del historial.
Vamos a implementar una muestra sencilla para este advisor:
@SpringBootTest(classes = ChatModel.class)
@EnableAutoConfiguration
@ExtendWith(SpringExtension.class)
public class SpringAILiveTest {
@Autowired
@Qualifier("openAiChatModel")
ChatModel chatModel;
ChatClient chatClient;
@BeforeEach
void setup() {
chatClient = ChatClient.builder(chatModel).build();
}
@Test
void givenMessageChatMemoryAdvisor_whenAskingChatToIncrementTheResponseWithNewName_thenNamesFromTheChatHistoryExistInResponse() {
ChatMemory chatMemory = new InMemoryChatMemory();
MessageChatMemoryAdvisor chatMemoryAdvisor = new MessageChatMemoryAdvisor(chatMemory);
String responseContent = chatClient.prompt()
.user("Add this name to a list and return all the values: Bob")
.advisors(chatMemoryAdvisor)
.call()
.content();
assertThat(responseContent)
.contains("Bob");
responseContent = chatClient.prompt()
.user("Add this name to a list and return all the values: John")
.advisors(chatMemoryAdvisor)
.call()
.content();
assertThat(responseContent)
.contains("Bob")
.contains("John");
responseContent = chatClient.prompt()
.user("Add this name to a list and return all the values: Anna")
.advisors(chatMemoryAdvisor)
.call()
.content();
assertThat(responseContent)
.contains("Bob")
.contains("John")
.contains("Anna");
}
}
En esta prueba, hemos creado una instancia de MessageChatMemoryAdvisor con InMemoryChatMemory dentro de ella. Luego enviamos varios prompts solicitando al chat que nos devuelva los nombres de las personas incluyendo los datos históricos. Como podemos ver, todos los nombres de la conversación fueron devueltos.
3.2. PromptChatMemoryAdvisor
Con PromptChatMemoryAdvisor logramos el mismo objetivo: proporcionar un historial de conversación al modelo de chat. La diferencia es que al usar este Advisor estamos añadiendo la memoria de chat al prompt. Bajo el capó estamos extendiendo nuestro texto de prompt con el siguiente consejo:
Use the conversation memory from the MEMORY section to provide accurate answers.
---------------------
MEMORY:
{memory}
---------------------
Vamos a verificar cómo funciona:
@Test
void givenPromptChatMemoryAdvisor_whenAskingChatToIncrementTheResponseWithNewName_thenNamesFromTheChatHistoryExistInResponse() {
ChatMemory chatMemory = new InMemoryChatMemory();
PromptChatMemoryAdvisor chatMemoryAdvisor = new PromptChatMemoryAdvisor(chatMemory);
String responseContent = chatClient.prompt()
.user("Add this name to a list and return all the values: Bob")
.advisors(chatMemoryAdvisor)
.call()
.content();
assertThat(responseContent)
.contains("Bob");
responseContent = chatClient.prompt()
.user("Add this name to a list and return all the values: John")
.advisors(chatMemoryAdvisor)
.call()
.content();
assertThat(responseContent)
.contains("Bob")
.contains("John");
responseContent = chatClient.prompt()
.user("Add this name to a list and return all the values: Anna")
.advisors(chatMemoryAdvisor)
.call()
.content();
assertThat(responseContent)
.contains("Bob")
.contains("John")
.contains("Anna");
}
Nuevamente, intentamos crear varios prompts solicitando al modelo de chat que considere la memoria de la conversación usando PromptChatMemoryAdvisor esta vez. Y como era de esperarse, todos los datos nos fueron devueltos correctamente.
3.3. VectorStoreChatMemoryAdvisor
Usando VectorStoreChatMemoryAdvisor, obtenemos una funcionalidad más potente. Buscamos el contexto de los mensajes mediante coincidencia de similitud en el vector store. Tomamos en cuenta el ID de la conversación al buscar los documentos relacionados. Para nuestro ejemplo, usaremos un SimpleVectorStore ligeramente sobreescrito, pero también podemos reemplazarlo con cualquier base de datos vectorial.
En primer lugar, vamos a crear un bean de nuestro vector store:
@Configuration
public class SimpleVectorStoreConfiguration {
@Bean
public VectorStore vectorStore(@Qualifier("openAiEmbeddingModel")EmbeddingModel embeddingModel) {
return new SimpleVectorStore(embeddingModel) {
@Override
public List<Document> doSimilaritySearch(SearchRequest request) {
float[] userQueryEmbedding = embeddingModel.embed(request.query);
return this.store.values()
.stream()
.map(entry -> Pair.of(entry.getId(),
EmbeddingMath.cosineSimilarity(userQueryEmbedding, entry.getEmbedding())))
.filter(s -> s.getSecond() >= request.getSimilarityThreshold())
.sorted(Comparator.comparing(Pair::getSecond))
.limit(request.getTopK())
.map(s -> this.store.get(s.getFirst()))
.toList();
}
};
}
}
Aquí hemos creado un bean de la clase SimpleVectorStore y sobreescrito su método doSimilaritySearch(). El SimpleVectorStore por defecto no soporta filtrado de metadatos y aquí ignoraremos este hecho. Dado que tendremos solo una conversación durante la prueba, este enfoque nos conviene perfectamente.
Ahora, probemos el comportamiento del contexto histórico:
@Test
void givenVectorStoreChatMemoryAdvisor_whenAskingChatToIncrementTheResponseWithNewName_thenNamesFromTheChatHistoryExistInResponse() {
VectorStoreChatMemoryAdvisor chatMemoryAdvisor = new VectorStoreChatMemoryAdvisor(vectorStore);
String responseContent = chatClient.prompt()
.user("Find cats from our chat history, add Lion there and return a list")
.advisors(chatMemoryAdvisor)
.call()
.content();
assertThat(responseContent)
.contains("Lion");
responseContent = chatClient.prompt()
.user("Find cats from our chat history, add Puma there and return a list")
.advisors(chatMemoryAdvisor)
.call()
.content();
assertThat(responseContent)
.contains("Lion")
.contains("Puma");
responseContent = chatClient.prompt()
.user("Find cats from our chat history, add Leopard there and return a list")
.advisors(chatMemoryAdvisor)
.call()
.content();
assertThat(responseContent)
.contains("Lion")
.contains("Puma")
.contains("Leopard");
}
Solicitamos al chat que rellene algunos ítems en la lista, mientras, bajo el capó, realizamos una búsqueda de similitud para obtener todos los documentos similares, y nuestro LLM de chat preparó la respuesta considerando esos documentos.
4. QuestionAnswerAdvisor
En aplicaciones RAG usamos ampliamente un QuestionAnswerAdvisor. Al usar este advisor, preparamos un prompt que solicita información basada en el contexto preparado. El contexto se recupera del vector store mediante una búsqueda de similitud. Veamos este comportamiento:
@Test
void givenQuestionAnswerAdvisor_whenAskingQuestion_thenAnswerShouldBeProvidedBasedOnVectorStoreInformation() {
Document document = new Document("The sky is green");
List<Document> documents = new TokenTextSplitter().apply(List.of(document));
vectorStore.add(documents);
QuestionAnswerAdvisor questionAnswerAdvisor = new QuestionAnswerAdvisor(vectorStore);
String responseContent = chatClient.prompt()
.user("What is the sky color?")
.advisors(questionAnswerAdvisor)
.call()
.content();
assertThat(responseContent)
.containsIgnoringCase("green");
}
Poblamos el vector store con información específica del documento. Luego, usamos un QuestionAnswerAdvisor para crear un prompt y verificamos que su respuesta coincidiera con el contenido del documento.
5. SafeGuardAdvisor
A veces debemos evitar que se usen ciertas palabras sensibles en los prompts del cliente. Innegablemente, podemos usar SafeGuardAdvisor para lograr esto especificando una lista de palabras prohibidas e incluyéndolas en la instancia del advisor del prompt. Si alguna de estas palabras se usa en una solicitud de búsqueda, será rechazada, y el advisor nos solicitará reformularla:
@Test
void givenSafeGuardAdvisor_whenSendPromptWithSensitiveWord_thenExpectedMessageShouldBeReturned() {
List<String> forbiddenWords = List.of("Word2");
SafeGuardAdvisor safeGuardAdvisor = new SafeGuardAdvisor(forbiddenWords);
String responseContent = chatClient.prompt()
.user("Please split the 'Word2' into characters")
.advisors(safeGuardAdvisor)
.call()
.content();
assertThat(responseContent)
.contains("I'm unable to respond to that due to sensitive content");
}
En este ejemplo, primero creamos un SafeGuardAdvisor con una sola palabra prohibida. Luego intentamos usar esta palabra en nuestro prompt y, como era de esperarse, recibimos el mensaje de validación de palabra prohibida.
6. Implementar un Advisor Personalizado
Seguro, estamos autorizados a implementar nuestros advisors personalizados con cualquier lógica que necesitemos. Vamos a crear un CustomLoggingAdvisor donde registraremos todas las solicitudes y respuestas de chat:
public class CustomLoggingAdvisor implements CallAroundAdvisor {
private final static Logger logger = LoggerFactory.getLogger(CustomLoggingAdvisor.class);
@Override
public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) {
advisedRequest = this.before(advisedRequest);
AdvisedResponse advisedResponse = chain.nextAroundCall(advisedRequest);
this.observeAfter(advisedResponse);
return advisedResponse;
}
private void observeAfter(AdvisedResponse advisedResponse) {
logger.info(advisedResponse.response()
.getResult()
.getOutput()
.getContent());
}
private AdvisedRequest before(AdvisedRequest advisedRequest) {
logger.info(advisedRequest.userText());
return advisedRequest;
}
@Override
public String getName() {
return "CustomLoggingAdvisor";
}
@Override
public int getOrder() {
return Integer.MAX_VALUE;
}
}
Aquí hemos implementado la interfaz CallAroundAdvisor y agregado la lógica de registro antes y después de la llamada. Además, hemos devuelto el valor entero máximo del método getOrder(), por lo que nuestro advisor será el último de la cadena.
Ahora, probemos nuestro nuevo advisor:
@Test
void givenCustomLoggingAdvisor_whenSendPrompt_thenPromptTextAndResponseShouldBeLogged() {
CustomLoggingAdvisor customLoggingAdvisor = new CustomLoggingAdvisor();
String responseContent = chatClient.prompt()
.user("Count from 1 to 10")
.advisors(customLoggingAdvisor)
.call()
.content();
assertThat(responseContent)
.contains("1")
.contains("10");
}
Hemos creado el CustomLoggingAdvisor y lo hemos adjuntado al prompt. Veamos lo que sucede en los logs después de la ejecución:
c.b.s.advisors.CustomLoggingAdvisor : Count from 1 to 10
c.b.s.advisors.CustomLoggingAdvisor : 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
Como podemos ver, nuestro advisor registró con éxito el texto del prompt y la respuesta del chat.
7. Conclusión
En este tutorial, hemos explorado una gran característica de Spring AI llamada Advisors. Con Advisors, obtenemos capacidades de memoria de chat, control sobre palabras sensibles e integración sin problemas con el vector store. Además, podemos crear fácilmente extensiones personalizadas para agregar funcionalidades específicas. Usar Advisors nos permite lograr todas estas capacidades de manera consistente y sencilla.
Newsletter Semanal de Java
Cada viernes recibe lo más nuevo del ecosistema Java: frameworks, herramientas y mejores prácticas.
Sin spam. Cancela cuando quieras.