Skip to content

Decodificación robusta de JSON en respuestas de LLM con PHP

Introducción

Le pides a un LLM que "responda en formato JSON" y recibes:

Aquí está el JSON que solicitaste:

```json
{"mood": "happy", "score": 8}

¡Avísame si necesitas algo más!


O peor — el JSON se ve limpio pero `json_decode()` devuelve `null` porque hay un espacio de ancho cero invisible escondido entre dos claves. O la respuesta comienza con un BOM UTF-8 que llegó desde una API intermedia.

El `json_decode()` de PHP es estricto por diseño. Espera JSON perfectamente formado — sin texto alrededor, sin bloques markdown, sin caracteres invisibles. Pero los LLMs son inherentemente desordenados: envuelven el JSON en bloques de código, anteponen texto explicativo y a veces introducen caracteres de control de sus datos de entrenamiento.

La solución no es pedirle al LLM con más insistencia. Es construir un pipeline de decodificación que se recupere de las malformaciones más comunes. En este artículo, construiremos exactamente eso — un método `jsonDecode()` que maneja lo que las integraciones con LLM en producción realmente te lanzan.

## El pipeline de recuperación

Nuestro enfoque es un pipeline de 5 etapas donde cada etapa maneja una clase específica de malformación:

Respuesta cruda del LLM │ ▼ ┌─────────────────────┐ │ 1. Extraer Markdown │ Quitar bloques json └─────────┬───────────┘ ▼ ┌─────────────────────┐ │ 2. Emparejar llaves │ Encontrar {} o [] más externos └─────────┬───────────┘ ▼ ┌─────────────────────┐ │ 3. Limpieza UTF-8 │ Quitar chars de control, BOM, ancho cero └─────────┬───────────┘ ▼ ┌─────────────────────┐ │ 4. Trim │ Quitar espacios en blanco alrededor └─────────┬───────────┘ ▼ ┌─────────────────────┐ │ 5. Decodificar │ json_decode con flags de error └─────────────────────┘


Cada etapa es idempotente — si la entrada no coincide con el patrón (por ejemplo, no tiene bloques markdown), pasa sin cambios. Esto significa que el JSON limpio atraviesa el pipeline con un costo mínimo, mientras que el JSON malformado se repara progresivamente.

## Etapa 1: Extracción de Markdown

Los LLMs frecuentemente envuelven el JSON en bloques de código markdown, especialmente cuando han sido entrenados con datos conversacionales. El patrón es lo suficientemente consistente para manejarlo con una sola expresión regular:

```php
if (preg_match('/```(?:json)?\s*\n?(.*?)\n?\s*```/s', $json, $matches)) {
    $json = $matches[1];
}

Desglosando esto:

  • ``` — Coincide con la cerca de apertura
  • (?:json)? — Identificador de lenguaje json opcional (grupo no capturante)
  • \s*\n? — Espacios en blanco flexibles y salto de línea opcional después de la cerca
  • (.*?) — Captura el contenido interior (coincidencia perezosa)
  • \n?\s* — Espacios en blanco flexibles antes de la cerca de cierre
  • ``` — Coincide con la cerca de cierre
  • /s — El punto coincide con saltos de línea (crítico para JSON multilínea)

La verificación con if hace que esto solo se ejecute cuando los bloques de código están presentes. El JSON limpio sin cercas pasa sin modificaciones.

Etapa 2: Emparejamiento de llaves

Después de quitar los bloques de código, puede que aún haya texto alrededor — el preámbulo del LLM "Aquí está el resultado:", o un "Avísame si necesitas cambios" al final. Necesitamos extraer solo el objeto o arreglo JSON.

Un enfoque ingenuo de strpos('{') a strrpos('}') falla porque los valores JSON pueden contener llaves dentro de strings:

json
{"message": "Usa {name} como placeholder"}

Necesitamos un emparejamiento de llaves que reconozca strings:

