Inicio / Artículos

Un agente de soporte local con SmolLM3: Modo de pensamiento, herramientas y límites de seguridad

Ejecutar un modelo de 3B en una laptop como agente de soporte: cuándo usar think o no_think, cómo funciona la llamada a herramientas y las protecciones que impiden las llamadas a herramientas de marcador de posición.

Editor: REACTAPP.TOP9 min de lectura1979 palabras

Fuentes: **A Local Support Agent with SmolLM3: Think Mode, Tools, and When Not to Reason**

¿Puede un modelo de 3 mil millones de parámetros manejar una solicitud de soporte sin que lo respalde un modelo de 70 mil millones? Para el aburrido 90 % de las solicitudes, sí, pero solo si deja de confiar en las llamadas a herramientas del propio modelo. Aquí SmolLM3 funciona localmente como un pequeño agente de soporte y muestra dónde se comporta bien y dónde el código que lo rodea debe ser más estricto que el modelo.

El soporte al cliente no es un lugar adecuado para implementar un chatbot genérico. Las solicitudes típicas requieren una búsqueda, una clasificación y una respuesta breve basada en políticas, mientras que una minoría está relacionada con cargos duplicados, pedidos perdidos o clientes insatisfechos. Para atender bien ambos casos con un mismo modelo, es necesario ejecutarlo de dos maneras diferentes.

