IA Intermedio

Construye un agente de IA con Python y LangGraph que diagnostica y recupera servicios

Construye paso a paso un agente local de IA con Python, Ollama, LangChain y LangGraph capaz de investigar fallos reales en una API. Aprenderás a reunir evidencia desde HTTP, logs, métricas y …

Publicado: 28/09/2026 Por: Juan Felipe Orozco Cortés Duración: 2–3 horas Nivel: Intermedio
☰ Contenido del tutorial ⌄
Python · Ollama · LangChain · LangGraph

Un HTTP 503 nos dice que algo falló. No nos dice por qué.

Imagina que una aplicación empieza a devolver errores en uno de sus endpoints. Podríamos reiniciar servicios hasta que vuelva a funcionar, pero eso no sería diagnosticar: sería probar acciones sin entender primero el problema.

Pregunta 01
¿La API está realmente caída?

Si recibimos un código HTTP 503, el servidor respondió. Eso es distinto de no poder alcanzarlo.

Pregunta 02
¿Falló PostgreSQL?

Los logs pueden sugerirlo, pero una pista no sustituye una comprobación directa de la dependencia.

Pregunta 03
¿Estamos autorizados a actuar?

Observar un sistema y modificarlo son cosas diferentes. Reiniciar un servicio requiere una frontera de seguridad.

La pregunta de este tutorial

¿Cómo construimos un agente de IA capaz de investigar un incidente real sin darle libertad ilimitada sobre el sistema?

Nuestro objetivo final

Vamos a construir el proyecto desde una herramienta HTTP pequeña hasta un agente local capaz de reunir evidencia, diagnosticar una dependencia caída, consultar una política de recuperación y detenerse antes de modificar el sistema.

“Algo está mal con la API. Investiga y arréglalo.”

Esa será la instrucción que recibirá el agente al final del tutorial. No tendremos que decirle manualmente que consulte PostgreSQL, que lea logs o que reinicie un servicio. Durante el tutorial iremos construyendo las reglas que determinan qué comprobaciones son obligatorias y qué acciones necesitan autorización.

observar el síntoma
↓
reunir evidencia
↓
diagnosticar
↓
proponer una recuperación
↓
esperar aprobación humana
↓
actuar y verificar
Idea que iremos demostrando

El LLM será útil para interpretar evidencia, pero las reglas críticas de diagnóstico y seguridad también vivirán en código.

Agente de IA que reúne evidencia de una API, logs y PostgreSQL antes de solicitar aprobación humana y verificar la recuperación
La arquitectura que construiremos separa observación, diagnóstico, autorización, acción y verificación.
Arquitectura del proyecto

El agente que vamos a construir

Vamos a construir el sistema por capas. No empezaremos con LangGraph ni con un agente capaz de reiniciar servicios. Primero crearemos capacidades pequeñas y verificables; después iremos conectándolas hasta obtener un flujo completo de diagnóstico y recuperación.

Observación
Herramientas que reúnen evidencia

HTTP, logs, PostgreSQL, métricas y runbooks nos permitirán observar diferentes partes del sistema sin modificar su estado.

Acción privilegiada
Una herramienta capaz de modificar el sistema

Reiniciar PostgreSQL será una operación separada. El agente podrá proponerla, pero no ejecutarla antes de recibir una aprobación humana.

Cómo recorreremos el código

Antes de mostrar un archivo veremos cuál es su responsabilidad, qué recibe, qué produce y con qué otras piezas se conecta. Después veremos el código y comprobaremos su comportamiento.

src/agent_ops/
lab_api.py API de laboratorio que podremos llevar deliberadamente a distintos estados.
runtime.py Centraliza las rutas donde el laboratorio guarda logs, PID y otros datos de ejecución.
ollama_agent.py Implementa manualmente nuestro primer ciclo de tool calling con Ollama.
langchain_agent.py Reproduce el mismo comportamiento delegando parte de la orquestación a LangChain.
diagnostic_agent.py Amplía el agente para investigar con HTTP, logs, métricas y PostgreSQL.
langgraph_diagnostic.py Convierte las comprobaciones críticas en un flujo explícito de LangGraph.
recovery_graph.py Añade propuesta de recuperación, aprobación humana y verificación posterior.
ops_agent.py Integra runbooks, checkpoints persistentes y la experiencia final de línea de comandos.
tools/ Contiene las capacidades concretas para HTTP, logs, métricas, base de datos, documentación y recuperación.
Primera capacidad

De una función Python a una herramienta de IA

Antes de conectar un modelo necesitamos darle al programa una capacidad concreta. Empezaremos con algo pequeño pero fundamental: observar qué ocurre cuando intentamos acceder a un endpoint HTTP.

src/agent_ops/tools/health.py
Responsabilidad Comprobar un endpoint HTTP y devolver evidencia estructurada.
Recibe Una URL y un timeout.
Produce Alcanzabilidad, éxito HTTP, código de estado, tiempo transcurrido y error.
Lo utilizarán después Ollama, LangChain y finalmente nuestros grafos de diagnóstico y recuperación.
Python
# archivo: src/agent_ops/tools/health.py

"""HTTP health-check tool used by the operations agent."""

from __future__ import annotations

import time
from typing import Any
from urllib.parse import urlparse

