Skip to content

Decodificação Robusta de JSON para Respostas de LLM em PHP

Introdução

Você envia um prompt para um LLM com "responda em formato JSON" e recebe de volta:

Aqui está o JSON que você solicitou:

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

Me avise se precisar de mais alguma coisa!


Ou pior ainda — o JSON parece limpo, mas `json_decode()` retorna `null` porque existe um zero-width space invisível escondido entre duas chaves. Ou a resposta começa com um BOM UTF-8 que chegou de uma API intermediária.

O `json_decode()` do PHP é rigoroso por design. Ele espera um JSON perfeitamente formado — sem texto ao redor, sem blocos markdown, sem caracteres invisíveis. Mas LLMs são inerentemente bagunçados: eles envolvem JSON em blocos de código, adicionam texto explicativo antes, e às vezes introduzem caracteres de controle dos seus dados de treinamento.

A solução não é pedir com mais insistência ao LLM. É construir um pipeline de decodificação que recupere graciosamente as malformações mais comuns. Neste artigo, vamos construir exatamente isso — um método `jsonDecode()` que lida com o que integrações de LLM em produção realmente jogam em você.

## O Pipeline de Recuperação

Nossa abordagem é um pipeline de 5 estágios onde cada estágio lida com uma classe específica de malformação:

Resposta Bruta do LLM │ ▼ ┌─────────────────────┐ │ 1. Extração Markdown │ Remover blocos json └─────────┬───────────┘ ▼ ┌─────────────────────┐ │ 2. Casamento de │ Encontrar {} ou [] mais externos │ Delimitadores │ └─────────┬───────────┘ ▼ ┌─────────────────────┐ │ 3. Limpeza UTF-8 │ Remover chars de controle, BOM, zero-width └─────────┬───────────┘ ▼ ┌─────────────────────┐ │ 4. Trim │ Remover espaços em branco ao redor └─────────┬───────────┘ ▼ ┌─────────────────────┐ │ 5. Decodificação │ json_decode com flags de erro └─────────────────────┘


Cada estágio é idempotente — se a entrada não corresponde ao padrão (por exemplo, sem blocos markdown), ela passa sem alterações. Isso significa que um JSON limpo passa direto com overhead mínimo, enquanto um JSON malformado é progressivamente reparado.

## Estágio 1: Extração do Markdown

LLMs frequentemente envolvem JSON em blocos de código markdown, especialmente quando foram treinados com dados conversacionais. O padrão é consistente o suficiente para ser tratado com uma única regex:

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

Quebrando isso:

  • ``` — Corresponde ao delimitador de abertura
  • (?:json)? — Identificador de linguagem json opcional (grupo de não-captura)
  • \s*\n? — Espaço em branco flexível e newline opcional após o delimitador
  • (.*?) — Captura o conteúdo interno (correspondência lazy)
  • \n?\s* — Espaço em branco flexível antes do delimitador de fechamento
  • ``` — Corresponde ao delimitador de fechamento
  • /s — Ponto corresponde a newlines (essencial para JSON multilinha)

A verificação com if significa que isso só é acionado quando os delimitadores estão realmente presentes. JSON limpo sem delimitadores passa sem alterações.

Estágio 2: Casamento de Delimitadores

Após remover os blocos de código, ainda pode haver texto ao redor — o preâmbulo do LLM "Aqui está o resultado:", ou um posfácio "Me avise se precisar de alterações". Precisamos extrair apenas o objeto ou array JSON.

Uma abordagem ingênua de strpos('{') até strrpos('}') falha porque valores JSON podem conter chaves dentro de strings:

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

Precisamos de um casamento de delimitadores que reconheça strings:

php
private static function extractJsonFromText(string $text): string
{
    // Encontra o primeiro { ou [
    $firstBrace = strpos($text, '{');
    $firstBracket = strpos($text, '[');

    if ($firstBrace === false && $firstBracket === false) {
        return $text; // Nenhuma estrutura JSON encontrada
    }

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

    // Rastreia profundidade com reconhecimento 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;
}

