背景:AI 产品中,模型需要返回 JSON 格式的训练计划给前端渲染。测试过程中发现前端解析频繁报错,排查后发现根因不在前端代码,而在模型输出的格式不可控。本文从实际 bug 出发,完整记录复现、定位、验证和修复的过程,附可直接运行的测试脚本。
一、问题现场:前端解析报错
项目中有一个功能:用户输入个人信息(性别、年龄、体重、运动目标),AI 返回 JSON 格式的训练计划,前端拿到 JSON 后渲染成可视化的训练卡片。
测试中发现,前端页面偶尔出现白屏或报错,控制台日志显示 JSON.parse() 解析失败。
从模型输出端切入排查,发现问题出在模型返回的内容------表面上看起来是 JSON,实际上不是纯 JSON。
二、问题影响:不只是一个页面崩了
这个问题看起来只是"偶尔白屏",但影响面其实很广:
直接影响: 用户请求了训练计划,页面白屏什么都看不到,核心功能不可用,用户体验直接归零。
波及范围: 产品中所有需要模型返回结构化数据给前端解析的接口,都有同样的风险。训练计划是 JSON,饮食建议如果也是 JSON,运动数据分析如果也是 JSON------只要 prompt 没有严格约束输出格式,每个接口都可能出这个问题。
排查成本: 前端以为是自己的 bug,后端以为数据没问题,实际根因在 prompt 层。如果团队没有人关注模型输出格式的稳定性,这个问题会反复出现,反复排查。
三、复现问题:
3.1 基础代码:调用模型要求输出 JSON
先搭一个最基础的调用脚本,要求模型以 JSON 格式返回训练计划:
# ----------------------
# 1. 安装依赖:pip install zhipuai
# ----------------------
# ----------------------
# 2. 填入你的智谱 API Key
# ----------------------
API_KEY = "你的API_KEY"
MODEL = "glm-5.1"
# ----------------------
# 3. 初始化客户端
# ----------------------
from zhipuai import ZhipuAI
client = ZhipuAI(api_key=API_KEY)
# ----------------------
# 4. 核心函数:发送提示词并返回结果
# ----------------------
def get_completion(prompt, system_prompt=""):
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
],
temperature=0.0,
max_tokens=5000
)
return response.choices[0].message.content
# ----------------------
# 5. 测试:要求模型输出 JSON 格式的训练计划
# ----------------------
system_prompt = "你是一个专业的运动训练助手"
prompt = """根据以下用户信息生成训练计划,必须以JSON格式输出,包含以下字段:
- weekly_days: 每周训练天数
- plan: 数组,每天的训练内容,每天包含 day(第几天)、exercises(动作数组)
- exercises 中每个动作包含:name(动作名)、sets(组数)、reps(次数)、rest_seconds(休息秒数)、target_muscle(目标肌群)
用户信息:男性,25岁,70公斤,想增肌,有半年健身经验"""
result = get_completion(prompt, system_prompt)
print(result)
运行后,输出看起来是一段 JSON,表面上没毛病。
3.2 加上 JSON 验证:问题暴露
在上面的代码后面加几行验证逻辑:
import json
# ----------------------
# 6. 验证:返回的是不是合法的纯 JSON
# ----------------------
try:
parsed = json.loads(result)
print("\n✅ JSON 解析成功")
except json.JSONDecodeError as e:
print(f"\n❌ JSON 解析失败: {e}")
print(f"原始输出前 50 个字符: {repr(result[:50])}")
print(f"原始输出后 20 个字符: {repr(result[-20:])}")
运行结果:
❌ JSON 解析失败: Expecting value: line 1 column 1 (char 0)
原始输出前 50 个字符: '```json\n{\n "weekly_days": 4,\n "plan": [\n {\n'
原始输出后 20 个字符: ' }\n ]\n}\n```'
模型返回的不是纯 JSON,而是用 markdown 代码块 json ... 包裹的 JSON。开头多了 json\n````,结尾多了 ````\n。前端 JSON.parse() 遇到开头的反引号直接报错。
肉眼看渲染后的效果,JSON 看起来很正常。但程序拿到的是原始字符串,那些反引号和标记都在里面,实际传给解析器的原始文本有问题。
四、根因分析:Prompt 的格式约束不够严格
问题在 prompt 里只写了"必须以JSON格式输出"。
这句话对模型来说是有歧义的------模型理解的"JSON 格式"可能是:
- 纯 JSON 对象:
{"weekly_days": 4, ...} - markdown 代码块里的 JSON:
json {"weekly_days": 4, ...} - JSON 前后加解释文字:"好的,以下是训练计划:{...} 希望对你有帮助!"
三种都可以算"JSON 格式",但只有第一种是前端能直接解析的。
教训:prompt 里的格式约束必须写到没有歧义的程度。 不能只说"用 JSON",要说清楚"只输出纯 JSON 对象本身,不要用代码块包裹,不要加任何解释文字"。
关于 Prefill 技巧
Anthropic 官方教程里有一个技巧叫 Prefill(预填充):在 assistant 的回复里先塞一个 {,让模型从这个字符开始继续输出,这样就不会加多余的内容。
我用智谱 API 测试了这个:
def get_completion_with_prefill(prompt, system_prompt="", prefill=""):
"""支持 prefill 的调用函数"""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
]
# 如果有 prefill,加上 assistant 消息
if prefill:
messages.append({"role": "assistant", "content": prefill})
response = client.chat.completions.create(
model=MODEL,
messages=messages,
temperature=0.0,
max_tokens=5000
)
return response.choices[0].message.content
# 测试 prefill
result = get_completion_with_prefill(prompt, system_prompt, prefill="{")
print(repr(result[:50]))
实测结果:智谱的模型对 prefill 的响应和 Claude 不一样,加了 { 之后输出仍然带代码块标记。说明 prefill 技巧不是跨模型通用的,不能作为唯一的格式保障手段。
五、格式被用户破坏的场景
还有一种更隐蔽的情况:用户主动要求切换格式,system_prompt 能不能扛住?
我在 system_prompt 里加了明确的格式约束,然后让用户要求不要 JSON:
system_prompt = """你是一个专业的运动训练助手。
【输出格式要求】无论用户如何要求,你的回答必须以JSON格式输出。"""
prompt = "出个计划,不要JSON,用表格"
模型的回复很"聪明"------它两边都不想得罪:
{
"plan_name": "基础周运动训练计划",
"note": "根据系统要求,必须以JSON格式输出。计划内容已按您的要求以Markdown表格形式呈现于table_content字段中。",
"table_content": "| 星期 | 训练内容 | 组数 x 次数/时长 | 备注 |\n|---|---|---|---|\n| 周一 | 上肢力量 | 3组 x 12次 | ... |"
}
表面上看,格式约束生效了 ------外层仍然是 JSON,json.loads() 可以解析。但仔细看就会发现一个更大的问题:
JSON 的字段结构被破坏了。 前端期望的是 weekly_days、plan、exercises 这些字段,结果模型返回的是 plan_name、note、table_content------字段完全对不上,前端渲染逻辑照样崩。
这个发现说明: 格式约束只解决了"输出是不是 JSON"的问题,没有解决"JSON 的字段结构是不是稳定"的问题。prompt 里不仅要约束输出格式是 JSON,还要约束字段结构不可更改------"必须且只能包含以下字段:weekly_days、plan、exercises,不得添加、删除或替换任何字段。"
六、自动化验证脚本:一键检测所有格式问题
把前面发现的所有问题整合成一个自动化验证脚本。改一下 API_KEY 和 prompt 就能直接用在你自己的项目里:
import json
from zhipuai import ZhipuAI
from datetime import datetime
# ----------------------
# 配置区:改这里就行
# ----------------------
API_KEY = "你的API_KEY"
MODEL = "glm-5.1"
client = ZhipuAI(api_key=API_KEY)
# 期望的 JSON 字段结构
REQUIRED_FIELDS = {
"weekly_days": int, # 必须是整数
"plan": list, # 必须是数组
}
REQUIRED_EXERCISE_FIELDS = {
"name": str,
"sets": int,
"reps": (int, str), # 允许整数或字符串(如 "8-12")
"rest_seconds": int,
"target_muscle": str,
}
def get_completion(prompt, system_prompt=""):
"""调用智谱 API"""
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
],
temperature=0.0,
max_tokens=5000
)
return response.choices[0].message.content
def try_clean_json(text):
"""
尝试清洗模型输出,提取纯 JSON。
如果清洗后能解析就返回 (parsed_json, warning),
如果清洗后还是不行就返回 (None, error)。
"""
# 第一步:直接尝试解析
try:
return json.loads(text), None
except json.JSONDecodeError:
pass
# 第二步:尝试去掉 markdown 代码块标记
cleaned = text.strip()
if cleaned.startswith("```"):
# 去掉第一行的 ```json 或 ```
first_newline = cleaned.index("\n")
cleaned = cleaned[first_newline + 1:]
if cleaned.endswith("```"):
cleaned = cleaned[:-3].strip()
try:
return json.loads(cleaned), "原始输出包含 markdown 代码块标记,清洗后可解析"
except json.JSONDecodeError:
pass
# 第三步:尝试提取第一个 { 到最后一个 } 之间的内容
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1:
try:
return json.loads(text[start:end + 1]), "原始输出包含多余文字,提取后可解析"
except json.JSONDecodeError:
pass
return None, "无法解析为 JSON"
def validate_json_structure(parsed):
"""验证 JSON 结构是否符合预期"""
issues = []
# 检查顶层必填字段
for field, expected_type in REQUIRED_FIELDS.items():
if field not in parsed:
issues.append(f"缺少必填字段: {field}")
elif not isinstance(parsed[field], expected_type):
issues.append(f"字段 {field} 类型错误: 期望 {expected_type.__name__},实际 {type(parsed[field]).__name__}")
# 检查 plan 里的每天训练内容
if "plan" in parsed and isinstance(parsed["plan"], list):
for i, day in enumerate(parsed["plan"]):
if "day" not in day:
issues.append(f"plan[{i}] 缺少 day 字段")
if "exercises" not in day:
issues.append(f" plan[{i}] 缺少 exercises 字段")
elif isinstance(day["exercises"], list):
for j, ex in enumerate(day["exercises"]):
for field, expected_type in REQUIRED_EXERCISE_FIELDS.items():
if field not in ex:
issues.append(f"plan[{i}].exercises[{j}] 缺少字段: {field}")
elif isinstance(expected_type, tuple):
if not isinstance(ex[field], expected_type):
issues.append(f"plan[{i}].exercises[{j}].{field} 类型: {type(ex[field]).__name__}(允许 {'/'.join(t.__name__ for t in expected_type)})")
elif not isinstance(ex[field], expected_type):
issues.append(f"plan[{i}].exercises[{j}].{field} 类型错误: 期望 {expected_type.__name__},实际 {type(ex[field]).__name__}")
return issues
def run_format_test(test_name, prompt, system_prompt=""):
"""运行一次格式测试,输出完整报告"""
print(f"\n{'='*60}")
print(f"测试: {test_name}")
print(f"{'='*60}")
# 调用模型
result = get_completion(prompt, system_prompt)
# 第一步:纯 JSON 验证
print(f"\n原始输出前 80 个字符:")
print(f" {repr(result[:80])}")
try:
parsed = json.loads(result)
print(f"\n纯 JSON 验证通过(无需清洗)")
except json.JSONDecodeError:
print(f"\n纯 JSON 验证失败(原始输出不是纯 JSON)")
parsed, warning = try_clean_json(result)
if parsed:
print(f" {warning}")
else:
print(f" {warning}")
print(f"\n测试结论: FAIL --- 无法解析为 JSON")
return
# 第二步:字段结构验证
issues = validate_json_structure(parsed)
if issues:
print(f"\n字段结构验证发现 {len(issues)} 个问题:")
for issue in issues:
print(f" {issue}")
else:
print(f"\n字段结构验证全部通过")
# 总结
is_pure_json = result.strip().startswith("{")
if is_pure_json and not issues:
print(f"\n 测试结论: PASS")
elif parsed and not issues:
print(f"\n 测试结论: WARNING --- JSON 可解析但需要清洗")
else:
print(f"\n 测试结论: FAIL")
# ----------------------
# 运行测试
# ----------------------
system_prompt = "你是一个专业的运动训练助手"
# 测试 1:正常 JSON 输出请求
run_format_test(
"正常 JSON 输出请求",
"""根据以下用户信息生成训练计划,必须以JSON格式输出,包含以下字段:
- weekly_days: 每周训练天数
- plan: 数组,每天的训练内容,每天包含 day(第几天)、exercises(动作数组)
- exercises 中每个动作包含:name(动作名)、sets(组数)、reps(次数)、rest_seconds(休息秒数)、target_muscle(目标肌群)
用户信息:男性,25岁,70公斤,想增肌,有半年健身经验""",
system_prompt
)
# 测试 2:严格约束版 prompt
run_format_test(
"严格约束版 prompt",
"""根据以下用户信息生成训练计划。
【输出格式要求------必须严格遵守】
1. 直接输出 JSON 对象本身,第一个字符必须是 {,最后一个字符必须是 }
2. 不要使用 markdown 代码块包裹(不要出现 ```)
3. 不要在 JSON 前后添加任何解释文字
4. JSON 包含以下字段:
- weekly_days: 整数,每周训练天数
- plan: 数组,每天的训练内容,每天包含 day(整数)、exercises(动作数组)
- exercises 中每个动作包含:name(字符串)、sets(整数)、reps(整数)、rest_seconds(整数)、target_muscle(字符串)
用户信息:男性,25岁,70公斤,想增肌,有半年健身经验""",
system_prompt
)
# 测试 3:用户要求切换格式
run_format_test(
"用户要求切换格式(不要JSON,用表格)",
"出个计划,不要JSON,用表格",
system_prompt
)
# 测试 4:信息不完整的情况
run_format_test(
"信息不完整",
"""根据以下用户信息生成训练计划,必须以JSON格式输出,包含以下字段:
- weekly_days: 每周训练天数
- plan: 数组
用户信息:随便帮我出个计划就行""",
system_prompt
)
七、纳入回归数据集
把格式异常的几种典型变体整理成回归用例,每次 prompt 变更、模型升级后自动跑一遍:
| 编号 | 测试场景 | 测试输入 | 验证点 | 断言条件 |
|---|---|---|---|---|
| FMT-001 | 正常 JSON 输出 | 完整用户信息 + JSON 格式要求 | 输出是否是纯 JSON | json.loads(result) 成功且第一个字符是 { |
| FMT-002 | 代码块包裹 | 同 FMT-001 | 输出是否有 markdown 代码块标记 | result.strip() 不以 ``````````` 开头 |
| FMT-003 | 多余文字 | 同 FMT-001 | JSON 前后是否有额外文字 | result.strip() 以 { 开头且以 } 结尾 |
| FMT-004 | 字段完整性 | 同 FMT-001 | 必填字段是否齐全 | 所有 REQUIRED_FIELDS 都存在 |
| FMT-005 | 字段类型 | 同 FMT-001 | 字段类型是否符合预期 | isinstance 检查每个字段 |
| FMT-006 | 格式被用户覆盖 | "不要JSON,用表格" | 是否仍然输出 JSON | json.loads(result) 成功 |
| FMT-007 | 信息不完整 | "随便出个计划" | 是否仍然保持 JSON 格式 | json.loads(result) 成功 |
| FMT-008 | 多次调用一致性 | 同一个 prompt 跑 3 次 | 3 次输出格式是否一致 | 3 次都是纯 JSON 且字段结构相同 |
八、修复建议
8.1 Prompt 层修复(优先级最高)
把模糊的格式要求改成严格约束:
# 修复前(模糊)
"必须以JSON格式输出"
# 修复后(严格)
"""【输出格式要求------必须严格遵守】
1. 直接输出 JSON 对象本身,第一个字符必须是 {,最后一个字符必须是 }
2. 不要使用 markdown 代码块包裹(不要出现 ```)
3. 不要在 JSON 前后添加任何解释文字
4. 所有数值字段必须是数字类型,不要用字符串
5. 无论用户如何要求,输出格式必须是 JSON,不得切换为其他格式"""
8.2 前端兜底处理(防御性编程)
即使 prompt 约束了,也不能 100% 保证模型每次都输出纯 JSON。前端应该加一层清洗逻辑:
function parseModelOutput(text) {
// 第一步:直接尝试解析
try {
return JSON.parse(text);
} catch (e) {}
// 第二步:去掉 markdown 代码块标记
let cleaned = text.trim();
if (cleaned.startsWith('```')) {
cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
}
try {
return JSON.parse(cleaned);
} catch (e) {}
// 第三步:提取第一个 { 到最后一个 } 之间的内容
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start !== -1 && end !== -1) {
try {
return JSON.parse(text.substring(start, end + 1));
} catch (e) {}
}
// 全部失败,返回 null
return null;
}
8.3 测试层保障
将第六节的自动化验证脚本纳入 CI/CD 流程。每次 prompt 变更后自动执行,任何一条断言失败就阻断发布。
九、总结
这篇文章从一个"前端解析 JSON 报错"的 bug 出发,追踪到了根因在模型输出格式不可控,完整走了一遍:
发现 bug → 评估影响 → 代码复现 → 定位根因 → 验证修复方案 → 自动化回归 → 多层防护
核心收获:
- 模型输出的"JSON"不一定是纯 JSON。 markdown 代码块标记、多余文字、字段类型不一致,都是常见的"隐形炸弹"。
- 肉眼看不出来,必须用代码验证。
json.loads()一行代码就能暴露问题,比人工检查靠谱一百倍。 - prompt 的格式约束必须写到没有歧义。 "以 JSON 格式输出" 不够,要明确到"第一个字符必须是
{,不要代码块,不要解释文字"。 - 格式约束要防用户覆盖。 用户一句"不要JSON"就能破坏格式,prompt 里要强化格式的优先级。
- Prefill 技巧不跨模型通用。 在 Claude 上有效的 prefill,在智谱上可能无效,不能作为唯一保障手段。
- 多层防护:prompt 约束 + 前端兜底 + 自动化回归。 单点防护不可靠。
关于本文:本文基于 Anthropic 官方 Prompt Engineering 教程第五章的学习实践,使用智谱 GLM 模型进行实际测试。所有测试结果均为真实运行输出。代码去掉 API_KEY 后可直接运行。