import httpx


def check_service(url: str, *, timeout: float = 5.0) -> dict[str, Any]:
    """Check whether an HTTP service is reachable and report its latency.

    The return value is deliberately JSON-serializable so it can later be sent
    back to a language model as the result of a tool call.
    """
    parsed = urlparse(url)

    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
        return {
            "url": url,
            "reachable": False,
            "ok": False,
            "status_code": None,
            "latency_ms": 0.0,
            "error_type": "invalid_url",
            "error": "The URL must use http:// or https:// and include a host.",
        }

    if timeout <= 0:
        return {
            "url": url,
            "reachable": False,
            "ok": False,
            "status_code": None,
            "latency_ms": 0.0,
            "error_type": "invalid_timeout",
            "error": "timeout must be greater than zero.",
        }

    started_at = time.perf_counter()

    try:
        response = httpx.get(
            url,
            timeout=timeout,
            follow_redirects=True,
        )
    except httpx.RequestError as exc:
        elapsed_ms = (time.perf_counter() - started_at) * 1000

        return {
            "url": url,
            "reachable": False,
            "ok": False,
            "status_code": None,
            "latency_ms": round(elapsed_ms, 2),
            "error_type": type(exc).__name__,
            "error": str(exc),
        }

    elapsed_ms = (time.perf_counter() - started_at) * 1000

    return {
        "url": url,
        "reachable": True,
        "ok": response.is_success,
        "status_code": response.status_code,
        "latency_ms": round(elapsed_ms, 2),
        "error_type": None,
        "error": None,
    }
¿Por qué no devolver simplemente True o False?

Porque para diagnosticar necesitamos conservar la evidencia. Dos situaciones pueden terminar en ok=false y aun así representar problemas completamente diferentes.

reachable
¿Obtuvimos una respuesta HTTP?

Si es true, conseguimos hablar con el servidor aunque haya respondido 404, 500 o 503.

ok
¿La respuesta HTTP fue exitosa?

Este campo nos permite separar una respuesta sana de una respuesta de error.

status_code
¿Qué respondió el servidor?

Conservar el código nos permite distinguir, por ejemplo, un 503 de una conexión que nunca consiguió llegar a HTTP.

error_type
¿Qué ocurrió antes de recibir HTTP?

Si no hubo respuesta podemos conservar información como un error de conexión y utilizarla después durante el diagnóstico.

Guarda esta distinción

reachable=true + status_code=503 significa que el servidor respondió HTTP. No significa que la API completa sea inalcanzable.

Más adelante veremos que confundir estas dos situaciones puede producir un diagnóstico incorrecto incluso cuando el modelo dispone de datos reales.

src/agent_ops/runtime.py
Responsabilidad Centralizar dónde el laboratorio guarda sus archivos generados durante la ejecución.
runtime_dir() Obtiene o crea el directorio runtime del proyecto.
lab_log_path() Devuelve la ruta del archivo de logs de la API de laboratorio.
lab_pid_path() Devuelve la ruta donde se registra el PID del proceso de FastAPI.
Python
# archivo: src/agent_ops/runtime.py

"""Runtime paths shared by the lab API and diagnostic tools."""

from __future__ import annotations

import os
from pathlib import Path


def runtime_dir() -> Path:
    """Return the writable runtime directory used by the local lab."""
    configured = os.getenv("AGENT_OPS_RUNTIME_DIR")
    path = Path(configured) if configured else Path.cwd() / "runtime"
    path.mkdir(parents=True, exist_ok=True)
    return path


def lab_log_path() -> Path:
    return runtime_dir() / "lab-api.log"


def lab_pid_path() -> Path:
    return runtime_dir() / "lab-api.pid"
Laboratorio reproducible

Una herramienta de diagnóstico necesita algo que observar. En lugar de experimentar contra un servicio real construiremos una API pequeña cuyos estados podamos provocar de forma controlada.

src/agent_ops/lab_api.py
/health Representa un servicio sano y devuelve HTTP 200.
/slow Introduce un retraso controlado sin convertirlo en un fallo.
/error Produce deliberadamente un HTTP 500.
/database Comprueba la dependencia PostgreSQL y puede responder 503 cuando esa dependencia no está disponible.
¿Por qué crear un laboratorio?

Porque queremos poder repetir exactamente los mismos incidentes mientras desarrollamos el agente. La reproducibilidad nos permite saber si una mejora del diagnóstico realmente funciona.

Python
# archivo: src/agent_ops/lab_api.py

"""Deterministic HTTP service used to exercise the agent tools."""

from __future__ import annotations

import asyncio
import os
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import AsyncIterator

from fastapi import FastAPI, HTTPException, Query

from agent_ops.runtime import lab_log_path, lab_pid_path
from agent_ops.tools.database import query_database


def _append_log(level: str, message: str, *, path: Path | None = None) -> None:
    target = path or lab_log_path()
    timestamp = datetime.now(timezone.utc).isoformat()
    with target.open("a", encoding="utf-8") as handle:
        handle.write(f"{timestamp} {level.upper()} {message}\n")


