Multimodal messages en Langchain: más allá del texto¶

Hasta ahora hemos hablado con nuestros agentes exclusivamente mediante texto. Pero los modelos modernos pueden entender mucho más: imágenes, audio e incluso combinaciones de varias modalidades en un solo mensaje.

En este artículo vamos a ampliar el canal de comunicación con el agente. Para mantener la continuidad con la serie, seguiremos con nuestro ejemplo guía de conversiones absurdas, pero ahora en un escenario de ciencia ficción: serás un escritor que describe la capital de un planeta donde los habitantes miden todo en plátanos y elefantes.

Objetivos de este notebook:

  1. Enviar un mensaje de texto con el formato estructurado que usan los modelos multimodales.
  2. Añadir una imagen al mensaje y pedirle al agente que la interprete.
  3. Añadir audio al mensaje y pedirle al agente que reaccione a él.
  4. Entender que cada modalidad puede requerir un modelo distinto.
In [1]:
# Cargamos variables de entorno. Buscamos el archivo .env hacia arriba desde
# la carpeta donde se ejecuta el notebook.
from dotenv import load_dotenv
import os

load_dotenv()

OPENROUTER_API_KEY = os.environ["OPENROUTER_API_KEY"]
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"

Antes de empezar necesitas:

  • Python 3.10+
  • pip install langchain langchain-openai python-dotenv langgraph ipywidgets sounddevice scipy numpy
  • Un archivo .env con OPENROUTER_API_KEY=...
  • Para la sección de audio: un micrófono y saldo disponible en OpenRouter (el modelo de audio tiene requisitos de saldo mínimo).

1. Mensaje de texto, pero con estructura multimodal¶

Incluso cuando enviamos solo texto, los modelos multimodales esperan una lista de bloques con tipo. Esto permite combinar después texto, imagen y audio en el mismo HumanMessage. Creamos un agente con un system prompt de escritor de ciencia ficción.

In [2]:
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage

# Modelo ligero que maneja texto e imagen.
vision_model = ChatOpenAI(
    model="openai/gpt-4o-mini",
    api_key=OPENROUTER_API_KEY,
    base_url=OPENROUTER_BASE_URL
)

# El system prompt fija el rol: escritor de ciencia ficción absurda.
agent = create_agent(
    vision_model,
    system_prompt="Eres un escritor de ciencia ficción creativo y algo absurdo. describes ciudades y planetas imaginarios."
)
In [3]:
# El contenido es una lista de bloques, aunque solo haya texto.
question = HumanMessage(content=[
    {"type": "text", "text": "Describe la capital de un planeta donde los habitantes miden todo en plátanos y elefantes."}
])

response = agent.invoke({"messages": [question]})
print(response["messages"][-1].content)
La capital de este fascinante planeta, conocido como Bananastrópico, se llama Platanópolis. Ubicada en una vasta llanura que se extiende hasta donde alcanza la vista, la ciudad brilla con una paleta de verdes vibrantes y amarillos deslumbrantes, el resultado de una flora exuberante que tiende a crecer en proporciones gigantescas. Aquí, cada edificio es una obra de arte arquitectónico, erigida en forma de plátano curvado, con techos en forma de hojas tropicales que parecen mecerse suavemente con la brisa.

Las calles están pavimentadas con un material que simula la textura de la cáscara de un plátano y, sorprendentemente, son resistentes al tiempo y al desgaste. En Platanópolis, la medida de distancias se realiza en “elefantes”, una unidad que equivale a aproximadamente tres metros y un plátano, la cual se utiliza como referencia para medir longitudes cortas. Así, los habitantes pueden decir que la tienda de platanos frescos está a solo “dos elefantes” de distancia, lo que se traduce en unos seis metros (o dos plátanos, si se prefiere la métrica más frutal).

La economía de Platanópolis gira en torno a la industria del plátano, que produce todo, desde alimentos hasta ropa. Los habitantes de Bananastrópico llevan vestimentas de seda de plátano, ligera y adaptable a las inusuales temperaturas del planeta. Las ferias locales se celebran en espacios públicos donde los comerciantes establecen puestos que ofrecen “plátano-comida” y “plátano-arte”, todo hecho con ingenio y creatividad.

