Skip to content

用 PHP 构建健壮的 LLM 响应 JSON 解码方案

简介

你在 prompt 中要求 LLM "以 JSON 格式回复",结果收到的却是这样的内容:

这是你要求的 JSON:

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

如果还需要其他内容请告诉我!


更糟糕的是——JSON 看起来完全正常,但 `json_decode()` 却返回 `null`,因为两个键之间隐藏着一个不可见的零宽空格。又或者响应以一个来自下游 API 的 UTF-8 BOM 开头。

PHP 的 `json_decode()` 在设计上是严格的。它要求完美格式的 JSON——不允许有多余的文本、Markdown 代码块或不可见字符。但 LLM 天生就是"不整洁"的:它们会用代码块包裹 JSON,在前面添加解释性文字,有时还会引入训练数据中的控制字符。

解决方案不是更强硬地要求 LLM。而是构建一个能够从常见畸形输出中优雅恢复的解码流水线。在这篇文章中,我们将构建一个 `jsonDecode()` 方法,用来应对生产环境中 LLM 集成实际遇到的各种问题。

## 恢复流水线

我们的方案是一个五阶段流水线,每个阶段处理一类特定的畸形问题:

原始 LLM 响应 │ ▼ ┌─────────────────────┐ │ 1. Markdown 提取 │ 去除 json 代码块 └─────────┬───────────┘ ▼ ┌─────────────────────┐ │ 2. 括号匹配 │ 找到最外层的 {} 或 [] └─────────┬───────────┘ ▼ ┌─────────────────────┐ │ 3. UTF-8 清理 │ 移除控制字符、BOM、零宽字符 └─────────┬───────────┘ ▼ ┌─────────────────────┐ │ 4. 去除空白 │ 去除首尾空白字符 └─────────┬───────────┘ ▼ ┌─────────────────────┐ │ 5. 解码 │ 带错误标志的 json_decode └─────────────────────┘


每个阶段都是幂等的——如果输入不匹配该阶段的模式(例如没有 Markdown 代码块),则原样传递。这意味着干净的 JSON 会以最小开销直接通过,而畸形的 JSON 则会被逐步修复。

## 阶段 1:Markdown 提取

LLM 经常用 Markdown 代码块包裹 JSON,特别是当它们基于对话数据进行训练时。这种模式足够一致,用一条 regex 就能处理:

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

逐一拆解:

  • ``` — 匹配开头的代码块标记
  • (?:json)? — 可选的 json 语言标识符(非捕获组)
  • \s*\n? — 代码块标记后的灵活空白和可选换行
  • (.*?) — 捕获内部内容(惰性匹配)
  • \n?\s* — 结束标记前的灵活空白
  • ``` — 匹配结尾的代码块标记
  • /s — 点号匹配换行符(对多行 JSON 至关重要)

if 检查意味着只有在确实存在代码块时才会执行。没有代码块的干净 JSON 会原样通过。

阶段 2:括号匹配

去除代码块后,可能仍然存在多余的文本——LLM 的"这是结果:"前言,或者"如果需要修改请告诉我"的后记。我们需要仅提取 JSON 对象或数组。

简单地用 strpos('{')strrpos('}') 的方法会失败,因为 JSON 字符串值中可能包含花括号:

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

我们需要具有字符串感知能力的括号匹配:

php
private static function extractJsonFromText(string $text): string
{
    // 找到第一个 { 或 [
    $firstBrace = strpos($text, '{');
    $firstBracket = strpos($text, '[');

    if ($firstBrace === false && $firstBracket === false) {
        return $text; // 未找到 JSON 结构
    }

    // 判断哪个先出现
    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;
}

该算法逐字符遍历,追踪我们是否在 JSON 字符串内部(此时花括号不计入层级),并处理转义序列(这样 \" 不会切换字符串状态)。当深度回到零时,我们就找到了匹配的闭合括号。

TIP

此方法同时处理对象({})和数组([])。文本中先出现的符号决定了我们要提取的类型。

阶段 3:UTF-8 清理

不可见字符是最隐蔽的问题。JSON 在编辑器中看起来完全正常,但 json_decode() 却拒绝解析。四轮 regex 处理清理最常见的"罪魁祸首":

php
// 第一轮:C1 控制字符 (U+0080–U+009F)
// 这些字符出现在 Windows-1252 编码来源的文本中
$cleanedJson = preg_replace('/\xC2[\x80-\x9F]/u', '', $json);

// 第二轮:零宽字符 (U+200B–U+200F)
// 零宽空格、零宽非连接符、零宽连接符等
// 常见于从网页复制粘贴的文本
$cleanedJson = preg_replace('/\xE2\x80[\x8B-\x8F]/u', '', $cleanedJson);

// 第三轮:UTF-8 BOM (U+FEFF)
// 某些编辑器和 API 会在前面添加字节顺序标记
$cleanedJson = preg_replace('/\xEF\xBB\xBF/u', '', $cleanedJson);

// 第四轮:ASCII 控制字符(tab、换行、回车除外)
// 字符 0x00–0x08、0x0B、0x0C、0x0E–0x1F 和 0x7F
$cleanedJson = preg_replace(
    '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u',
    '',
    $cleanedJson
);

为什么用四轮单独处理而不是一条大 regex?为了可读性和可调试性。当某个字符类引发问题时,你可以禁用某一轮来隔离问题。/u 标志确保每轮处理都将输入视为 UTF-8。

WARNING

不要去除 tab(\x09)、换行符(\x0A)或回车符(\x0D)——这些在 JSON 字符串中是合法的。ASCII 处理轮次明确跳过了它们。

阶段 4-5:带标志的解码

提取和清理之后,我们去除空白并解码:

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
);

