Continuamos esta serie de posts en la que comparamos las dos principales plataformas de LLM engineering: Langfuse y LangSmith. Si quieres saber más sobre la motivación de esta serie y cómo se configuran las distintas plataformas y qué hay que hacer en cada una de ellas para versionar los prompts y tracear las llamadas al LLM, échale un vistazo al primer post.

Datasets

La definición de datasets es clave para poder tener un control del ciclo de vida de nuestros prompts. Los datasets son conjuntos de inputs y las correspondientes respuestas esperadas del LLM a esos inputs, y se emplean para evaluar la aplicación o la parte de ella que nos interese (el prompt, el LLM, la base de datos…).

Hay que tener en cuenta que para crear un dataset necesitamos las ground truths, las respuestas correctas a las preguntas que queremos que pueda responder el LLM, con las que comparar sus respuestas reales. Pero tanto en LangSmith como en Langfuse, los datasets se pueden crear de forma iterativa, pudiendo insertar en ellos los casos reales ocurridos en entornos productivos que escojamos. Pueden crearse tanto desde la interfaz gráfica como desde código.

LangSmith

Para construir un dataset en LangSmith, es tan sencillo como usar el método .create_dataset() con el nombre que deseemos darle. Después, para insertar ejemplos, hay que usar el método .create_examples() y completar los parámetros:

dataset_name = "Dataset1"
dataset = client.create_dataset(dataset_name)
client.create_examples(
    inputs = [
        {"question": "¿De qué color era la caperucita?"},
        {"question": "¿Qué quiere el lobo?"},
    ],
    outputs = [
        {"answer": "La caperucita era roja"},
        {"answer": "El lobo quiere comerse a Caperucita"},
    ],
    dataset_id = dataset.id,
)

Dentro de LangSmith, un dataset se ve como a continuación:

Vista del Dataset1 dentro de la pestaña Datasets & Testing. Está seleccionada la pestaña Examples. Se ve una tabla con las columnas Input, Output, Created At, Modified At, Splits y un menú con acciones por cada registro del dataset. Hay dos registros: la pregunta "¿De qué color era la caperucita?" y la pregunta "¿Qué quiere el lobo?".

Langfuse

En Langfuse también existe un método .create_dataset():

langfuse.create_dataset(
    name = "dataset_sencillo",
    description = "Dataset de prueba",
    # Metadata opcional
    metadata = {
        "author": "Miguel",
        "date": "2024-08-06",
        "type": "tutorial"
    }
)

Sin embargo, para insertar items en el dataset, es necesario hacerlo de uno en uno:

langfuse.create_dataset_item(
    dataset_name = "dataset_sencillo",
    input = {
        "text": "¿De qué color era la caperucita?"
    },
    expected_output = {
        "text": "La caperucita era roja"
    },
)
​
langfuse.create_dataset_item(
    dataset_name = "dataset_sencillo",
    input = {
        "text": "¿Qué quiere el lobo?"
    },
    expected_output = {
        "text": "El lobo quiere comerse a Caperucita"
    },
)
Se ve una tabla con las columnas Item id, Source, Status, Created At, Input, Expected Output, Metadata y un menú con acciones por cada registro del dataset.

Los datasets también permiten tener un control del deterioro de un prompt asociado a, por ejemplo, un cambio en la versión del LLM o, al poder ser incrementados, un deterioro a lo largo del tiempo debido a cambios en los datos de entrada.

Experimentación y evaluación

Como hemos dicho, el objetivo de crear un dataset es evaluar una parte de la aplicación.

Tanto LangSmith como Langfuse se integran fácilmente con el framework de evaluación de LangChain y también con RAGAS (una librería de métricas automáticas para evaluar el caso de uso RAG). Sin embargo, en este caso crearemos un prompt propio de evaluación, para evaluar si el contenido generado contiene la información del contenido de referencia (respuesta generada vs. respuesta correcta).

Para ello volveremos a echar mano de un ChatPromptTemplate, pero esta vez también le pasaremos algunos ejemplos de comportamiento (técnica que se llama few shot), ya que necesitamos que el formato sea más predecible (que diga “True” o “False”).

LangSmith

Para empezar, vamos a escribir código común para las dos plataformas: la plantilla de sistema, la plantilla donde se insertará el mensaje de la persona humana y una plantilla con los ejemplos:

