Trabajar con IA generativa tiene sus diferencias y parecidos con los sistemas clásicos de machine learning. Por ejemplo:

A día de hoy, las dos herramientas más conocidas de LLM engineering son LangSmith y Langfuse. En esta serie de posts compararemos algunas de sus principales funcionalidades, viendo cómo funcionan en cada plataforma. Para ello usaremos Python y lo integraremos con LangChain, que, como os contamos hace unos meses, es el framework de facto para trabajar con LLMs.

Antes de empezar, hay que tener en cuenta que LangSmith requiere de licencia para poder explotarlo comercialmente, aunque hay una versión gratuita muy interesante. Por otro lado, Langfuse te permite alojarlo en tu propio servidor o también pagar licencia para no tener que preocuparte por eso. En este post, se va a usar una licencia gratuita de LangSmith y un Langfuse autoalojado.

Configuración en local

LangSmith

En LangSmith, es tan sencillo como obtener las API keys de LangChain (con el que LangSmith está totalmente integrado) y de OpenAI (porque en este ejemplo vamos a usar un modelo de OpenAI) y configurar el resto de variables de entorno pertinentes:

import os

os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"] = # a rellenar por el usuario
os.environ["LANGCHAIN_PROJECT"] = "default"
os.environ["LANGCHAIN_ENDPOINT"] = "https://api.smith.langchain.com"
os.environ["OPENAI_API_KEY"] = # a rellenar por el usuario

Langfuse

En Langfuse es un poco más complicado. Hay que seguir los siguientes pasos:

  1. Clonar el repositorio oficial de Langfuse:
>>>git clone https://github.com/langfuse/langfuse.git
  1. Lanzar docker compose (esto hará que se desplieguen en local los servicios necesarios: una base de datos postgres y el servicio de Langfuse):
>>>cd langfuse
>>>docker compose up
  1. Crear una cuenta y un proyecto. Para ello, vamos a la dirección http://localhost:3000 y pinchamos en "Sign up". Introducimos nuestros datos de registro, entramos a nuestro perfil y creamos un nuevo proyecto pinchando en "New Project". Seleccionamos el nombre deseado.
  2. Y ya solo queda crear API keys para poder conectar desde código al servicio de Langfuse:
import os
os.environ["LANGFUSE_HOST"] = "http://localhost:3000"
os.environ["LANGFUSE_PUBLIC_KEY"] = # a rellenar por el usuario
os.environ["LANGFUSE_SECRET_KEY"] = # a rellenar por el usuario
os.environ["OPENAI_API_KEY"] = # a rellenar por el usuario

Versionado de prompts

En cualquier aplicación basada en un modelo de ML, existe una etapa de experimentación para elegir el mejor modelo y una etapa de control del ciclo de vida del modelo. En un flujo MLOps, un modelo de ML es evaluado en ambas etapas y versionado según su funcionamiento, permitiendo un control de versiones de los distintos modelos a lo largo del tiempo.

Si hacemos un símil con las soluciones basadas en LLMs, el equivalente a un modelo de ML sería un prompt. Este prompt debería pasar también por una primera etapa de experimentación, para poner en producción un primer prompt, y posteriormente por una etapa de control de su funcionamiento para, en el caso de ser necesario, crear nuevos prompts o volver a versiones anteriores.

Tanto LangSmith como Langfuse permiten almacenar prompts, versionarlos y referenciarlos desde la aplicación para acceder a sus distintas versiones.

LangSmith

Para subir un prompt en LangSmith solo tenemos que usar el método .push_prompt() del cliente, al que tenemos que pasarle un PromptTemplate (objeto de LangChain) como object. Para ello vamos a construir un ChatPromptTemplate a partir de una plantilla para el sistema y de otra plantilla donde se insertará la pregunta del humano. Debemos darle un nombre y podemos añadir una descripción y todos los tags que queramos.

