En vivo

La sesión en vivo: tu primer modelo corriendo en tu laptop

Esta es la guía completa de la sesión en vivo, en una sola página. Vamos en cinco partes, en este orden. Síguelas de arriba abajo: cada una usa lo que dejaste listo en la anterior.

ParteQué hacesQué entiendes
1Cargas el modelo y miras cuánta memoria ocupaQué cabe en tu laptop, qué es un token y de qué depende la velocidad
2Creas chat.py y le hablas a tu modelo desde PythonPor qué el mismo código sirve para tu laptop y para la nube
3Creas providers.py y measure.py y mides laptop contra nubeCómo comparar con justicia: tiempo, velocidad y aciertos
4Creas agent.py y le das herramientas al modelo pequeñoCómo funciona una herramienta y dónde se rompe un modelo chico
5Armas tu criterio y liberas la memoriaCuándo conviene ir local y cuándo no

Recuerda la analogía del taller: la nube es un restaurante (chef enorme, cobra por plato, ve tus ingredientes) y lo local es un cocinero en tu cocina (no cobra por plato, nadie ve nada, pero tiene que caber y sabe menos). Cada término nuevo se explica cuando aparece; si quieres el mapa completo, está en el Vocabulario del taller, en la lección de bienvenida.

Antes de empezar: tu punto de partida

De la preparación tienes que traer esto. Si algo falta, es justo lo que resolvemos en los primeros minutos:

  • La carpeta ~/local-model-workshop con expenses.csv y requirements.txt, y el entorno virtual .venv con la librería openai.
  • LM Studio instalado y qwen/qwen3-4b-2507 descargado.
  • (Opcional) Tu llave de OpenRouter, para la parte 3.

Abre la carpeta en VS Code y una terminal dentro de ella. Activa el entorno virtual:

Mac y Linux:

# Activa el entorno virtual del proyecto
source .venv/bin/activate

Windows (PowerShell):

# Permite el script de activación solo en esta ventana, y activa el entorno
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
.\.venv\Scripts\Activate.ps1

La línea de la terminal tiene que empezar con (.venv).

Parte 1: Qué cabe en tu laptop

Antes de escribir código, vamos a entender qué pasa dentro de tu computadora cuando un modelo contesta. Con eso, los números que vas a medir en la parte 3 van a tener sentido.

En esta parte vas a entender:

  1. Qué pasa cuando LM Studio "carga" un modelo.
  2. Qué es un token y cómo genera texto un modelo.
  3. Por qué la velocidad depende tanto de tu máquina.
  4. Qué es el contexto y por qué también ocupa memoria.

1. Cargar un modelo: de la bodega a la cocina

Ya sabes que el modelo es un archivo de unos 2.5 GB en tu disco. Para contestar, todo ese archivo tiene que estar en la RAM. Eso es "cargar": copiarlo del disco a la memoria. Tarda unos segundos, y mientras el modelo está cargado tu laptop se siente más pesada.

Hay dos formas de cargarlo. Tú, a mano (con el selector de modelos de la app o con lms load), o LM Studio solo, cuando le llega el primer pedido para un modelo que no está en la RAM. A eso último LM Studio le llama JIT (just in time, "justo a tiempo") y viene encendido por defecto. Si está apagado y le pides algo a un modelo sin cargar, contesta con el error No models loaded.

Antes de cargarlo, LM Studio te puede decir cuánta memoria va a necesitar, sin cargar nada:

# Calcula cuánta memoria pediría el modelo, sin cargarlo
lms load qwen/qwen3-4b-2507 --estimate-only

En una Mac con chip Apple, la estimación dice algo así (Confidence: LOW solo quiere decir que es un cálculo aproximado, y GiB es casi lo mismo que GB: 1 GiB son unos 1.07 GB):

Model: qwen/qwen3-4b-2507
Estimated GPU Memory:   2.33 GiB
Estimated Total Memory: 2.33 GiB
Confidence: LOW

Ahora cárgalo de verdad y enciende el servidor (en la app: selector de modelos arriba y pestaña Developer; o en la terminal):

# Enciende el servidor local de LM Studio
lms server start

# Carga el modelo en la RAM
lms load qwen/qwen3-4b-2507

# Muestra qué está cargado en la RAM ahora mismo
lms ps

Abre el monitor de tu sistema (Monitor de Actividad en Mac, Administrador de tareas en Windows) y mira la memoria usada antes y después de cargar. Ese salto es el cocinero entrando a tu cocina.

2. Qué es un token y cómo escribe un modelo

Primero, una palabra que vas a oír todo el taller: al texto que tú le mandas al modelo (tu pregunta, tu instrucción) se le llama prompt. En la analogía, es el pedido que le haces al cocinero.

Un modelo no lee ni escribe letras sueltas, ni palabras completas: trabaja con tokens, pedazos de palabra. Una palabra corta y común suele ser un token; una palabra larga o rara se parte en varios.

  "Explícame qué es un archivo CSV"

  [Expl][ícame][ qué][ es][ un][ archivo][ CSV]     <- unos 7 tokens
  (el corte exacto depende de cada modelo)

Y escribe un token a la vez. Para cada token nuevo, hace cuentas con todos sus parámetros, elige el siguiente pedazo más probable, lo agrega al texto y vuelve a empezar:

  "Un CSV es"  -> modelo -> " un"
  "Un CSV es un"  -> modelo -> " archivo"
  "Un CSV es un archivo"  -> modelo -> " de"
  ...  y así, un token por vuelta, hasta terminar

En la analogía: el cocinero sirve un bocado a la vez, y para cada bocado repasa todo lo que sabe.

3. De qué depende la velocidad

Como cada token pide recorrer los 2.5 GB de parámetros, la velocidad depende sobre todo de qué tan rápido tu computadora puede leer su memoria:

Tu máquinaQué pasaQué esperar
Mac con chip AppleEl procesador y la tarjeta de video comparten una memoria muy rápidaDe lo mejor para modelos locales
PC con tarjeta de video (GPU) con memoria propiaSi el modelo cabe en la memoria de la tarjeta, vuelaMuy rápido; si no cabe completo, se reparte y baja
PC sin tarjeta de video dedicadaTodo lo hace el procesador (CPU) con la RAM normalFunciona, más lento. Suficiente para la sesión

La velocidad se mide en tokens por segundo: cuántos pedazos de palabra escribe el modelo cada segundo. La vas a medir tú en la parte 3.

4. El contexto también ocupa memoria

El contexto es todo el texto que el modelo tiene delante en un pedido: tus instrucciones, tu pregunta, lo que ya respondió. Es su mesa de trabajo: solo razona con lo que está sobre la mesa. Y la mesa también ocupa RAM: un contexto más grande pide más memoria, además de la del modelo.

  RAM usada por el modelo cargado
  +------------------------------+----------------------+
  |  Parametros (unos 2.5 GB)    |  Contexto (la mesa)  |
  |  fijo: siempre el mismo      |  crece con el texto  |
  +------------------------------+----------------------+

Para este taller los textos son cortos, así que el tamaño de contexto que LM Studio pone por defecto alcanza.

Para comprobar que entendiste

  1. Le mandas un pedido a un modelo que no está cargado y la respuesta tarda mucho más que las siguientes. ¿Qué pasó?
Respuesta

