Spring AI 2.0 学习指南企业级智能应用开发实践 环境准备 速查 ⌂ 全部教程
首页 / Spring AI 指南 / 第 3 章

CH 03结构化输出与 Advisors

前面两章模型只会「说人话」,这一章要让它「交作业」。结构化输出解决「模型输出能不能被程序稳定消费」, Advisor 解决「请求前后的横切逻辑往哪放」。这两件事分别对应企业级应用里最要命的两个问题:接口稳定性 和 代码可维护性。

学习目标
输出可编程 + 切面可扩展
前置要求
第 1、2 章
本章产出
订单信息提取(含校验兜底)
建议学时
4–5 小时

3.1为什么输出必须是对象

先看一个真实场景。用户输入一段话,你要提取成订单数据:

// 用户输入(一段自由文本,可能来自邮件、聊天记录、OCR 结果)
"你好,我是张三,订单 2024001 在 3 月 5 日下的单,金额 899 元,
 买了一个机械键盘和一个鼠标垫,现在还没发货,麻烦催一下。联系电话 138****8888"

如果用纯文本返回,你拿到的是一段字符串,要写正则去抠订单号、抠金额——模型稍微换个说法(「总计 899 元」/「共 899.00 元」),你的正则就废了。这就是非结构化输出的致命问题:不可靠。

❌ 纯文本输出的问题

  • 字段顺序、措辞可能变化,解析逻辑脆
  • 无法用 Java 类型系统约束
  • 无法做校验,脏数据直接入库
  • 下游要写一堆容错代码

✅ 结构化输出的价值

  • 模型输出 JSON → 自动映射成 record/POJO
  • 字段类型、必填项由 Java 定义
  • 可直接接入 JSR-303 校验
  • 下游代码干净、可测试

我们的目标类型(用 record,简洁且不可变):

package com.example.springai.ch03.model;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

/**
 * 订单信息提取结果。
 * 用 record 的好处:字段即构造参数,Jackson 反序列化开箱即用(JDK 17+ / Jackson 2.12+)。
 * 注意字段名要和提示词里的 JSON schema 对应,否则映射不上会得到 null。
 */
public record OrderInfo(
        String orderNo,              // 订单号,如 2024001
        String customerName,         // 客户姓名
        BigDecimal amount,           // 金额。★ 必须用 BigDecimal,绝不能用 double
        String status,               // 状态:未发货/已发货/已完成/已取消
        LocalDate orderDate,         // 下单日期
        List<String> items,          // 商品列表
        String phone                 // 联系电话
) {}
金额字段为什么必须用 BigDecimal?用 double 会产生浮点误差(0.1+0.2=0.30000000000000004), 在财务场景是灾难。另外模型有时会返回 "899元" 这种带单位的字符串,映射到 BigDecimal 会失败—— 解决办法是在字段上写明要求(3.4 节会讲怎么通过 JSON Schema 描述做到),或加一个自定义反序列化器清洗。

3.2entity():一行代码拿到对象

Spring AI 把这个过程封装得极其简单——把 .content() 换成 .entity():

import org.springframework.ai.chat.client.ChatClient;

// 返回单个对象
OrderInfo order = chatClient.prompt()
        .user(userText)
        .call()
        .entity(OrderInfo.class);      // ★ 关键就这一个方法

System.out.println(order.orderNo());   // 2024001
System.out.println(order.amount());    // 899

// 返回集合:泛型需要用 ParameterizedTypeReference 保住类型信息
List<OrderInfo> orders = chatClient.prompt()
        .user("把这段文本里的所有订单都提取出来:\n" + multiOrderText)
        .call()
        .entity(new ParameterizedTypeReference<List<OrderInfo>>() {});

// 返回 Map(字段不固定时用)
Map<String, Object> map = chatClient.prompt()
        .user(userText)
        .call()
        .entity(Map.class);