from langsmith import Client
from langchain_core.prompts import ChatPromptTemplate
 
system_prompt_template = """
Eres un bot que responde a preguntas según un contexto proporcionado por el usuario. El mensaje del usuario tendrá
el siguiente formato:
## CONTEXTO ##
Conocimiento que usarás para contestar a la pregunta
## PREGUNTA ##
Pregunta que debes de contestar

Debes tener en cuenta lo siguiente:
- Contesta de la forma más natural posible. No digas "según el
contexto proporcionado" o frases similares.
- Limita tu respuesta únicamente al contexto que te proporcione
el usuario.
- Si es necesario, usa información de toda la conversación para
contestar.
"""
user_prompt_template = """
## CONTEXTO ##
{contexto}
## PREGUNTA ##
{pregunta}
"""

client = Client()
prompt = ChatPromptTemplate.from_messages([
    ("system", system_prompt_template),
    ("user", user_prompt_template)
])
client.push_prompt(
    "prompt_rag",
    object = prompt,
    description = "Este es un prompt de prueba",
    tags = ["primer_prompt", "prompt_prueba"]
)

Ahora podemos subir una nueva versión del prompt. Imagina que queremos quitarle la parte de ”Debes tener en cuenta lo siguiente:” de la plantilla del sistema:

system_prompt_template =
"""
Eres un bot que responde a preguntas según un contexto
proporcionado por el usuario. El mensaje del usuario tendrá
el siguiente formato:
## CONTEXTO ##
Conocimiento que usarás para contestar a la pregunta
## PREGUNTA ##
Pregunta que debes de contestar
"""

prompt = ChatPromptTemplate.from_messages([
    ("system", system_prompt_template),
    ("user", user_prompt_template)
])
client.push_prompt(
    "prompt_rag",
    object = prompt,
    description = "Prompt para RAG",
    tags = ["rag"]
)

Para recuperar el prompt, es tan sencillo como usar el método .pull_prompt(). Por defecto, cuando recuperemos el prompt se recuperará la última versión subida, pero podemos recuperar una versión anterior especificando el commit al que pertenece.

client.pull_prompt("prompt_rag")
client.pull_prompt("prompt_rag:5e12e879")

En LangSmith, la funcionalidad de versionado de prompts permite tener un histórico de los distintos prompts usados para las distintas tasks dentro de una aplicación. Este traceo de prompts permite acceder a distintas versiones de un mismo prompt, almacenarlos con tags... pero no permite estructurarlos según proyecto, por lo que hay que tenerlo en cuenta a la hora de definir su nomenclatura de manera que sea característica o indicarlo mediante tags.

Langfuse

En Langfuse, prácticamente solo cambia el objeto que le pasamos al método .create_prompt(). En este caso, es una lista de diccionarios con las claves role y content. Los valores de content serán las plantillas que hemos definido. Además, deberemos especificar para el prompt un nombre, para qué configuración está pensado (como el modelo, la temperatura, las lenguas soportadas…) y las labels, donde por defecto se le asignará la de production, porque es obligatoria y marca el prompt que está en producción. También se le pueden asignar tags que, a diferencia de las labels, se pueden repetir entre prompts, y un type dependiendo de si es para texto o para chat.

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.prompts import HumanMessagePromptTemplate, SystemMessagePromptTemplate
from langfuse import Langfuse
 
langfuse = Langfuse()
system_prompt_template = """
Eres un bot que responde a preguntas según un contexto
proporcionado por el usuario. El mensaje del usuario tendrá
el siguiente formato:
## CONTEXTO ##
Conocimiento que usarás para contestar a la pregunta
## PREGUNTA ##
Pregunta que debes de contestar

Debes tener en cuenta lo siguiente:
- Contesta de la forma más natural posible. No digas "según el
contexto proporcionado" o frases similares.
- Limita tu respuesta únicamente al contexto que te proporcione
el usuario.
- Si es necesario, usa información de toda la conversación para
contestar.
"""
user_prompt_template = """
## CONTEXTO ##
{{contexto}}

## PREGUNTA ##
{{pregunta}}
"""

