当大模型返回的 JSON 抽风时,前端这样解析还没崩
大模型返回的 JSON 不稳定,本质不是解析问题,是契约问题——但现实是你改不了模型,只能在前端兜底。我的核心策略就三条:先保证页面不崩,再尽量救数据,最后给用户一个能用的降级方案。
下面是我在生产环境踩坑后沉淀的一套容错解析流程,从拦截、修复到降级,每一步都有具体做法。
先拦截:在 JSON.parse 之前把明显的脏数据挡住
结论:别把大模型吐出来的字符串直接扔给 JSON.parse,先做三道预检查,能拦住 80% 的崩。
大模型返回的字符串常见三种脏数据:首尾有废话、混入了 Markdown 代码块标记、字段值里有未转义的控制字符。这些在标准 JSON 解析器里直接抛异常,但修复成本很低。
第一道检查:去掉 Markdown 包裹。很多模型即使你 prompt 里写了「只返回纯 JSON」,还是会给你包一层 json 代码块:
function stripMarkdownCodeBlock(raw: string): string {
// 匹配 ```json ... ``` 或 ``` ... ```
const codeBlockPattern = /^```(?:json)?\s*\n?([\s\S]*?)\n?```$/;
const match = raw.trim().match(codeBlockPattern);
return match ? match[1].trim() : raw.trim();
}
第二道检查:截取 JSON 边界。有时候模型会在 JSON 前面加一句「好的,这是你要的数据:」,或者在后面加「以上是结果」。直接找第一个 { 或 [ 和最后一个对应的闭合符号:
function extractJsonLike(raw: string): string {
const trimmed = raw.trim();
// 尝试找到 JSON 对象的起止位置
const firstBrace = trimmed.indexOf('{');
const firstBracket = trimmed.indexOf('[');
let start = -1;
let end = -1;
if (firstBrace !== -1 && (firstBrace < firstBracket || firstBracket === -1)) {
start = firstBrace;
end = trimmed.lastIndexOf('}');
} else if (firstBracket !== -1) {
start = firstBracket;
end = trimmed.lastIndexOf(']');
}
if (start !== -1 && end !== -1 && end > start) {
return trimmed.slice(start, end + 1);
}
return trimmed;
}
第三道检查:扫描控制字符。JSON.parse 对字符串值里的换行符 \n、制表符 \t 容忍度很低——如果这些字符没有正确转义就直接出现在字符串字面量里,解析必崩。在尝试解析之前,先对字符串值区间做一次正则扫描。不过这一步可以放在修复阶段统一处理,下面会讲。
这三道检查之后,基本能拿到一个「看起来像 JSON」的字符串。
再修复:用渐进式策略抢救残缺 JSON
结论:不要试图写一个万能 JSON 修复器,按常见破损模式分层处理,命中率高且可控。
大模型 JSON 的破损模式其实很集中,我统计了线上两个月的错误日志,排名前五的是:
- 字段值里的换行符没转义(约占 35%)
- 对象或数组末尾多了逗号(约占 20%)
- 字符串值未闭合,模型输出被截断(约占 18%)
- 数字或布尔值被写成字符串,比如
"true"或"42"(约占 12%) - 混入了注释或非 JSON 文本片段(约占 10%)
针对这些模式,我实现了一个 repairJSON 函数,按顺序尝试修复:
function repairJSON(raw: string): string {
let repaired = raw;
// 1. 去除尾逗号:匹配 , 后面紧跟 } 或 ]
repaired = repaired.replace(/,\s*([}\]])/g, '$1');
// 2. 修复未转义的控制字符:在字符串值内部替换裸换行符
// 思路:找到所有引号对,对其内容做转义
repaired = repaired.replace(
/"((?:[^"\\]|\\.)*)"/g,
(match, content) => {
// 替换裸控制字符为转义形式
const escaped = content
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t');
return `"${escaped}"`;
}
);
// 3. 补全截断的字符串:如果最后一个引号前的内容是未闭合的
// 检测模式:倒数第二个 " 后面没有对应的闭合 "
const quoteCount = (repaired.match(/"/g) || []).length;
if (quoteCount % 2 !== 0) {
// 奇数个引号,可能在末尾补一个
repaired = repaired + '"';
}
// 4. 补全未闭合的括号
const openBraces = (repaired.match(/{/g) || []).length;
const closeBraces = (repaired.match(/}/g) || []).length;
const openBrackets = (repaired.match(/\[/g) || []).length;
const closeBrackets = (repaired.match(/\]/g) || []).length;
for (let i = 0; i < openBraces - closeBraces; i++) repaired += '}';
for (let i = 0; i < openBrackets - closeBrackets; i++) repaired += ']';
return repaired;
}
这个修复器的顺序很重要。先去尾逗号,再处理字符串内容,最后补括号——因为补括号操作可能把之前没处理干净的问题掩盖掉。每步都是独立的正则替换,性能开销可以忽略不计。
实际使用时,我会用 try-catch 套一个循环:先尝试直接解析,失败后用 repairJSON 修复再试,还失败就进入降级策略。不要无限制重试,两次足够——第一次裸解析,第二次修复后解析,两次全挂说明数据已经烂到不值得救了。
降级展示:解析失败时给用户一个能用的界面
结论:JSON 解析挂了不代表功能挂了,降级方案要保证核心信息可达。
这里的关键是区分两类场景:一是拿到了数据但格式不对,二是完全没拿到数据。前者需要尽力提取部分信息,后者需要用默认值保持界面完整。
我处理降级的策略是一个 safeParse 包装函数,它永远不抛异常,而是返回一个带状态标记的结果对象:
interface ParseResult<T> {
data: T | null;
partial: boolean; // 是否只解析出了部分数据
errors: string[]; // 解析过程中遇到的问题
raw: string; // 保留原始字符串,方便调试
}
function safeParse<T>(
raw: string,
fallback: T,
fieldExtractors?: Record<string, (raw: string) => unknown>
): ParseResult<T> {
const errors: string[] = [];
// 预处理
let cleaned = stripMarkdownCodeBlock(raw);
cleaned = extractJsonLike(cleaned);
// 第一轮:直接解析
try {
const data = JSON.parse(cleaned) as T;
return { data, partial: false, errors, raw };
} catch (e) {
errors.push(`直接解析失败: ${(e as Error).message}`);
}
// 第二轮:修复后解析
const repaired = repairJSON(cleaned);
try {
const data = JSON.parse(repaired) as T;
return { data, partial: true, errors, raw };
} catch (e) {
errors.push(`修复后解析仍失败: ${(e as Error).message}`);
}
// 第三轮:字段级提取
if (fieldExtractors) {
const partialData: Record<string, unknown> = {};
for (const [key, extractor] of Object.entries(fieldExtractors)) {
try {
partialData[key] = extractor(cleaned);
} catch {
partialData[key] = null;
}
}
const data = { ...fallback, ...partialData } as T;
return { data, partial: true, errors, raw };
}
// 最终兜底
return { data: fallback, partial: false, errors, raw };
}
fieldExtractors 是最后的救命稻草——当整个 JSON 结构崩了,但你知道某些关键字段大概会以什么形式出现时,直接用正则去原始字符串里捞。举个例子,如果大模型应该返回 {"name": "...", "score": ...},但 JSON 烂了,你可以这样提取:
const extractors = {
name: (raw: string) => {
const match = raw.match(/"name"\s*:\s*"([^"]+)"/);
return match ? match[1] : '未知';
},
score: (raw: string) => {
const match = raw.match(/"score"\s*:\s*(\d+(\.\d+)?)/);
return match ? Number(match[1]) : 0;
},
};
这个方案的核心思想是逐级降级,永不崩溃。用户在界面上最多看到「部分数据可能不准确」的提示,而不是一个白屏或者「系统错误」的弹窗。
把完整流程嵌入请求层
以上逻辑不应该散落在各个组件里,最好封装成一个请求中间件。我在项目里用了一个自定义的 fetch 包装,对每个大模型 API 响应自动走这套流程:
async function fetchLLM<T>(
url: string,
options: RequestInit,
config: {
fallback: T;
fieldExtractors?: Record<string, (raw: string) => unknown>;
}
): Promise<ParseResult<T>> {
const response = await fetch(url, options);
const raw = await response.text();
const result = safeParse(raw, config.fallback, config.fieldExtractors);
// 上报解析异常,方便后续优化 prompt 或修复规则
if (result.errors.length > 0) {
reportParseError({
url,
errors: result.errors,
raw: result.raw,
timestamp: Date.now(),
});
}
return result;
}
异常上报很重要。我一开始觉得修复了就完事了,后来发现每次模型升级或换模型版本,破损模式都会变——GPT-4-turbo 和 GPT-4o 的 JSON 输出习惯就不一样,前者喜欢多打逗号,后者偶尔会在长字符串里夹裸换行。持续收集错误日志才能及时调整修复规则。
常见问题
为什么不直接用 JSON5 或者 jsonrepair 这类库?
jsonrepair 库确实好用,体积也不大(压缩后约 15KB),处理尾逗号、注释、单引号这些常见问题很稳。但它对大模型特有的问题覆盖不全——比如字符串值里的裸换行符,jsonrepair 在某些版本(我测试的 3.6.1)下会直接跳过不处理,解析照样崩。我的建议是先用 jsonrepair 做第一道通用修复,再用自己的针对性补丁处理模型特有的坑。JSON5 解析器同理,它能容忍尾逗号和注释,但遇到未转义的控制字符一样跪。
这套修复逻辑的性能开销有多大?高频调用会不会成为瓶颈?
正则处理几 KB 的字符串,在现代 JS 引擎上耗时在 1ms 以内。我实际测过一个 4.7KB 的 JSON 字符串,走完 stripMarkdownCodeBlock + extractJsonLike + repairJSON 三连,平均耗时 0.6ms(Chrome 120, M1 Pro)。唯一可能有开销的是 fieldExtractors 里的复杂正则回溯,但只要别写灾难性回溯的正则就行。如果你同时并发几十个大模型请求,这点开销可以忽略不计,真正慢的是网络 IO。
修复规则会不会误伤正常 JSON?比如把字符串里本来的 \n 又转义了一遍?
代码里那行 content.replace(/\n/g, '\\n') 处理的是引号对之间的原始内容,但正则 /"((?:[^"\\]|\\.)*)"/g 里有一个关键限制:[^"\\] 会跳过已经被反斜杠转义的字符。也就是说,如果原始 JSON 里写的是 "line1\\nline2",这里的 \n 前面有反斜杠,会被 \\. 分支匹配走,不会进入 content 再次转义。真正被捕获的是裸换行符,即字面意义上的 ASCII 10 字符。这个正则我线上跑了三个月没出过误伤案例。