Skip to content

json-repair ​

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

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


1. 定位 ​

LLM 被要求输出 JSON 时,实际输出常有格式问题:包了 ```json 代码块、前后夹了一段自然语言解释、字符串里写了没转义的英文引号、尾部多了逗号。直接 JSON.parse 会抛出异常。

被输出上限截断的 JSON 本库不补结尾,照样解析失败;截断处恰好落在一个完整对象之后时又解析得出前半截。截断要看模型插件报的结束原因(api-llm 的 finishReason 为 'length'),消费方见到它就按解析失败处理。

@aalis/util-json-repair 把这些修复策略集中到一处,按「由轻到重」依次尝试,直到 JSON.parse 成功或全部用尽(index.ts)。它是一个纯函数工具库(package.json 的 keywords 含 aalis-util,不经服务容器、不参与 DI)——插件在 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.ts。parsed 永远是「非空对象、非数组、非原始值」或 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):模型会在字符串里直接写英文引号,严格 JSON 要转义。状态机识别字符串里没转义的 ",例如 "message": "他说"你好"然后走了";字符串里的 <face id="14"/> 这类标签属性引号也由这一步转义。
    • 移除尾部多余逗号(index.ts):模型照 JS 习惯在最后一项后面留逗号,严格 JSON 不收:{...,} → {...},只删字符串外的。
  3. 全部用尽仍失败 → parsed: null(index.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';

// 以下在插件 apply 内,logger 取自 uses 解构
const raw = await model.chat(/* ... */); // 模型可能输出 ```json{...}``` + 一段解释
const { parsed, repairsApplied } = parseLLMJsonObject(typeof raw.content === 'string' ? raw.content : '');

if (!parsed) {
  logger.use.warn('LLM 输出无法解析为 JSON 对象');
  return;
}
if (repairsApplied.length > 0) {
  logger.use.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 —— src/index.ts 直接 import 本包:
    • src/index.ts 有 outputFormat 时,extractJsonCandidate + tryParseJsonObject 结构化解析,并把 repairsApplied 写进 debug 日志。
  • @aalis/plugin-user-profile —— src/index.ts import parseLLMJsonObject 解析事实提取结果,是范本:首解析失败或回复报了 finishReason: 'length'(看着完整也不收)后带显式反馈重试一次(写明是截断还是不合法,提示模型「第一个字符必须是 {」),仍失败才放弃本批次。
  • @aalis/plugin-user-relation —— src/extractor.ts import,src/extractor.ts 的 parseExtraction 用 parseLLMJsonObject 解析图谱抽取结果,截断的回复同 user-profile 按解析失败重试。注意它把所有 persons/events/edges 等数组都包在一个顶层对象里(extractor.ts),所以能用本库——这正是「对象内可以有数组、但顶层不能是数组」的正面示范。

5. 边界与注意事项 ​

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

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

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

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

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

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

  • 修复策略基于启发式(裸引号闭合判断靠「下一个非空白字符像不像 JSON 分隔符」,见 index.ts)。极端嵌套或对抗性输入下,补出来的对象可能在语法上合法、语义上却错位。拿到 parsed 后务必按你的 schema 校验字段类型(参考 persona 的 persistStateFromParsed 按 fieldType 强转,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 关键词的语义。

文档对应 dev@c4e046e41,core 0.21.0,构建于 2026-11-02
基于 MIT 许可发布