【LangChain4j系列07】结构化输出与类型安全

让 LLM 返回类型安全的 Java 对象(POJO、Record、Enum),而非无结构的 String。本章深入解析 JSON Schema 模式、JsonSchemaElement 体系、多态类型支持、以及三种输出方式的可靠性对比。


7.1 为什么结构化输出很重要?

csharp 复制代码
❌ 自由文本输出:
   LLM: "姓名:张三,年龄:30,身高:1.78,已婚"
   → 需要手写正则/字符串解析,脆弱且不可靠

✅ 结构化输出:
   LLM: {"name":"张三","age":30,"height":1.78,"married":true}
   → 直接反序列化为 Person record,编译期类型安全

7.2 三种输出方式(可靠性从高到低)

方式 可靠性 Provider 支持 配置复杂度
JSON Schema ⭐⭐⭐⭐⭐ OpenAI, Gemini, Mistral, Ollama, Bedrock, Azure 低(AI Services 自动)
Prompting + JSON Mode ⭐⭐⭐ 部分 Provider 中(需配置 responseFormat)
Prompting ⭐⭐ 所有 低(默认,但不推荐)

方式3:Prompting(默认,不可靠)

java 复制代码
// AI Services 默认行为:在用户消息末尾追加格式指令
interface PersonExtractor {
    Person extractPersonFrom(String text);
}
// 自动追加: "You must answer strictly in the following JSON format: {...}"
// ⚠️ LLM 有时不遵循指令,输出包含额外解释文字

方式2:JSON Mode(Provider 特定配置)

java 复制代码
// OpenAI 旧模型
OpenAiChatModel.builder()
    .responseFormat("json_object")
    .build();

// Google Gemini
GoogleAiGeminiChatModel.builder()
    .responseFormat(ResponseFormat.JSON)
    .build();

// Ollama
OllamaChatModel.builder()
    .responseFormat(ResponseFormat.JSON)
    .build();

方式1:JSON Schema(最可靠,推荐)

java 复制代码
ChatModel model = OpenAiChatModel.builder()
    .apiKey(System.getenv("OPENAI_API_KEY"))
    .modelName("gpt-4o-mini")
    .supportedCapabilities(RESPONSE_FORMAT_JSON_SCHEMA) // 显式启用
    .strictJsonSchema(true)                              // 严格模式
    .build();

interface PersonExtractor {
    Person extractPersonFrom(String text);
}

PersonExtractor extractor = AiServices.create(PersonExtractor.class, model);
Person person = extractor.extractPersonFrom("张三,30岁,身高1米78,已婚");
// 框架自动:生成 JSON Schema → 发给 API → 解析返回

触发条件(必须全部满足):

  1. 方法返回 POJO
  2. ChatModel 支持 JSON Schema(需要通过 supportedCapabilities 开启)
  3. AI Service 支持该功能

7.3 Provider JSON Schema 配置速查

java 复制代码
// OpenAI / Azure OpenAI
.supportedCapabilities(RESPONSE_FORMAT_JSON_SCHEMA)
.strictJsonSchema(true)

// Google AI Gemini / Vertex AI Gemini
.supportedCapabilities(RESPONSE_FORMAT_JSON_SCHEMA)

// Mistral AI
.supportedCapabilities(RESPONSE_FORMAT_JSON_SCHEMA)
.strictJsonSchema(true)

// Ollama
.supportedCapabilities(RESPONSE_FORMAT_JSON_SCHEMA)

// Amazon Bedrock
.supportedCapabilities(RESPONSE_FORMAT_JSON_SCHEMA)

7.4 支持的返回类型

java 复制代码
interface Assistant {
    // === 基本类型 ===
    boolean isPositive(String text);
    int extractAge(String text);
    double extractHeight(String text);

    // === 枚举 ===
    Sentiment analyzeSentiment(String text);           // enum Sentiment { POSITIVE, NEGATIVE, NEUTRAL }

    // === POJO / Record ===
    Person extractPerson(String text);

    // === 集合类型 ===
    List<String> generateOutline(String topic);
    List<Person> extractPeople(String text);           // JSON Schema 支持
    Set<Person> extractUniquePeople(String text);      // JSON Schema 支持
    List<Sentiment> analyzeSentiments(String text);

    // === 多态类型 ===
    Animal identifyAnimal(String description);         // sealed interface Animal permits Dog, Cat

    // === 日期类型(仅 Prompting 模式) ===
    LocalDate extractDate(String text);
    LocalDateTime extractDateTime(String text);
}

