Skip to content

引言

2023 年的一个深夜,一家 AI 创业公司的值班工程师被 PagerDuty 的警报吵醒。他们的核心管线——一个用 ChatGPT 分析新闻情感并自动交易股票的系统——崩溃了。

排查日志,错误堆栈指向一行代码:json.loads(model_output)

模型的输出是这样的:

json
{
  "sentiment": "positive",
  "confidence": 0.85,
  "summary": "科技公司业绩超预期"
}

看起来没问题?仔细看——summary 后面多了一个逗号。合法的 JSON 不能在最后一个字段后面有逗号。json.loads() 解析失败,整条管线崩了。

一个多余的逗号,让公司损失了一整晚的交易时间。

这是大模型应用开发中最普遍的痛点:模型输出的是自然语言,而你的代码需要的是结构化数据。这两者之间的鸿沟,是一切痛苦的根源。


问题:模型输出不可预测

你说「输出 JSON」,模型理解成「尽量输出 JSON」

你说:输出 JSON 格式
模型理解:大概输出一个像 JSON 的东西

实际可能出现的问题:
  1. 输出 "Here is the JSON:\n{...}"(多了前缀文字)
  2. 输出 {key: "value"}(key 没有引号,不是合法 JSON)
  3. 输出 {...} 然后加一段 "以上是分析结果"(多了后缀)
  4. 输出 {...,"items": [1,2,3],}(多了尾逗号)
  5. 输出 {...} 这里其实想输出字符串,但 JSON 里嵌套了新 JSON
  6. 什么格式都对,但字段名和之前不一样(不稳定)

早期解决方案:手工修补

python
import json
import re

def extract_json_from_llm(text: str) -> dict:
    """从模型输出中提取 JSON 的各种黑科技"""
    # 方法 1:直接解析
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        pass

    # 方法 2:找第一个 { 和最后一个 }
    try:
        start = text.index("{")
        end = text.rindex("}") + 1
        return json.loads(text[start:end])
    except (ValueError, json.JSONDecodeError):
        pass

    # 方法 3:用正则提取
    json_pattern = r'```(?:json)?\s*([\s\S]*?)\s*```'
    match = re.search(json_pattern, text)
    if match:
        try:
            return json.loads(match.group(1))
        except json.JSONDecodeError:
            pass

    # 方法 4:尝试修复常见问题
    text = text.rstrip(",")  # 去掉尾逗号
    text = re.sub(r',\s*}', '}', text)  # 去掉 } 前的逗号
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        pass

    raise ValueError(f"无法从模型输出中提取 JSON: {text[:100]}...")

这种「打补丁」的方式:

  • 脆弱:模型换个花样输出就又崩了
  • 复杂:代码里一堆 try-except 和正则
  • 不可靠:总有新的边界情况

JSON Mode:API 级别的解决方案

GPT-4 的 json_object 模式

2023 年下半年,OpenAI 在 API 中加入了 JSON Mode:

python
from openai import OpenAI

client = OpenAI()

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": "你是一个数据分析助手。始终以 JSON 格式输出。"},
        {"role": "user", "content": "分析以下评论的情感:这家餐厅太好吃了!"}
    ],
    response_format={"type": "json_object"}  # ← 关键参数
)

result = json.loads(response.choices[0].message.content)
# 保证是合法 JSON!

JSON Mode 的工作原理

普通模式:
  模型生成 → 任意文本 → 你来解析(可能失败)

JSON Mode:
  模型生成 → 约束为合法 JSON → 保证可以解析

技术原理(简化):
  在 token 生成时,模型只选择符合 JSON 语法的 token
  例如:
    生成 "{" 之后,下一个 token 只能是字符串 key 或 "}"
    生成 "key": 之后,下一个 token 必须是值
    生成字符串值之后,只允许 "," 或 "}"

JSON Mode 的注意事项

必须做的事:
  ✓ 在 system 或 user 消息中明确指示「输出 JSON」
    (虽然 response_format 已经设定了,但不说的话模型可能输出 {"message": "好的"})
  ✓ 用 json.loads() 解析结果(保证是合法 JSON)

不能做的事:
  ✗ 不能保证 JSON 的 schema(字段名、类型)
    JSON Mode 只保证语法合法,不保证结构正确
  ✗ 不能保证特定字段一定存在
  ✗ 输出可能是一个完全空的对象 {}

JSON Mode 的局限

python
# 你想要的结构:
{
    "sentiment": "positive",
    "confidence": 0.85,
    "keywords": ["餐厅", "好吃"]
}

# JSON Mode 可能输出的结构:
{"sentiment": "正面", "score": "高"}  # 字段名不同
{"result": "好评"}                    # 完全不同的结构
{"sentiment": "positive", "confidence": "high"}  # 类型不对(字符串而非数字)

这些都是合法 JSON,但不等于你想要的 JSON

错误处理模式

生产级 JSON 解析

python
from pydantic import BaseModel, ValidationError
from typing import List

class SentimentResult(BaseModel):
    sentiment: str
    confidence: float
    keywords: List[str]

def analyze_sentiment(text: str, max_retries: int = 3) -> SentimentResult:
    """带重试和验证的情感分析"""
    for attempt in range(max_retries):
        try:
            response = client.chat.completions.create(
                model="gpt-4o",
                messages=[
                    {"role": "system", "content": """分析文本情感。
输出 JSON,包含以下字段:
- sentiment: "positive" 或 "negative" 或 "neutral"
- confidence: 0.0 到 1.0 的浮点数
- keywords: 关键词列表"""},
                    {"role": "user", "content": text}
                ],
                response_format={"type": "json_object"}
            )

            data = json.loads(response.choices[0].message.content)
            return SentimentResult(**data)  # Pydantic 验证

        except (json.JSONDecodeError, ValidationError) as e:
            if attempt == max_retries - 1:
                raise
            continue

    raise RuntimeError("重试次数用尽")

格式约束的进化

从最原始到最可靠:

  Level 0: 只在 prompt 里说「输出 JSON」
    → 约束力:❌ 可能输出任何东西

  Level 1: prompt + 正则提取
    → 约束力:⚠️ 脆弱,总有新问题

  Level 2: JSON Mode(response_format: json_object)
    → 约束力:✅ 保证合法 JSON,但不保证结构

  Level 3: Structured Outputs(下一节课)
    → 约束力:✅ 保证合法 JSON + 指定 schema

每一级都在减少不确定性,把控制权从「提示词」转移到「API 约束」。

本节小结

概念要点
核心痛点模型输出自然语言,代码需要结构化数据——格式不可靠
早期方案正则提取、JSON 修复——脆弱且复杂
JSON ModeAPI 层面保证输出合法 JSON
JSON Mode 局限只保证语法合法,不保证 schema(字段名、类型)
进化方向从 prompt 约束 → API 约束 → Schema 约束

思考题

  1. 为什么 JSON Mode 不能保证字段名和值的类型?想想模型在生成 token 时能控制什么。
  2. 在 JSON Mode 出现之前,开发者是如何解决格式问题的?这些方法今天还有用吗?
  3. 为什么说「格式约束从提示词转移到 API」是一个重要的趋势?这和传统软件工程中「契约优于约定」有什么相似之处?