Platanópolis también alberga el Gran Elefante Cantor, una escultura monumental que rinde homenaje a los elefantes que habitan en los bosques cercanos. Este lugar es famoso por sus conciertos al aire libre donde se interpreta la música melódica de un plátano, un instrumento de cuerda que suena como una mezcla entre un arpa y un saxofón, y se toca en festividades que celebran la abundancia de la fruta.

Las comunicaciones también son curiosas; los habitantes usan plátanomóviles, autos de cuatro ruedas con forma de plátano, que funcionan con una energía generada por la fermentación de plátanos muy maduros. La velocidad de estos vehículos es también medida en “elefantes por hora”, y se organizan competiciones donde los ciudadanos compiten no solo por la velocidad, sino por la originalidad y el estilo de sus plátanomóviles. 

En esencia, Platanópolis es una realidad donde la absurdidad se vuelve rutina, donde la vida gira en torno a lo bananero y los elefantes, y donde cada día es una celebración del peculiar equilibrio entre lo fantástico y lo cotidiano.

2. Añadir una imagen al mensaje¶

El mismo agente puede recibir una imagen junto al texto. En un entorno interactivo puedes subir tu propia imagen con el widget. En la exportación HTML usamos la imagen de ejemplo incluida en el repositorio.

In [4]:
from ipywidgets import FileUpload
from IPython.display import display

# Widget para subir una imagen PNG desde tu ordenador.
# Si no subes nada, el siguiente bloque usa la imagen de ejemplo del repo.
uploader = FileUpload(accept='.png', multiple=False)
display(uploader)
In [5]:
import base64
from pathlib import Path

# Intentamos usar la imagen subida por el widget.
# Si no hay nada, caemos en la imagen de ejemplo del planeta alienígena.
try:
    uploaded_file = uploader.value[0]
    img_bytes = bytes(uploaded_file["content"])
except (IndexError, NameError, KeyError):
    img_path = Path("../An_alien_planet_where_the_inhabitants_measure_everything_in_bananas_and_elephants.png")
    if not img_path.exists():
        # Fallback si el notebook se ejecuta desde otro directorio.
        img_path = Path("notebooks/module-1/An_alien_planet_where_the_inhabitants_measure_everything_in_bananas_and_elephants.png")
    img_bytes = img_path.read_bytes()

img_b64 = base64.b64encode(img_bytes).decode("utf-8")
print(f"Imagen cargada: {len(img_bytes)} bytes" )
Imagen cargada: 1884062 bytes
In [6]:
# Combinamos texto e imagen en un único HumanMessage.
multimodal_question = HumanMessage(content=[
    {"type": "text", "text": "Eres un escritor de ciencia ficción. Mira este boceto de una capital alienígena y descríbela como si sus habitantes midieran todo en plátanos y elefantes."},
    {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_b64}"}}
])

response = agent.invoke({"messages": [multimodal_question]})
print(response["messages"][-1].content)
En el corazón del planeta Gizmo-47 se yergue la capital alienígena llamada Bananalux, una metrópoli vibrante que se extiende a lo largo de un vasto paisaje que parece estar hecho de plátanos dorados y elefantes voladores. Las estructuras son una mezcla surrealista de arquitectura. Las torres tienen la forma de enormes plátanos encorvados, que se elevan a 200 plátanos (equivalentes a 100 elefantes) hacia el cielo. En el centro, una cúpula circular se alza como un símbolo de unidad, decorada con mosaicos de cáscaras de plátano.

Las calles, pavimentadas con pulpa de plátano, están siempre húmedas y resbaladizas, llenas de habitantes que se miden entre sí en "plátanos de altura". A cada lado, monumentos conmemorativos de elefantes en diferentes posturas celebran momentos de gran sabiduría en su historia; algunos son tan grandes como tres elefantes apilados, creando una imponente sombra sobre la plaza central.

Cada rincón de Bananalux emana un aire de festividad constante, con mercados donde se cambia todo en plátanos y elefantes. La moneda local es "el platanón", un intercambio que se establece por el volumen de plátanos que se puede llevar, mientras que los servicios son medidos en un sistema que involucra los "elefantes de esfuerzo": los eléctricos, los artísticos y los curativos, que brindan un sentido de valor muy particular.

