第 05 篇:结构化输出 —— 让模型稳定返回 JSON

第 05 篇:结构化输出 ------ 让模型稳定返回 JSON

系列:《Java 大模型应用开发入门》第 05 篇。

源码定位:com.example.llm.util.AiJsonUtilscom.example.llm.dto.chat.ResponseFormatcom.example.llm.service.AiInvoker#chatJsoncom.example.llm.prompt.Prompts


一、背景

第 04 篇结束时,我们拿到了一个能稳定对话的客户端。但它产出的是散文:

json 复制代码
{ "content": "好的,我来帮你分析一下这个问题。首先......其次......希望对你有帮助!" }

散文对人类友好,对程序是灾难。你真正想要的是:

json 复制代码
{ "label": "物流", "confidence": 0.9 }

这是大模型应用开发的分水岭。

在大模型应用里,模型输出的唯一可信接口是结构化数据。只要输出还依赖自然语言的措辞,你的业务代码就永远处在「可能解析失败」的状态。

这篇解决的就是这个问题。而且我们要做的不只是「打开 JSON Mode」,而是建立四层防护。


二、目标

  1. 理解 JSON Mode 能保证什么、不能保证什么;
  2. 学会写「让模型必须按结构输出」的提示词;
  3. 用 Jackson 把模型输出反序列化成 Java 对象;
  4. 处理模型的三种「手滑」:Markdown 包裹、前后加废话、字段名不符;
  5. 实现解析失败后自动纠错重问的兜底机制;
  6. 让模型输出真正接入业务对象,而不是停留在字符串。

三、前置

  • 第 04 篇的 AiClient / WebClientAiClient 已就绪;
  • AiProperties 已能读到模型配置。

四、核心概念

JSON Mode 保证什么,不保证什么

在请求体里加上:

json 复制代码
{ "response_format": { "type": "json_object" } }

上游会用约束解码强制模型输出合法 JSON。它保证一件事:输出是一个语法合法的 JSON 对象。

它不保证的是:

  • 字段名符合你的预期(你可能得到 {"category":"物流"},而不是 {"label":"物流"});
  • 字段类型正确(confidence 可能是字符串 "0.9");
  • 字段齐全(可能只返回 {"label":"物流"},没有 reason);
  • 语义正确(可能把「物流」判成「其他」);
  • 所有厂商都支持(部分兼容实现直接报 400)。

所以结论是:JSON Mode 只是语法保险,业务校验一层都不能省。

一个必须知道的坑:提示词里要出现 "json"

OpenAI 官方文档明确要求:使用 json_object 模式时,提示词里必须包含 "json" 字样,否则会报错:

复制代码
Invalid parameter: 'messages' must contain the word 'json' in some form

这个坑很隐蔽,因为它只在你换了一种写法的时候才出现。比如你把提示词从「请返回 JSON」改成「请按要求输出结构化结果」,代码就突然挂了。

本工程的 Prompts 里,所有结构化提示词都显式带了 "JSON" 字样。

四层防护

复制代码
第 1 层  提示词约束      ── system 里写清字段名、类型、示例
第 2 层  response_format ── 让上游保证语法合法
第 3 层  解析清洗        ── 剥掉 Markdown 包裹和前后废话再交给 Jackson
第 4 层  失败重问        ── 解析失败时追加一句纠错提示,重问一次

第 4 层性价比最高。多花一次调用(约 0.0005 元),换来结构化成功率大幅提升,比让业务随机失败划算得多。

提示词的五段式

本工程把所有提示词集中在 Prompts 类里,每条都遵循同样的五段结构:

复制代码
① 角色:你是谁
② 任务:做什么
③ 约束:只返回 JSON、不要 Markdown、缺失返回 null、禁止编造
④ 输出结构:给出字段名与示例
⑤ 输入数据:用醒目标记包裹

为什么提示词要集中管理?因为它是 AI 应用里修改最频繁、最容易出事故的资产。散落在各个 Service 里,改一个字段名要全局搜索,还容易漏。

集中到一个类,至少有三个好处:

  1. 改提示词只动一个文件,代码评审时一眼能看出这次只改了什么;
  2. 可以写单元测试,比如检查「所有提示词必须包含 JSON 字样」;
  3. 未来要接提示词管理平台或者做 A/B 测试,有统一的接入点。

