当大模型返回的 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 的破损模式其实很集中,我统计了线上两个月的错误日志,排名前五的是:

  1. 字段值里的换行符没转义(约占 35%)
  2. 对象或数组末尾多了逗号(约占 20%)
  3. 字符串值未闭合,模型输出被截断(约占 18%)
  4. 数字或布尔值被写成字符串,比如 "true""42"(约占 12%)
  5. 混入了注释或非 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 字符。这个正则我线上跑了三个月没出过误伤案例。