Que LM Studio lo cargó en ese momento (JIT): ese primer pedido incluyó copiar todo el archivo del disco a la RAM, y el disco es lento. Una vez en la RAM, el modelo queda listo y las siguientes respuestas ya no pagan ese costo.

  1. ¿Por qué una Mac con chip Apple suele ser más rápida que una laptop sin tarjeta de video, con la misma cantidad de RAM?
Respuesta

Porque por cada token el modelo recorre todos sus parámetros, y la velocidad depende de qué tan rápido se lee la memoria. Los chips Apple tienen una memoria compartida muy rápida; sin tarjeta de video, todo lo hace el procesador con la RAM normal.

Resumen

  • Cargar es copiar el modelo del disco a la RAM; mientras está cargado, ocupa memoria aunque no le hables.
  • Un modelo escribe un token a la vez, recorriendo todos sus parámetros en cada uno. Por eso la velocidad depende de qué tan rápido lee tu memoria.
  • El contexto es la mesa de trabajo del modelo, y también ocupa RAM.

En la analogía: el cocinero (el modelo) espera en la bodega (el disco). Cargarlo es traerlo a tu cocina (la RAM), donde también necesita espacio para su mesa de trabajo (el contexto). Sirve un bocado (token) a la vez, y qué tan rápido sirve depende de qué tan ágil es tu cocina.

Parte 2: Tu código, hablando con tu laptop

Ahora sí, código. Al terminar esta parte vas a tener un script de Python que le manda una pregunta a tu modelo local y te muestra la respuesta. Y vas a ver por qué ese mismo script, cambiando sus tres líneas de configuración, le hablaría a la nube.

En esta parte vas a entender:

  1. Qué es una API compatible y por qué LM Studio la imita.
  2. Cómo se arma un pedido desde Python: cliente, modelo y mensajes.
  3. Cómo leer un error de Python sin asustarte.

Si un término de Python no te suena (variable, lista, diccionario, función, import), está explicado en la lección Prepara tu laptop, en la idea 6.

Qué es la API compatible y por qué importa

Los proveedores en la nube aceptan pedidos con un formato muy concreto: a qué dirección se mandan, qué campos llevan (el modelo, los mensajes) y cómo viene la respuesta. Ese formato se volvió un estándar, y LM Studio lo imita: su servidor acepta exactamente la misma comanda que un proveedor en la nube.

  El mismo pedido (la misma comanda)
  +--------------------------------------------+
  | model: "qwen/qwen3-4b-2507"                |
  | messages: [ {role: user, content: "..."} ] |
  +--------------------------------------------+
            |                         |
            v                         v
  http://localhost:1234/v1    https://openrouter.ai/api/v1
  (tu cocina: LM Studio)      (el restaurante: OpenRouter)

Por eso la librería se llama openai aunque no vayamos a usar esa empresa: es la librería que sabe escribir esa comanda, y sirve para cualquier servidor que la entienda.

1. Crea chat.py

En VS Code, en el Explorador, crea un archivo nuevo llamado chat.py dentro de local-model-workshop, pega esto y guarda (Cmd + S en Mac, Ctrl + S en Windows y Linux):

# chat.py: le manda una pregunta a un modelo y te muestra la respuesta
import sys

from openai import OpenAI

# A dónde va la petición: el servidor de LM Studio, en tu propia laptop
BASE_URL = "http://localhost:1234/v1"
# Qué modelo contesta: el que cargaste en LM Studio
MODEL = "qwen/qwen3-4b-2507"
# La llave: LM Studio no la revisa, pero la librería exige que haya un texto
API_KEY = "lm-studio"

# El cliente sabe armar el pedido con el formato que entiende la API
client = OpenAI(base_url=BASE_URL, api_key=API_KEY)

# La pregunta sale de lo que escribiste después de "python chat.py"
question = " ".join(sys.argv[1:])
# Si no escribiste nada, usamos una pregunta de ejemplo
if not question:
    question = "Explica en dos frases qué es un archivo CSV."

# Mandamos el pedido: qué modelo y qué mensajes forman la conversación
response = client.chat.completions.create(
    model=MODEL,
    messages=[{"role": "user", "content": question}],
)

# El texto de la respuesta viene en la primera opción (choices[0])
print(response.choices[0].message.content)

Si en la preparación curl .../v1/models te mostró un id distinto para tu modelo, cambia el texto de MODEL por ese id, exactamente igual.

Qué hace cada parte

Parte del códigoQué haceEn la analogía
BASE_URLLa dirección del servidor que va a recibir el pedidoLa dirección de la cocina
MODELQué modelo debe contestarA qué cocinero le pides
API_KEYLa llave. LM Studio no la revisa, pero la librería no acepta que esté vacíaEl número de cliente que en casa no se usa
OpenAI(...)Crea el cliente: el objeto que sabe escribir y mandar la comandaEl mesero que lleva el papelito
messagesLa conversación: una lista de mensajes, cada uno con su role (quién habla) y su content (qué dice)El pedido escrito
choices[0].message.contentDónde viene el texto de la respuestaEl plato que regresa

Y las líneas de Python que hacen que el script reciba tu pregunta:

LíneaQué hace
import sys y from openai import OpenAITraen código ya hecho: sys da acceso a lo que escribiste en la terminal; OpenAI es el cliente
sys.argv[1:]sys.argv es la lista de palabras que escribiste al correr el script (python chat.py hola mundo da ["chat.py", "hola", "mundo"]). [1:] toma todas menos la primera, que es el nombre del archivo
" ".join(...)Junta esas palabras en un solo texto, separadas por espacios
if not question:Si el texto quedó vacío (no escribiste pregunta), corre la línea de abajo
print(...)Muestra un texto en la terminal

role: "user" quiere decir "esto lo dice la persona". En la parte 4 vas a ver otros roles.

2. Córrelo

python chat.py

Espera la respuesta. Ahora con tu propia pregunta, entre comillas:

python chat.py "Dame tres ideas para ahorrar en el súper. Una línea cada una."

Qué puedes ver:

Lo que apareceQué significaQué haces
Un texto que contesta tu preguntaLo lograste: tu código le habló a un modelo que vive en tu laptopSigue al punto 3
Al final del error: APIConnectionError o Connection errorNadie atiende en localhost:1234: el servidor está apagadoEnciéndelo en Developer o con lms server start
Al final del error: No models loaded. Please load a model in the developer page or use the 'lms load' command.El servidor está encendido, pero sin modelo en la RAM, y la carga automática (JIT) está apagadaCárgalo: lms load qwen/qwen3-4b-2507
Un error que menciona el nombre del modeloEl texto de MODEL no coincide con el id que usa LM StudioCorre curl http://localhost:1234/v1/models y copia el id exacto
ModuleNotFoundError: No module named 'openai'El entorno virtual no está activo en esta terminalCorre la línea de activación del inicio

Cuando Python falla, muestra un bloque largo de texto (el traceback). Asusta, pero lo importante está en la última línea: ahí dice qué tipo de error fue y su mensaje. Léela primero.

La prueba de fuego: desconecta el wifi y vuelve a correr python chat.py. Contesta igual. Tu código, tu servidor y tu modelo están en la misma máquina.

3. El mismo código, hacia la nube