五、代码实操

声明响应格式

java 复制代码
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ResponseFormat {

    /** 格式类型,目前常用 json_object。 */
    private String type;

    public static ResponseFormat jsonObject() {
        return new ResponseFormat("json_object");
    }
}

在客户端里按需挂上(第 04 篇已经接好):

java 复制代码
ChatCompletionRequest request = ChatCompletionRequest.of(model, messages, temperature);
request.setMaxTokens(aiProperties.getMaxTokens());
if (jsonMode) {
    request.setResponseFormat(ResponseFormat.jsonObject());
}

写好提示词

摘要用的 Prompts.SUMMARY_SYSTEM

java 复制代码
public static final String SUMMARY_SYSTEM = """
        你是一名专业的文本摘要助手。
        要求:
        1. 只依据用户提供的文本内容做摘要,禁止编造文本中不存在的信息;
        2. 保留关键事实、数字与结论;
        3. 必须只返回一个 JSON 对象,不要输出 Markdown 代码块,不要输出任何解释性文字。

        输出 JSON 结构(字段名必须完全一致):
        {"summary": "150 字以内的摘要", "keyPoints": ["要点1", "要点2"], "keywords": ["关键词1", "关键词2"]}
        """;

几处措辞是有讲究的:

措辞 为什么这么写
「必须只返回一个 JSON 对象」 加强约束
「不要输出 Markdown 代码块」 直接堵住最高频的 ```````json```` 包裹
「不要输出任何解释性文字」 堵住「好的,结果如下:」这种开场白
「字段名必须完全一致」 明确告诉模型别自作主张改字段名
「禁止编造文本中不存在的信息」 抽取和摘要场景最重要的约束

分类用的提示词带 Few-shot 示例:

java 复制代码
public static String classifySystem(List<String> labels) {
    String labelText = String.join("、", labels);
    return """
            你是一名文本分类助手。
            要求:
            1. 只能从下列固定标签中选择一个,禁止自创标签:%s
            2. 必须只返回一个 JSON 对象,不要输出 Markdown 代码块,不要输出任何解释性文字;
            3. confidence 是 0 到 1 之间的小数,表示你对本次判断的把握程度。

            输出 JSON 结构:
            {"label": "标签", "confidence": 0.9, "reason": "一句话说明判断依据"}

            参考示例:
            输入:我的快递三天没动了,什么时候能到  ->  {"label": "物流", "confidence": 0.95, "reason": "询问包裹运输进度"}
            输入:这个东西质量太差了,我要投诉你们  ->  {"label": "投诉", "confidence": 0.93, "reason": "表达不满并要求投诉"}
            """.formatted(labelText);
}

labels 是从配置读的,所以标签体系可以不重启就改:

yaml 复制代码
ai:
  classify:
    labels: [咨询, 投诉, 退款, 物流, 其他]

防御性清洗:处理模型的三种「手滑」

即使开了 JSON Mode,模型偶尔还是会给你这些:

复制代码
① 好的,结果如下:
   ```json
   {"label":"物流"}

② {"label":"物流"}

希望对你有帮助!

③ ```JSON

{"label":"物流"}

复制代码

所以解析这一步必须先清洗,再交给 Jackson:

java 复制代码
public final class AiJsonUtils {

    private static final Pattern FENCE =
            Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```", Pattern.CASE_INSENSITIVE);

    /** 从模型输出中抠出 JSON 主体。 */
    public static String extractJson(String raw) {
        if (raw == null) {
            return "";
        }
        String text = raw.trim();

        // ① 剥掉 Markdown 代码块
        Matcher matcher = FENCE.matcher(text);
        if (matcher.find()) {
            text = matcher.group(1).trim();
        }

        // ② 从第一个 { 截到最后一个 } ------ 一次搞定前后废话
        int start = text.indexOf('{');
        int end = text.lastIndexOf('}');
        if (start >= 0 && end > start) {
            return text.substring(start, end + 1);
        }
        return text;
    }

    public static <T> T parse(String raw, Class<T> type, ObjectMapper mapper) {
        String json = extractJson(raw);
        try {
            return mapper.readValue(json, type);
        } catch (Exception e) {
            throw new AiBadResponseException(
                    "模型输出无法解析为 " + type.getSimpleName() + ":" + e.getMessage(), brief(raw));
        }
    }
}