Por la noche, la ciudad se ilumina con faros que brillan como lunas de plátano, formando un espectáculo que encanta a los ciudadanos. Las fiestas nunca terminan, y cada celebración está dedicada a un aspecto diferente de la vida cotidiana: la cosecha de plátanos, el vuelo de los elefantes y la reunión de comunidades, todo medido y disfrutado en plátanos y elefantes.

Bananalux es un lugar donde la lógica terrenal no aplica; cada esquina tiene su propio significado único y cada habitante un concepto despreocupado y exuberante de vida, donde hasta las estructuras más extrañas son convencionales, y donde medir en plátanos y elefantes es tan natural como respirar.

3. Añadir audio al mensaje¶

Para el audio usamos un modelo específico: openai/gpt-audio-mini vía OpenRouter. La celda siguiente graba tu voz durante 5 segundos. Si no tienes micrófono o estás viendo el HTML exportado, el código genera un tono de prueba para demostrar la estructura del mensaje.

Instrucción: graba en español una pregunta absurda, por ejemplo: "¿Cómo sería una capital donde las distancias se midan en saltos de canguro?"

In [7]:
import io
import base64
import numpy as np
from scipy.io.wavfile import write

# Intentamos grabar audio del micrófono.
# Si falla (sin micrófono, entorno no interactivo), generamos un tono de prueba.
try:
    import sounddevice as sd
    from tqdm import tqdm
    import time

    duration = 5  # segundos
    sample_rate = 24000
    print("Grabando... habla ahora.")
    audio = sd.rec(int(duration * sample_rate), samplerate=sample_rate, channels=1)
    for _ in tqdm(range(duration * 10)):
        time.sleep(0.1)
    sd.wait()
    print("Grabación finalizada.")

    buf = io.BytesIO()
    write(buf, sample_rate, audio)
    wav_bytes = buf.getvalue()
except Exception as e:
    print(f"No se pudo grabar audio ({e}). Generando tono de prueba...")
    sample_rate = 24000
    duration = 3
    t = np.linspace(0, duration, int(sample_rate * duration), False)
    tone = np.sin(440 * t * 2 * np.pi)
    audio = np.int16(tone * 32767)
    buf = io.BytesIO()
    write(buf, sample_rate, audio)
    wav_bytes = buf.getvalue()