Para que este mismo script le hable a un modelo en la nube, cambian las tres líneas de configuración, y hay que agregar import os arriba, porque la llave se lee con os.environ. Así se vería el diff (- se quita, + se agrega):

+  import os
-  BASE_URL = "http://localhost:1234/v1"
+  BASE_URL = "https://openrouter.ai/api/v1"
-  MODEL = "qwen/qwen3-4b-2507"
+  MODEL = "google/gemma-4-31b-it:free"
-  API_KEY = "lm-studio"
+  API_KEY = os.environ["OPENROUTER_API_KEY"]

No lo cambies todavía: en la parte 3 vamos a guardar las dos configuraciones en un archivo aparte para no tener que editar a mano cada vez. Lo que importa es la idea: el código que arma el pedido y lee la respuesta es idéntico. Lo único que decide si tus datos salen de tu laptop es una dirección.

Para comprobar que entendiste

  1. ¿Por qué hace falta API_KEY = "lm-studio" si LM Studio no revisa llaves?
Respuesta

Porque la librería openai no acepta crear un cliente sin llave. LM Studio ignora el valor, así que cualquier texto sirve.

  1. ¿Qué tendrías que cambiar en chat.py para que le hable a la nube?
Respuesta

Las tres líneas de configuración: la dirección (BASE_URL), el modelo (MODEL) y la llave (API_KEY), más el import os para leer la llave de la terminal. El resto del código es igual, porque LM Studio imita el formato de los proveedores en la nube.

Resumen

  • LM Studio acepta la misma comanda que los proveedores en la nube. Por eso usamos la librería openai con base_url apuntando a http://localhost:1234/v1.
  • Creaste chat.py: arma un pedido con model y messages y muestra choices[0].message.content.
  • Ante un error, lee la última línea del traceback: servidor apagado, modelo sin cargar, id distinto o entorno sin activar.
  • Hacia la nube cambian las tres líneas de configuración (dirección, modelo y llave), más un import os.

En la analogía: tu script escribe la comanda (el pedido en el formato de la API) y el cliente, que es el mesero, la lleva a la ventanilla de tu cocina (localhost:1234). El restaurante acepta exactamente la misma comanda: para pedir allá, solo cambias la dirección y enseñas tu número de cliente (la llave).

Parte 3: Medir: tu laptop contra la nube

"Los modelos locales son peores" o "son igual de buenos" son frases que no sirven de nada sin medir. En esta parte mandas los mismos pedidos a tu laptop y a un modelo en la nube, y comparas tiempo, velocidad y aciertos con tus propios ojos.

En esta parte vas a entender:

  1. Cómo guardar dos destinos sin escribir tu llave en el código.
  2. Qué se mide y cómo se mide con justicia.
  3. Cómo leer los resultados.

1. Crea providers.py: las dos direcciones

Crea un archivo nuevo llamado providers.py en local-model-workshop y pega esto:

# providers.py: las dos direcciones a las que puedes mandar tus pedidos
import os
import sys

import openai
from openai import OpenAI

PROVIDERS = {
    # LM Studio, en tu laptop: no sale nada a internet y no pide llave
    "local": {
        "base_url": "http://localhost:1234/v1",
        # LM Studio no revisa la llave, pero la librería exige un texto
        "api_key": "lm-studio",
        "model": "qwen/qwen3-4b-2507",
    },
    # OpenRouter, en la nube: tu llave viaja en cada petición
    "cloud": {
        "base_url": "https://openrouter.ai/api/v1",
        # La llave se lee de una variable de entorno, nunca se escribe aquí
        "api_key": os.environ.get("OPENROUTER_API_KEY", ""),
        "model": "google/gemma-4-31b-it:free",
    },
}


def get_provider(name):
    # Si el nombre no es "local" ni "cloud", avisamos y salimos
    if name not in PROVIDERS:
        sys.exit(f"Destino desconocido: '{name}'. Usa 'local' o 'cloud'.")
    provider = PROVIDERS[name]
    # Sin llave, la nube contestaría con un error de autenticación
    if not provider["api_key"]:
        sys.exit("Falta tu llave: define OPENROUTER_API_KEY en esta terminal.")
    return provider


def make_client(provider):
    # El mismo cliente sirve para los dos destinos: solo cambia la dirección
    return OpenAI(base_url=provider["base_url"], api_key=provider["api_key"])


def explain_error(error):
    # No hay nadie escuchando en la dirección: el servidor está apagado
    if isinstance(error, openai.APIConnectionError):
        return "No hay conexión. Si es 'local': ¿está encendido el servidor de LM Studio?"
    # La llave no es válida o se pegó incompleta
    if isinstance(error, openai.AuthenticationError):
        return "La llave no es válida. Revisa OPENROUTER_API_KEY."
    # Demasiadas peticiones: el plan gratis de la nube te frenó
    if isinstance(error, openai.RateLimitError):
        return "Límite de peticiones (rate limit). Espera un poco o cambia de modelo."
    # LM Studio contesta, pero no tiene ningún modelo cargado en memoria
    if "No models loaded" in str(error):
        return "LM Studio está encendido pero sin modelo cargado. Cárgalo y vuelve a correr."
    # Cualquier otro error con código: lo mostramos tal cual
    if isinstance(error, openai.APIStatusError):
        return f"Error {error.status_code}: {error.message}"
    # Cualquier otro problema: el texto del error, sin el traceback
    return str(error)

Este archivo no se corre solo: lo van a usar measure.py y agent.py. Guarda en un diccionario (una lista de pares nombre: valor) las dos configuraciones que en chat.py estaban sueltas, y trae tres funciones:

FunciónQué hace
get_provider(name)Te da la configuración de "local" o de "cloud", y avisa si falta la llave
make_client(provider)Crea el cliente con esa dirección y esa llave
explain_error(error)Traduce los errores más comunes a una frase que dice qué hacer

El Python nuevo que aparece en este archivo:

PiezaQué hace
os.environ.get("OPENROUTER_API_KEY", "")Lee una variable de entorno (la explicamos en el paso 2). Si no existe, devuelve el texto vacío ""
sys.exit("mensaje")Detiene el programa y muestra ese mensaje
isinstance(error, openai.APIConnectionError)Pregunta si el error es de ese tipo. La librería tiene un tipo de error distinto para cada problema (sin conexión, llave inválida, demasiadas peticiones)
PROVIDERS[name]Toma del diccionario el valor guardado bajo ese nombre, por ejemplo PROVIDERS["local"]

El modelo de la nube: google/gemma-4-31b-it:free es un modelo gratuito de OpenRouter con 31 mil millones de parámetros: unas ocho veces más que el tuyo. Lista revisada el 14 de octubre de 2026. Si ya no aparece, cambia ese texto por nvidia/nemotron-3-super-120b-a12b:free.

2. Dale tu llave a la terminal, no al código

providers.py lee la llave de una variable de entorno: un valor con nombre que vive en la terminal mientras está abierta, y que los programas que corres desde ahí pueden leer. Así la llave nunca queda escrita en un archivo que podrías compartir o subir a internet por accidente.

Si no tienes llave de OpenRouter, sáltate este paso y la medición de la nube: mide solo tu laptop.

Mac y Linux:

# Te pide la llave sin mostrarla en pantalla; pégala y presiona Enter
read -s OPENROUTER_API_KEY
# La deja disponible para los programas que corras desde esta terminal
export OPENROUTER_API_KEY