prompt_registry = {
    "name": "prompt_rag",
    "prompt": [
        {"role": "system", "content": system_prompt_template},
        {"role": "user", "content": user_prompt_template}],
    "config": {
        "model": "gpt-4o",
        "temperature": 0.1,
        "supported_languages": ["es"]
  },
    "labels": ["production", "staging", "latest"],
    "tags": ["RAG"],
    "type": "chat"
}

langfuse.create_prompt(
    **prompt_registry
)

Para subir una nueva versión del prompt, como en Langsmith, basta con escribir la nueva, generar el objeto y volver a usar el método:

system_prompt_template = """
Eres un bot que responde a preguntas según un contexto proporcionado por el usuario. El mensaje del usuario tendrá
el siguiente formato:
## CONTEXTO ##
Conocimiento que usarás para contestar a la pregunta

## PREGUNTA ##
Pregunta que debes de contestar
"""

prompt_registry = {
    "name": "prompt_rag",
    "prompt": [
        {"role": "system", "content": system_prompt_template},
        {"role": "user", "content": user_prompt_template}],
    "config": {
        "model": "gpt-4o",
        "temperature": 0.1,
        "supported_languages": ["es"]
  },
    "labels": ["production", "staging", "latest"],
    "tags": ["RAG"],
    "type": "chat"
}

langfuse.create_prompt(
    **prompt_registry
)

También se accede por defecto a la versión más nueva del prompt, pero se pueden obtener versiones anteriores:

langfuse.get_prompt("prompt_rag")
langfuse.get_prompt("prompt_rag", version = 1)

Este prompt no es un prompt de LangChain (como lo era en LangSmith), por lo que para usarlo en LangChain tendremos que convertirlo, pero es sencillo:

from langchain_core.prompts import ChatPromptTemplate

langchain_prompt = ChatPromptTemplate.from_messages(
    langfuse.get_prompt("prompt_rag").get_langchain_prompt()
)

Traceo de llamadas al LLM

El traceo es el registro de todas las interacciones hechas con el LLM. Es interesante porque puedes ver cuánto se está usando la aplicación, si hay muchos errores, si hay un usuario en concreto que está usándola más de lo normal (lo cual es útil para detectar posibles intentos de prompt hacking), cuánto están tardando las llamadas, cuánto están costando…

También se puede "personalizar" la traza: añadir metadatos (como el nombre del LLM) en cada llamada para distinguir los usos de LLMs y hacer embeddings o métricas de aquellos que generan una respuesta y así trazar cada uso por separado, por ejemplo. Tanto Langsmith como Langfuse ofrecen este traceo.

LangSmith

En LangSmith basta con invocar una chain o un llm para que el traceo se realice de forma directa. Vamos a hacer el "Hola, mundo" del LLM engineering:

from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model = "gpt-4o")
llm.invoke("Hello, world!")
AIMessage(content='Hello! How can I assist you today?', response_metadata={'token_usage': {'completion_tokens': 9, 'prompt_tokens': 11, 'total_tokens': 20, 'completion_tokens_details': {'reasoning_tokens': 0}}, 'model_name': 'gpt-4o', 'system_fingerprint': 'fp_3537616b13', 'finish_reason': 'stop', 'logprobs': None}, id='run-cf556208-5a03-4597-95b7-809623ec5a79-0', usage_metadata={'input_tokens': 11, 'output_tokens': 9, 'total_tokens': 20})

En la UI de LangSmith se ve así:

Imagen de traza simple en Langsmith. Es como un registro de una tabla, con las columnas Name, Input, Output, Start Time, Latency, Dataset, Annotation Queue y Tokens.