JSON Schema 与 Prompting 的支持矩阵

类型 JSON Schema Prompting
POJO
List<POJO>, Set<POJO>
Enum
List<Enum>, Set<Enum>
List<String>, Set<String>
多态类型(含 List/Set)
基本类型(int, boolean, ...)
BigInteger, BigDecimal
Date, LocalDate, LocalTime, LocalDateTime
Map<?, ?>

7.5 @Description 注解

为 Schema 添加人类可读的描述,让 LLM 更准确理解字段含义:

java 复制代码
@Description("a person with basic identity information")
record Person(
    @Description("person's full name, e.g. 'John Doe'")
    String name,

    @Description("person's age in years, e.g. 42")
    int age,

    @Description("person's height in meters, e.g. 1.78")
    double height,

    @Description("whether the person is married, e.g. false")
    boolean married
) {}

⚠️ @Description枚举值无效(不会写入 JSON Schema)。


7.6 Required vs Optional 字段

AI Services 默认:全部 Optional

java 复制代码
record Person(String name, int age, double height, boolean married) {}
// 生成的 JSON Schema: required = []  (所有字段都是可选的)
// 原因: LLM 在缺少信息时会"编造"值,全 optional 比编造更好

副作用:

  • int → 默认 0
  • boolean → 默认 false
  • 对象类型 → null

⚠️ optional 的 enum 字段在严格模式下仍可能被 LLM 编造出不在枚举列表中的值。

用 @JsonProperty 标记 Required

java 复制代码
record Person(
    @JsonProperty(required = true) String name,  // 必填
    String surname                                // 可选
) {}

工具方法中的默认:全部 Required

java 复制代码
// @Tool 方法中,所有参数默认 required=true
@Tool
void getWeather(
    @P String city,             // required
    @P(required = false) TemperatureUnit unit  // 显式设为 optional
) {}

7.7 低层 API:手动构建 JsonSchema

当 AI Services 的自动生成不够灵活时,手动控制:

JsonSchemaElement 层次结构

arduino 复制代码
JsonSchemaElement (接口)
├── JsonObjectSchema       --- 对象类型(通常作为 rootElement)
├── JsonStringSchema       --- String, char, Character
├── JsonIntegerSchema      --- int, long, BigInteger
├── JsonNumberSchema       --- float, double, BigDecimal
├── JsonBooleanSchema      --- boolean, Boolean
├── JsonEnumSchema         --- enum 类型
├── JsonArraySchema        --- List, Set, 数组
├── JsonReferenceSchema    --- 递归引用(树形结构)
├── JsonAnyOfSchema        --- 多态类型
├── JsonNullSchema         --- nullable
└── JsonRawSchema          --- 自定义原始 JSON Schema

完整示例

java 复制代码
// 构建 Person Schema
JsonObjectSchema personSchema = JsonObjectSchema.builder()
    .addStringProperty("name", "person's full name")
    .addIntegerProperty("age", "age in years")
    .addNumberProperty("height", "height in meters")
    .addBooleanProperty("married", "marital status")
    .required("name", "age", "height", "married")
    .build();

// 封装为 ResponseFormat
ResponseFormat responseFormat = ResponseFormat.builder()
    .type(ResponseFormatType.JSON)
    .jsonSchema(JsonSchema.builder()
        .name("Person")        // OpenAI 要求指定 schema name
        .rootElement(personSchema)
        .build())
    .build();

// 用于 ChatRequest
ChatRequest request = ChatRequest.builder()
    .messages(UserMessage.from("提取以下文本中的人物信息:..."))
    .responseFormat(responseFormat)
    .build();

ChatResponse response = model.chat(request);
Person person = new ObjectMapper().readValue(
    response.aiMessage().text(), Person.class);

枚举 Schema

java 复制代码
JsonEnumSchema enumSchema = JsonEnumSchema.builder()
    .description("Marital status of the person")
    .enumValues(List.of("SINGLE", "MARRIED", "DIVORCED"))
    .build();

数组 Schema

java 复制代码
JsonArraySchema arraySchema = JsonArraySchema.builder()
    .description("All names of the people found in the text")
    .items(JsonStringSchema.builder().build())
    .build();

7.8 多态类型(Polymorphic Types)

密封接口(推荐,零注解)

java 复制代码
sealed interface Animal permits Dog, Cat {}
record Dog(String name, String breed) implements Animal {}
record Cat(String name, boolean indoor) implements Animal {}