indexOf('{')lastIndexOf('}') 这一招很实用,它一次解决两个问题:前面有「好的,结果如下:」会被切掉;后面有「希望对你有帮助!」也会被切掉。

要注意它的适用边界:这招只适用于「整体就是一个 JSON 对象」的场景。如果输出里含多个 JSON,或者正文本身带 {}(比如抽取任务抽到的文本里有花括号),就得换更严谨的方案,比如按括号配对扫描。

失败重问

这是本篇的核心机制:

java 复制代码
public <T> AiResult<T> chatJson(List<Message> messages, double temperature, Class<T> type) {
    ChatCompletionResponse first = resilience.execute(() -> aiClient.chat(messages, temperature, true));
    try {
        T parsed = AiJsonUtils.parse(first.firstContent(), type, objectMapper);
        return new AiResult<>(parsed, prompt(first), completion(first), first.firstFinishReason());
    } catch (AiBadResponseException e) {
        log.warn("结构化输出解析失败,追加纠错提示后重问一次: {}", e.getMessage());
        List<Message> retryMessages = new ArrayList<>(messages);
        retryMessages.add(Message.user(RETRY_HINT));
        ChatCompletionResponse second = resilience.execute(
                () -> aiClient.chat(retryMessages, temperature, true));
        T parsed = AiJsonUtils.parse(second.firstContent(), type, objectMapper);
        return new AiResult<>(parsed,
                prompt(first) + prompt(second),           // ← Token 要累加
                completion(first) + completion(second),
                second.firstFinishReason());
    }
}

纠错提示写得很直白:

java 复制代码
private static final String RETRY_HINT = "上一次的输出无法被解析为 JSON。"
        + "请只输出一个合法的 JSON 对象,不要输出 Markdown 代码块,不要输出任何解释文字。";

三个设计要点:

  1. 只重问一次。重问是补救,不是重试。无限重问会变成成本黑洞;
  2. Token 必须累加。否则成本统计会漏掉重问那次,账单对不上;
  3. 重问时带上原始消息。只发纠错提示,模型不知道原来问的是什么。

为什么用「追加一条 user 消息」而不是「重新提问」?因为追加消息能保留原上下文,模型知道「我上次答错了什么」。这也符合对话语义 ------ 模型在上下文里能看到自己刚才的输出。

定义结果 DTO

java 复制代码
public class SummaryDtos {

    @Data
    public static class Request {
        @NotBlank(message = "text 不能为空")
        private String text;
        private Integer maxLength;
    }

    @Data
    public static class Result {
        private String summary;
        private List<String> keyPoints;
        private List<String> keywords;

        // 以下字段由本地填充,不从模型读取
        private Boolean chunked;
        private Integer chunkCount;
        private Boolean degraded;
    }
}

注意最后那三个字段:它们是本地状态,不是模型输出。

把它们和模型字段放在同一个 DTO 里,可以省掉一层包装,但必须在注释里写清楚,否则接手的人会以为「模型也会返回 chunked」。

归一化:不要信任模型输出

拿到对象之后,还有一步不能省:

java 复制代码
private SummaryDtos.Result normalize(SummaryDtos.Result result) {
    if (result.getSummary() == null || result.getSummary().isBlank()) {
        throw new AiBadResponseException("模型返回的摘要为空", null);
    }
    if (result.getKeyPoints() == null) {
        result.setKeyPoints(new ArrayList<>());
    }
    if (result.getKeywords() == null) {
        result.setKeywords(new ArrayList<>());
    }
    return result;
}

为什么「摘要为空」要抛异常,而不是返回空对象?因为空摘要对业务没有意义,返回它等于把问题推到下游。抛异常会触发兜底逻辑(第 09 篇的降级),下游拿到的是一个明确的「AI 不可用」信号,而不是一个「看起来成功但内容为空」的假成功。

这条原则很通用:宁可明确失败,也不要静默返回无意义的结果。


六、验证

验证 JSON Mode 生效