SmolLM3-3B se adapta a esto por tres razones que el número de parámetros oculta:

  • Viene con dos modos de razonamiento. Al añadir /no_think responde de inmediato, mientras que /think hace que escriba un registro de razonamiento oculto primero.
  • La llamada a herramientas está integrada: las herramientas descritas como esquemas JSON se incluyen en el template de chat, y el modelo responde con un bloque <tool_call> cuando desea ejecutarla.
  • Es lo suficientemente pequeño para un portátil. Todo funcionó en una MacBook con 24 GB de memoria unificada: el modelo de precisión completa mediante Transformers en MPS, y una versión de 8 bits MLX para el bucle del agente.
  • No se realiza ningún entrenamiento, y los pesos nunca se modifican. El trabajo consiste en separar el proceso de razonamiento de la acción y hacer que el código circundante sea más estricto que el modelo.

    La configuración

    Existen dos scripts que utilizan dos entornos de ejecución pero pertenecen a la misma familia de modelos. Para la comparación en modo de razonamiento, el muestreo sigue las especificaciones del modelo card, con temperature=0.6 y top_p=0.95. El agente funciona de manera más conservadora con temperature=0.3, por lo que el JSON de la herramienta permanece sin cambios significativos.

    En esta máquina, el viaje de ida y vuelta del agente de 8 bits tardó unos cuatro segundos. El modelo completo de 3 mil millones de parámetros basado en Transformers generó aproximadamente 10 tokens por segundo, lo cual es suficiente para leer registros posteriormente pero demasiado lento para un cliente que observa una ventana de chat. Transformers resulta útil para estudiar el patrón. Cuando hay una persona real al otro lado, cámbiese a MLX o llama.cpp.

    Pensar es una decisión de producto

    SmolLM3 piensa por defecto. Existen dos formas equivalentes de desactivarlo. Una es mediante un mensaje del sistema:

    messages = [
        {"role": "system", "content": "/no_think"},
        {"role": "user", "content": prompt},
    ]
    

    El otro es una bandera al renderizar la plantilla:

    tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True,
        enable_thinking=False,  # the /no_think flag in the system prompt wins if both are set
    )
    

    La plantilla hace más que invertir un valor booleano. Escribe el modo de razonamiento en el encabezado del sistema, y cuando se selecciona /no_think también inserta por adelantado un bloque de razonamiento vacío, de modo que la generación continúa directamente con la respuesta:

    <|im_start|>assistant
    <think>
    
    </think>
    

    Esa es la razón por la cual una simple llamada a generate() en modo sin procesamiento aún puede devolver <think></think> en el texto. Para una respuesta dirigida al cliente, hay que eliminarlo:

    final = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
    

    Se aplicó el mismo mensaje de soporte en ambos modos: a un cliente se le cobraron dos veces por un mismo pedido, ¿cuáles son los tres pasos concretos que debe seguir el equipo de soporte?

    Dos relojes

    El rendimiento fue más o menos el mismo en ambos modos, pero la cantidad de trabajo no. El modo de razonamiento dedicó la mayor parte de sus 939 tokens al bloque de razonamiento. El cliente nunca lee ese registro, pero sigue esperando a que termine.

    Dos guías de acción

    /no_think llevó directamente a una lista de verificación estándar: confirmar el pedido, contactar al vendedor, reembolsar el cargo. Funciona como boceto inicial, pero resulta ligeramente inadecuado para el equipo de soporte interno del vendedor, ya que “contactar al vendedor” es un consejo que da un banco, no algo que un agente interno le diga a su propio cliente.

    /think dedicó casi una página a debatir consigo mismo sobre si realizar un reembolso o un crédito en la tienda, cómo gestionar la escalada del problema y si verificar el envío, hasta que llegó a un plan más concreto:

    1. Disculparse y obtener el número de pedido, la fecha y el método de pago.
    2. Localizar la transacción duplicada y cancelarla.
    3. Explicar al cliente qué salió mal y confirmar que el dinero será devuelto, ya sea en forma de reembolso o crédito en la tienda.

    A partir de esto se determina cómo distribuir los esfuerzos en el envío:

    • /no_think para tareas de alto volumen: clasificación de intenciones, consultas como “¿dónde está mi pedido?”, respuestas predefinidas y llamadas a herramientas.
    • /think cuando un paso incorrecto tiene consecuencias graves: reembolsos, devoluciones de pago, problemas de seguridad y cualquier situación que requiera una lista de verificación al estilo humano antes de responder.

    Las cifras reportadas por Hugging Face indican lo mismo en cuanto al uso de herramientas. En el benchmark BFCL, SmolLM3 obtiene una puntuación de 92.3 sin razonamiento extendido y 88.8 con él. El razonamiento no es gratuito, y en este tipo de tareas ni siquiera mejoró los resultados. El agente que se muestra a continuación fuerza enable_thinking=False, ya que la búsqueda de pedidos debería ser una llamada a función y no un ensayo.

    Una herramienta y un bucle de dos turnos

    SmolLM3 espera que las herramientas se presenten como objetos JSON-schema enviados a través de xml_tools. La plantilla los copia en un bloque de herramientas e indica al modelo que responda con esta estructura:

    <tool_call>
    {"name": <function-name>, "arguments": <args-json-object>}
    </tool_call>
    

    La herramienta de soporte está diseñada intencionalmente de forma limitada:

    TOOLS = [
        {
            "name": "lookup_order_status",
            "description": (
                "Look up the current status, estimated delivery date, and carrier "
                "for a specific customer order. Call this when the customer mentions "
                "an order number or asks where their order is."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "order_id": {
                        "type": "string",
                        "description": "The order ID, usually in the format ORD-XXXXXX.",
                    }
                },
                "required": ["order_id"],
            },
        }
    ]
    

    Detrás de él se encuentra una base de datos falsa. En producción, eso sería una consulta SQL o una llamada a un servicio de pedidos.

    ORDERS = {
        "ORD-4821": {"status": "shipped", "eta": "June 18, 2026", "carrier": "DHL"},
        "ORD-3307": {"status": "processing", "eta": "June 20, 2026", "carrier": None},
        "ORD-1190": {"status": "delivered", "eta": None, "carrier": "FedEx"},
    }
    

    El bucle representa todo el proceso. El modelo recibe el mensaje del cliente, genera una primera respuesta; si se trata de una llamada a una herramienta, el código ejecuta la función, devuelve el resultado y solicita una segunda respuesta:

    def respond_with_tools(user_message: str) -> str:
        messages = [{"role": "user", "content": user_message}]
        turn1 = generate_reply(messages)
        name, args = parse_tool_call(turn1)
        if name == "lookup_order_status":
            result = lookup_order_status(**args)
            messages += [
                {"role": "assistant", "content": turn1},
                {"role": "tool", "content": json.dumps(result), "name": name},
            ]
            return generate_reply(messages)
        return turn1
    

    El camino feliz

    Después de una semana, un cliente pregunta dónde se encuentra el pedido ORD-4821. El modelo no responde. Llama a la herramienta:

    <tool_call>
    {"name": "lookup_order_status", "arguments": {"order_id": "ORD-4821"}}
    </tool_call>
    

    La función indica que el envío se realizó vía DHL el 18 de junio de 2026. En la segunda iteración, que duró 4.2 segundos en total, se obtuvo una respuesta indicando que el pedido ya fue enviado y debería llegar el 18 de junio a través de DHL.

    Para ORD-3307, el estado es “en proceso” sin transportista especificado y se estima una llegada para el 20 de junio. El modelo indicó que no había transportista disponible en lugar de inventar uno, lo cual es exactamente lo que se espera de un modelo sencillo: leer el JSON de la herramienta sin modificarlo.

    Para ORD-9999, la llamada a la herramienta fue correcta y la función devolvió not_found. La respuesta pidió disculpas y solicitó al cliente que verificara el número. Una respuesta estructurada de “no encontrado” por parte de la función era suficiente por sí sola, sin necesidad de indicaciones adicionales.

    Donde muere el bucle ingenuo

    La tercera consulta estaba destinada como control negativo: el cliente solo quiere cambiar su dirección de correo electrónico. No existe ninguna herramienta para ello, por lo que un agente competente lo indica y detiene el proceso. El bucle ingenuo hizo algo diferente:

    1. Comenzó correctamente, indicando que no se puede cambiar la información personal y que el cliente debe contactar al servicio de atención.
    2. Luego continuó y emitió una solicitud a la herramienta utilizando un marcador de posición:
    <tool_call>
    {"name": "lookup_order_status", "arguments": {"order_id": "your_order_id"}}
    </tool_call>
    
    1. El analizador ejecutó todo lo que estaba dentro de <tool_call>. Buscó en la base de datos falsa your_order_id, recibió el valor not_found y lo devolvió al modelo.
    2. En la segunda fase, el modelo mostró al cliente el esquema de la herramienta y le indicó que reemplazara your_order_id por un número de pedido real.

    Cuatro cosas fallaron al mismo tiempo, una tras otra. La parte relacionada con el modelo es la menos problemática. Los modelos pequeños toman precauciones, y eso es normal. Si su bucle consiste en una expresión regular para <tool_call> seguida de la llamada a la función con los argumentos que aparezcan, eventualmente ejecutará lookup_order_status("your_order_id") en producción, pero eso no ocurrirá durante una demostración.

    Protecciones que no necesitan ajustes

    Dos reglas simples solucionan la mayor parte de los problemas sin cambiar las ponderaciones.

    Primero, ejecute una llamada a la herramienta únicamente cuando todo el turno consista exclusivamente en dicha llamada. Un párrafo seguido de una llamada se considera texto ordinario, y el XML debe eliminarse antes de que el cliente lo vea.

    Segundo, valide los argumentos antes de que lleguen a la base de datos. Los IDs de pedido aquí son ORD- seguido de dígitos, por lo que your_order_id nunca llegará a procesarse.

    ORDER_RE = re.compile(r"^ORD-\d{4,}quot;)
    PURE_TOOL_RE = re.compile(r"^\s*<tool_call>(.*?)</tool_call>\s*quot;, re.DOTALL)
    
    def parse_executable_tool_call(output: str):
        cleaned = re.sub(r"<think>.*?</think>", "", output, flags=re.DOTALL).strip()
        match = PURE_TOOL_RE.match(cleaned)
        if not match:
            return None
        payload = json.loads(match.group(1).strip())
        order_id = str(payload.get("arguments", {}).get("order_id", "")).strip()
        if not ORDER_RE.match(order_id):
            return None
        return payload
    

    También hay que reforzar la descripción de la herramienta, indicando que solo se debe utilizar cuando el cliente ya haya proporcionado un ID de pedido concreto. Tras ese cambio, la consulta por correo electrónico en el proceso protegido no realizó ninguna llamada a la herramienta, tardando 3.5 segundos frente a los 15.4 de la versión defectuosa con dos pasos. La respuesta pidió disculpas, indicó que no podía gestionar cambios en la cuenta y remitió al servicio al cliente. Aún así es genérica, pero sin la herramienta update_email no debería existir de todos modos un camino de autoservicio mágico. Al menos dejó de fingir que buscaba un pedido.

    Las búsquedas en el escenario óptimo siguieron funcionando y ORD-9999 seguía reportándose como perdido. El modelo no se volvió más inteligente. Considérenlo más bien como un cinturón de seguridad que como un trasplante.

    Lo que esto no demuestra

    Esto es una demostración, y un copiloto de soporte real necesita mucho más que una sola función y un diccionario con tres pedidos. Hay varias afirmaciones a las que se debería oponer:

    • No se trata de una planificación con herramientas múltiples. Una herramienta sirve para realizar búsquedas, y con unas dos herramientas es cuando los modelos de 3B comienzan a inventar códigos de pedido para adaptarse a esquemas que no comprenden del todo.
    • La base de datos es falsa, por lo que la autenticación, los datos personales y la opción “que este usuario vea este pedido” nunca fueron un problema para el modelo.
    • Se activó la toma de muestras en todo momento, por lo que volver a ejecutar la consulta por correo electrónico no devolverá las mismas palabras. Lo que se repetía una y otra vez era el fallo en sí, una llamada a la herramienta llena de marcadores de posición. La redacción exacta varía, así que no confíe en ella.
    • Nada se comparó con un modelo en la nube ni se probó contra una exportación real de tickets, por lo que las cifras describen a una sola máquina y no constituyen un punto de referencia.

    Eso conlleva una afirmación más específica que “los modelos pequeños pueden brindar soporte”: un modelo de 3 mil millones de parámetros puede llamar a una herramienta bien definida, resumir el resultado y retirarse, siempre y cuando la llamada a la herramienta en sí sea considerada algo que requiere verificación de permisos y no algo en lo que se pueda confiar solo porque el modelo la generó.

    Ejecutarlo

    Desde el directorio del proyecto con su entorno virtual activo, ejecute los tres scripts en orden: la comparación entre el enfoque con reflexión y sin ella, el agente simple y el agente protegido.

    python llm-learning/smollm3-local-support-agent/code/think_vs_nothink.py
    python llm-learning/smollm3-local-support-agent/code/support_agent.py
    python llm-learning/smollm3-local-support-agent/code/support_agent_guarded.py
    

    Los scripts generan transcripciones que puedes leer posteriormente para comparar las ejecuciones.

    Lecturas relacionadas

  • Ejecutar Qwen3.8-Flash-Next localmente como agente de codificación con llama.cpp y OpenCode — Configuración paso a paso para ejecutar un modelo de codificación de 125B MoE en tu propia GPU con llama.cpp, servirlo a través de una API al estilo OpenAI y controlarlo desde OpenCode.
  • NOVA: clasificar tickets con Jev, reglas en Python y un agente LangChain — Un asistente de soporte de nivel 1 que separa el análisis de los tickets con Jev, las reglas empresariales en Python y la redacción de un borrador por parte de un agente LangChain.