@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
    pid_path = lab_pid_path()
    pid_path.write_text(str(os.getpid()), encoding="utf-8")
    _append_log("INFO", f"lab-api started pid={os.getpid()}")

    try:
        yield
    finally:
        _append_log("INFO", f"lab-api stopped pid={os.getpid()}")
        try:
            if pid_path.read_text(encoding="utf-8").strip() == str(os.getpid()):
                pid_path.unlink(missing_ok=True)
        except OSError:
            pass


app = FastAPI(
    title="Agent Ops Lab API",
    version="0.2.0",
    description="API reproducible para probar herramientas y diagnósticos del agente.",
    lifespan=lifespan,
)


@app.get("/health")
async def health() -> dict[str, str]:
    """Return a healthy response."""
    _append_log("INFO", "GET /health status=200")
    return {
        "status": "ok",
        "service": "agent-ops-lab",
    }


@app.get("/slow")
async def slow(
    delay_ms: int = Query(default=1500, ge=0, le=5000),
) -> dict[str, str | int]:
    """Respond successfully after a controlled delay."""
    await asyncio.sleep(delay_ms / 1000)
    _append_log("WARNING", f"GET /slow status=200 delay_ms={delay_ms}")

    return {
        "status": "slow",
        "service": "agent-ops-lab",
        "delay_ms": delay_ms,
    }


@app.get("/error")
async def error() -> None:
    """Return an intentional server error for diagnostics."""
    _append_log("ERROR", "GET /error status=500 reason=intentional_lab_failure")
    raise HTTPException(
        status_code=500,
        detail="Intentional lab failure",
    )


@app.get("/database")
async def database() -> dict[str, str]:
    """Check the real PostgreSQL dependency used by the lab."""
    result = query_database(
        "SELECT current_database() AS database"
    )

    if not result["ok"]:
        _append_log(
            "ERROR",
            "GET /database status=503 "
            f"error_type={result['error_type']} error={result['error']!r}",
        )
        raise HTTPException(
            status_code=503,
            detail="Database dependency unavailable",
        )

    database_name = str(result["rows"][0]["database"])
    _append_log(
        "INFO",
        f"GET /database status=200 database={database_name}",
    )

    return {
        "status": "ok",
        "database": database_name,
    }
Pon en marcha el laboratorio

Con el entorno del proyecto activado, abre una terminal en la raíz del proyecto e inicia FastAPI. Déjala abierta porque utilizaremos una segunda terminal para provocar y observar diferentes estados.

PowerShell
python -m uvicorn agent_ops.lab_api:app `
  --host 127.0.0.1 `
  --port 8000
Probemos los tres primeros comportamientos

Abre otra terminal. Primero comprobaremos que la API puede estar sana, después introduciremos latencia y finalmente provocaremos un error HTTP de forma intencional.

PowerShell
Invoke-RestMethod `
  http://127.0.0.1:8000/health

Invoke-RestMethod `
  "http://127.0.0.1:8000/slow?delay_ms=1500"