bash 复制代码
curl -X POST http://127.0.0.1:8080/ai/chat/json \
  -H "Content-Type: application/json" \
  -d '{"prompt":"请返回一个 JSON:{\"lang\":\"Java\",\"version\":21}","temperature":0.2}'

实测输出:

json 复制代码
{
  "code": 0,
  "message": "成功",
  "traceId": "5d3ea7a2254346af",
  "data": {
    "content": "{\"status\":\"ok\",\"received\":\"请返回一个 JSON:{\\\"lang\\\":\\\"Java\\\",\\\"version\\\":21}\",\"note\":\"JSON Mode 已生效:这是本地模拟服务返回的结构化占位数据。\"}",
    "model": "mock-gpt-4o-mini",
    "finishReason": "stop",
    "promptTokens": 47,
    "completionTokens": 48,
    "totalTokens": 95,
    "latencyMs": 12
  }
}

注意 data.content 是一个字符串,它的内容恰好是合法 JSON。

这里有个常见误解:JSON Mode 返回的 content 仍然是字符串,只不过内容是合法 JSON。你还需要自己反序列化一次。这正是 AiJsonUtils 存在的原因。

对比一下不开 JSON Mode 的 /ai/chat

复制代码
"content": "【本地模拟回复】我收到了你的问题:「请返回一个 JSON:......」当前运行的是 mock-llm-server,......"

一个是 JSON,一个是散文。这就是第 05 篇要解决的差距。

验证「字符串到 Java 对象」

/ai/chat/json 只证明上游能返回 JSON。真正的验证要看业务接口有没有把 JSON 变成 Java 对象:

bash 复制代码
curl -X POST http://127.0.0.1:8080/ai/summarize \
  -H "Content-Type: application/json" \
  -d '{"text":"本次发布主要完成了三件事。第一,重构了大模型客户端,把超时、重试、限流统一收口到一处。第二,新增结构化输出能力,业务可以直接拿到 Java 对象。第三,补齐了链路追踪,每个请求都有 traceId,可以按 traceId 回放整条调用链。上线后接口平均耗时从 1.8 秒降到 0.9 秒,失败率从 3.2% 降到 0.4%。","maxLength":150}'

实测输出:

json 复制代码
{
  "code": 0,
  "message": "成功",
  "traceId": "448cb236346d438a",
  "data": {
    "summary": "本次发布主要完成了三件事。第一,重构了大模型客户端,把超时、重试、限流统一收口到一处。第二,新增结构化输出能力,业务可以直接拿到 Java 对象。第三,补齐了链路追踪,每个请求都有 traceId,可以按 traceId 回放整条调用链。上线后接口平均耗时从 1.8 秒降到 0.9 秒,失败率从 3.2% 降到 0.4%。",
    "keyPoints": [
      "本次发布主要完成了三件事",
      "第一,重构了大模型客户端,把超时、重试、限流统一收口到一处",
      "第二,新增结构化输出能力,业务可以直接拿到 Java 对象",
      "第三,补齐了链路追踪,每个请求都有 traceId,可以按 traceId 回放...",
      "上线后接口平均耗时从 1.8 秒降到 0.9 秒,失败率从 3.2% 降到 0...."
    ],
    "keywords": ["降到", "本次发布", "主要完成", "了三件事", "重构了大"],
    "chunked": false,
    "chunkCount": 1,
    "degraded": false
  }
}

这个 JSON 是 SummaryDtos.Result 对象被 Jackson 序列化的结果。keyPoints 是一个真正的 List<String>,不是一段需要你去正则解析的文本 ------ 这就是结构化输出落地后的样子。

关于 keywords 字段:它是 mock 服务用简化的 n-gram 词频算法产出的,所以看起来像碎词。

这恰好是上面「JSON Mode 不保证内容好」的实证。真实模型会给出语义更准的关键词,比如「重构」「超时」「重试」「限流」「链路追踪」。

mock 的价值是让你在没有 API Key 时也能验证链路,而不是验证智能。

演示页上的效果

打开演示页,点「JSON Mode」按钮:

尝试制造解析失败

ai.summary.max-input-chars 改小然后传一个超大文本,或者临时把 AiJsonUtils.extractJson 的清洗逻辑注释掉,你会看到日志:

复制代码
WARN  c.e.llm.service.AiInvoker - 结构化输出解析失败,追加纠错提示后重问一次: 模型输出无法解析为 SummaryDtos.Result:...

紧接着是第二次调用的日志。这就是第 4 层防护在工作。

如果想观察「重问也失败」的情况,可以临时把 RETRY_HINT 改成一句无用的话,日志里最终会抛出 AiBadResponseException,并被第 09 篇的降级逻辑接住。


七、常见坑

表现 解法
以为 JSON Mode 返回的是对象 content 当对象用,编译报错 它仍是 String,需要反序列化
提示词里没有 "json" 字样 开了 json_object 直接 400 提示词里显式出现 JSON
开着 JSON Mode 就不校验字段 偶发 NPE(字段缺失) 归一化加默认值和空值校验
不剥 Markdown 包裹 `Unrecognized token '````解析失败 AiJsonUtils.extractJson 清洗
只取 {} 却不考虑含花括号的正文 抽取任务解析出错 抽取场景改用括号配对扫描,或要求模型输出前先转义
重问不累加 Token 成本统计偏低,账单对不上 重问的 Token 必须加起来
无限重问 上游故障时变成成本黑洞 只重问 1 次,失败即降级
提示词散落在各 Service 改一个字段要全局搜索,易漏 集中到 Prompts
temperature 用默认 0.7 同一输入结果不稳定 结构化任务用 0~0.2(本工程固化在 Service 里)
degraded / chunked 当成模型字段 误解日志与接口语义 DTO 里注释清楚哪些是本地状态

八、小结与下一篇

这篇做完的事:

  • 澄清了 JSON Mode 只保证语法合法,业务校验一层不能省;
  • 学会了五段式提示词的写法,并把提示词集中到 Prompts 类;
  • AiJsonUtils 处理了模型的三类「手滑」;
  • 实现了解析失败自动纠错重问,并正确处理了 Token 累加;
  • 定义了结果 DTO 与归一化逻辑,让模型输出真正接入 Java 对象;
  • 验证了 /ai/chat/json/ai/summarize 两条链路。

现在模型能返回稳定的结构了。但用过 ChatGPT 的人都知道,它是一个字一个字往外蹦的。

而我们现在的接口是「等全部生成完再一次返回」,用户盯着空白屏幕等 5 秒,体验很差。

而且这条路上藏着两个非常隐蔽的坑:Reactor 的 map 不允许返回 nulldata: [DONE] 不是 JSON。踩中任何一个,整条流都会直接崩掉。

下一篇把这条流打通:SSE 流式输出。

下一篇 → 第 06 篇:SSE 流式输出 ------ Spring Boot 返回打字机效果

跟着敲的建议:留意上面 keywords 字段的结果。

它是本系列唯一一处「故意保留 mock 局限」的地方,也是理解「结构 vs 语义」区别的最好例子。

相关推荐
moonsims1 小时前
AiBrainBox-UGV 的目标跟踪从“传统视觉跟踪”升级为“语义目标跟踪”
前端·人工智能·量子计算
AI你一生一世1 小时前
当手机镜头遇上实时流:移动端4K直播的技术突围与选型指南
人工智能·音视频编解码·技术选型·4k视频·移动端直播·实时流媒体
知几蜗牛1 小时前
100万Token不等于模型全记住:从KV Cache看懂长上下文成本
人工智能
土司大王1 小时前
LeetCode hot100——394.字符串解码:Java 双栈模拟
java·算法·leetcode
邪修king1 小时前
Re:Linux系统篇(二十五):文件系统(一):磁盘硬件底层原理:从物理结构到 CHS/LBA 寻址,搞懂硬盘数据的定位逻辑
java·linux·运维·gpt
驭渊的小故事1 小时前
Spring Boot 注解详解01
java·前端·spring boot
泡海椒1 小时前
jquick-pdf 超详细入门教程:Java 轻量级 HTML 模板生成 PDF 工具
java·开发语言
yangmu32031 小时前
RTX 40系显卡性能终极解锁:OptiScaler开启DLSS 5与6倍帧生成硬核指南
人工智能·游戏程序