O algoritmo percorre caractere por caractere, rastreando se estamos dentro de uma string JSON (onde chaves não contam) e tratando sequências de escape (para que \" não alterne o estado da string). Quando a profundidade retorna a zero, encontramos o delimitador de fechamento correspondente.

TIP

Isso lida tanto com objetos ({}) quanto com arrays ([]). O que aparecer primeiro no texto determina o que estamos extraindo.

Estágio 3: Limpeza UTF-8

Caracteres invisíveis são o problema mais insidioso. O JSON parece perfeito no seu editor, mas json_decode() o rejeita. Quatro passadas de regex limpam os ofensores mais comuns:

php
// Passada 1: Caracteres de controle C1 (U+0080–U+009F)
// Aparecem em texto de fontes codificadas em Windows-1252
$cleanedJson = preg_replace('/\xC2[\x80-\x9F]/u', '', $json);

// Passada 2: Caracteres zero-width (U+200B–U+200F)
// Zero-width space, zero-width non-joiner, zero-width joiner, etc.
// Comuns em texto copiado de páginas web
$cleanedJson = preg_replace('/\xE2\x80[\x8B-\x8F]/u', '', $cleanedJson);

// Passada 3: BOM UTF-8 (U+FEFF)
// Byte Order Mark que alguns editores e APIs adicionam no início
$cleanedJson = preg_replace('/\xEF\xBB\xBF/u', '', $cleanedJson);

// Passada 4: Caracteres de controle ASCII (exceto tab, newline, carriage return)
// Caracteres 0x00–0x08, 0x0B, 0x0C, 0x0E–0x1F e 0x7F
$cleanedJson = preg_replace(
    '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u',
    '',
    $cleanedJson
);

Por que quatro passadas separadas em vez de uma regex gigante? Legibilidade e facilidade de depuração. Quando uma classe de caracteres causa problemas, você pode desabilitar passadas individuais para isolar o problema. A flag /u garante que cada passada trate a entrada como UTF-8.

WARNING

Não remova tabs (\x09), newlines (\x0A) ou carriage returns (\x0D) — estes são válidos dentro de strings JSON. A passada ASCII os pula explicitamente.

Estágios 4–5: Decodificação com Flags

Após a extração e limpeza, removemos espaços em branco e 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
);

Duas flags trabalham juntas aqui:

  • JSON_THROW_ON_ERROR — Lança uma JsonException em vez de silenciosamente retornar null. Isso nos permite capturar falhas de forma estruturada em vez de verificar json_last_error() após cada chamada.
  • JSON_INVALID_UTF8_IGNORE — Silenciosamente descarta quaisquer sequências UTF-8 inválidas restantes em vez de falhar. Esta é nossa rede de segurança para caracteres que sobreviveram às passadas de limpeza.

O parâmetro associative: true retorna arrays em vez de objetos stdClass — uma escolha prática para pipelines de processamento de dados onde você acessa chaves pelo nome.

Exemplos do Mundo Real

Aqui estão as malformações reais que encontramos em integrações de LLM em produção, e como cada estágio do pipeline as trata:

JSON Envolvido em Markdown

Aqui está a análise:

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

O **Estágio 1** remove os delimitadores → `{"sentiment": "positive", "confidence": 0.92}` → decodificado.

### JSON Cercado por Texto

Com base na conversa, aqui estão os dados estruturados: {"intent": "booking", "date": "2026-03-15", "guests": 4} Por favor, confirme se está correto.


O Estágio 1 não encontra delimitadores (passa direto). O **Estágio 2** faz o casamento de delimitadores do primeiro `{` até o `}` correspondente → `{"intent": "booking", "date": "2026-03-15", "guests": 4}` → decodificado.

### Contaminação por Zero-Width

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

Zero-width spaces invisíveis (U+200B) após "María" e "São". Os Estágios 1 e 2 passam direto (a estrutura está correta). O Estágio 3 remove os caracteres zero-width → JSON limpo → decodificado.

Resposta com Prefixo BOM

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

Um BOM UTF-8 precede o JSON (comum em certos middlewares de API). O Estágio 3 remove o BOM → {"status": "ok", "data": [1, 2, 3]} → decodificado.

A Implementação Completa