Windows (PowerShell):

# Te pide la llave; pégala y presiona Enter (no compartas pantalla en este momento)
$env:OPENROUTER_API_KEY = Read-Host "Pega tu llave de OpenRouter"

La variable dura mientras esa terminal esté abierta. Si abres otra, repite este paso.

3. Crea measure.py: los mismos pedidos, medidos

Crea un archivo nuevo llamado measure.py en local-model-workshop y pega esto:

# measure.py: corre los mismos prompts contra un destino y mide cada respuesta
import sys
import time

import openai

from providers import explain_error, get_provider, make_client

# Cada prueba trae su prompt y la respuesta correcta, para que tú compares
TESTS = [
    # 1. Obedecer una instrucción corta y su formato
    {
        "name": "clasificar",
        "prompt": "Clasifica este gasto en una sola categoría: food, transport, home o fun. "
        "Responde solo con la categoría.\nGasto: Taxi a casa",
        "expected": "transport",
    },
    # 2. Sacar datos de un texto y devolverlos como JSON
    {
        "name": "extraer",
        "prompt": "Del texto, extrae la categoría (food, transport, home o fun) y el monto. "
        'Responde solo con JSON así: {"category": "...", "amount": 0.0}\n'
        "Texto: Ayer pagué 35.00 del recibo de internet.",
        "expected": '{"category": "home", "amount": 35.0}',
    },
    # 3. Aritmética con decimales
    {
        "name": "sumar",
        "prompt": "Suma estos montos y responde solo con el total: 54.20, 6.50, 61.75, 11.90, 58.30",
        "expected": "192.65",
    },
    # 4. Combinar dos datos y sacar una conclusión
    {
        "name": "razonar",
        "prompt": "Gasté 385.15 este mes y mi meta era gastar menos de 350. "
        "¿Cumplí la meta? ¿Por cuánto me pasé o cuánto me sobró? Responde en una frase.",
        "expected": "No la cumpliste: te pasaste por 35.15",
    },
    # 5. Resumir sin inventar nada
    {
        "name": "resumir",
        "prompt": "Resume en una frase: Quiero saber en qué se me va el dinero cada mes. "
        "Mi meta para octubre es gastar menos de 350 en total. "
        "Sospecho que la comida es lo más caro, pero no lo he sumado.",
        "expected": "(libre: tú decides si el resumen es fiel)",
    },
]


def run_test(client, model, test):
    # Tomamos la hora justo antes de mandar el pedido
    start = time.perf_counter()
    response = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": test["prompt"]}],
        # temperature=0: la respuesta más probable, para comparar con justicia
        temperature=0,
    )
    # Cuántos segundos pasaron hasta tener la respuesta completa
    seconds = time.perf_counter() - start
    # usage cuenta los tokens que escribió el modelo (algunos proveedores no lo mandan)
    tokens = response.usage.completion_tokens if response.usage else 0
    # Velocidad: tokens escritos por segundo
    speed = tokens / seconds if seconds > 0 else 0
    # El texto de la respuesta, sin espacios sobrantes al inicio y al final
    answer = (response.choices[0].message.content or "").strip()
    return seconds, tokens, speed, answer


def main():
    # El primer argumento elige el destino: local (por defecto) o cloud
    name = sys.argv[1] if len(sys.argv) > 1 else "local"
    # Tomamos la configuración de ese destino y creamos su cliente
    provider = get_provider(name)
    client = make_client(provider)
    print(f"Destino: {name} · modelo: {provider['model']}\n")

    # Aquí juntamos el resultado de cada prueba para escribir el archivo al final
    rows = []
    for test in TESTS:
        # Si una prueba falla (servidor apagado, rate limit), explicamos y paramos
        try:
            seconds, tokens, speed, answer = run_test(client, provider["model"], test)
        except openai.OpenAIError as error:
            sys.exit(explain_error(error))
        # Mostramos cada prueba en cuanto termina, con lo esperado al lado
        print(f"[{test['name']}] {seconds:.1f} s · {tokens} tokens · {speed:.1f} tokens/s")
        print(f"  respuesta: {answer}")
        print(f"  esperado:  {test['expected']}\n")
        rows.append((test["name"], seconds, speed, answer, test["expected"]))

    # Guardamos todo en un archivo para comparar local contra nube después
    out_path = f"results-{name}.md"
    with open(out_path, "w", encoding="utf-8") as out:
        out.write(f"# Resultados: {name} ({provider['model']})\n\n")
        out.write("| prueba | segundos | tokens/s | respuesta | esperado |\n")
        out.write("| --- | --- | --- | --- | --- |\n")
        for test_name, seconds, speed, answer, expected in rows:
            # Las respuestas de varias líneas se aplanan para que quepan en la tabla
            flat = answer.replace("\n", " ").replace("|", "/")
            out.write(f"| {test_name} | {seconds:.1f} | {speed:.1f} | {flat} | {expected} |\n")
    print(f"Guardado en {out_path}")


if __name__ == "__main__":
    main()

Qué mide y por qué así

Las cinco pruebas no son al azar: cada una pone a prueba algo distinto, y todas tienen una respuesta que puedes comprobar (salvo el resumen, que juzgas tú).

PruebaQué pone a pruebaRespuesta correcta
clasificarEntender una instrucción corta y obedecer el formato ("solo la categoría")transport
extraerSacar datos de un texto y devolverlos como JSON (el formato de datos que viste en la preparación), para que otro programa pueda leerlos{"category": "home", "amount": 35.0}
sumarAritmética con decimales: los modelos predicen texto, no calculan192.65
razonarCombinar dos datos y sacar una conclusiónNo se cumplió: 35.15 de más
resumirComprimir un texto sin inventar nadaLibre: tú decides si es fiel

Tres detalles del código que hacen la comparación justa:

  • temperature=0: un modelo no siempre elige el token más probable; la temperatura controla cuánto se permite variar. En 0 elige casi siempre el más probable, así que si corres la prueba dos veces, la respuesta casi no cambia. Para medir, eso es lo que quieres.
  • time.perf_counter(): un cronómetro de alta precisión. Lo arrancamos justo antes del pedido y lo detenemos al tener la respuesta completa.
  • usage.completion_tokens: el servidor nos dice cuántos tokens escribió el modelo. Dividido entre los segundos, da la velocidad.

Y el Python nuevo de measure.py:

PiezaQué hace
TESTS = [ {...}, {...} ]Una lista de diccionarios: cada diccionario es una prueba con su name, su prompt y su expected
def run_test(...) y def main()Dos funciones: una corre una prueba y devuelve sus números; la otra organiza todo
for test in TESTS:Repite el bloque una vez por cada prueba de la lista
try: ... except openai.OpenAIError as error:Si la petición falla, en vez de mostrar el error crudo, explain_error lo traduce
f"{seconds:.1f} s"Un f-string: mete el valor de seconds en el texto. :.1f quiere decir "con un decimal"
with open(out_path, "w", encoding="utf-8") as out:Abre (o crea) un archivo para escribir; with lo cierra solo al terminar el bloque
if __name__ == "__main__": main()"Si corriste este archivo directamente, ejecuta main()". Así el archivo se puede correr, y también importar desde otro sin que se ejecute solo

4. Corre la medición en tu laptop

python measure.py local