它背后做了什么?Spring AI 会自动:① 生成一段 JSON Schema 描述附到提示词末尾; ② 在提示词里加入「必须输出符合此 JSON Schema 的内容,不要输出解释」的指令; ③ 解析模型返回的文本(会容忍 ```json 代码块包裹); ④ 用 Jackson 反序列化并做类型转换。 所以它不是魔法,而是「提示词工程 + 解析」的自动化封装。理解这一点,你就能在它失败时自己排查。

2.0 重点让它自己纠错:validateSchema()

即使有 schema 约束,模型仍然可能返回不符合要求的 JSON——少字段、类型不对、套了 Markdown 代码块。 2.0 之前你只能自己写重试逻辑(本章 3.4 节后面那种写法);2.0 把它内置了:

// 1.x 的老写法(仍可用)
OrderInfo order = chatClient.prompt().user(text).call().entity(OrderInfo.class);

// ★ 2.0:给 entity() 传第二个参数,两个开关可以组合
OrderInfo order2 = chatClient.prompt()
        .user(text)
        .call()
        .entity(OrderInfo.class, spec -> spec
                // ① 解析前按 schema 校验;失败时把具体错误回喂给模型并自动重试(默认最多 3 次)
                .validateSchema()
                // ② 把 schema 作为 API 级约束发给厂商(原生结构化输出),而不是靠提示词里写
                .useProviderStructuredOutput()
        );

这两个开关分别在解决什么

validateSchema() —— 解决「模型偶尔返回不合法 JSON」。 由 StructuredOutputValidationAdvisor 支撑,校验失败会带着错误信息重试, 默认最多 3 次。

useProviderStructuredOutput() —— 解决「靠提示词约束不够硬」。 它让支持该能力的厂商(OpenAI、Google GenAI 等)在 API 层强制 schema, 比在提示词里写「请输出 JSON」可靠得多。

使用建议

  • 两个都打开是最稳的:前者兜底重试,后者从源头提高一次成功率。
  • 厂商不支持原生结构化输出时,useProviderStructuredOutput() 会自动降级回提示词方式,不会报错。
  • 注意它不是免费:重试意味着多一次模型调用,成本与延迟都会上升。 对成本敏感的高频接口,先测一次成功率达到多少再决定是否需要重试。
  • 开了它之后,本章 3.4 节那个「手写重试」的示例就不再是必需的了—— 但请仍读一遍,理解它内部在做什么,出问题时才知道去哪看。
2.0 相关变更:BeanOutputConverter.postProcessSchema(JsonNode) 扩展点被移除了。 如果你之前靠它自定义生成的 JSON Schema,需要改用自定义 StructuredOutputConverter 实现——2.0 允许自定义转换器参与原生结构化输出流水线。 另外 schema 生成已委托给 JsonSchemaGenerator, 生成的 schema 现在会带 OpenAPI 风格的 format 提示(如 int32、date-time), Kotlin 的可空字段也不再被错误地放进 required。

集合与嵌套结构的写法

// 嵌套对象(订单里有收货地址)
public record OrderDetail(
        String orderNo,
        BigDecimal amount,
        // 嵌套的 record 同样能被 Jackson 处理;
        // 如果模型没识别到地址,这个字段会是 null,调用方必须判空
        Address shippingAddress,
        List<OrderItem> items
) {}

public record Address(String province, String city, String detail) {}
public record OrderItem(String name, int quantity, BigDecimal price) {}

3.3底层原理:手写一遍 converter,才算真懂

如果哪天 entity() 不满足需求(比如你要在系统提示里插入 schema、或者要自定义解析逻辑),就得手动用 StructuredOutputConverter。理解它,你的排查能力会上一个台阶。

import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.converter.MapOutputConverter;
import org.springframework.ai.converter.ListOutputConverter;

// ① 创建转换器,泛型就是目标类型
BeanOutputConverter<OrderInfo> converter = new BeanOutputConverter<>(OrderInfo.class);

// ② 取出"格式说明"——这是一段自动生成的 JSON Schema 文本
String format = converter.getFormat();
System.out.println(format);
// 输出大致如下(这就是 entity() 偷偷帮你加进提示词的东西):
// Your response should be in JSON format.
// Do not include any explanations, only provide a JSON object.
// The JSON object should conform to the following schema:
// {"type":"object","properties":{"orderNo":{"type":"string"}, ... }}

// ③ 把它作为 {format} 占位符参数写进你的提示词
String template = """
        从下面的文本中提取订单信息。

        文本:
        {input}

        {format}
        """;

Prompt prompt = PromptTemplate.builder()
        .template(template)
        .build()
        .create(Map.of("input", userText, "format", converter.getFormat()));

// ④ 调用模型后,用 converter 解析
String rawJson = chatModel.call(prompt).getResult().getOutput().getText();
OrderInfo order = converter.convert(rawJson);   // 得到对象

三种内置 Converter 对照

Converter产出类型适用场景
BeanOutputConverter<T>单个 record / POJO字段固定的业务对象(最常用)
ListOutputConverterList<String>让模型给出标签、关键词、建议列表
MapOutputConverterMap<String,Object>字段不固定、探索期使用
最隐蔽的坑:模型返回了「思考过程 + JSON」。 当你同时在提示词里要求「请分步推理」和「输出 JSON」时,模型很可能先写一段推理文字,再输出 JSON。 而 converter.convert() 从头解析,遇到前面的文字就会抛 JsonParseException。
标准解法:把「要 JSON 的任务」和「要推理的任务」拆成两次调用 (第一次让它分析,第二次让它按 schema 输出结论),或者用 entity() + 明确的「不要输出任何解释」约束。 企业项目里推荐拆成两次调用——更稳、更可控、也更方便单独评测每一步。

3.4输出约束与校验:让脏数据进不来

结构化输出保证了「形状正确」,但不保证「内容合法」。模型完全可能给你一个金额为 -100、或者状态为「随便什么」的订单。所以必须加校验。

用 JSON Schema 描述把约束前置到提示词

// Spring AI 支持在 record 字段上加 @JsonPropertyDescription,
// 这些描述会写进生成的 JSON Schema,等于"写给模型看的字段说明书"
import com.fasterxml.jackson.annotation.JsonPropertyDescription;

public record OrderInfo(

    @JsonPropertyDescription("订单号,纯数字字符串,例如 2024001;找不到则返回 null")
    String orderNo,

    @JsonPropertyDescription("客户姓名;只保留姓名本身,不要带'先生''女士'等称谓")
    String customerName,

    @JsonPropertyDescription("订单总金额,单位元,只输出数字(如 899.00),不要带'元'或'¥'")
    BigDecimal amount,

    @JsonPropertyDescription("订单状态,只能是以下之一:未发货、已发货、已完成、已取消")
    String status
) {}
这个技巧能省掉大量调试时间。模型对「枚举值只能是这几个」「不要带单位」这类信息, 写在字段描述里的效果远好于写在系统提示的一大段话里——因为 schema 是紧贴着字段的,模型注意力更集中。

用 JSR-303 做业务校验

import jakarta.validation.constraints.*;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;

public record OrderInfo(
    @NotBlank(message = "订单号不能为空") String orderNo,
    @Min(value = 0, message = "金额不能为负") BigDecimal amount,
    @Pattern(regexp = "^(未发货|已发货|已完成|已取消)$", message = "状态非法") String status
) {}
import org.springframework.stereotype.Service;
import java.util.stream.Collectors;

@Service
public class OrderExtractionService {

    private final ChatClient chatClient;
    private final Validator validator;   // Spring Boot 自动装配

    private static final String TEMPLATE = """
            从下面的文本中提取订单信息。

            规则:
            1. 找不到的字段返回 null,禁止猜测。
            2. 金额只输出数字,不带单位和符号。
            3. 只输出 JSON,不要任何解释文字,不要用代码块包裹。

            文本:
            {input}

            {format}
            """;

    public OrderInfo extract(String input) {
        OrderInfo order = chatClient.prompt()
                .user(u -> u.text(TEMPLATE).param("input", input))
                .options(OpenAiChatOptions.builder()
                        .temperature(0.0)      // ★ 抽取类任务温度设为 0,追求确定性
                        .build())
                .call()
                .entity(OrderInfo.class);

        // 业务校验:不合格直接抛,由全局异常处理器翻译成 400
        var violations = validator.validate(order);
        if (!violations.isEmpty()) {
            String msg = violations.stream()
                    .map(ConstraintViolation::getMessage)
                    .collect(Collectors.joining(";"));
            throw new IllegalArgumentException("提取结果校验失败:" + msg);
        }
        return order;
    }
}

校验失败怎么办:重试 with 错误反馈

/**
 * 带自动重试的提取:把校验失败的原因回传给模型,让它自己修正。
 * 这是生产环境的标准做法——重试 1~2 次能解决绝大部分格式问题。
 */
public OrderInfo extractWithRetry(String input, int maxAttempts) {
    String lastError = null;

    for (int attempt = 1; attempt <= maxAttempts; attempt++) {
        String userText = TEMPLATE.replace("{input}", input);
        if (lastError != null) {
            // ★ 关键:把上次的错误当成新指令喂回去
            userText += "\n\n注意:上一次的输出存在问题(" + lastError
                      + "),请修正后重新输出。";
        }
        try {
            OrderInfo order = chatClient.prompt()
                    .user(userText)
                    .options(OpenAiChatOptions.builder().temperature(0.0).build())
                    .call()
                    .entity(OrderInfo.class);

            var violations = validator.validate(order);
            if (violations.isEmpty()) {
                return order;                    // 成功
            }
            lastError = violations.iterator().next().getMessage();
        } catch (Exception e) {
            // 解析失败(返回的不是合法 JSON),同样记为错误重试
            lastError = "返回内容不是合法 JSON:" + e.getMessage();
        }
        log.warn("第 {} 次提取失败:{}", attempt, lastError);
    }
    // 全部失败:返回 null 或抛业务异常,让上游决定降级策略(如转人工)
    throw new IllegalStateException("订单信息提取失败,已重试 " + maxAttempts + " 次");
}
记住这条工程原则:凡是让模型输出结构化数据的场景,都必须有 「Schema 约束 → 反序列化 → 业务校验 → 失败重试 → 最终降级」这五层防线。 只有第一层(提示词里写「请输出 JSON」)就上线的项目,迟早会在生产环境炸掉。

3.5Advisor 机制:把横切逻辑从业务里抽出来

假设现在有 30 个 AI 接口,你希望每个接口都:打印请求日志、统计 Token 消耗、做敏感词过滤、失败自动重试。 如果写在每个 Service 里,就是 30 份复制粘贴——这正是 Spring AOP 要解决的问题,而 Spring AI 给出的答案是 Advisor。

对比项Spring MVCSpring AI
切面接口HandlerInterceptor / FilterCallAdvisor / StreamAdvisor
顺序控制Ordered 接口getOrder(),值小的在外层(先执行)
链式调用chain.doFilter()chain.nextCall(request)
能否改写请求能(包装 request)能(改写 Prompt / 注入消息)
能否改写响应能(包装 response)能(改写 ChatClientResponse)

Advisor 的执行模型

请求 ──▶ Advisor(A) 前置 └──▶ Advisor(B) 前置 └──▶ Advisor(C) 前置 └──▶ ChatModel ──▶ 模型 ◀── Advisor(C) 后置 ◀── Advisor(B) 后置 请求 ◀── Advisor(A) 后置 getOrder() 越小 → 越靠外层 → 前置先执行、后置最后执行(像洋葱一样)
2.0 改了默认顺序常量,涉及工具调用的项目务必留意。 Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER 从 HIGHEST_PRECEDENCE + 1000 调整为 HIGHEST_PRECEDENCE + 200, 而 2.0 新增的 ToolCallingAdvisor 默认在 HIGHEST_PRECEDENCE + 300。
这意味着默认情况下「记忆 Advisor 在工具调用循环之外」:循环前加载一次历史, 循环结束后只持久化最终的 user / assistant 消息,看不到中间的 tool-call 与 tool-response 消息。
需要完整工具转录(做审计用)时,把 memory advisor 的 order 设到 HIGHEST_PRECEDENCE + 400 之上,让它落到循环内部。第 6 章会展开。
顺序陷阱:如果你希望「先做内容安全过滤,再做 RAG 检索」, 那么安全 Advisor 的 order 必须小于 RAG Advisor 的 order。 很多人凭直觉以为 order 小是「后执行」,结果顺序全反了。 记忆口诀:order 小 = 离用户近 = 先看到用户输入。

内置 Advisor 一览(课程会逐步用到)

Advisor作用章节
SimpleLoggerAdvisor打印请求/响应,调试用1
MessageChatMemoryAdvisor以多条消息形式注入对话历史2 / 6
ToolCallingAdvisor2.0 新增:接管工具调用循环,ChatClient 自动注册7 / 9
ToolSearchToolCallingAdvisor2.0 新增:渐进式工具披露,工具多时省 34%~64% token7
StructuredOutputValidationAdvisor结构化输出校验失败自动重试(可自动注册)3
QuestionAnswerAdvisor自动做向量检索并拼进提示词,RAG 最简方案5
RetrievalAugmentationAdvisor模块化 RAG:可插查询改写、多路检索、重排5
StructuredOutputValidationAdvisor结构化输出失败自动重试校验3
SafeGuardAdvisor简单的敏感词拦截3

3.6自定义 Advisor 实战:Token 统计 + 内容安全

我们一次写两个,覆盖「后置读取元数据」和「前置拦截改写」两类典型需求。

① Token 用量与耗时统计 Advisor

package com.example.springai.common.advisor;

import io.micrometer.core.instrument.MeterRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;

/**
 * 统计每次模型调用的输入/输出 Token 与耗时。
 * 同时把指标写进 Micrometer,第 10 章可在 Prometheus/Grafana 里看趋势。
 */
@Slf4j
@Component
public class TokenUsageAdvisor implements CallAdvisor {

    private final MeterRegistry meterRegistry;

    public TokenUsageAdvisor(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest request,
                                          CallAdvisorChain chain) {
        long start = System.currentTimeMillis();

        // ★ 前置逻辑:在调用模型之前
        // ...(这里可以改写 request、记录开始时间、打日志)

        // ★ 交给链上的下一个 Advisor / 最终交给模型
        ChatClientResponse response = chain.nextCall(request);

        // ★ 后置逻辑:拿到响应之后
        long cost = System.currentTimeMillis() - start;
        var usage = response.chatResponse() == null ? null
                : response.chatResponse().getMetadata().getUsage();

        if (usage != null) {
            log.info("[AI用量] 耗时={}ms 输入={} 输出={} 合计={}",
                    cost, usage.getPromptTokens(),
                    usage.getCompletionTokens(), usage.getTotalTokens());

            // 埋点:第 10 章用 Grafana 画"每日 Token 成本"曲线
            meterRegistry.counter("ai.tokens.prompt")
                    .increment(usage.getPromptTokens());
            meterRegistry.counter("ai.tokens.completion")
                    .increment(usage.getCompletionTokens());
        }
        meterRegistry.timer("ai.call.duration").record(cost, java.util.concurrent.TimeUnit.MILLISECONDS);

        return response;
    }

    /**
     * 顺序:设成最小,尽量包在最外层,这样能统计到包含其他 Advisor 在内的总耗时。
     */
    @Override
    public int getOrder() {
        return 0;
    }

    @Override
    public String getName() {
        return "TokenUsageAdvisor";
    }
}

② 内容安全 Advisor(前置拦截 + 改写)

@Slf4j
@Component
public class SafeGuardAdvisor implements CallAdvisor {

    /** 简化版敏感词表;生产环境应放数据库或接入内容安全服务 */
    private static final List<String> BLOCKED = List.of(
            "身份证号", "银行卡密码", "内部机密");

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest request,
                                          CallAdvisorChain chain) {

        // 取出本次请求的用户文本。Prompt 里最后一条通常是当前用户消息
        String userText = extractUserText(request);

        for (String word : BLOCKED) {
            if (userText.contains(word)) {
                log.warn("命中敏感词:{},已拦截", word);
                // ★ 关键:直接返回构造好的响应,不再调用 chain.nextCall(),
                //   这样请求根本不会打到模型,既省钱又安全
                return blockedResponse(request, "抱歉,您的请求包含不允许的内容。");
            }
        }
        return chain.nextCall(request);
    }

    private String extractUserText(ChatClientRequest request) {
        return request.prompt().getInstructions().stream()
                .filter(m -> m.getMessageType() == MessageType.USER)
                .map(m -> m.getText() == null ? "" : m.getText())
                .reduce("", (a, b) -> a + "\n" + b);
    }

    private ChatClientResponse blockedResponse(ChatClientRequest request, String msg) {
        // 构造一个"假的"模型响应,字段结构与真实响应一致
        AssistantMessage message = new AssistantMessage(msg);
        ChatResponse chatResponse = new ChatResponse(
                List.of(new Generation(message)));
        return ChatClientResponse.builder()
                .chatResponse(chatResponse)
                .context(request.context())
                .build();
    }

    @Override
    public int getOrder() {
        // ★ 必须比其他 Advisor 小,保证最先检查用户输入
        return Ordered.HIGHEST_PRECEDENCE;
    }

    @Override
    public String getName() { return "SafeGuardAdvisor"; }
}

③ 挂载 Advisor

// 全局挂载:所有请求都生效
ChatClient chatClient = ChatClient.builder(chatModel)
        .defaultAdvisors(safeGuardAdvisor, tokenUsageAdvisor)
        .build();

// 单次请求挂载:只对这次生效(临时调试很方便)
chatClient.prompt()
        .user("...")
        .advisors(new SimpleLoggerAdvisor())
        .call()
        .content();

// 通过上下文给 Advisor 传参(Advisor 里用 request.context().get("key") 取)
chatClient.prompt()
        .user("...")
        .advisors(a -> a.param("tenantId", "t001"))
        .call()
        .content();
Advisor 里绝对不能做的事:
  1. 不要吞异常。Advisor 里 catch 了却返回 null,会让上层拿到空指针,排查成本极高;要么包装成业务异常抛出,要么返回明确的错误响应。
  2. 不要在 Advisor 里做重 IO。Advisor 是每个请求都过的路径,一次同步数据库查询就会拖慢所有接口。要 IO 就加缓存。
  3. 不要依赖可变共享状态。Advisor 是单例,请用局部变量或 ThreadLocal(并记得清理),否则并发下会串数据。

3.7完整示例:订单信息提取服务

把本章能力组装成完整可运行的服务:结构化输出 + 校验 + 重试 + Advisor 治理。

src/main/java/com/example/springai/ch03/ ├── model/ │ ├── OrderInfo.java # record,含 @JsonPropertyDescription 与校验注解 │ └── ExtractResult.java # 包装结果:成功数据 + 置信度 + 重试次数 ├── OrderExtractionService.java # 核心:提取 + 校验 + 重试 └── OrderExtractionController.java # POST /api/v1/orders/extract
package com.example.springai.ch03.model;

import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

public record OrderInfo(

    @JsonPropertyDescription("订单号,纯数字字符串,例如 2024001;找不到则返回 null")
    String orderNo,

    @JsonPropertyDescription("客户姓名,不含称谓")
    String customerName,

    @NotNull(message = "金额缺失")
    @DecimalMin(value = "0", message = "金额不能为负")
    @JsonPropertyDescription("订单总金额,只输出数字如 899.00,不带单位")
    BigDecimal amount,

    @Pattern(regexp = "^(未发货|已发货|已完成|已取消)$",
             message = "状态必须是:未发货/已发货/已完成/已取消")
    @JsonPropertyDescription("订单状态,四选一")
    String status,

    @JsonPropertyDescription("下单日期,格式 yyyy-MM-dd;无法确定则 null")
    LocalDate orderDate,

    @JsonPropertyDescription("商品名称列表;没有则返回空数组 []")
    List<String> items,

    @JsonPropertyDescription("联系电话原文,保持原样不做脱敏")
    String phone
) {}
package com.example.springai.ch03;

/** 提取结果包装:让上游知道"这是几次尝试拿到的、可靠性如何" */
public record ExtractResult(OrderInfo data, int attempts, boolean success, String message) {}
@Slf4j
@Service
public class OrderExtractionService {

    private final ChatClient chatClient;
    private final Validator validator;

    public OrderExtractionService(ChatClient chatClient, Validator validator) {
        this.chatClient = chatClient;
        this.validator = validator;
    }

    private static final String TEMPLATE = """
            你是一名订单信息抽取助手。请从下面的文本中提取订单结构。

            抽取规则:
            1. 找不到的字段返回 null(列表返回 []),严禁编造或推测。
            2. 金额只输出数字,不带"元""¥"等符号。
            3. 状态只能从四个值里选,无法判断时返回 null。
            4. 只输出 JSON 对象本身,不要解释、不要代码块标记。

            待抽取文本:
            {input}

            {format}
            """;

    public ExtractResult extract(String input) {
        final int MAX = 3;
        String lastError = null;

        for (int attempt = 1; attempt <= MAX; attempt++) {
            String userText = TEMPLATE
                    .replace("{input}", input)
                    .replace("{format}", "");  // format 由 entity() 自动注入

            if (lastError != null) {
                userText += "\n\n【重要】上次输出有问题:" + lastError + ",请修正。";
            }

            try {
                OrderInfo order = chatClient.prompt()
                        .user(userText)
                        // 抽取类任务:温度必须为 0,追求可复现
                        .options(OpenAiChatOptions.builder().temperature(0.0).build())
                        .call()
                        .entity(OrderInfo.class);

                var violations = validator.validate(order);
                if (violations.isEmpty()) {
                    return new ExtractResult(order, attempt, true, "OK");
                }
                lastError = violations.stream()
                        .map(ConstraintViolation::getMessage)
                        .collect(Collectors.joining(";"));

            } catch (Exception e) {
                // 常见:模型输出了非 JSON(含解释文字 / 代码块包裹)
                lastError = "输出不是合法 JSON(" + e.getClass().getSimpleName() + ")";
                log.warn("第 {} 次提取失败:{}", attempt, lastError);
            }
        }
        // 三次都失败:不抛异常,返回失败标记,让上游走人工/降级流程
        return new ExtractResult(null, MAX, false,
                "自动提取失败,请转人工处理。最后错误:" + lastError);
    }
}
@RestController
@RequestMapping("/api/v1/orders")
public class OrderExtractionController {

    private final OrderExtractionService service;

    public OrderExtractionController(OrderExtractionService service) {
        this.service = service;
    }

    @PostMapping("/extract")
    public ExtractResult extract(@RequestBody Map<String, String> body) {
        return service.extract(body.get("text"));
    }
}

验证

curl -X POST http://localhost:8080/api/v1/orders/extract \
  -H "Content-Type: application/json" \
  -d '{"text":"你好,我是张三,订单 2024001 在 3 月 5 日下的单,金额 899 元,买了一个机械键盘和一个鼠标垫,现在还没发货,联系电话 13800008888"}'

# 期望输出(字段名与 record 完全一致):
# {"data":{"orderNo":"2024001","customerName":"张三","amount":899.00,
#          "status":"未发货","orderDate":"2024-03-05",
#          "items":["机械键盘","鼠标垫"],"phone":"13800008888"},
#  "attempts":1,"success":true,"message":"OK"}

# 故意给一段信息不全的文本,验证"不编造"规则
curl -X POST http://localhost:8080/api/v1/orders/extract \
  -H "Content-Type: application/json" \
  -d '{"text":"帮我看看那个订单发货了没有"}'

# 期望:orderNo/amount 为 null,success=false(因为 amount 有 @NotNull),
#       最终返回"请转人工处理" —— 这正是一个"不瞎编"的正确表现

3.8常见坑排错

现象原因解决
JsonParseException: Unexpected character '`'模型用 ```json 代码块包裹了输出提示词里明确「不要代码块标记」;或先做一次字符串清洗:剥掉首尾的 ```
JsonParseException: Unrecognized token '好的'模型在 JSON 前写了客套话/推理过程提示词加「只输出 JSON」;把推理与输出拆成两次调用
对象字段全是 null字段名与 JSON key 不一致(大小写/下划线)用 @JsonProperty("order_no") 显式映射;或统一命名风格
金额映射失败 Cannot deserialize value of type BigDecimal from String "899元"模型带上了单位字段描述里写「只输出数字不带单位」;或在 record 里定义紧凑构造器做清洗
日期解析失败模型返回 2024/3/5 或「3月5日」字段描述里写死格式 yyyy-MM-dd;或改用 String 接收后自己解析
entity() 偶尔成功偶尔失败temperature 太高,输出不稳定抽取类任务 temperature 设 0;并加重试
自定义 Advisor 没生效没注册进 defaultAdvisors,或 order 冲突检查是否被 Spring 扫描到;打印 getName() 确认;检查 order 值
敏感词拦截后返回 500构造的 ChatResponse 结构不完整确保 getResult() 非空;必要时改成抛业务异常,由全局处理器返回 400

3.9自测题

Q1. .entity(OrderInfo.class) 背后到底做了什么?

四步:① 用 BeanOutputConverter 反射生成 JSON Schema;② 把 schema + 「必须输出符合此 schema 的 JSON」指令追加到提示词; ③ 调用模型后取出文本,清洗(容忍代码块包裹);④ 用 Jackson 反序列化成目标类型。

Q2. 为什么抽取类任务的 temperature 要设成 0?

temperature 控制采样随机性。抽取任务要求「同一输入稳定产出同一结果」,随机性越低越好。 设 0 时模型倾向于选概率最高的 token,输出更确定、更可复现,也更容易通过回归测试。

Q3. Advisor 的 order 值是 0 和 100,哪个先执行前置逻辑?

0 先执行。order 小的在外层,前置逻辑先跑、后置逻辑最后跑。 所以「内容安全」这类要先看到用户输入的 Advisor,应该用很小的 order(甚至 Ordered.HIGHEST_PRECEDENCE)。

Q4. 结构化输出已经有 schema 约束了,为什么还要 jаkarta validation 校验?

Schema 只约束形状(字段有没有、类型对不对),不约束业务语义。 模型完全可能给出格式合法但业务荒谬的数据(金额为负、状态值为「已发货啦」)。 Schema 是第一道防线,JSR-303 是第二道,重试是第三道,降级是第四道。

Q5.【动手题】写一个 Advisor:每次调用后把「用户问题 + 模型回答 + Token 数」写入一张 MySQL 审计表。要求不能拖慢主流程。

提示:Advisor 里把记录扔进一个 BlockingQueue 或 Spring 的 ApplicationEventPublisher, 由独立的消费线程/监听器批量写库;绝对不要在 Advisor 里同步执行 insert。 这是「审计与可观测性」最常用的异步化模式。

本章验收标准:
  • 能说清 entity() 的四个内部步骤,并能手写 converter 版本等价实现。
  • 订单提取接口在正常输入下返回完整的 OrderInfo,在信息不足时不编造。
  • 能解释为什么金额用 BigDecimal、为什么温度设 0。
  • 自定义的 Token 统计 Advisor 能在日志里打印出准确的 Token 数。
  • 能画出 Advisor 的洋葱模型并说明 order 的语义。