try {
    Invoke-RestMethod `
      http://127.0.0.1:8000/error
}
catch {
    $_.Exception.Response.StatusCode.value__
}
Criterio de aceptación · laboratorio
✓ FastAPI responde en 127.0.0.1:8000
✓ /health responde HTTP 200
✓ /slow introduce una latencia controlada y continúa respondiendo HTTP 200
✓ /error produce un HTTP 500 reproducible
P2 · Ollama

Cómo funciona realmente tool calling

Nuestra función Python sabe comprobar una URL, pero un modelo todavía no sabe que esa capacidad existe. El siguiente paso será exponerla como una herramienta que Ollama puede solicitar durante una conversación.

Antes de continuar

Un LLM no ejecuta una función Python solo por escribir su nombre. En tool calling el modelo genera una solicitud estructurada; después nuestra aplicación decide si esa solicitud está permitida y ejecuta la función real.

src/agent_ops/ollama_agent.py
Responsabilidad Implementar manualmente nuestro primer ciclo completo de tool calling.
Por qué manual primero Porque queremos entender el protocolo antes de permitir que LangChain oculte esa orquestación.
Python
# fragmento de: src/agent_ops/ollama_agent.py

"""Minimal agent loop using Ollama directly, before introducing LangChain."""

from __future__ import annotations

import argparse
import json
import os
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse

import httpx

from agent_ops.tools import check_service

DEFAULT_OLLAMA_BASE_URL = os.getenv(
    "OLLAMA_BASE_URL",
    "http://127.0.0.1:11434",
).rstrip("/")
DEFAULT_MODEL = os.getenv("OLLAMA_MODEL", "qwen3.5:4b")

SYSTEM_PROMPT = """Eres un agente local de diagnóstico HTTP.
Cuando el usuario pida comprobar la API de laboratorio, usa check_service.
La única API autorizada está en http://127.0.0.1:8000.
Después de recibir el resultado de la herramienta, responde en español de forma
breve indicando si el servicio está sano y qué código HTTP devolvió.
Si hubo respuesta HTTP, puedes mencionar la latencia observada.
Si status_code es null o existe error_type, indica que el servicio no fue
alcanzable y NO describas latency_ms como latencia del servicio: en ese caso es
solo el tiempo transcurrido hasta detectar el fallo de conexión.
"""
Antes de darle una herramienta al modelo, fijamos su contexto

DEFAULT_OLLAMA_BASE_URL indica dónde está ejecutándose Ollama y DEFAULT_MODEL selecciona el modelo local. Ambos valores pueden sobrescribirse mediante variables de entorno, por lo que la lógica del agente no queda atada permanentemente a una instalación concreta.

El SYSTEM_PROMPT tampoco ejecuta ninguna acción. Su trabajo es enseñarle al modelo cómo interpretar la evidencia que recibirá después.

Fíjate en una regla que ya conocemos

El prompt distingue entre una respuesta HTTP y un fallo de conexión. Esa diferencia nace de check_service(), que construimos antes: el modelo recibe evidencia estructurada en lugar de tener que adivinar qué ocurrió.

Python
# fragmento de: src/agent_ops/ollama_agent.py

CHECK_SERVICE_TOOL: dict[str, Any] = {
    "type": "function",
    "function": {
        "name": "check_service",
        "description": (
            "Comprueba disponibilidad, código HTTP y latencia de un endpoint "
            "de la API de laboratorio en http://127.0.0.1:8000."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "url": {
                    "type": "string",
                    "description": (
                        "URL HTTP completa de la API de laboratorio. "
                        "Debe usar 127.0.0.1 y el puerto 8000."
                    ),
                }
            },
            "required": ["url"],
        },
    },
}
Este diccionario no ejecuta check_service()

CHECK_SERVICE_TOOL describe una capacidad que el modelo puede solicitar. Le dice cómo se llama la herramienta, para qué sirve y qué argumento debe proporcionar.

Si el modelo necesita comprobar la API, puede producir una solicitud equivalente a:

JSON
{
  "tool_calls": [
    {
      "function": {
        "name": "check_service",
        "arguments": {
          "url": "http://127.0.0.1:8000/health"
        }
      }
    }
  ]
}
Aquí aparece una frontera de seguridad

No podemos tomar cualquier nombre de función y cualquier argumento generado por el modelo y ejecutarlos sin control.

Antes de llegar a check_service(), nuestra aplicación comprobará dos cosas: que la herramienta solicitada está autorizada y que la URL pertenece al laboratorio local.

Python
# fragmento de: src/agent_ops/ollama_agent.py

def _validate_lab_url(url: str) -> None:
    """Restrict the tutorial tool to the local lab API.

    This deliberately demonstrates that an LLM should not receive unrestricted
    network access just because it can generate a URL.
    """
    parsed = urlparse(url)

    try:
        port = parsed.port
    except ValueError as exc:
        raise ValueError("Invalid port in tool URL.") from exc

    if (
        parsed.scheme != "http"
        or parsed.hostname != "127.0.0.1"
        or port != 8000
    ):
        raise ValueError(
            "check_service is restricted to http://127.0.0.1:8000."
        )


def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
    """Execute one allowlisted tool requested by the model."""
    if name != "check_service":
        raise ValueError(f"Unknown or unauthorized tool: {name}")

    url = arguments.get("url")
    if not isinstance(url, str):
        raise ValueError("check_service requires a string 'url' argument.")

    _validate_lab_url(url)
    return check_service(url)
1 · LLM
Solicita una operación

El modelo puede proponer llamar a check_service y generar los argumentos necesarios.

2 · Aplicación
Valida antes de ejecutar

execute_tool() comprueba el nombre de la herramienta y _validate_lab_url() restringe el destino al laboratorio.

El detalle importante

La autoridad sigue estando en nuestro código. El modelo puede solicitar una acción, pero no puede inventar una herramienta nueva ni convertir check_service en acceso arbitrario a Internet.

Ciclo de tool calling donde el modelo solicita una herramienta, la aplicación valida la petición, ejecuta Python y devuelve el resultado al modelo
El modelo solicita una herramienta; nuestra aplicación valida la petición, ejecuta Python y devuelve el resultado al modelo.
Ya podemos validar una herramienta. Ahora necesitamos conversar con Ollama.

Hasta este punto el modelo conoce la descripción de check_service y nuestra aplicación sabe ejecutar esa herramienta de forma controlada.

Falta conectar ambos extremos: enviar mensajes y herramientas a Ollama, recibir un tool_call, ejecutar Python y devolver el resultado al modelo.

Python
# fragmento de: src/agent_ops/ollama_agent.py

ChatCallable = Callable[..., dict[str, Any]]
ToolExecutor = Callable[[str, dict[str, Any]], dict[str, Any]]


@dataclass(frozen=True)
class AgentRun:
    """Observable result of one agent execution."""

    answer: str
    trace: list[dict[str, Any]]
    messages: list[dict[str, Any]]
    model: str
AgentRun
No guardamos solo la respuesta

También conservamos la traza de herramientas utilizadas, todos los mensajes intercambiados y el modelo que participó en la ejecución.

¿Por qué?
Queremos poder observar al agente

Si un diagnóstico sale mal, necesitamos reconstruir qué pidió el modelo, qué ejecutó Python y qué evidencia recibió después.

Python
# fragmento de: src/agent_ops/ollama_agent.py

def ollama_chat(
    *,
    messages: list[dict[str, Any]],
    tools: list[dict[str, Any]],
    model: str = DEFAULT_MODEL,
    base_url: str = DEFAULT_OLLAMA_BASE_URL,
) -> dict[str, Any]:
    """Call Ollama's native /api/chat endpoint."""
    payload = {
        "model": model,
        "messages": messages,
        "tools": tools,
        "stream": False,
        "think": False,
        "options": {
            "temperature": 0,
        },
    }

    with httpx.Client(timeout=180.0) as client:
        response = client.post(
            f"{base_url.rstrip('/')}/api/chat",
            json=payload,
        )
        response.raise_for_status()
        data = response.json()

    if not isinstance(data, dict) or not isinstance(data.get("message"), dict):
        raise RuntimeError("Ollama returned an unexpected response shape.")

    return data
Aquí todavía no existe un agente: existe una llamada a Ollama

ollama_chat() construye un payload y lo envía al endpoint /api/chat. Además de los mensajes, enviamos la descripción de las herramientas disponibles.

messages
Contexto de la conversación

Incluye mensajes del sistema, usuario, modelo y resultados de herramientas.

tools
Capacidades disponibles

Ollama conoce sus esquemas, pero Python continúa controlando su ejecución.

temperature = 0
Menor variabilidad

Para diagnóstico preferimos respuestas menos aleatorias.

stream = false
Respuesta completa

Esperamos el mensaje completo antes de continuar con nuestro bucle.

El corazón de P2

La siguiente función convierte las piezas anteriores en un agente mínimo. Aquí veremos por primera vez el ciclo completo: modelo → herramienta → Python → resultado → modelo.

Python
# fragmento de: src/agent_ops/ollama_agent.py

def run_health_agent(
    prompt: str,
    *,
    model: str = DEFAULT_MODEL,
    base_url: str = DEFAULT_OLLAMA_BASE_URL,
    max_steps: int = 4,
    chat_callable: ChatCallable = ollama_chat,
    tool_executor: ToolExecutor = execute_tool,
) -> AgentRun:
    """Run a small observe-act-observe loop until the model answers."""
    if max_steps < 1:
        raise ValueError("max_steps must be at least 1.")

    messages: list[dict[str, Any]] = [
        {
            "role": "system",
            "content": SYSTEM_PROMPT,
        },
        {
            "role": "user",
            "content": prompt,
        },
    ]
    trace: list[dict[str, Any]] = []
    tools = [CHECK_SERVICE_TOOL]

    for _ in range(max_steps):
        response = chat_callable(
            messages=messages,
            tools=tools,
            model=model,
            base_url=base_url,
        )
        raw_message = response["message"]

        assistant_message: dict[str, Any] = {
            "role": "assistant",
            "content": raw_message.get("content", ""),
        }

        tool_calls = raw_message.get("tool_calls") or []
        if tool_calls:
            assistant_message["tool_calls"] = tool_calls

        messages.append(assistant_message)

        if not tool_calls:
            return AgentRun(
                answer=assistant_message["content"],
                trace=trace,
                messages=messages,
                model=model,
            )

        for call in tool_calls:
            function = call.get("function") or {}
            name = function.get("name")
            arguments = function.get("arguments") or {}

            if not isinstance(name, str) or not isinstance(arguments, dict):
                raise RuntimeError("The model returned an invalid tool call.")

            result = tool_executor(name, arguments)

            trace.append(
                {
                    "tool": name,
                    "arguments": arguments,
                    "result": result,
                }
            )

            messages.append(
                {
                    "role": "tool",
                    "tool_name": name,
                    "content": json.dumps(result, ensure_ascii=False),
                }
            )

    raise RuntimeError(
        f"The agent did not produce a final answer after {max_steps} steps."
    )
Sigamos una ejecución paso a paso
1 · Guardamos system prompt + petición del usuario
↓
2 · Enviamos messages + tools a Ollama
↓
3 · El modelo puede devolver tool_calls
↓
4 · execute_tool() valida y ejecuta Python
↓
5 · El resultado se añade con role="tool"
↓
6 · El historial completo vuelve a Ollama
↓
7 · Cuando ya no hay tool_calls, obtenemos la respuesta final
Esto es tool calling

El modelo no entra a Python ni ejecuta funciones por sí mismo. Conversa con nuestra aplicación mediante mensajes estructurados.

Python
# fragmento de: src/agent_ops/ollama_agent.py

def main() -> None:
    parser = argparse.ArgumentParser(
        description="Run the P2 Ollama agent against the local lab API."
    )
    parser.add_argument(
        "prompt",
        nargs="?",
        default=(
            "Comprueba si http://127.0.0.1:8000/health está funcionando."
        ),
    )
    args = parser.parse_args()

    run = run_health_agent(args.prompt)

    for index, event in enumerate(run.trace, start=1):
        print(
            f"[tool {index}] {event['tool']}("
            f"{json.dumps(event['arguments'], ensure_ascii=False)})"
        )
        print(
            "[result] "
            f"{json.dumps(event['result'], ensure_ascii=False)}"
        )

    print(f"\nAgente: {run.answer}")


if __name__ == "__main__":
    main()
Ejecutemos el agente por primera vez

FastAPI debe seguir ejecutándose y Ollama debe estar disponible localmente. El modelo utilizado por defecto en este proyecto es qwen3.5:4b.

PowerShell
ollama pull qwen3.5:4b

python -m agent_ops.ollama_agent `
  "Comprueba si http://127.0.0.1:8000/health está funcionando."
Criterio de aceptación · P2
✓ El modelo solicita check_service
✓ La aplicación valida la herramienta
✓ Python ejecuta check_service()
✓ El resultado vuelve al modelo
✓ El modelo produce una respuesta final basada en evidencia
P3 · LangChain

LangChain sin magia: automatizar la orquestación que ya entendemos

Ahora que implementamos manualmente el protocolo, podemos introducir LangChain sin tratarlo como una caja negra. La herramienta seguirá siendo nuestra y las restricciones seguirán viviendo en nuestro código.

src/agent_ops/langchain_agent.py
Responsabilidad Reproducir el comportamiento anterior utilizando la abstracción de agente de LangChain.
Lo que no cambia check_service, la allowlist, la validación de URL y Ollama continúan existiendo.
Python
# fragmento de: src/agent_ops/langchain_agent.py

@tool("check_service")
def check_service_tool(url: str) -> dict[str, Any]:
    """Comprueba disponibilidad, código HTTP y latencia de la API local.

    Solo se permite acceder a la API de laboratorio en
    http://127.0.0.1:8000.
    """
    return execute_tool("check_service", {"url": url})


def build_agent(
    *,
    model_name: str = DEFAULT_MODEL,
    base_url: str = DEFAULT_OLLAMA_BASE_URL,
):
    """Build the LangChain agent without contacting Ollama yet."""
    model = ChatOllama(
        model=model_name,
        base_url=base_url,
        temperature=0,
        reasoning=False,
        validate_model_on_init=False,
    )

    return create_agent(
        model=model,
        tools=[check_service_tool],
        system_prompt=SYSTEM_PROMPT,
    )
Antes
Nosotros gestionábamos el protocolo

Construíamos los mensajes, detectábamos tool_calls, ejecutábamos la herramienta y añadíamos manualmente el mensaje de resultado.

Ahora
LangChain orquesta ese ciclo

Declaramos una tool y creamos el agente, pero seguimos utilizando execute_tool(), por lo que nuestras restricciones permanecen intactas.

Python
# fragmento de: src/agent_ops/langchain_agent.py

def run_langchain_agent(
    prompt: str,
    *,
    model_name: str = DEFAULT_MODEL,
    base_url: str = DEFAULT_OLLAMA_BASE_URL,
    agent: Any | None = None,
) -> LangChainRun:
    """Run one user request through the LangChain agent."""
    runtime = agent or build_agent(
        model_name=model_name,
        base_url=base_url,
    )

    result = runtime.invoke(
        {
            "messages": [
                {
                    "role": "user",
                    "content": prompt,
                }
            ]
        }
    )

    messages = result["messages"]
    if not messages:
        raise RuntimeError("LangChain returned no messages.")

    answer = messages[-1].content
    if not isinstance(answer, str):
        answer = str(answer)

    return LangChainRun(
        answer=answer,
        trace=extract_trace(messages),
        messages=messages,
        model=model_name,
    )
Qué aportó realmente LangChain

LangChain nos evita mantener manualmente parte del protocolo de mensajes y herramientas. No sustituye nuestras funciones, no decide nuestras políticas de seguridad y tampoco convierte automáticamente al agente en un buen diagnosticador.

PowerShell
python -m agent_ops.langchain_agent `
  "Comprueba si http://127.0.0.1:8000/health está funcionando."
P4 · Diagnóstico

Un agente necesita evidencia, no intuiciones

Hasta ahora el agente puede observar HTTP. Pero un código 503 no contiene suficiente información para explicar por qué ocurrió. Vamos a darle tres nuevas fuentes de evidencia: logs, métricas y PostgreSQL.

tools/logs.py
Eventos recientes

Permite leer únicamente los logs del servicio de laboratorio autorizado.

tools/metrics.py
Estado del host y del proceso

Separa las métricas de toda la máquina de las métricas específicas de lab-api.

tools/database.py
Prueba directa de PostgreSQL

Ejecuta consultas acotadas dentro de una transacción de solo lectura.

Python
# archivo: src/agent_ops/tools/logs.py

"""Read recent logs from the local laboratory service."""

from __future__ import annotations

from pathlib import Path
from typing import Any

from agent_ops.runtime import lab_log_path

_ALLOWED_SERVICES = {"lab-api"}


def read_logs(
    service: str = "lab-api",
    *,
    lines: int = 50,
    path: Path | None = None,
) -> dict[str, Any]:
    """Return the last log lines for an allowlisted local service."""
    if service not in _ALLOWED_SERVICES:
        raise ValueError(f"Unknown or unauthorized service: {service}")

    if not 1 <= lines <= 200:
        raise ValueError("lines must be between 1 and 200.")

    target = path or lab_log_path()
    if not target.exists():
        return {
            "service": service,
            "available": False,
            "lines": [],
            "error": f"Log file does not exist yet: {target}",
        }

    content = target.read_text(
        encoding="utf-8",
        errors="replace",
    ).splitlines()

    return {
        "service": service,
        "available": True,
        "lines": content[-lines:],
        "error": None,
    }
Python
# archivo: src/agent_ops/tools/metrics.py

"""System and process metrics for the local laboratory."""

from __future__ import annotations

from pathlib import Path
from typing import Any

import psutil

from agent_ops.runtime import lab_pid_path

_ALLOWED_SERVICES = {"lab-api"}


def get_system_metrics(
    service: str = "lab-api",
    *,
    pid_path: Path | None = None,
) -> dict[str, Any]:
    """Return host metrics and, when available, lab API process metrics."""
    if service not in _ALLOWED_SERVICES:
        raise ValueError(f"Unknown or unauthorized service: {service}")

    memory = psutil.virtual_memory()

    result: dict[str, Any] = {
        "service": service,
        "host": {
            "scope": "host_system",
            "cpu_percent": psutil.cpu_percent(interval=0.1),
            "memory_percent": memory.percent,
            "memory_available_mb": round(
                memory.available / 1024 / 1024,
                2,
            ),
        },
        "process": {
            "scope": "lab_api_process",
            "running": False,
            "pid": None,
            "status": None,
            "memory_percent": None,
            "memory_rss_mb": None,
        },
    }

    target = pid_path or lab_pid_path()
    if not target.exists():
        return result

    try:
        pid = int(
            target.read_text(
                encoding="utf-8"
            ).strip()
        )

        process = psutil.Process(pid)
        process_memory = process.memory_info()

        result["process"] = {
            "scope": "lab_api_process",
            "running": process.is_running(),
            "pid": pid,
            "status": process.status(),
            "memory_percent": round(
                process.memory_percent(),
                3,
            ),
            "memory_rss_mb": round(
                process_memory.rss / 1024 / 1024,
                2,
            ),
        }

    except (ValueError, psutil.Error):
        return result

    return result
Una distinción que será crucial

host.memory_percent describe toda la máquina. process.memory_percent y process.memory_rss_mb describen únicamente el proceso de nuestra API.

Una máquina con mucha memoria ocupada no demuestra que nuestra aplicación sea la responsable.

src/agent_ops/tools/database.py
Responsabilidad Obtener evidencia directamente desde PostgreSQL.
Restricción Solo acepta consultas SELECT o WITH y además utiliza una transacción read-only.
Python
# fragmento de: src/agent_ops/tools/database.py

_MAX_ROWS = 50

_FORBIDDEN_TOKENS = re.compile(
    r"\b(insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|"
    r"copy|vacuum|analyze|refresh|reindex|cluster|call|do)\b",
    flags=re.IGNORECASE,
)


def _validate_read_only_sql(sql: str) -> str:
    """Apply a narrow SQL policy before PostgreSQL's read-only transaction."""
    query = sql.strip()

    if not query:
        raise ValueError("SQL query cannot be empty.")

    if ";" in query.rstrip(";"):
        raise ValueError("Only one SQL statement is allowed.")

    query = query.rstrip(";").strip()

    if not re.match(
        r"^(select|with)\b",
        query,
        flags=re.IGNORECASE,
    ):
        raise ValueError("Only SELECT or WITH queries are allowed.")

    if _FORBIDDEN_TOKENS.search(query):
        raise ValueError("Potentially mutating SQL is not allowed.")

    return query
Python
# fragmento de: src/agent_ops/tools/database.py

def query_database(
    sql: str,
    *,
    database_url: str | None = None,
    connect_factory: Callable[..., Any] = psycopg.connect,
) -> dict[str, Any]:
    """Execute one bounded query inside a PostgreSQL read-only transaction."""
    query = _validate_read_only_sql(sql)
    dsn = database_url or DEFAULT_DATABASE_URL

    try:
        with connect_factory(
            dsn,
            connect_timeout=3,
            row_factory=dict_row,
            autocommit=True,
        ) as connection:
            with connection.cursor() as cursor:
                cursor.execute("BEGIN READ ONLY")
                cursor.execute(
                    "SET LOCAL statement_timeout = '2000ms'"
                )
                cursor.execute(query)

                columns = (
                    [column.name for column in cursor.description]
                    if cursor.description
                    else []
                )

                rows = (
                    cursor.fetchmany(_MAX_ROWS + 1)
                    if cursor.description
                    else []
                )

                truncated = len(rows) > _MAX_ROWS
                rows = rows[:_MAX_ROWS]

                cursor.execute("ROLLBACK")

        return {
            "ok": True,
            "columns": columns,
            "rows": rows,
            "row_count": len(rows),
            "truncated": truncated,
            "error_type": None,
            "error": None,
        }

    except psycopg.Error as exc:
        return {
            "ok": False,
            "columns": [],
            "rows": [],
            "row_count": 0,
            "truncated": False,
            "error_type": type(exc).__name__,
            "error": str(exc),
        }
Defensa en capas

No dependemos de una instrucción como “por favor, no modifiques la base de datos”. La política también está codificada.

solo SELECT / WITH
↓
tokens mutantes bloqueados
↓
BEGIN READ ONLY
↓
timeout de 2 segundos
↓
máximo 50 filas
src/agent_ops/diagnostic_agent.py
Responsabilidad Combinar HTTP, logs, métricas y PostgreSQL durante una misma investigación.
Problema nuevo Ahora el modelo tiene varias herramientas y debe decidir qué evidencia necesita.
Python
# fragmento de: src/agent_ops/diagnostic_agent.py

@tool("check_service")
def check_service_tool(url: str) -> dict[str, Any]:
    """Comprueba estado HTTP y latencia de la API local autorizada."""
    from agent_ops.ollama_agent import _validate_lab_url

    _validate_lab_url(url)
    return check_service(url)


@tool("read_logs")
def read_logs_tool(
    service: str = "lab-api",
    lines: int = 30,
) -> dict[str, Any]:
    """Lee las últimas líneas de logs del servicio local lab-api."""
    return read_logs(service, lines=lines)


@tool("get_system_metrics")
def get_system_metrics_tool(
    service: str = "lab-api",
) -> dict[str, Any]:
    """Obtiene CPU, memoria y estado del proceso lab-api."""
    return get_system_metrics(service)


@tool("query_database")
def query_database_tool(sql: str) -> dict[str, Any]:
    """Ejecuta una consulta PostgreSQL acotada dentro de una transacción read-only."""
    return query_database(sql)
Python
# fragmento de: src/agent_ops/diagnostic_agent.py

def build_diagnostic_agent(
    *,
    model_name: str = DEFAULT_MODEL,
    base_url: str = DEFAULT_OLLAMA_BASE_URL,
):
    """Build the P4 LangChain diagnostic agent."""
    model = ChatOllama(
        model=model_name,
        base_url=base_url,
        temperature=0,
        reasoning=False,
        validate_model_on_init=False,
    )

    return create_agent(
        model=model,
        tools=[
            check_service_tool,
            read_logs_tool,
            get_system_metrics_tool,
            query_database_tool,
        ],
        system_prompt=DIAGNOSTIC_SYSTEM_PROMPT,
    )
Hemos cambiado el problema

Antes el agente tenía una sola herramienta. Ahora puede comprobar HTTP, leer logs, observar métricas y consultar PostgreSQL.

Eso parece una mejora evidente, pero introduce una pregunta mucho más difícil: ¿podemos confiar en que el LLM reúna siempre la evidencia correcta?

PowerShell
docker compose stop postgres

Invoke-RestMethod `
  http://127.0.0.1:8000/health

try {
    Invoke-RestMethod `
      http://127.0.0.1:8000/database
}
catch {
    $_.Exception.Response.StatusCode.value__
}
Observa cuidadosamente el incidente

FastAPI sigue respondiendo. Lo que falla es el endpoint que depende de PostgreSQL.

Por ahora nuestra mejor descripción es: API viva con una dependencia posiblemente no disponible.

Todavía necesitamos comprobar PostgreSQL directamente antes de convertir esa posibilidad en una conclusión.

Lo que salió mal

Cuando el LLM se equivoca aunque tenga herramientas

Darle más información al modelo no garantiza que la utilice correctamente. Durante la construcción de este proyecto aparecieron tres errores especialmente útiles porque terminaron cambiando la arquitectura.

Error 01 · Correlación no es causa

El agente observó una utilización de memoria muy alta en el host y la trató como posible explicación principal del incidente.

Pero el proceso de FastAPI consumía mucha menos memoria. Una métrica global de la máquina no demostraba que nuestra API fuera responsable.

Error 02 · Una pista no sustituye una prueba

Los logs mostraban errores de conexión hacia PostgreSQL y el modelo podía concluir que la base de datos estaba caída sin ejecutar una prueba directa.

Si investigamos /database, comprobar PostgreSQL con SELECT 1 AS ok debe ser obligatorio.

Error 03 · 503 no significa API inalcanzable

La herramienta podía devolver reachable=true y status_code=503, pero el modelo describía la situación como si toda la API estuviera caída.

Si recibimos 503, hubo una respuesta HTTP. El problema puede encontrarse en una dependencia del endpoint y no en el proceso web completo.

La conclusión que cambió el proyecto

Podríamos continuar añadiendo más reglas al system prompt: “comprueba siempre PostgreSQL”, “no confundas host y proceso”, “interpreta correctamente reachable”.

Pero algunas reglas son demasiado importantes para depender únicamente de que un modelo las recuerde durante cada ejecución.

La siguiente versión del agente moverá esas decisiones desde el prompt hacia la arquitectura.

Evolución de un agente de IA desde errores de razonamiento hacia un grafo que obliga a reunir evidencia crítica
Los fallos del agente revelan qué decisiones no deberían depender solo del prompt: la evidencia crítica pasará a estar controlada por el grafo.
P5 · LangGraph

Del prompt a un grafo que obliga a reunir evidencia

Hasta ahora el modelo podía decidir qué herramientas utilizar. LangGraph nos permitirá mover parte de esa responsabilidad hacia un flujo explícito: determinadas comprobaciones ocurrirán porque el sistema las exige, no porque el LLM recuerde hacerlas.

src/agent_ops/langgraph_diagnostic.py
Responsabilidad Determinar qué evidencia debe reunirse antes del diagnóstico.
El LLM hará La síntesis final de la evidencia ya recopilada.
El grafo hará Decidir qué comprobaciones son obligatorias y en qué orden pueden ocurrir.

Estás viendo solo el 60% del contenido. Hazte Premium para acceder al tutorial completo.

Comunidad

Comentarios y valoraciones

No hay comentarios aún. ¡Sé el primero en opinar!