aud_b64 = base64.b64encode(wav_bytes).decode("utf-8")
print(f"Audio preparado: {len(wav_bytes)} bytes" )
Grabando... habla ahora.
  0%|          | 0/50 [00:00<?, ?it/s]
  2%|▏         | 1/50 [00:00<00:05,  9.60it/s]
  4%|▍         | 2/50 [00:00<00:04,  9.78it/s]
  6%|▌         | 3/50 [00:00<00:04,  9.83it/s]
  8%|▊         | 4/50 [00:00<00:04,  9.88it/s]
 10%|█         | 5/50 [00:00<00:04,  9.88it/s]
 12%|█▏        | 6/50 [00:00<00:04,  9.78it/s]
 14%|█▍        | 7/50 [00:00<00:04,  9.83it/s]
 16%|█▌        | 8/50 [00:00<00:04,  9.86it/s]
 18%|█▊        | 9/50 [00:00<00:04,  9.77it/s]
 20%|██        | 10/50 [00:01<00:04,  9.82it/s]
 22%|██▏       | 11/50 [00:01<00:03,  9.77it/s]
 24%|██▍       | 12/50 [00:01<00:03,  9.82it/s]
 26%|██▌       | 13/50 [00:01<00:03,  9.85it/s]
 28%|██▊       | 14/50 [00:01<00:03,  9.73it/s]
 30%|███       | 15/50 [00:01<00:03,  9.77it/s]
 32%|███▏      | 16/50 [00:01<00:03,  9.82it/s]
 34%|███▍      | 17/50 [00:01<00:03,  9.85it/s]
 36%|███▌      | 18/50 [00:01<00:03,  9.85it/s]
 38%|███▊      | 19/50 [00:01<00:03,  9.74it/s]
 40%|████      | 20/50 [00:02<00:03,  9.70it/s]
 42%|████▏     | 21/50 [00:02<00:02,  9.77it/s]
 44%|████▍     | 22/50 [00:02<00:02,  9.81it/s]
 46%|████▌     | 23/50 [00:02<00:02,  9.72it/s]
 48%|████▊     | 24/50 [00:02<00:02,  9.73it/s]
 50%|█████     | 25/50 [00:02<00:02,  9.67it/s]
 52%|█████▏    | 26/50 [00:02<00:02,  9.63it/s]
 54%|█████▍    | 27/50 [00:02<00:02,  9.65it/s]
 56%|█████▌    | 28/50 [00:02<00:02,  9.65it/s]
 58%|█████▊    | 29/50 [00:02<00:02,  9.58it/s]
 60%|██████    | 30/50 [00:03<00:02,  9.69it/s]
 62%|██████▏   | 31/50 [00:03<00:01,  9.76it/s]
 64%|██████▍   | 32/50 [00:03<00:01,  9.81it/s]
 66%|██████▌   | 33/50 [00:03<00:01,  9.71it/s]
 68%|██████▊   | 34/50 [00:03<00:01,  9.77it/s]
 70%|███████   | 35/50 [00:03<00:01,  9.82it/s]
 72%|███████▏  | 36/50 [00:03<00:01,  9.70it/s]
 74%|███████▍  | 37/50 [00:03<00:01,  9.68it/s]
 76%|███████▌  | 38/50 [00:03<00:01,  9.62it/s]
 78%|███████▊  | 39/50 [00:04<00:01,  9.27it/s]
 80%|████████  | 40/50 [00:04<00:01,  8.82it/s]
 82%|████████▏ | 41/50 [00:04<00:01,  8.91it/s]
 84%|████████▍ | 42/50 [00:04<00:00,  9.13it/s]
 86%|████████▌ | 43/50 [00:04<00:00,  9.36it/s]
 88%|████████▊ | 44/50 [00:04<00:00,  9.53it/s]
 90%|█████████ | 45/50 [00:04<00:00,  9.52it/s]
 92%|█████████▏| 46/50 [00:04<00:00,  9.64it/s]
 94%|█████████▍| 47/50 [00:04<00:00,  9.53it/s]
 96%|█████████▌| 48/50 [00:04<00:00,  9.64it/s]
 98%|█████████▊| 49/50 [00:05<00:00,  9.70it/s]
100%|██████████| 50/50 [00:05<00:00,  9.65it/s]
100%|██████████| 50/50 [00:05<00:00,  9.66it/s]

Grabación finalizada.
Audio preparado: 480058 bytes
In [8]:
# Modelo específico para audio. Requiere saldo disponible en OpenRouter.
audio_model = ChatOpenAI(
    model="openai/gpt-audio-mini",
    api_key=OPENROUTER_API_KEY,
    base_url=OPENROUTER_BASE_URL
)

audio_agent = create_agent(audio_model)

audio_question = HumanMessage(content=[
    {"type": "text", "text": "Responde como escritor de ciencia ficción absurdo."},
    {"type": "input_audio", "input_audio": {"data": aud_b64, "format": "wav"}}
])

try:
    response = audio_agent.invoke({"messages": [audio_question]})
    print(response["messages"][-1].content)
except Exception as e:
    print("El modelo de audio requiere saldo disponible en OpenRouter o la modalidad no está habilitada.")
    print(f"Error: {e}")
El modelo de audio requiere saldo disponible en OpenRouter o la modalidad no está habilitada.
Error: Error code: 402 - {'error': {'message': 'This request requires at least $0.50 in balance for audio', 'code': 402}}

Cierre y siguiente paso¶

Hemos visto que un HumanMessage no está limitado a texto plano: puede contener bloques de texto, imágenes en base64 y audio. Esto abre la puerta a agentes que perciben el mundo de forma mucho más rica.

Honestidad técnica: no todos los modelos soportan todas las modalidades. El texto e imagen funcionan con gpt-4o-mini, pero el audio requiere un modelo específico (gpt-audio-mini) y, en OpenRouter, condiciones de saldo mínimo. Si una llamada falla, lo primero que hay que revisar es si el modelo elegido acepta la modalidad que estamos enviando.

El siguiente artículo de la serie será una sorpresa: un proyecto integrador donde usaremos la mayoría de lo aprendido hasta ahora —modelos, prompts, tools, memoria y mensajes multimodales— para construir algo más ambicioso que un simple script aislado.