interface AnimalIdentifier {
    Animal identifyFrom(String description);
}

// "Rex is a Labrador." → LLM 返回 {"type":"Dog","name":"Rex","breed":"Labrador"}
// 框架自动反序列化为 Dog 实例

Jackson 注解(需要自定义判别器名)

java 复制代码
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
@JsonSubTypes({
    @JsonSubTypes.Type(value = Square.class, name = "square"),
    @JsonSubTypes.Type(value = Circle.class, name = "circle")
})
interface Shape {}

判别器名称解析顺序

  1. @JsonSubTypes.Type(name = "...")
  2. @JsonTypeName 注解
  3. Class.getSimpleName()(默认)

defaultImpl:对抗 LLM 幻觉

java 复制代码
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, defaultImpl = UnknownTool.class)
@JsonSubTypes({
    @JsonSubTypes.Type(value = Hammer.class, name = "hammer"),
    @JsonSubTypes.Type(value = Wrench.class, name = "wrench")
})
interface Tool {
    String use();
}

record UnknownTool() implements Tool {
    public String use() {
        return "Unknown tool --- cannot operate";
    }
}
// LLM 编造 "saw" → 反序列化为 UnknownTool 而非抛异常

多态字段碰撞检查

如果子类型中有与判别器同名的字段,框架会在 Schema 生成时抛出清晰的错误消息:

scala 复制代码
Field 'type' in subtype Dog conflicts with discriminator property.
To fix this, either rename the field, change the discriminator property name via @JsonTypeInfo,
or set visible=true to keep the discriminator on the bean.

7.9 递归类型(树形结构)

java 复制代码
record TreeNode(
    String name,
    List<TreeNode> children    // 递归引用
) {}

// 框架自动生成: 使用 JsonReferenceSchema 和 $defs

⚠️ 递归类型目前只被 Azure OpenAI、Mistral、OpenAI 支持。


7.10 JsonRawSchema:完全自定义

当需求超出框架 Schema DSL 的能力:

java 复制代码
String rawSchema = """
{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "type": "object",
    "properties": {
        "city": { "type": "string", "description": "城市名" },
        "date": { "type": "string", "format": "date" }
    },
    "required": ["city"],
    "additionalProperties": false
}
""";

JsonRawSchema schema = JsonRawSchema.from(rawSchema);

7.11 JSON Mode vs Tools(Function Calling)

两者都涉及 JSON,但用途完全不同:

维度 JSON Mode / Structured Outputs Tools / Function Calling
目的 从非结构化文本中提取结构化数据 让 LLM 触发真实世界的操作
调用方式 每次都是 responseFormat LLM 决定是否调用、调用哪个
典型场景 简历解析、发票提取、情感分析 查数据库、调 API、发邮件、计算
Schema 角色 定义输出格式 定义工具输入参数格式
循环 单次调用 可能多轮(执行→反馈→再推理)

7.12 最佳实践

建议 说明
优先 JSON Schema Prompting 不可靠,尽量避免
字段全 Optional 让 LLM 省略不确定的字段,比编造值更好
加 @Description 描述的质量直接影响提取的准确度
严格模式 OpenAI/Azure/Mistral 开启 strictJsonSchema(true)
defaultImpl 多态类型永远提供默认实现,对抗 LLM 幻觉
测试驱动 对不同输入写集成测试,验证提取准确性
相关推荐
用户298698530142 小时前
3 种方法,轻松将 PowerPoint 转换为 PDF 格式
人工智能·后端·c#
安逸sgr2 小时前
视觉 Token 是什么?图片是怎么送进大模型的?
人工智能·ai·大模型·agent·智能体
一招合理2 小时前
数据治理:企业BI深入应用的关键门槛
人工智能
yinshuzhineng2 小时前
问题:如何实现数字化转型,增强竞争优势?
大数据·人工智能·制造
吃饱了得干活2 小时前
为什么你的Service越写越臃肿?三层架构的“业务逻辑层”是个黑盒
java·后端·架构
何时梦醒2 小时前
Docker 容器化入门:从「我电脑能跑」到「哪台机器都能跑」
后端·docker·面试
颜进强2 小时前
11 - 从需求拆解到 OpenSpec:为什么不要直接敲 /opsx:explore
前端·后端·ai编程
foggyprojects2 小时前
AI 说销售额下降了,哪些客户拖累了结果?
后端
用户852495071842 小时前
NestJS 架构实战:给后端代码请来一位“项目经理
后端