实际案例:模型正确返回了 JSON,但因包装在 markdown 代码块中导致 JSON.parse 失败,问题根源在解析层而非模型。
几天前,我的副项目机器人每隔几分钟就崩溃一次。日志全部指向同一行代码:一处 JSON.parse 调用突然拒绝了模型的输出。HTTP 状态码是 200,内容字段也有数据,然而解析器在机器人能用上一个字之前就抛出了异常。我的第一反应是怪模型,而这个反应完全错了。
这个机器人跑在 MonkeyCode 的免费服务器上,模型调用走的是 MonkeyCode 的免费模型访问。免责声明:本文是 MonkeyCode 产品推广内容的一部分。在那样的环境下,你根本浪费不起 tokens 来盲目调试。于是我放慢脚步,捕获了原始响应,发现了一个跟模型毫无关系的 bug。
错误日志是这样的,在十几条消息中反复出现:
SyntaxError: Unexpected token '`', ..."```
json\n{ ... }"... is not valid JSON
at JSON.parse (<anonymous>)
at parseSummary (bot.js:42)
每一次失败都指向同一行,每一次失败都发生在一次成功的 HTTP 交互之后。机器人调用了模型,模型也回答了,然后我的解析器在把回答转成对象时崩溃了。我多少次因为一个实际存在于自己代码中的 bug 而怪罪模型?
我反复学到的一条规则是:你无法通过盯着解析器来调试解析器。我写了一个小小的复现脚本,调用同一个端点。它把原始响应体保存到一个文件里,不做任何转换直接打印出来。
// repro.mjs
import fs from "node:fs";
const res = await fetch(process.env.MODEL_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: process.env.MODEL_NAME,
messages: [{ role: "user", content: "Return a JSON summary." }]
})
});
const raw = await res.text(); // no .json() yet
console.log("HTTP", res.status);
console.log(raw);
fs.writeFileSync("fixture.txt", raw);
三分钟后答案一目了然。模型完全按我说的做了:它产生了有效的 JSON。然后它把那个 JSON 包进了一个 Markdown 代码围栏——这正是聊天模型喜欢做的事。我的解析器要求的是赤裸 JSON,所以它拒绝了一个完全可用的回答。
HTTP 200
```json
{"title":"Why parsers fail","bullets":["trust the status code","ignore the shape"]}
`
## 根本原因:我信任了 response.ok 跳过了验证
这里是让人难受的部分。HTTP 状态码是 200,choices[0].message.content 字段有数据,我的代码把这两个事实当作可用回答的证明。但这两个事实哪个都没说明内容是否是可以解析的 JSON,更不用说它是否包含机器人需要的字段。
```javascript
// before: the fragile version
const data = await res.json();
const content = data.choices[0].message.content;
const summary = JSON.parse(content); // boom
真正的 bug 不是代码围栏;而是假设成功的 HTTP 响应能保证成功的应用层结果。这个假设让解析器变得脆弱,并且把问题藏了起来,直到真实用户触发它。状态码只告诉你服务器接受了请求;它没有说明负载的含义。
这次修复有三个层次,只有第一个层次碰了解析器。首先,我写了一个小小的提取器,能够容忍 JSON 周围的围栏和零散 prose。其次,我加了形状检查,这样一个有效但错误的对象不会静默通过。第三,我让失败大声暴露出来,而不是吞掉它。
// after: tolerant extraction
function extractJson(text) {
if (!text) throw new Error("empty model output");
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
const candidate = fenced ? fenced[1] : text;
const start = candidate.indexOf("{");
const end = candidate.lastIndexOf("}");
if (start === -1 || end <= start) {
throw new Error("no JSON object found in model output");
}
return JSON.parse(candidate.slice(start, end + 1));
}
// after: shape validation
function parseSummary(content) {
const parsed = extractJson(content);
if (typeof parsed.title !== "string" || parsed.title.length === 0) {
throw new Error("summary is missing a non-empty title");
}
if (!Array.isArray(parsed.bullets) || parsed.bullets.length === 0) {
throw new Error("summary is missing bullets");
}
return parsed;
}
提取器找到第一个 { 和最后一个 },一次遍历就处理了围栏、前导文字和尾部注释。验证器随后检查机器人其余部分依赖的字段。现在一个语法有效但形状错误的对象会快速失败,而不是在下游产生垃圾。
我把原始的失败响应存为 fixture,写了一个小的测试矩阵。真正的 bug 不是围栏;而是我的假设——模型总是会输出一种精确的形状。以下是现在在机器人 CI 中运行的矩阵:
# fixture cases (input -> expected)
{"title":"x","bullets":["a"]} -> parsed
```json\n{"title":"x","bullets":["a"]}\n``` -> parsed
Here you go:\n{"title":"x","bullets":["a"]} -> parsed
{"title":"x","bullets":["a"]}\nHope this helps! -> parsed
{"bullets":["a"]} -> rejected: missing title
{"title":"x"} -> rejected: missing bullets
{"title":"x","bullets":["a"] -> rejected: truncated JSON
Sorry, I cannot do that. -> rejected: no JSON found
`
每一行都映射到我见过的真实模型输出中的一种失败模式,测试文件现在是机器人 CI 的一部分。如果解析器再次坏掉,它会在中午的测试运行中坏掉,而不是凌晨三点在机器人里坏掉。
这个具体的 bug 很小,但找到它的工作流值得保留:
在解析之前先打印原始响应。保存到文件;永远不要为了调试解析器而重新跑模型,因为下一次响应会不一样。
检查形状,不只是状态码。HTTP 200 加上非空内容不是一份契约,把它当作契约会掩盖真正的 bug。
用最小的脚本复现。我的复现大概十五行,三分钟就找到了根本原因。
把失败转成 fixture。这个咬过你的 bug 会变成永远保护你的测试。
宽容解析是一块创可贴,不是契约。如果你的应用需要一个保证的模式,请用结构化输出或 function calling。不要在 prose 里问模型要 JSON 然后祈祷得到最好的结果。
这个提取器也假设是对象而不是数组;如果你的用例不同请调整分隔符。当提取器抛出异常时,你仍然需要一个重试策略或人工兜底,因为没有任何解析器能修复真正损坏的模型输出。如果你在构建支付流程或任何字段错误比崩溃更糟糕的系统,请大声失败而不是猜测。在错误的地方使用宽容解析可以把一个可见的错误变成静默的数据损坏。
下次模型调用出问题的时候,在怪任何人之前先打印原始响应。这一条习惯救了我今年比任何其他技巧都多的调试时间。它可能也会救你的。