两个标志协同工作:

  • JSON_THROW_ON_ERROR — 抛出 JsonException 而不是静默返回 null。这让我们能够以结构化的方式捕获失败,而不需要在每次调用后检查 json_last_error()
  • JSON_INVALID_UTF8_IGNORE — 静默丢弃任何残留的无效 UTF-8 序列,而不是直接失败。这是我们针对清理阶段遗漏字符的安全网。

associative: true 参数返回数组而非 stdClass 对象——这对于按键名访问数据的处理流水线来说是更实用的选择。

实际案例

以下是我们在生产环境 LLM 集成中遇到的真实畸形情况,以及每个流水线阶段如何处理它们:

Markdown 包裹的 JSON

这是分析结果:

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

**阶段 1** 去除代码块 → `{"sentiment": "positive", "confidence": 0.92}` → 解码成功。

### 被文本包围的 JSON

根据对话内容,以下是结构化数据: {"intent": "booking", "date": "2026-03-15", "guests": 4} 请确认这是否正确。


阶段 1 未找到代码块(直接通过)。**阶段 2** 从第一个 `{` 到其对应的 `}` 进行括号匹配 → `{"intent": "booking", "date": "2026-03-15", "guests": 4}` → 解码成功。

### 零宽字符污染

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

"María"和"São"后面有不可见的零宽空格(U+200B)。阶段 1 和 2 直接通过(结构没问题)。阶段 3 移除零宽字符 → 干净的 JSON → 解码成功。

BOM 前缀的响应

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

JSON 前面有一个 UTF-8 BOM(常见于某些 API 中间件)。阶段 3 去除 BOM → {"status": "ok", "data": [1, 2, 3]} → 解码成功。

完整实现

将所有阶段整合到一个可用于生产环境的方法中:

php
class JsonHelper
{
    /**
     * 安全解码 JSON 字符串,具备常见 LLM 畸形输出的恢复能力。
     *
     * 处理:Markdown 代码块、多余文本、控制字符、
     * 零宽空格、BOM 和无效 UTF-8 序列。
     */
    public static function decode(?string $json): ?array
    {
        if (empty($json)) {
            return null;
        }

        try {
            // 阶段 1:去除 Markdown 代码块
            if (preg_match('/```(?:json)?\s*\n?(.*?)\n?\s*```/s', $json, $matches)) {
                $json = $matches[1];
            }

            // 阶段 2:通过匹配最外层括号提取 JSON
            $json = self::extractJsonFromText($json);

            // 阶段 3:移除不可见/控制字符
            $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
            );

            // 阶段 4:去除空白
            $cleaned = trim($cleaned);

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

            // 阶段 5:带安全标志的解码
            return json_decode(
                json: $cleaned,
                associative: true,
                flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
            );
        } catch (\Throwable $e) {
            Log::warning('Failed to decode JSON', [
                'error' => $e->getMessage(),
                'input' => mb_substr($json, 0, 500),
            ]);
        }

        return null;
    }

    /**
     * 通过查找最外层匹配括号从周围文本中提取 JSON。
     */
    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;
    }
}

辅助工具:检测意外的 JSON

有时会遇到相反的问题——你要求 LLM 返回纯文本,它却返回了一个 JSON 对象。这个辅助方法用于检测并过滤这类情况:

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('AI returned JSON instead of plain text', [
                'json_response' => $decoded,
            ]);
            return null;
        }
    }

    return $answer;
}

这在对话式 AI 流水线中非常有用——当模型的回复应该直接展示给用户时。如果它意外返回了结构化数据而非正常文本,这个方法会在数据到达 UI 之前将其拦截。

测试

以下是构建全面测试套件所需的关键测试用例:

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 = "这是结果:\n{\"key\": \"value\"}\n希望对你有帮助!";
        $result = JsonHelper::decode($input);
        $this->assertSame(['key' => 'value'], $result);
    }

    public function test_handles_braces_inside_strings(): void
    {
        $input = 'prefix {"msg": "use {name} here"} suffix';
        $result = JsonHelper::decode($input);
        $this->assertSame(['msg' => 'use {name} here'], $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 = '列表项如下:[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('这根本不是 json'));
    }

    public function test_remove_json_if_exists_passes_text(): void
    {
        $text = '这是一个正常的回复。';
        $this->assertSame($text, JsonHelper::removeJsonIfExists($text));
    }

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

TIP

零宽空格测试使用原始字节(\xE2\x80\x8B)而不是 Unicode 转义,因为 PHP 字符串字面量不支持 \u 转义。这也使得不可见字符在测试文件中变得可见。

结论

这个流水线背后的核心理念是:在 AI 集成场景中,"解析并恢复"优于"验证并拒绝"。当人类编写 JSON 时,格式错误通常意味着 bug,应该大声报错。当 LLM 编写 JSON 时,格式错误是预期内的噪声,应该被静默清理。

五阶段流水线处理了最常见的情况:

  1. Markdown 代码块 — regex 提取
  2. 多余文本 — 具有字符串感知的括号匹配
  3. 不可见字符 — 有针对性的 UTF-8 清理
  4. 空白字符 — trim
  5. 残留问题 — 用 JSON_INVALID_UTF8_IGNORE 作为安全网

一个重要的注意事项:不要在生产环境中静默吞掉失败。catch 块应始终记录失败的输入,以便你能发现新的畸形模式。今天的边缘情况就是明天的常见模式。

对于流水线仍然无法处理的情况——严重畸形的 JSON、因 token 限制导致的截断响应,或缺失键等结构性错误——正确的回退方案是用包含错误信息的纠正性 prompt 重新调用 LLM。恢复处理噪声;重试处理失败。