from langchain_core.prompts.few_shot import FewShotChatMessagePromptTemplate
from langchain_core.messages.human import HumanMessage
​
evaluation_prompt_system = f"""
Recibirás dos trozos de texto, uno que será la referencia
y otro que será un texto a evaluar. Tu tarea consiste en
ver si el texto a evaluar contiene semánticamente todo lo
que contiene el texto referencia y contestar solo con 'True'
o 'False'. Ten en cuenta lo siguiente:
    1. El texto a evaluar no tiene que ser literalmente
igual que el texto de referencia.
    2. El texto a evaluar tiene que contener toda la
información dada en el texto de referencia.
    3. El texto a evaluar puede contener más información
que el texto de referencia.
    4. Devuelve solamente 'True' o 'False' dependiendo de
si se cumplen las condiciones o no.


Lo que recibirás será:
reference_text: texto de referencia
evaluate_text: texto a evaluar
"""
​
human_prompt = """
reference_text: {reference_text}
evaluate_text: {evaluate_text}
"""
​
# Ejemplos few shot
examples = [{
    "reference": "La casa es azul",
    "evaluate": "La casa es azul y tiene una puerta marrón",
    "response": "True"
}, {
    "reference": "La revolución rusa empieza en 1328",
    "evaluate": "La revolución rusa empieza en 1917",
    "response": "False"
}, {
    "reference": "Mi primo es rubio y tiene ojos azules",
    "evaluate": "Mi primo Pedro tiene ojos azules",
    "response": "False"
}, {
    "reference": "Tengo dos casas en Formentera",
    "evaluate": "Tengo dos casas preciosas en Formentera",
    "response": "True"
}]
​
example_prompt = ChatPromptTemplate.from_messages([
    ('human', 'reference_text: {reference}\nevaluate_text: {evaluate}'),
    ('ai', '{response}')
])

# Creación del prompt few shot
few_shot_template = FewShotChatMessagePromptTemplate(
    example_prompt = example_prompt,
    examples = examples
)

Una vez tenemos las tres plantillas, tenemos que crear nuestro prompt_to_evaluate para subirlo a la plataforma. Empezamos por LangSmith:

examples_msgs = [
    ("human", i.content) if type(i) == HumanMessage
    else ("ai", i.content)
    for i in few_shot_template.format_messages()
]

prompt_to_evaluate = ChatPromptTemplate.from_messages(
    [("system", evaluation_prompt_system)] +
    examples_msgs +
    [("human", human_prompt)]
)

client.push_prompt(
    "prompt_for_evaluation",
    object = prompt_to_evaluate
)

Para la etapa de evaluación personalizada, es necesario definir:

A continuación definimos ambas funciones. La función de generación (generate_response()) instancia el LLM, instancia el prompt a evaluar e invoca el modelo. Debe recibir como input un diccionario, que corresponderá a los diccionarios definidos como inputs a la hora de crear el dataset. Este input contendrá los campos necesarios que hemos definido en la propia creación del dataset (question en nuestro caso). La salida debe de ser también un diccionario cuyos campos luego serán referenciados desde la función de evaluación (en nuestro caso, output).

La función de evaluación (evaluate_shot()) recibe como argumentos un objeto Run y un objeto Example de LangSmith. Dentro de la función, instanciamos el prompt de evaluación y obtenemos por un lado el output del ejemplo (que en el almacenamiento del dataset hemos definido como answer) y la salida de la ejecución, que seguiría la misma estructura que la definida en el return de la función de generación (en nuestro caso solo tendría el campo output).

from langsmith.schemas import Example, Run
from langsmith.evaluation import evaluate
from langchain.chains.question_answering import load_qa_chain
​
def generate_response(inputs: dict):
    llm = ChatOpenAI(model = "gpt-4o", temperature = 0.1)
    prompt = client.pull_prompt("prompt_rag")
    chain = load_qa_chain(
        llm = llm,
        chain_type = "stuff",
        prompt = prompt,
        document_variable_name = "contexto"
    )

    # Se realiza la generación
    response = chain.invoke({
        "input_documents": chunks,
        "pregunta": inputs["question"]
    })
    return {"output": response["output_text"]}
​
def evaluate_shot(run: Run, example: Example):
    prompt_evaluation = client.pull_prompt(
        "prompt_for_evaluation"
    )
    reference = example.outputs["answer"]
    output_to_evaluate = run.outputs["output"]


    chain = prompt_evaluation | ChatOpenAI(
        model = "gpt-4o"
    )
    response = chain.invoke({
        "evaluate_text": output_to_evaluate,
        "reference_text": reference
    }).content

    return {"key": "accuracy", "score" : float(eval(response))}

Finalmente, usamos la función evaluate() para crear un experimento, indicando cuál es la función de generación, cual la de evaluación y el dataset sobre el que realizaremos el experimento.​

from langsmith.evaluation import evaluate
​​
results = evaluate(
    generate_response,
    data = "Dataset1",
    evaluators = [evaluate_shot]
)

Y ya podemos ir a LangSmith a ver nuestro experimento, que ha recibido automáticamente un nombre compuesto por dos nombres y un número aleatorios separados por guiones. En este caso, advanced-exchange-25.

Vista del Dataset1 dentro de la pestaña Datasets & Testing. Está seleccionada la pestaña Examples. Se ve una tabla con las columnas Input, Output, Created At, Modified At, Splits y un menú con acciones por cada registro del dataset. Hay dos registros: la pregunta "¿De qué color era la caperucita?" y la pregunta "¿Qué quiere el lobo?".

Cuando entramos en el experimento, vemos que podemos añadir otro para compararlos. Como ves, la columna advanced-exchange-25 contiene las respuestas reales y el score que se les ha dado. En este caso, la primera respuesta tiene un score de 0 porque la respuesta real dice "El lobo quiere comerse a la abuela, a Caperucita Roj..." cuando la de referencia es "El lobo quiere comerse a Caperucita", y la segunda respuesta tiene un score de 1 porque dice "La caperucita es de color rojo" cuando la de referencia es "La caperucita es roja".