Podemos hacer algo más complejo, como cargar el cuento de Caperucita Roja en caperucita_roja.txt y preguntarle sobre él, un caso de uso que se conoce como Retrieval-Augmented Generation (RAG). Tendremos que invocar una chain, pasarle el texto troceado y la pregunta:

from langchain.chains.question_answering import load_qa_chain
​from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.docstore.document import Document

with open("caperucita_roja.txt", "r") as f:
    caperucita_story = f.read()
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size = 500, chunk_overlap = 50
)
chunks = text_splitter.split_documents(
    [Document(page_content = caperucita_story)]
)

langchain_prompt = client.pull_prompt("prompt_rag")
​chain = load_qa_chain(
    llm = llm,
    chain_type = "stuff",
    prompt = langchain_prompt,
    document_variable_name = "contexto"
)

response = chain.invoke({
    "input_documents": chunks,
    "pregunta": "¿De qué color es la caperucita?"
})

Las trazas desde el portal de LangSmith se verían como en las dos siguientes figuras. Se trata de cada una de las etapas de la cadena invocada. En el interior podemos ver los fragmentos enviados al LLM y su respuesta.

Se ve una traza en Langsmith, que consta de dos pasos: StuffDocumentsChain y AzureChatOpenAI. En este caso está seleccionado el primero, que es donde está la pregunta del usuario y los documentos que se le van a pasar como contexto al LLM. A la derecha se ven una serie de metadatos: start y end time, time to first token, status, total tokens, latency y type.
Ahora está seleccionado el paso de AzureChatOpenAI, donde se ve cómo los documentos han pasado al input que le llega al LLM como plantilla del humano.

Langsmith ofrece ciertas funcionalidades adaptadas a la naturaleza conversacional de muchas aplicaciones basadas en LLM, permitiendo agrupar trazas en hilos según ids de sesiones.

Langfuse

Langfuse, por supuesto, también permite registrar las trazas de llamadas a LLM y ofrece un detalle del tiempo de ejecución y coste de las llamadas. Para usarlo de forma integrada en LangChain, es necesario explicitar el callback handler en LangChain o bien usar el decorador observe, que hace que se observe todo lo ejecutado dentro de la función a la que se le aplica y que es la opción elegida en este ejemplo:

from langfuse.decorators import langfuse_context, observe
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain.chains.question_answering import load_qa_chain
 
prompt = langfuse.get_prompt("question_and_answer")
langchain_prompt = ChatPromptTemplate.from_messages(
    prompt.get_langchain_prompt()
)

@observe()
def generate_response(llm_model, query, chunks, model_kwargs):
    langfuse_handler = langfuse_context.get_current_langchain_handler()

    llm = ChatOpenAI(model = llm_model, **model_kwargs)
    chain = load_qa_chain(
        llm = llm,
        chain_type = "stuff",
        prompt = langchain_prompt,
        document_variable_name = "contexto"
    )

    response = chain.invoke(
        {"input_documents": chunks, "pregunta": query},
        config = {"callbacks": [langfuse_handler]}
    )
    return response

generate_response(
    "gpt-4o",
    "¿De qué color es la caperucita?",
    chunks,
    model_kwargs = {"temperature": 0.2}
)

Las trazas se pueden ver en lista como en LangSmith y también acceder a ellas para ver el detalle.

Imagen de la traza en Langfuse. Es como un registro de una tabla, con las columnas ID, Timestamp, Name, User, Session, Latency, Usage, Total cost, Scores, Tags y una columna con acciones que se pueden aplicar a las trazas (solo eliminarlas de momento).
Imagen del detalle de la traza en Langfuse. Es muy parecido al de Langsmith pero hay 4 pasos en vez de 2.

Hasta ahora, hemos visto las principales funcionalidades en ambas plataformas. Si quieres saber cómo se gestionan los datasets y cómo se ejecutan experimentos y evaluaciones, en unos días publicaremos la segunda parte de la serie 😉.

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete