AI 产品输出格式测试实战:为什么模型返回的 JSON 前端解析总报错

背景: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 格式"可能是:

  1. 纯 JSON 对象:{"weekly_days": 4, ...}
  2. markdown 代码块里的 JSON:json {"weekly_days": 4, ...}
  3. 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_daysplanexercises 这些字段,结果模型返回的是 plan_namenotetable_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 → 评估影响 → 代码复现 → 定位根因 → 验证修复方案 → 自动化回归 → 多层防护

核心收获:

  1. 模型输出的"JSON"不一定是纯 JSON。 markdown 代码块标记、多余文字、字段类型不一致,都是常见的"隐形炸弹"。
  2. 肉眼看不出来,必须用代码验证。 json.loads() 一行代码就能暴露问题,比人工检查靠谱一百倍。
  3. prompt 的格式约束必须写到没有歧义。 "以 JSON 格式输出" 不够,要明确到"第一个字符必须是 {,不要代码块,不要解释文字"。
  4. 格式约束要防用户覆盖。 用户一句"不要JSON"就能破坏格式,prompt 里要强化格式的优先级。
  5. Prefill 技巧不跨模型通用。 在 Claude 上有效的 prefill,在智谱上可能无效,不能作为唯一保障手段。
  6. 多层防护:prompt 约束 + 前端兜底 + 自动化回归。 单点防护不可靠。

关于本文:本文基于 Anthropic 官方 Prompt Engineering 教程第五章的学习实践,使用智谱 GLM 模型进行实际测试。所有测试结果均为真实运行输出。代码去掉 API_KEY 后可直接运行。

https://github.com/anthropics/prompt-eng-interactive-tutorial/blob/master/Anthropic%201P/05_Formatting_Output_and_Speaking_for_Claude.ipynb

相关推荐
IT_陈寒1 小时前
SpringBoot自动配置坑了我,原来要这样绕过去
前端·人工智能·后端
东方小月2 小时前
Claude Code 完整上手指南:MCP、Skills、第三方模型配置一次搞定
前端·人工智能·后端
AIFQuant2 小时前
2026 全球股票/外汇/贵金属行情 API 深度对比:延迟、覆盖、价格与稳定性
python·websocket·ai·金融·mcp
EnCi Zheng2 小时前
01d-前馈神经网络代码实现 [特殊字符]
人工智能·深度学习·神经网络
雅斯驰2 小时前
AES-128加密+滚动码认证:ATA5702W如何防御中继攻击与信号重放
运维·单片机·嵌入式硬件·物联网·自动化
阿里云大数据AI技术2 小时前
登顶WorldArena榜单!阿里云PAI助力中科院自动化所、中科第五纪打造具身世界模型FlowWAM
人工智能
hixiong1232 小时前
C# TensorRT部署RF-DETR目标检测&分割模型
人工智能·目标检测·计算机视觉·ai·c#
小程故事多_802 小时前
[大模型面试系列] 深度解析ReAct框架,大模型Agent的“思考+行动”底层逻辑
人工智能·react.js·面试·职场和发展·智能体
逍遥德2 小时前
AI时代,计算机专业大学生学习指南
java·javascript·人工智能·学习·ai编程