Vas a ver las cinco pruebas, una por una, con esta forma:

Destino: local · modelo: qwen/qwen3-4b-2507

[clasificar] (segundos) s · (tokens) tokens · (velocidad) tokens/s
  respuesta: (lo que contestó tu modelo)
  esperado:  transport

...

Guardado en results-local.md

Fíjate en la primera prueba: si el modelo no estaba cargado todavía, LM Studio lo carga con ese primer pedido (JIT) y el tiempo se dispara. Si te pasa, corre la medición otra vez: la segunda vuelta es la que vale.

5. Corre la misma medición en la nube

Si definiste tu llave en el paso 2:

python measure.py cloud

Se guarda en results-cloud.md. Si ves Límite de peticiones (rate limit), el modelo gratuito está saturado: espera un poco, o cambia el modelo de "cloud" en providers.py por el alternativo y vuelve a correr.

6. Compara

Los resultados se guardan en archivos .md (Markdown): texto normal con un formato muy simple, donde las líneas con | forman una tabla. Abre results-local.md y results-cloud.md en VS Code, uno al lado del otro, y llena esta tabla con tus números:

Tu laptop (Qwen3 4B)La nube (Gemma 4 31B)
¿Cuántas de las 4 pruebas comprobables acertó?
¿Respetó el formato pedido ("solo la categoría", "solo JSON")?
Velocidad típica (tokens/s)
Tiempo total de las cinco pruebas
¿Algún resumen inventó algo que no estaba en el texto?

Cómo leerla:

  • Mira primero los aciertos, después la velocidad. Una respuesta rápida y equivocada no sirve.
  • La suma es la prueba más traicionera para cualquier modelo, y más para uno chico: el modelo escribe el número que parece correcto. Cuando preparamos este taller (14 de octubre de 2026, con temperature=0), Qwen3 4B sumó bien en español (192.65) y mal en inglés (292.65, las tres veces que lo corrimos): los mismos números, otro idioma, otro resultado. Si tu tarea depende de cuentas exactas, el modelo no debe hacerlas: debe pedírselas a tu código. Eso es justo lo que hacemos en la parte 4.
  • Las tareas acotadas (clasificar, extraer a un formato fijo) son donde un modelo pequeño suele quedar más cerca del grande. Compruébalo con tus números.
  • La velocidad de la nube incluye el viaje por internet y la fila del servicio gratuito. La de tu laptop no tiene ninguna de las dos cosas, pero depende de tu máquina.
  • Desconfía de los tokens por segundo en respuestas muy cortas. Antes de escribir, el modelo tiene que leer todo tu prompt, y eso también toma tiempo. En clasificar la respuesta es un solo token o dos, así que la "velocidad" sale bajísima aunque el modelo vuele. Para comparar velocidad, mira la prueba con la respuesta más larga (resumir).

7. Agrega tu propio prompt

La medición que de verdad te sirve es la de tu tarea. Agrega una prueba al final de la lista TESTS en measure.py, antes del corchete de cierre ]:

    # Tu propia prueba: un pedido de tu trabajo, con datos de ejemplo
    {
        "name": "mi-tarea",
        "prompt": "Escribe aquí el pedido que harías en tu trabajo, con datos inventados.",
        "expected": "Escribe aquí cómo sabrías que la respuesta está bien",
    },

Y vuelve a correr las dos mediciones:

python measure.py local
python measure.py cloud

Recuerda: a la nube solo datos de ejemplo. A tu laptop puedes mandarle lo que quieras: no sale de ahí.

Para comprobar que entendiste

  1. ¿Por qué measure.py usa temperature=0?
Respuesta

Para que el modelo elija casi siempre el token más probable y la respuesta sea estable entre corridas. Si cada corrida diera una respuesta distinta, no podrías comparar con justicia.

  1. Tu laptop fue más lenta en la primera prueba que en las demás. ¿Es un problema del modelo?
Respuesta

No necesariamente. Si el modelo no estaba cargado, LM Studio lo cargó con ese primer pedido (JIT), y eso incluye copiarlo del disco a la RAM. Por eso se corre la medición una segunda vez y se usa esa.

  1. ¿Por qué la llave va en una variable de entorno y no escrita en providers.py?
Respuesta

Porque un archivo de código se comparte, se copia y se sube a internet con facilidad, y la llave viajaría con él. La variable de entorno vive solo en tu terminal mientras está abierta.

Resumen

  • providers.py guarda los dos destinos y lee la llave de la nube de una variable de entorno.
  • measure.py manda cinco pedidos comprobables a un destino, con temperature=0, y mide segundos, tokens y tokens por segundo. Guarda todo en results-local.md o results-cloud.md.
  • Para comparar: primero aciertos y formato, después velocidad. La aritmética es el punto débil; las tareas acotadas, el fuerte de un modelo pequeño.
  • La medición que importa es la de tu propia tarea: agrégala a TESTS.

En la analogía: le diste los mismos cinco pedidos al cocinero de tu casa y al chef del restaurante, con la receta fija (temperature=0, que el cocinero no improvise), y cronometraste el servicio. Ahora sabes qué platos le salen bien a tu cocinero y cuáles todavía no.

Parte 4: Herramientas con un modelo pequeño

En la parte 3 viste que un modelo no es una calculadora. La solución no es pedirle que sume mejor: es darle herramientas. Que el modelo decida qué dato necesita, y que tu código lo calcule. En esta parte le das dos herramientas al modelo de tu laptop y ves qué aguanta y dónde se rompe.

En esta parte vas a entender:

  1. Qué es una herramienta y quién la ejecuta de verdad.
  2. Cómo es el ciclo de un pedido con herramientas.
  3. Dónde suele romperse un modelo pequeño, y cómo lo protege tu código.

1. Qué es una herramienta

Una herramienta (tool) es una función de tu código que le describes al modelo: su nombre, para qué sirve y qué datos recibe. El modelo no la ejecuta: el modelo solo produce texto. Lo que hace es contestar con un pedido estructurado del tipo "quiero que corras category_total con category = food". Tu código lo lee, corre la función de verdad y le devuelve el resultado.

En la analogía: el cocinero no tiene llave de la despensa. Te dice "tráeme el total de lo que gastaste en comida", tú vas a la despensa (expenses.csv), haces la cuenta y se la traes. Él decide qué pedir; tú haces el trabajo exacto.

  Tu pregunta: "¿Cuánto gasté en comida?"

  Tu código (agent.py)                       Modelo (en LM Studio)
  |                                                   |
  |-- pregunta + menú de herramientas --------------->|
  |<-- "corre category_total(category='food')" -------|  vuelta 1
  |                                                   |
  (tu código lee expenses.csv y suma: 192.65)          |
  |                                                   |
  |-- resultado: {"total": 192.65} ------------------>|
  |<-- "Gastaste 192.65 en comida." ------------------|  vuelta 2

Cada flecha de ida es un pedido completo al modelo. Una pregunta con herramientas son, como mínimo, dos vueltas.

2. Crea agent.py

Crea un archivo nuevo llamado agent.py en local-model-workshop y pega esto:

# agent.py: el modelo pide herramientas, tu código las ejecuta y le devuelve el resultado
import csv
import json
import sys
from pathlib import Path

import openai

from providers import explain_error, get_provider, make_client

