Skip to content

json-repair

受众:写「让 LLM 按 JSON 格式回话、再把回话解析成结构化对象」的第三方插件作者(人设输出格式、信息抽取、状态持久化等)。 本文讲清如何从一坨脏乱的模型输出里把一个 JSON 对象捞出来、并尽力修复常见格式错误。

全部断言以代码为准并标注 file:line。包名 @aalis/util-json-repair,源码全在 packages/util-json-repair/src/index.ts


1. 定位

LLM 被要求输出 JSON 时,实际产出经常带毛病:包了 ```json 代码块、前后夹了一段自然语言解释、字符串里写了没转义的英文引号、被 max_tokens 截断少了结尾的 }、尾部多了逗号。直接 JSON.parse 必炸。

@aalis/util-json-repair 把这些修复策略集中到一处,按「由轻到重」依次尝试,直到 JSON.parse 成功或全部用尽(index.ts)。它是一个纯函数工具库aalis-util keyword,无 ctx、无 DI,见 package.json"aalis": { "util": true })——插件在 package.json 里依赖它、直接 import 函数即可。

最重要的边界(先记住):本库只解析顶层 JSON 对象 {...}。顶层是 JSON 数组 [...] 的输出会被判为失败(返回 null)。详见 §5。


2. 导出 API

三个函数 + 一个结果类型。三者递进:extractJsonCandidate(提取子串)→ tryParseJsonObject(容错解析)→ parseLLMJsonObject(两步合一)。

2.1 RepairResult

ts
export interface RepairResult {
  /** 解析得到的对象;解析全部失败时为 null。 */
  parsed: Record<string, unknown> | null;
  /** 命中的修复步骤名称(按顺序累积)。直接解析成功则为空数组。 */
  repairsApplied: string[];
}

index.tsparsed 永远是「非空对象、非数组、非原始值」或 null,二选一;repairsApplied 让你能在日志里看到到底动了哪些修复(调试模型输出质量很有用)。

2.2 extractJsonCandidate(raw: string): string

index.ts。从模型原始输出里提取 JSON 子串,不做解析。行为:

  1. 去掉 ```json / ``` 围栏(index.ts)。
  2. 扫描所有顶层 {,取最后一个「括号配平且含 :」的对象(index.ts)。
    • 取最后一个:模型常先输出推理/自由文本、最后才吐 JSON payload。
    • 必须含 ::排除数学集合写法 {1,2,3} 这类无 key:value 的伪对象(index.ts)。
    • 只扫顶层:找到配平对象后跳到其末尾之后,不把嵌套的内层 {} 当候选(index.ts)。
  3. 无合格候选时降级:从第一个 { 取配平片段,配不平则直接截到末尾(留给后续修复步骤补 },见 index.ts)。

返回的是字符串(可能仍不是合法 JSON),交给 tryParseJsonObject 收尾。

2.3 tryParseJsonObject(jsonStr: string): RepairResult

index.ts。对已经是 JSON 子串的文本做容错解析

  1. 先按原文 JSON.parse;成功且是对象则直接返回,repairsApplied: []index.ts)。
  2. 失败则依次叠加修复策略(index.ts),每命中一个就重试一次解析:
    • 字符串内部裸引号转义index.ts):状态机识别字符串里没转义的 ",例如 "message": "他说"你好"然后走了"
    • XML 属性引号转义index.ts):把 <face id="14"/> 这类标签属性里的引号转义。
    • 移除尾部多余逗号index.ts):{...,}{...}
    • 补全缺失的 }]index.ts):截断少括号时,在字符串外统计缺口并补齐(先补 ] 再补 })。
  3. 全部用尽仍失败 → parsed: nullindex.ts)。

关键约束:tryParse 内部显式拒绝数组与原始值——只有 typeof obj === 'object' && !Array.isArray(obj) 才算成功(index.ts)。

2.4 parseLLMJsonObject(raw: string): RepairResult

index.ts。一站式入口,等价于 tryParseJsonObject(extractJsonCandidate(raw))绝大多数插件直接用这个:传入模型原始 content,拿回 RepairResult


3. 用法示例

ts
import { parseLLMJsonObject } from '@aalis/util-json-repair';

const raw = await model.chat(/* ... */); // 模型可能输出 ```json{...}``` + 一段解释
const { parsed, repairsApplied } = parseLLMJsonObject(typeof raw.content === 'string' ? raw.content : '');

if (!parsed) {
  ctx.logger.warn('LLM 输出无法解析为 JSON 对象');
  return;
}
if (repairsApplied.length > 0) {
  ctx.logger.debug(`JSON 自动修复成功:${repairsApplied.join(' → ')}`);
}
// parsed 此时一定是 Record<string, unknown>
const reply = typeof parsed.response === 'string' ? parsed.response : '';

若你已经自己剥过围栏、只想做容错解析,跳过提取直接用 tryParseJsonObject(jsonStr) 即可。


4. 谁在用(真实消费点)

依赖声明:plugin-persona / plugin-user-profile / plugin-user-relation 三个插件的 package.json 都依赖本包。

  • @aalis/plugin-persona —— 保留了一个 re-export 兼容壳 src/json-repair.ts(老代码走相对 import,新代码应直接 import 本包)。实际用法在 src/index.ts
    • src/index.tsoutputFormat 时,用 tryParseJsonObject 自动解包模型误用 JSON 包裹的回复,逐个尝试 response/reply/content 等字段(index.ts)。
    • src/index.tsoutputFormat 时,extractJsonCandidate + tryParseJsonObject 结构化解析,并把 repairsApplied 写进 debug 日志。
  • @aalis/plugin-user-profile —— src/index.ts import parseLLMJsonObject,在 src/index.ts 三处解析画像抽取结果。其中 978-1006 是范本:首解析失败后带显式反馈重试一次(提示模型「第一个字符必须是 {」),仍失败才放弃本批次。
  • @aalis/plugin-user-relation —— src/extractor.ts import,src/extractor.tsparseExtractionparseLLMJsonObject 解析图谱抽取结果。注意它把所有 persons/events/edges数组都包在一个顶层对象里extractor.ts),所以能用本库——这正是「对象内可以有数组、但顶层不能是数组」的正面示范。

5. 边界与坑

5.1 顶层只认对象,不认数组(最重要)

tryParseJsonObject 把顶层数组视为解析失败:!Array.isArray(obj) 直接把数组结果归零成 nullindex.ts)。原始值(字符串/数字/true/null)同样返回 null

这不是疏漏,是设计取舍:本库面向「让模型回一个结构化对象」的主流场景,且 §2.2 的提取逻辑全程围绕 { 配平展开(index.ts),对 [...] 无能为力。

反例 / 为什么有插件不复用本库@aalis/plugin-user-relation 的实体层级推断把模型 prompt 成「只输出 JSON 数组」(consolidate-llm.ts),因此它自己维护了一个独立解析器 tryParseJsonconsolidate-llm.ts,返回 unknown),并在 consolidate-llm.tsArray.isArray(parsed) 校验。如果硬塞给 parseLLMJsonObject,会直接拿到 null

经验法则:需要顶层数组 → 不要用本库,自己 JSON.parse 或包一层对象({ "items": [...] })。其余顶层对象场景一律走 parseLLMJsonObject

5.2 修复是「尽力而为」,不是保证正确

  • 修复策略基于启发式(裸引号闭合判断靠「下一个非空白字符像不像 JSON 分隔符」,见 index.ts)。极端嵌套或对抗性输入下,补出来的对象可能在语法上合法、语义上却错位。拿到 parsed 后务必按你的 schema 校验字段类型(参考 persona 的 persistStateFromParsedfieldType 强转,index.ts)。
  • repairsApplied 非空意味着模型这次输出不规范。建议像消费者那样落 debug/warn 日志,长期可作为「该调 prompt 了」的信号。

5.3 与 @aalis/util-text-normalize 的分工

util-text-normalize/src/index.ts 明确划界:本库处理「被 prompt 要求输出 JSON 的 LLM 响应」的解析问题;util-text-normalize 处理的是另一类文本归一化问题。要解析模型 JSON 用本库,不要混用。

5.4 解析失败时不要静默丢数据

parsed === null 是常态而非异常(模型偶发跑偏)。好的处理:带反馈重试一次(user-profile index.ts 范本),仍失败再降级/放弃,并 warn 出原文前若干字便于排查——不要直接吞掉。


6. 交叉链接

  • 消息 → LLM 管线 —— 模型 content 从哪来、Message/ContentSegment 形态;本库消费的就是这里产出的字符串内容。
  • 服务模型 —— util 与 service 的区别:util 是零服务纯函数库(直接 import),不经 DI。
  • Manifest 元数据 —— aalis-util keyword / aalis.util 标记的语义。