php
private static function extractJsonFromText(string $text): string
{
    // Encontrar la primera { o [
    $firstBrace = strpos($text, '{');
    $firstBracket = strpos($text, '[');

    if ($firstBrace === false && $firstBracket === false) {
        return $text; // No se encontró estructura JSON
    }

    // Determinar cuál aparece primero
    if ($firstBrace !== false
        && ($firstBracket === false || $firstBrace < $firstBracket)) {
        $startPos = $firstBrace;
        $openChar = '{';
        $closeChar = '}';
    } else {
        $startPos = $firstBracket;
        $openChar = '[';
        $closeChar = ']';
    }

    // Rastrear profundidad con reconocimiento de strings
    $depth = 0;
    $inString = false;
    $escapeNext = false;
    $endPos = false;

    for ($i = $startPos; $i < strlen($text); $i++) {
        $char = $text[$i];

        if ($escapeNext) {
            $escapeNext = false;
            continue;
        }

        if ($char === '\\' && $inString) {
            $escapeNext = true;
            continue;
        }

        if ($char === '"' && !$escapeNext) {
            $inString = !$inString;
            continue;
        }

        if ($inString) {
            continue;
        }

        if ($char === $openChar) {
            $depth++;
        } elseif ($char === $closeChar) {
            $depth--;
            if ($depth === 0) {
                $endPos = $i;
                break;
            }
        }
    }

    if ($endPos !== false) {
        return substr($text, $startPos, $endPos - $startPos + 1);
    }

    return $text;
}