# El archivo de gastos vive junto a este script
CSV_PATH = Path(__file__).with_name("expenses.csv")
# Tope de vueltas: si el modelo no termina en 8, lo cortamos
MAX_ROUNDS = 8


def load_rows():
    # Leemos el CSV como una lista de diccionarios (una fila = un dict)
    with open(CSV_PATH, newline="", encoding="utf-8") as file:
        return list(csv.DictReader(file))


def list_categories():
    # Pasamos todo a minúsculas: para la computadora "Food" y "food" son distintos
    categories = {row["category"].strip().lower() for row in load_rows()}
    return {"categories": sorted(categories)}


def category_total(category):
    # Comparamos en minúsculas, igual que en list_categories
    wanted = category.strip().lower()
    rows = [row for row in load_rows() if row["category"].strip().lower() == wanted]
    # Si la categoría no existe, devolvemos un error con las válidas
    if not rows:
        return {
            "error": f"Unknown category '{category}'",
            "valid_categories": list_categories()["categories"],
        }
    # Sumamos los montos y redondeamos a centavos
    total = round(sum(float(row["amount"]) for row in rows), 2)
    return {"category": wanted, "total": total, "rows": len(rows)}


# El menú que ve el modelo: nombre, para qué sirve y qué argumentos lleva
TOOLS = [
    # Herramienta 1: no recibe nada y devuelve las categorías
    {
        "type": "function",
        "function": {
            "name": "list_categories",
            "description": "List the expense categories that exist in expenses.csv.",
            "parameters": {"type": "object", "properties": {}},
        },
    },
    # Herramienta 2: recibe una categoría y devuelve su total
    {
        "type": "function",
        "function": {
            "name": "category_total",
            "description": "Total amount spent in one category of expenses.csv.",
            # Los argumentos se describen con JSON Schema: nombre, tipo y para qué sirve
            "parameters": {
                "type": "object",
                "properties": {
                    "category": {
                        "type": "string",
                        "description": "One category, as returned by list_categories.",
                    }
                },
                "required": ["category"],
            },
        },
    },
]

# Qué función de Python corre cuando el modelo pide cada herramienta
FUNCTIONS = {"list_categories": list_categories, "category_total": category_total}

# Las reglas que el modelo lee antes que tu pregunta
SYSTEM_PROMPT = (
    "Eres un asistente de gastos personales. Para cualquier monto, usa las herramientas: "
    "nunca inventes cifras. Responde en español y en pocas líneas."
)


def run_tool(name, raw_arguments):
    # Los argumentos llegan como texto JSON: si no se pueden leer, es un error del modelo
    try:
        arguments = json.loads(raw_arguments or "{}")
    except json.JSONDecodeError:
        return {}, {"error": "Arguments are not valid JSON"}
    # El modelo pidió una herramienta que no está en el menú
    if name not in FUNCTIONS:
        return arguments, {"error": f"Unknown tool '{name}'"}
    # Ejecutamos la función; si los argumentos no cuadran, devolvemos el error
    try:
        return arguments, FUNCTIONS[name](**arguments)
    except TypeError as error:
        return arguments, {"error": f"Bad arguments: {error}"}


