24 - 结构化输出与Schema注入

从 Java 类型自动生成 JSON Schema,注入提示词,解析 LLM 输出为强类型对象

一、什么是结构化输出?

LLM 默认返回自由文本(natural language),但很多场景需要机器可解析的结构化数据。结构化输出就是通过在提示词中注入 JSON Schema,引导 LLM 返回符合特定格式的 JSON,再将其反序列化为强类型 Java 对象。

价值:将 LLM 从"聊天机器人"升级为"可编程的数据处理引擎"。不再需要手动解析自然语言,直接拿到类型安全的对象。

二、工作流程

  结构化输出端到端流程:

  ┌─────────────┐     ┌──────────────────┐     ┌───────────────────┐     ┌─────────────────┐
  │ 1. 配置      │     │ 2. 生成 Schema    │     │ 3. 注入 System    │     │ 4. 解析输出     │
  │ outputType   │ ──→ │ Java Class →     │ ──→ │    Prompt         │ ──→ │ JSON → Object   │
  │ = Report.class│    │ JSON Schema      │     │                   │     │                 │
  └─────────────┘     └──────────────────┘     └───────────────────┘     └─────────────────┘

  详细步骤:

  Step 1: Agent 配置指定 outputType = Report.class

  Step 2: BeanOutputConverter 自动分析 Report 类的字段
          生成 JSON Schema:
          {
            "type": "object",
            "properties": {
              "title": { "type": "string" },
              "score": { "type": "integer" },
              "tags":  { "type": "array", "items": { "type": "string" } }
            },
            "required": ["title", "score"]
          }

  Step 3: OutputSchemaInjector 将 Schema 追加到 System Prompt:
          "... 你的回答必须严格遵循以下 JSON Schema: {schema}"

  Step 4: StructuredOutputConverter 解析 LLM 返回的 JSON
          → 剥离可能的 markdown 代码块
          → 反序列化为 Report 对象

三、OutputSchemaInjector

静态工具类,负责将 JSON Schema 注入到 System Prompt 中。

public class OutputSchemaInjector {

    // 注入模板
    private static final String SCHEMA_TEMPLATE = """

        你的回答必须是一个严格符合以下 JSON Schema 的 JSON 对象。
        不要包含任何额外文字说明,只输出纯 JSON。

        JSON Schema:
        %s
        """;

    // 两种 Schema 来源:

    // 1. 自动生成: 从 Java Class 自动推导
    public static String inject(String systemPrompt, Class<?> outputType) {
        BeanOutputConverter<?> converter = new BeanOutputConverter<>(outputType);
        String schema = converter.getJsonSchema();
        return systemPrompt + String.format(SCHEMA_TEMPLATE, schema);
    }

    // 2. 手动指定: 直接传入 schema 字符串
    public static String inject(String systemPrompt, String schemaJson) {
        return systemPrompt + String.format(SCHEMA_TEMPLATE, schemaJson);
    }
}
两种 Schema 来源:大多数场景使用自动生成(传入 Java Class),系统自动分析字段和注解生成 Schema。少数需要精细控制的场景可以手动编写 Schema JSON 字符串。

四、StructuredOutputConverter

泛型转换器,包装 Spring AI 的 BeanOutputConverter,增加了 markdown 代码块剥离和优雅降级能力。

public class StructuredOutputConverter<T> {

    private final BeanOutputConverter<T> delegate;

    public StructuredOutputConverter(Class<T> targetType) {
        this.delegate = new BeanOutputConverter<>(targetType);
    }

    public T convert(String llmOutput) {
        try {
            // Step 1: 剥离 markdown 代码块(如果存在)
            String cleanJson = stripMarkdownCodeBlock(llmOutput);

            // Step 2: 委托给 BeanOutputConverter 解析
            return delegate.convert(cleanJson);

        } catch (Exception e) {
            // Step 3: 优雅降级 — 返回 null,不抛异常
            log.warn("结构化输出解析失败: {}", e.getMessage());
            return null;
        }
    }

    public String getJsonSchema() {
        return delegate.getJsonSchema();
    }
}
特性说明
Markdown 剥离自动去除 ```json...``` 包裹
优雅降级解析失败返回 null,不抛出异常
泛型支持支持任意 Java 类型作为目标类型
Schema 获取代理 BeanOutputConverter 的 Schema 生成

五、Markdown 代码块剥离

LLM 经常在 JSON 外面包裹 markdown 代码块,即使提示词明确要求"不要包含额外文字"。这是一个常见的 LLM 行为,需要在解析前主动处理。

// LLM 实际输出(常见情况):
```json
{"title": "分析报告", "score": 85, "tags": ["AI", "Agent"]}
```

// stripMarkdownCodeBlock 处理逻辑:
private String stripMarkdownCodeBlock(String text) {
    if (text == null) return null;
    String trimmed = text.trim();

    // 检测是否以 ``` 开头
    if (!trimmed.startsWith("```")) return trimmed;

    // 找到第一个换行符(跳过 ```json / ```JSON 等标记)
    int firstNewline = trimmed.indexOf('\n');
    if (firstNewline == -1) return trimmed;

    // 截取 ``` 之后、结尾 ``` 之前的内容
    String content = trimmed.substring(firstNewline + 1);
    if (content.endsWith("```")) {
        content = content.substring(0, content.length() - 3);
    }

    return content.trim();
}

// 处理后的干净 JSON:
{"title": "分析报告", "score": 85, "tags": ["AI", "Agent"]}

六、面试高频问题

Q: 为什么需要剥离 markdown?
A: 这是 LLM 的一个普遍行为特征:即使提示词明确要求"只输出纯 JSON",LLM 仍然经常会用 ```json...``` 代码块包裹 JSON 输出。这是因为 LLM 在训练数据中看到大量 markdown 格式的代码展示,形成了"展示代码时用代码块包裹"的强烈倾向。与其依赖提示词控制 LLM 行为,不如在解析端做防御性处理
Q: 解析失败为什么不抛异常?
A: 这是优雅降级(Graceful Degradation)设计。LLM 的输出具有不确定性,不能保证每次都返回合法 JSON。抛异常会导致整个请求失败,而返回 null 让调用方有机会实施自定义降级策略——比如重试、返回默认值、或降级为非结构化文本响应。异常处理权交给最了解业务场景的调用方。
Q: 和 Spring AI 的 OutputConverter 有什么关系?
A: 我们的 StructuredOutputConverter 是 Spring AI BeanOutputConverter增强包装器(Decorator 模式)。BeanOutputConverter 提供了核心能力:Java Class → JSON Schema 生成、JSON → Java Object 反序列化。我们在此基础上增加了两个关键能力:① markdown 代码块剥离(处理 LLM 的常见行为);② 优雅降级(catch 异常返回 null)。这使得在生产环境中使用结构化输出更加健壮。