文章分析了 LLM 输出 JSON 常见的类型错误和 markdown 包裹问题,介绍了 z.coerce、jsonrepair 和 instructor-js 等工具的正确用法和局限性。
你让模型返回 { id: number, active: boolean },但它回来是这样的:
{"id": "42", "active": "true", "role": "admin"}
每个字段都差点意思。id 是字符串。active 是字符串。还多出来一个你根本没要的 role。而且整个东西被包在 markdown 代码围栏里。所以这样写:
const user = User.parse(JSON.parse(raw)); // 💥
会抛两次错——一次在围栏,一次在类型。于是你开始手写补丁:
const unfenced = raw.replace(/```json\n?|\n?```/g, "");
const obj = JSON.parse(unfenced);
obj.id = Number(obj.id);
obj.active = obj.active === "true"; // 还有 "yes"? "on"? "1"?
// ...然后对下一个 schema 再来一遍
这是每个 LLM 集成里没人写博客讲的部分。让我们彻底解决这个问题。
1. z.coerce
Zod 可以强制转换原生类型——z.coerce.number()、z.coerce.boolean()。但你只能逐字段 opt-in,它处理不了围栏或 prose,而且 z.coerce.boolean() 只是调用 Boolean(x)——所以 "false" 会变成 true。更糟的是:它做强制转换时一声不吭。你没法记录到底改了什么。
2. jsonrepair / json-repair
修复破损语法——未闭合的括号、缺失的引号、尾部逗号——方面是把好手。但它们是 schema 盲区。喂进去一个 {"id":"42"} 它原样吐出来,因为 JSON 本身就是有效的。字符串和数字不匹配恰恰就是问题所在,但它看不见。
3. instructor-js 和 re-prompting
验证,失败后把错误发回给模型重试。有效——但你把一个本地几微秒就能完成的 42 → 42 转换,变成了又一次网络往返、更多 token 和不确定的重试次数。
共同的问题:每个工具只管一小块。转换原生类型,或修复语法,或重新 prompt。没有任何一个工具会读你已经有的 schema、强制值去适配它、并告诉你改了哪里。
coerce-json 是一个零依赖的库,接收几乎有效的模型输出,让它适配你的 schema——并报告每一次修复:
import { coerce } from "coerce-json";
import { z } from "zod";
const User = z.object({ id: z.number(), active: z.boolean(), role: z.string().default("user") });
const { value, ok, changes } = coerce('```json\n{"id":"42","active":"true"}\n```', User);
// value → { id: 42, active: true, role: "user" }
// ok → true
// changes → strip-fence, string->number @ id, string->boolean @ active, fill-default @ role
这行代码之所以有分量,是因为三件事:
它感知 schema。 它读取你的 Zod schema(或 JSON Schema,或它自己的核心 spec)并向其做强制转换——围栏被摘掉,"42" 变成数字因为 id 需要数字,缺失的 role 获得了文档中记录的默认值。
它报告每一次修复。 changes 是一个有序的、可审计的日志。强制转换从来不是沉默的黑箱——你可以记录它、警告它、或根据它做门控。
它绝不捏造。 它添加的值只能是文档中记录的 schema 默认值,且每一个都会记录。缺失的可选字段保持缺席,而不是被猜出来填上。
我构建了一个包含 37 个代表性 LLM 错误的语料库——数字写成字符串、"yes" 布尔值、带围栏和 prose 包裹的对象、枚举大小写、缺失默认值。
fuzzy: true:100% 通过(这是一个手建的、用于说明的语料库,不是科学样本。测试框架和完整分解在 BENCHMARKS.md 里,引用数字前先跑一遍。)
它能处理模型实际输出的各种形态:
// prose 包裹的输出
coerce('Sure! Here it is: {"a":"1"}', z.object({ a: z.number() }));
// → { a: 1 } (extract-json,然后 string->number)
// 模型拼写布尔值的各种方式
coerce('{"active":"yes"}', z.object({ active: z.boolean() })); // → { active: true }
// 枚举大小写,始终安全
coerce('{"status":"ACTIVE"}', z.object({ status: z.enum(["active","inactive"]) }));
// → { status: "active" } (大小写不敏感,默认开启)
损耗更大的猜测——枚举近似匹配("activ" → "active")和键名重命名(first_name → firstName)——在 { fuzzy: true } 后面是 opt-in,因为它们可能改变含义。而模糊匹配遇到歧义时会被拒绝,而不是猜一个。
同样的 API,通过可选的 Ajv hook 做权威验证:
import { coerce, coerceWithAjv } from "coerce-json/json-schema";
coerce('{"id":"5","active":"yes"}', {
type: "object",
properties: { id: { type: "integer" }, active: { type: "boolean" }, role: { type: "string", default: "user" } },
required: ["id", "active"],
additionalProperties: false,
});
// → { id: 5, active: true, role: "user" }, ok: true
如果一个库默默重写你的数据,你在 pipeline 里没法信任它。因此 coerce-json 坚守四个不变式,由属性测试保证:
changes 为空当且仅当输出等于输入。__proto__ / constructor / prototype 键会被丢弃并记录。这是 trickle-json 的配套工具,trickle-json 是我用于 LLM 流式输出的增量 partial-JSON 解析器。两者一起是流式结构化输出的主干:
fetch → SSE → parse partial JSON (trickle-json) → repair/coerce to schema (coerce-json) → validate
trickle-json 在每个流式 chunk 上给你最佳可用值而不抛错;coerce-json 让那个值适配你的 schema 并把收据交给你:
import { StreamingJsonParser } from "trickle-json";
import { coerce } from "coerce-json/zod";
const parser = new StreamingJsonParser();
parser.on("snapshot", renderPreview);
for await (const chunk of res.body) parser.write(chunk);
const { value, ok, changes } = coerce(parser.end(), Answer);
if (ok) save(value);
else console.warn("could not fully repair:", changes);
"但提供商的结构化输出已经解决这个问题了!"——它们有帮助,而且帮助很大。但它们覆盖不了本地模型和开源模型、旧版 endpoint、流式 partial 输出,或任何被 prose 或围栏包裹的东西。这恰恰是 coerce-json 的 niche——而且当输出本来就是干净的,它几乎是个空操作,所以留着它很安全。
npm install coerce-json
npm: https://www.npmjs.com/package/coerce-json
GitHub: https://github.com/H1manshu01/coerce-json
零运行时依赖,ESM + CJS,完整类型,发布时附带 provenance。zod 和 ajv 是可选的 peer 依赖——只在用到时才引入。
如果它误处理了不该误处理的输入,开个 issue 附上那个字符串和 schema——changelog 和"绝不捏造"的保证就是全部意义,所以我想知道。觉得有用的话给个 ⭐。