def run_agent(client, model, question):
    # La conversación empieza con las reglas (system) y tu pregunta (user)
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": question},
    ]
    for round_number in range(1, MAX_ROUNDS + 1):
        # Cada vuelta es una petición nueva, con el menú de herramientas
        response = client.chat.completions.create(model=model, messages=messages, tools=TOOLS)
        # El mensaje del modelo: o un texto final, o una lista de pedidos de herramientas
        message = response.choices[0].message
        # Si el modelo no pidió herramientas, eso es su respuesta final
        if not message.tool_calls:
            print(f"[vuelta {round_number}] el modelo responde")
            return message.content
        # Guardamos lo que pidió, para que en la siguiente vuelta lo recuerde
        messages.append(
            {
                "role": "assistant",
                "content": message.content or "",
                "tool_calls": [call.model_dump() for call in message.tool_calls],
            }
        )
        for call in message.tool_calls:
            # Tu código (no el modelo) ejecuta la herramienta
            arguments, result = run_tool(call.function.name, call.function.arguments)
            print(f"[vuelta {round_number}] pide {call.function.name}({arguments}) -> {result}")
            # Le devolvemos el resultado, ligado al id de su pedido
            messages.append(
                {"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)}
            )
    return f"(El agente no terminó en {MAX_ROUNDS} vueltas: se quedó dando vueltas.)"


def main():
    # Primer argumento: local o cloud. Lo que sigue: tu pregunta
    name = sys.argv[1] if len(sys.argv) > 1 else "local"
    question = " ".join(sys.argv[2:]) or "¿Cuánto gasté en comida en septiembre?"
    # Tomamos la configuración de ese destino y creamos su cliente
    provider = get_provider(name)
    client = make_client(provider)
    # Mostramos con qué modelo y qué pregunta vamos a trabajar
    print(f"Destino: {name} · modelo: {provider['model']}")
    print(f"Pregunta: {question}\n")
    # Si algo falla en la conexión, lo explicamos en vez de mostrar el error crudo
    try:
        answer = run_agent(client, provider["model"], question)
    except openai.OpenAIError as error:
        sys.exit(explain_error(error))
    # La respuesta final del modelo
    print(f"\n{answer}")


if __name__ == "__main__":
    main()

Las piezas, en orden

PiezaQué hace
list_categories() y category_total()Las herramientas: funciones normales de Python que leen expenses.csv. Hacen el trabajo exacto: la suma la hace tu código, no el modelo
.lower() en las dosPasa las categorías a minúsculas. Recuerda el detalle raro del CSV: para la computadora Food y food son textos distintos. La herramienta los junta
TOOLSEl menú que ve el modelo: el nombre de cada herramienta, una descripción y qué argumentos lleva. El modelo decide con base en estas descripciones
SYSTEM_PROMPTUn mensaje con role: "system": las reglas del juego, que el modelo lee antes que tu pregunta
run_tool()Lee el pedido del modelo y corre la función. Si el modelo pidió algo mal formado, devuelve un error en vez de romperse
run_agent()El ciclo: pedir, ver si el modelo quiere herramientas, correrlas, devolverle el resultado y volver a pedir. Tope de 8 vueltas
role: "tool"Así se le devuelve al modelo el resultado de una herramienta, ligado con tool_call_id al pedido que lo originó

Y el Python nuevo de agent.py:

PiezaQué hace
Path(__file__).with_name("expenses.csv")__file__ es la ruta de este script; .with_name(...) arma la ruta de expenses.csv en la misma carpeta. Así el script encuentra el CSV aunque lo corras desde otro lado
csv.DictReader(file)Lee el CSV y convierte cada fila en un diccionario, con los nombres de la primera línea: {"date": ..., "category": ..., "amount": ...}
{row["category"]... for row in load_rows()}Recorre todas las filas y junta las categorías en un conjunto (set): una colección donde cada valor aparece una sola vez. sorted(...) lo ordena
sum(float(row["amount"]) for row in rows)Convierte cada monto de texto a número (float) y los suma. round(..., 2) deja dos decimales
"parameters": {"type": "object", ...}La descripción de los argumentos en JSON Schema: un formato estándar para decir qué datos lleva algo, de qué tipo y cuáles son obligatorios (required)
json.loads(...) y json.dumps(...)loads convierte un texto JSON en un diccionario de Python; dumps hace lo contrario
FUNCTIONS[name](**arguments)Busca la función por su nombre y la llama. **arguments pasa cada par del diccionario como argumento: {"category": "food"} se vuelve category_total(category="food")
call.model_dump()Convierte el pedido de herramienta que devolvió la librería en un diccionario normal, para guardarlo en la conversación

El tope de MAX_ROUNDS es tu red de seguridad: un modelo confundido puede pedir herramientas una y otra vez sin llegar a responder. Sin tope, tu programa no terminaría nunca. ¿Por qué 8? Porque "¿cuánto gasté en total?" bien hecho ya son 6 vueltas: listar las categorías, pedir el total de cada una de las cuatro y responder. Un tope de 6 lo dejaría sin margen.

3. Pregúntale, y mira cada vuelta

python agent.py local "¿Cuánto gasté en comida en septiembre?"

Cada vez que el modelo pide una herramienta, tu código imprime una línea. Esto salió cuando lo probamos con Qwen3 4B:

Destino: local · modelo: qwen/qwen3-4b-2507
Pregunta: ¿Cuánto gasté en comida en septiembre?

[vuelta 1] pide list_categories({}) -> {'categories': ['food', 'fun', 'home', 'transport']}
[vuelta 2] pide category_total({'category': 'food'}) -> {'category': 'food', 'total': 192.65, 'rows': 5}
[vuelta 3] el modelo responde

Gastaste 192.65 en comida en septiembre.

Fíjate en lo que hizo: primero preguntó qué categorías existen, vio que "comida" se llama food y pidió ese total. Cada línea te dice qué pidió el modelo (el nombre y los argumentos) y qué le devolvió tu código. Así no tienes que creerle: ves el dato exacto que tuvo delante. El total correcto de comida es 192.65, con 5 filas (las cuatro food y la Food).

Ahora, preguntas que exigen más:

python agent.py local "¿Cuánto gasté en transporte?"
python agent.py local "¿En qué categoría gasté más, y cuánto?"
python agent.py local "¿Cuánto gasté en total en septiembre?"

La segunda y la tercera piden varios pasos: saber qué categorías existen y pedir el total de cada una. Los valores correctos:

CategoríaTotal
food192.65
home77.10
fun63.00
transport52.40
Total385.15

Fíjate en la tercera pregunta: no hay una herramienta de "total general". El modelo tiene que pedir las cuatro categorías y sumarlas él. Compara su respuesta con 385.15.

4. Lo que pasó cuando lo probamos

Corrimos estas preguntas varias veces con Qwen3 4B mientras preparábamos el taller. Las dos primeras salieron bien. Las otras dos fallaron todas las veces, y de dos formas que vale la pena reconocer.

La pregunta de "en qué categoría gasté más" tomó un atajo. Esto salió las cuatro veces:

[vuelta 1] pide list_categories({}) -> {'categories': ['food', 'fun', 'home', 'transport']}
[vuelta 2] pide category_total({'category': 'food'}) -> {'category': 'food', 'total': 192.65, 'rows': 5}
[vuelta 3] el modelo responde

Gastaste más en "food" con un total de 192.65.

La respuesta es correcta, pero por suerte: solo pidió el total de food y supuso que era el mayor, sin revisar las otras tres categorías. Con otros datos, habría contestado mal con la misma seguridad. Por eso imprimimos cada vuelta: la respuesta sola no te dice si el modelo hizo el trabajo.

La pregunta del total ni siquiera usó herramientas. Esto salió las cuatro veces:

[vuelta 1] el modelo responde

No puedo determinar el gasto total en septiembre sin datos específicos de gastos por mes. Por favor, proporciona más detalles o verifica si los datos están disponibles.

El modelo se rindió. ¿Por qué? Lee las descripciones de las herramientas en TOOLS: hablan de "categories" y de "expenses.csv", pero ninguna dice de qué mes son los gastos. El modelo vio "septiembre" en la pregunta, no encontró nada sobre meses en el menú y concluyó que no tenía esos datos. Con la misma pregunta sin "en septiembre" ("¿Cuánto gasté en total?"), sí pidió las cinco herramientas y respondió 385.15.

5. Arréglalo: dile al modelo qué hay en la despensa

El modelo solo sabe de tus herramientas lo que dicen sus descripciones. Si le falta un dato para decidir, no lo adivina: se rinde o se equivoca. Así que se lo das.

En agent.py, dentro de TOOLS, cambia la descripción de list_categories:

            "description": "List the expense categories in expenses.csv (all expenses of September 2026).",

Y la de category_total:

            "description": "Total spent in one category in September 2026, from expenses.csv.",

Guarda y vuelve a preguntar:

python agent.py local "¿Cuánto gasté en total en septiembre?"

Con ese cambio, cuando lo probamos, el modelo pidió las cinco herramientas las tres veces y contestó El total gastado en septiembre fue de 385.15. Mismo modelo, misma pregunta: lo único que cambió fue una frase en el menú.

Ojo con lo que no arregló: la pregunta de "en qué categoría gasté más" siguió tomando el atajo. Eso no es falta de información, es falta de cuidado del modelo, y ahí un modelo pequeño tiene menos margen que uno grande. La solución de fondo es la misma de la parte 3: darle una herramienta que haga el trabajo exacto (por ejemplo, una que devuelva todos los totales ordenados), en vez de esperar que el modelo lo haga bien solo.

6. Dónde se rompe un modelo pequeño

Un modelo de 4 mil millones de parámetros puede usar herramientas, pero tiene menos margen que uno grande. Junto con lo que ya viste, esto es lo que te puedes encontrar, y qué hace tu código en cada caso:

Lo que puedes verQué pasóQué lo protege
No pide ninguna herramienta y contesta con un númeroRespondió "de memoria": el número puede ser inventadoNada: por eso imprimimos cada vuelta. Si no ves una línea pide ..., no le creas
No pide ninguna herramienta y dice que no tiene los datosNo encontró en las descripciones lo que tu pregunta menciona (como el mes)Descripciones que digan qué datos hay, como hiciste en el punto 5
Pide solo una parte de lo necesario y contesta igualTomó un atajo: la respuesta puede salir bien por suerteLeer las vueltas, y herramientas que hagan el trabajo completo
pide category_total({'category': 'comida'}) -> {'error': "Unknown category 'comida'", ...}Tradujo la categoría al español. La herramienta no la conoceLa herramienta devuelve el error con las categorías válidas; un buen modelo corrige y vuelve a pedir
Una respuesta final con texto raro, como llaves o etiquetas, en vez de una línea pide ...Intentó pedir una herramienta pero escribió mal el formato, y LM Studio no pudo reconocerlo como pedidoNada automático: el pedido nunca llegó a tu código
(El agente no terminó en 8 vueltas...)Se quedó pidiendo herramientas sin llegar a una respuestaMAX_ROUNDS corta el ciclo
El total general no da 385.15Pidió bien los cuatro totales, pero la suma final la hizo élNada: es el límite de la parte 3. La solución sería otra herramienta que sume

Fíjate en el patrón: las herramientas con descripciones completas y errores claros ayudan al modelo a decidir y a corregirse. category_total no se limita a decir "error": dice cuáles son las categorías válidas. Y una frase sobre el mes en la descripción convirtió un "no puedo" en la respuesta correcta. Eso es diseñar herramientas pensando en un modelo pequeño.

7. La misma pregunta, en la nube

Si tienes tu llave definida, corre las mismas preguntas contra el modelo grande:

python agent.py cloud "¿En qué categoría gasté más, y cuánto?"
python agent.py cloud "¿Cuánto gasté en total en septiembre?"

Compara: ¿cuántas vueltas necesitó cada uno? ¿Alguno pidió mal una herramienta? ¿Alguno sumó mal? Esa diferencia, medida con tus preguntas, es la que te dice si una tarea con herramientas puede ir local hoy.

Ojo con la privacidad: en la nube, cada vuelta manda al proveedor tu pregunta y los resultados de las herramientas. Si expenses.csv tuviera tus gastos reales, irían en el pedido. En local, nada sale de tu laptop.

Para comprobar que entendiste

  1. Cuando el modelo "usa" category_total, ¿quién lee expenses.csv y hace la suma?
Respuesta

Tu código. El modelo solo contesta con un pedido estructurado (nombre de la herramienta y argumentos). agent.py lo lee, corre la función de Python, que lee el CSV y suma, y le devuelve el resultado al modelo.

  1. ¿Por qué category_total devuelve la lista de categorías válidas cuando no encuentra una?
Respuesta

Para que el modelo pueda corregirse en la siguiente vuelta. Un error que dice qué valores sí existen le da la información que le faltaba; un "error" a secas lo deja adivinando.

  1. ¿Para qué sirve MAX_ROUNDS?
Respuesta

Es un tope de vueltas. Un modelo confundido puede pedir herramientas sin parar; el tope garantiza que el programa termine y te avise.

Resumen

  • Una herramienta es una función de tu código descrita en un menú (TOOLS). El modelo pide; tu código ejecuta y le devuelve el resultado con role: "tool".
  • Cada pedido con herramientas son al menos dos vueltas. agent.py imprime cada una: lo que pidió el modelo y lo que le devolviste.
  • Un modelo pequeño puede no pedir la herramienta, rendirse por falta de contexto, tomar atajos, pedirla con argumentos equivocados, escribir mal el formato o quedarse dando vueltas. Descripciones completas, errores claros y un tope de vueltas lo protegen.
  • Lo comprobaste en vivo: una frase en la descripción ("September 2026") cambió un "no puedo" por 385.15.
  • Las cuentas exactas van en tu código, no en el modelo.

En la analogía: el cocinero no tiene llave de la despensa (expenses.csv): te pide lo que necesita y tú se lo traes, con la cuenta exacta hecha. Pero solo pide lo que sabe que existe: si el menú de herramientas no dice que la despensa es de septiembre, cree que no hay nada de septiembre. Y a veces, con prisa, revisa un estante y no los cuatro.

Parte 5: Cuándo ir local

Ya tienes datos propios: cuánto tarda tu laptop, en qué acierta y en qué se equivoca, y qué tal usa herramientas. Ahora los ponemos en una balanza. La pregunta no es "¿local o nube?" en general: es "¿local o nube para esta tarea?".

En esta parte vas a entender:

  1. Qué cuatro preguntas deciden si una tarea va local o a la nube.
  2. Cómo convertir tu medición en una decisión.
  3. Por qué la respuesta cambia con el tiempo.

Las cuatro preguntas

PreguntaSi la respuesta es sí...Por qué
Privacidad: ¿los datos no pueden salir de tu máquina o de tu empresa?Inclina a localEn la nube, todo lo que mandas (y lo que leen tus herramientas) llega a un tercero
Volumen y costo: ¿la tarea corre cientos o miles de veces al día?Inclina a localEn la nube pagas cada llamada; en local, el costo es tu máquina, ya pagada
Calidad: ¿la tarea pide razonar varios pasos, mucho contexto o encadenar herramientas?Inclina a la nubeAhí es donde un modelo pequeño pierde más, como viste en la parte 4
Velocidad y hardware: ¿la necesitas rápida en una máquina sin chip Apple ni tarjeta de video?Inclina a la nubeSin hardware que lea la memoria rápido, un modelo local va lento

Y una que se olvida: ¿tiene que funcionar sin internet? Si sí, solo local.

La regla práctica

  ¿Los datos pueden salir de tu maquina?
          |
     no --+--> LOCAL (y diseña la tarea para que un modelo chico la aguante)
          |
     si   v
  ¿Es una tarea acotada (clasificar, extraer, resumir corto)
   y tu medicion dice que el modelo local acierta?
          |
     si --+--> LOCAL si el volumen es alto; cualquiera si es bajo
          |
     no   v
  NUBE (razonamiento largo, mucho contexto, cadenas de herramientas)

Tres ideas para llevarte:

  1. Mide con tu tarea, no con la opinión de nadie. Lo que hiciste con measure.py vale más que cualquier tabla de internet, porque es tu pedido, en tu máquina.
  2. Achica la tarea para que quepa en el modelo. Un modelo pequeño rinde en tareas acotadas con un formato claro. Lo que sea cálculo exacto o búsqueda de datos, dáselo como herramienta.
  3. No es para siempre. Los modelos pequeños mejoran rápido. Lo que hoy no aguanta un 4B, en unos meses tal vez sí: vuelve a correr tu medición cuando salga uno nuevo. Solo cambias el MODEL en providers.py.

Antes de irte: libera la memoria

El modelo sigue cargado en tu RAM. Si no vas a seguir practicando, sácalo y apaga el servidor:

# Saca de la RAM todos los modelos cargados
lms unload --all

# Apaga el servidor local
lms server stop

O en la app: Eject junto al modelo y, en Developer, apaga el servidor. El modelo queda en tu disco para la próxima vez.

Para comprobar que entendiste

  1. Una clínica quiere clasificar notas de pacientes en cinco categorías, miles de veces al día. ¿Local o nube?
Respuesta

Local es el mejor candidato: los datos son sensibles, el volumen es alto y la tarea es acotada. Antes de decidir, se mide con notas de ejemplo para confirmar que el modelo pequeño acierta lo suficiente.

  1. Quieres un asistente que lea contratos largos y razone sobre varias cláusulas a la vez. Los contratos no son confidenciales. ¿Local o nube?
Respuesta

Nube. Mucho contexto y razonamiento de varios pasos es justo donde un modelo pequeño pierde más, y no hay una razón de privacidad que obligue a quedarse en local.

Resumen

  • La decisión es por tarea: privacidad y volumen empujan a local; razonamiento largo, mucho contexto y hardware lento empujan a la nube. Sin internet, solo local.
  • Mide con tu propia tarea, achícala para que quepa en un modelo pequeño y pon las cuentas exactas en herramientas.
  • Al terminar, libera la memoria: lms unload --all y lms server stop.
  • Para seguir, ve al módulo Después: los ecosistemas de NIEVA a tu ritmo o los bootcamps si quieres algo guiado.

En la analogía: el restaurante sigue siendo mejor para los platos difíciles. Pero para los pedidos sencillos que se repiten mil veces, o con ingredientes que no quieres que nadie vea, tu cocina gana. Y cuando termines, manda al cocinero de vuelta a la bodega: si no, sigue ocupando tu cocina.

Recursos