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 residuais — JSON_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.