用 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 字符串值中可能包含花括号:
{"message": "Use {name} as placeholder"}我们需要具有字符串感知能力的括号匹配:
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 处理清理最常见的"罪魁祸首":
// 第一轮: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:带标志的解码
提取和清理之后,我们去除空白并解码:
$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]} → 解码成功。
完整实现
将所有阶段整合到一个可用于生产环境的方法中:
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 对象。这个辅助方法用于检测并过滤这类情况:
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 之前将其拦截。
测试
以下是构建全面测试套件所需的关键测试用例:
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 时,格式错误是预期内的噪声,应该被静默清理。
五阶段流水线处理了最常见的情况:
- Markdown 代码块 — regex 提取
- 多余文本 — 具有字符串感知的括号匹配
- 不可见字符 — 有针对性的 UTF-8 清理
- 空白字符 — trim
- 残留问题 — 用
JSON_INVALID_UTF8_IGNORE作为安全网
一个重要的注意事项:不要在生产环境中静默吞掉失败。catch 块应始终记录失败的输入,以便你能发现新的畸形模式。今天的边缘情况就是明天的常见模式。
对于流水线仍然无法处理的情况——严重畸形的 JSON、因 token 限制导致的截断响应,或缺失键等结构性错误——正确的回退方案是用包含错误信息的纠正性 prompt 重新调用 LLM。恢复处理噪声;重试处理失败。