El algoritmo recorre carácter por carácter, rastreando si estamos dentro de un string JSON (donde las llaves no cuentan) y manejando secuencias de escape (para que \" no alterne el estado del string). Cuando la profundidad vuelve a cero, hemos encontrado la llave de cierre correspondiente.

TIP

Esto funciona tanto para objetos ({}) como para arreglos ([]). El que aparezca primero en el texto determina qué estamos extrayendo.

Etapa 3: Limpieza UTF-8

Los caracteres invisibles son el problema más insidioso. El JSON se ve perfecto en tu editor, pero json_decode() lo rechaza. Cuatro pasadas de regex limpian los infractores más comunes:

php
// Pasada 1: Caracteres de control C1 (U+0080–U+009F)
// Aparecen en texto de fuentes codificadas en Windows-1252
$cleanedJson = preg_replace('/\xC2[\x80-\x9F]/u', '', $json);

// Pasada 2: Caracteres de ancho cero (U+200B–U+200F)
// Espacio de ancho cero, no-unión de ancho cero, unión de ancho cero, etc.
// Comunes en texto copiado de páginas web
$cleanedJson = preg_replace('/\xE2\x80[\x8B-\x8F]/u', '', $cleanedJson);

// Pasada 3: BOM UTF-8 (U+FEFF)
// Marca de orden de bytes que algunos editores y APIs anteponen
$cleanedJson = preg_replace('/\xEF\xBB\xBF/u', '', $cleanedJson);

// Pasada 4: Caracteres de control ASCII (excepto tab, salto de línea, retorno de carro)
// Caracteres 0x00–0x08, 0x0B, 0x0C, 0x0E–0x1F y 0x7F
$cleanedJson = preg_replace(
    '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u',
    '',
    $cleanedJson
);

¿Por qué cuatro pasadas separadas en lugar de una sola regex grande? Legibilidad y facilidad para depurar. Cuando una clase de caracteres causa problemas, puedes desactivar pasadas individuales para aislar el problema. El flag /u asegura que cada pasada trate la entrada como UTF-8.

WARNING

No elimines tabs (\x09), saltos de línea (\x0A) ni retornos de carro (\x0D) — estos son válidos dentro de strings JSON. La pasada de ASCII los omite explícitamente.

Etapas 4–5: Decodificar con flags

Después de la extracción y limpieza, eliminamos espacios en blanco y decodificamos:

php
$cleanedJson = trim($cleanedJson);

if (empty($cleanedJson)) {
    return null;
}

return json_decode(
    json: $cleanedJson,
    associative: true,
    flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);

Dos flags trabajan juntos aquí:

  • JSON_THROW_ON_ERROR — Lanza una JsonException en lugar de devolver null silenciosamente. Esto nos permite capturar fallos de manera estructurada en lugar de revisar json_last_error() después de cada llamada.
  • JSON_INVALID_UTF8_IGNORE — Descarta silenciosamente cualquier secuencia UTF-8 inválida restante en lugar de fallar. Esta es nuestra red de seguridad para caracteres que sobrevivieron las pasadas de limpieza.

El parámetro associative: true devuelve arreglos en lugar de objetos stdClass — una elección práctica para pipelines de procesamiento de datos donde accedes a las claves por nombre.

Ejemplos del mundo real

Estas son las malformaciones reales que hemos encontrado en integraciones con LLM en producción, y cómo cada etapa del pipeline las maneja:

JSON envuelto en Markdown

Aquí está el análisis:

```json
{"sentiment": "positive", "confidence": 0.92}

La **Etapa 1** elimina los bloques de código → `{"sentiment": "positive", "confidence": 0.92}` → decodificado.

### JSON rodeado de texto

Basándome en la conversación, aquí están los datos estructurados: {"intent": "booking", "date": "2026-03-15", "guests": 4} Por favor confirma si esto se ve correcto.


La Etapa 1 no encuentra bloques de código (pasa sin cambios). La **Etapa 2** empareja llaves desde la primera `{` hasta su `}` de cierre → `{"intent": "booking", "date": "2026-03-15", "guests": 4}` → decodificado.

### Contaminación de ancho cero

```json
{"name": "María​", "city": "São​ Paulo"}

Espacios de ancho cero invisibles (U+200B) después de "María" y "São". Las Etapas 1 y 2 pasan sin cambios (la estructura está bien). La Etapa 3 elimina los caracteres de ancho cero → JSON limpio → decodificado.

Respuesta con prefijo BOM

\xEF\xBB\xBF{"status": "ok", "data": [1, 2, 3]}

Un BOM UTF-8 precede al JSON (común en ciertos middlewares de API). La Etapa 3 elimina el BOM → {"status": "ok", "data": [1, 2, 3]} → decodificado.

La implementación completa

Juntando todas las etapas en un único método listo para producción:

php
class JsonHelper
{
    /**
     * Decodifica de forma segura un string JSON con recuperación
     * para malformaciones comunes de LLM.
     *
     * Maneja: bloques markdown, texto alrededor, caracteres de control,
     * espacios de ancho cero, BOM y secuencias UTF-8 inválidas.
     */
    public static function decode(?string $json): ?array
    {
        if (empty($json)) {
            return null;
        }

        try {
            // Etapa 1: Quitar bloques de código markdown
            if (preg_match('/```(?:json)?\s*\n?(.*?)\n?\s*```/s', $json, $matches)) {
                $json = $matches[1];
            }

            // Etapa 2: Extraer JSON emparejando las llaves más externas
            $json = self::extractJsonFromText($json);

            // Etapa 3: Eliminar caracteres invisibles/de control
            $cleaned = preg_replace('/\xC2[\x80-\x9F]/u', '', $json);
            $cleaned = preg_replace('/\xE2\x80[\x8B-\x8F]/u', '', $cleaned);
            $cleaned = preg_replace('/\xEF\xBB\xBF/u', '', $cleaned);
            $cleaned = preg_replace(
                '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u',
                '',
                $cleaned
            );

            // Etapa 4: Eliminar espacios en blanco
            $cleaned = trim($cleaned);

            if (empty($cleaned)) {
                return null;
            }

            // Etapa 5: Decodificar con flags de seguridad
            return json_decode(
                json: $cleaned,
                associative: true,
                flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
            );
        } catch (\Throwable $e) {
            Log::warning('Error al decodificar JSON', [
                'error' => $e->getMessage(),
                'input' => mb_substr($json, 0, 500),
            ]);
        }

        return null;
    }

    /**
     * Extrae JSON del texto circundante encontrando las llaves
     * correspondientes más externas.
     */
    private static function extractJsonFromText(string $text): string
    {
        $firstBrace = strpos($text, '{');
        $firstBracket = strpos($text, '[');

        if ($firstBrace === false && $firstBracket === false) {
            return $text;
        }

        if ($firstBrace !== false
            && ($firstBracket === false || $firstBrace < $firstBracket)) {
            $startPos = $firstBrace;
            $openChar = '{';
            $closeChar = '}';
        } else {
            $startPos = $firstBracket;
            $openChar = '[';
            $closeChar = ']';
        }

        $depth = 0;
        $inString = false;
        $escapeNext = false;
        $endPos = false;

        for ($i = $startPos; $i < strlen($text); $i++) {
            $char = $text[$i];

            if ($escapeNext) {
                $escapeNext = false;
                continue;
            }

            if ($char === '\\' && $inString) {
                $escapeNext = true;
                continue;
            }

            if ($char === '"' && !$escapeNext) {
                $inString = !$inString;
                continue;
            }

            if ($inString) {
                continue;
            }

            if ($char === $openChar) {
                $depth++;
            } elseif ($char === $closeChar) {
                $depth--;
                if ($depth === 0) {
                    $endPos = $i;
                    break;
                }
            }
        }

        if ($endPos !== false) {
            return substr($text, $startPos, $endPos - $startPos + 1);
        }

        return $text;
    }
}

Utilidad complementaria: Detectar JSON no deseado

A veces ocurre el problema opuesto — le pides al LLM texto plano y devuelve un objeto JSON. Este helper detecta y filtra esos casos:

php
public static function removeJsonIfExists(?string $answer): ?string
{
    if (!$answer) {
        return null;
    }

    $trimmed = trim($answer);

    if (str_starts_with($trimmed, '{') && str_ends_with($trimmed, '}')) {
        $decoded = self::decode($answer);
        if ($decoded !== null) {
            Log::warning('La IA devolvió JSON en lugar de texto plano', [
                'json_response' => $decoded,
            ]);
            return null;
        }
    }

    return $answer;
}

Esto es útil en pipelines de IA conversacional donde la respuesta del modelo debe mostrarse directamente a los usuarios. Si accidentalmente devuelve datos estructurados en lugar de prosa, esto lo detecta antes de que llegue a la interfaz.

Pruebas

Estos son los casos de prueba clave para un conjunto de pruebas completo:

php
use PHPUnit\Framework\TestCase;

class JsonHelperTest extends TestCase
{
    public function test_decodes_clean_json(): void
    {
        $result = JsonHelper::decode('{"key": "value"}');
        $this->assertSame(['key' => 'value'], $result);
    }

    public function test_strips_markdown_fences(): void
    {
        $input = "```json\n{\"key\": \"value\"}\n```";
        $result = JsonHelper::decode($input);
        $this->assertSame(['key' => 'value'], $result);
    }

    public function test_extracts_from_surrounding_text(): void
    {
        $input = "Aquí está el resultado:\n{\"key\": \"value\"}\n¡Espero que esto ayude!";
        $result = JsonHelper::decode($input);
        $this->assertSame(['key' => 'value'], $result);
    }

    public function test_handles_braces_inside_strings(): void
    {
        $input = 'prefijo {"msg": "usa {name} aquí"} sufijo';
        $result = JsonHelper::decode($input);
        $this->assertSame(['msg' => 'usa {name} aquí'], $result);
    }

    public function test_removes_zero_width_spaces(): void
    {
        $input = "{\"key\":\xE2\x80\x8B \"value\"}";
        $result = JsonHelper::decode($input);
        $this->assertSame(['key' => 'value'], $result);
    }

    public function test_strips_utf8_bom(): void
    {
        $input = "\xEF\xBB\xBF{\"key\": \"value\"}";
        $result = JsonHelper::decode($input);
        $this->assertSame(['key' => 'value'], $result);
    }

    public function test_decodes_array_responses(): void
    {
        $input = 'Los elementos son: [1, 2, 3]';
        $result = JsonHelper::decode($input);
        $this->assertSame([1, 2, 3], $result);
    }

    public function test_returns_null_for_empty_input(): void
    {
        $this->assertNull(JsonHelper::decode(null));
        $this->assertNull(JsonHelper::decode(''));
    }

    public function test_returns_null_for_invalid_json(): void
    {
        $this->assertNull(JsonHelper::decode('esto no es json'));
    }

    public function test_remove_json_if_exists_passes_text(): void
    {
        $text = 'Esta es una respuesta normal.';
        $this->assertSame($text, JsonHelper::removeJsonIfExists($text));
    }

    public function test_remove_json_if_exists_catches_json(): void
    {
        $this->assertNull(
            JsonHelper::removeJsonIfExists('{"key": "value"}')
        );
    }
}

TIP

La prueba de espacio de ancho cero usa bytes crudos (\xE2\x80\x8B) en lugar de un escape Unicode porque los strings literales de PHP no soportan escapes \u. Esto también hace visible el carácter invisible en tu archivo de pruebas.

Conclusión

La idea clave detrás de este pipeline es que "parsear y recuperar" supera a "validar y rechazar" en integraciones con IA. Cuando un humano escribe JSON, una malformación usualmente significa un bug que debería fallar ruidosamente. Cuando un LLM escribe JSON, una malformación es ruido esperado que debería limpiarse silenciosamente.

El pipeline de 5 etapas maneja los casos más comunes:

  1. Bloques markdown — extracción con regex
  2. Texto circundante — emparejamiento de llaves con reconocimiento de strings
  3. Caracteres invisibles — limpieza UTF-8 dirigida
  4. Espacios en blanco — trim
  5. Problemas residualesJSON_INVALID_UTF8_IGNORE como red de seguridad

Una advertencia importante: no silencies los fallos en producción. El bloque catch siempre debería registrar la entrada fallida para que puedas identificar nuevos patrones de malformación. El caso borde de hoy es el patrón común de mañana.

Para los casos donde el pipeline aún falla — JSON profundamente malformado, respuestas truncadas por límites de tokens o errores estructurales como claves faltantes — la alternativa correcta es reintentar la llamada al LLM con un prompt correctivo que incluya el mensaje de error. La recuperación maneja el ruido; el reintento maneja los fallos.