Juntando todos os estágios em um único método pronto para produção:

php
class JsonHelper
{
    /**
     * Decodifica com segurança uma string JSON com recuperação para
     * malformações comuns de LLM.
     *
     * Lida com: blocos markdown, texto ao redor, caracteres de controle,
     * zero-width spaces, BOM e sequências UTF-8 inválidas.
     */
    public static function decode(?string $json): ?array
    {
        if (empty($json)) {
            return null;
        }

        try {
            // Estágio 1: Remover blocos de código markdown
            if (preg_match('/```(?:json)?\s*\n?(.*?)\n?\s*```/s', $json, $matches)) {
                $json = $matches[1];
            }

            // Estágio 2: Extrair JSON casando os delimitadores mais externos
            $json = self::extractJsonFromText($json);

            // Estágio 3: Remover caracteres invisíveis/de controle
            $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
            );

            // Estágio 4: Remover espaços em branco
            $cleaned = trim($cleaned);

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

            // Estágio 5: Decodificar com flags de segurança
            return json_decode(
                json: $cleaned,
                associative: true,
                flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
            );
        } catch (\Throwable $e) {
            Log::warning('Falha ao decodificar JSON', [
                'error' => $e->getMessage(),
                'input' => mb_substr($json, 0, 500),
            ]);
        }

        return null;
    }

    /**
     * Extrai JSON do texto ao redor encontrando os delimitadores
     * mais externos correspondentes.
     */
    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;
    }
}

Utilitário Complementar: Detectando JSON Indesejado

Às vezes o problema oposto ocorre — você pede texto puro ao LLM e ele retorna um objeto JSON. Este helper detecta e filtra esses 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('IA retornou JSON em vez de texto puro', [
                'json_response' => $decoded,
            ]);
            return null;
        }
    }

    return $answer;
}

Isso é útil em pipelines de IA conversacional onde a resposta do modelo deve ser exibida diretamente aos usuários. Se ele acidentalmente retorna dados estruturados em vez de texto corrido, isso intercepta antes de chegar à interface.

Testes

Aqui estão os principais casos de teste para uma suíte de testes abrangente:

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 = "Aqui está o resultado:\n{\"key\": \"value\"}\nEspero que ajude!";
        $result = JsonHelper::decode($input);
        $this->assertSame(['key' => 'value'], $result);
    }

    public function test_handles_braces_inside_strings(): void
    {
        $input = 'prefixo {"msg": "use {name} aqui"} sufixo';
        $result = JsonHelper::decode($input);
        $this->assertSame(['msg' => 'use {name} aqui'], $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 = 'Os itens são: [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('isso não é json'));
    }

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

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

TIP

O teste de zero-width space usa bytes brutos (\xE2\x80\x8B) em vez de um escape Unicode porque literais de string do PHP não suportam escapes \u. Isso também torna o caractere invisível visível no seu arquivo de teste.

Conclusão

O insight principal por trás deste pipeline é que "parsear e recuperar" supera "validar e rejeitar" para integrações com IA. Quando um humano escreve JSON, malformação geralmente significa um bug que deve falhar ruidosamente. Quando um LLM escreve JSON, malformação é ruído esperado que deve ser limpo silenciosamente.

O pipeline de 5 estágios lida com os casos mais comuns:

  1. Blocos markdown — extração com regex
  2. Texto ao redor — casamento de delimitadores com reconhecimento de strings
  3. Caracteres invisíveis — limpeza UTF-8 direcionada
  4. Espaços em branco — trim
  5. Problemas residuaisJSON_INVALID_UTF8_IGNORE como rede de segurança

Uma ressalva importante: não engula falhas silenciosamente em produção. O bloco catch deve sempre registrar a entrada que falhou para que você possa identificar novos padrões de malformação. O caso extremo de hoje é o padrão comum de amanhã.

Para casos onde o pipeline ainda falha — JSON profundamente malformado, respostas truncadas por limite de tokens, ou erros estruturais como chaves ausentes — o fallback correto é reenviar a chamada ao LLM com um prompt corretivo que inclua a mensagem de erro. Recuperação lida com ruído; retry lida com falhas.