Vista del experimento advanced-exchange-25 dentro de la pestaña Datasets & Testing. Se ve una tabla con las columnas Input, Reference Output, advanced-exchange-25 y un botón para añadir más experimentos para comparar. Hay dos registros: la pregunta "¿Qué quiere el lobo?" y la pregunta "¿De qué color era la caperucita?".

Langfuse

Ahora vamos a crear el prompt_to_evaluate necesario para subirlo a Langfuse, usando las mismas plantillas que definimos para LangSmith.

examples_msgs = [
    {
        "role": "human",
        "content": i.content
    } if type(i) == HumanMessage
    else {"role": "ai", "content": i.content}
    for i in few_shot_template.format_messages()
]

prompt_to_evaluate = [{
    "role": "system", "content": evaluation_prompt_system
    }] + examples_msgs + [{
    "role": "human", "content": human_prompt
}]

langfuse.create_prompt(
    name = "evaluation_prompt",
    prompt = prompt_to_evaluate,
    labels = ["production"],
    tags = ["evaluation", "simple_evaluation_prompt"],
    config = {"llm": "gpt-4o"},
    type = "chat"
)

A continuación definimos la función que gestionará el prompt de evaluación. Esta, al ser también una generación, se traceará también, indicando que se trata de una traza de evaluación:

@observe()
def generate_evaluation(llm_model, reference_text, evaluate_text , model_kwargs, metadata):
    langfuse_handler = langfuse_context.get_current_langchain_handler()
    langfuse_context.update_current_trace(
        name = "evaluation_trace",
        tags = ["evaluacion", "primera_evaluacion"],
        metadata = metadata
    )
    llm = ChatOpenAI(model = llm_model, **model_kwargs)

    # Se realiza la generación
    chain = evaluation_prompt | llm
    response = chain.invoke(
        {
            "reference_text": reference_text,
            "evaluate_text": evaluate_text
        },
        config = {"callbacks": [langfuse_handler]}
    )

    return response

Instanciamos el dataset y el prompt de evaluación:

dataset = langfuse.get_dataset("dataset_sencillo")
evaluation_prompt = ChatPromptTemplate.from_messages(
    langfuse.get_prompt(
        "evaluation_prompt"
    ).get_langchain_prompt())

​Y, por último, iteramos sobre el dataset, aplicamos la evaluación y guardamos el score.

for item in dataset.items:
    with item.observe(
        run_name = "evaluation",
        run_description = "Primera evaluación",
        run_metadata = {"model": "gpt4-o"}
    ) as trace_id:
        question = item.input['text']
        expected_output = item.expected_output['text']

        # Se realiza la generación
        response = generate_response(
            "gpt-4o",
            question,
            chunks,
            {"temperature": 0.1}
        )['output_text']

        # Evaluación
        evaluation_value = generate_evaluation(
            "gpt-4o",
            expected_output,
            response,
            {"temperature": 0.1},
            metadata = {"date": "2024-08-06"}
        ).content

        # Se registra la puntuación
        langfuse.score(
            trace_id = trace_id,
            name = "evaluate_" + item.id,
            value = float(eval(evaluation_value))
        )

Y ya podemos observar en Langfuse las trazas de las evaluaciones, con el nombre y los tags que les hemos dado y con sus scores.

Vista de la pestaña Traces dentro de Tracing. Se ve una tabla con las columnas ID, Timestamp, Name, User, Session, Latency, Usage, Total Cost, Scores y Tags. Hay un par de trazas con el nombre generate_response que tienen valores en la columna Scores, y justo encima de cada una de ellas, sendas trazas con el nombre evaluation_trace y los tags "evaluacion" y "primera_evaluacion".

También podemos ir directamente a la pestaña de “scores” si solo nos interesa ver cómo han ido cambiando los scores.

Vista de la pestaña Scores dentro de Tracing. Se ve una tabla con las columnas Trace ID, Observation ID, Trace Name, Trace User ID, Timestamp, Source, Name, Data Type, Value, Author y Eval Configuration ID. Hay dos registros con el nombre generate_response y valor 1.

Y, por último, también queda registrado en la vista del dataset, en la pestaña de “runs”, si queremos comparar evaluaciones con ese mismo dataset de un vistazo.

Vista del dataset dataset_sencillo dentro de la pestaña de Datasets. Está seleccionada la pestaña Runs. Se ve una tabla con las columnas Name, Description, Run Items, Latency (avg), Total Cost (avg), Scores (avg), Created y Metadata. Hay un solo registro, con el nombre evaluation, 4 run items y dos scores, de valor 1 cada uno.

Conclusiones

Como hemos visto, se trata de dos plataformas muy parecidas, pero resumimos aquí sus diferencias:

Ambas son muy buenas opciones para seguir monitorizando tus aplicaciones basadas en LLMs, solo tienes que dar con la que mejor se ajuste a tus necesidades.

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