Featured image of post Spring AI结构化输出实战

Spring AI结构化输出实战

什么是结构化输出?

在使用大语言模型(LLM)时,模型默认输出的是自由文本。但在企业级应用中,我们往往需要将 AI 的响应解析成结构化的 Java 对象,以便后续业务处理。例如:

  • 简历分析:提取评分、优点、改进建议
  • 订单处理:解析订单号、金额、商品列表
  • 内容审核:返回审核结果、类别、置信度

Spring AI 提供了 BeanOutputConverter 来实现这一功能,而我们需要解决的问题是:LLM 输出不稳定时的重试机制

项目集成

Maven 依赖

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<properties>
    <spring-ai.version>2.0.0-M4</spring-ai.version>
</properties>

<dependencies>
    <!-- Spring AI OpenAI Starter -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>

    <!-- Micrometer(可选,用于指标监控) -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-core</artifactId>
    </dependency>
</dependencies>

配置类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Data
@Component
@ConfigurationProperties(prefix = "app.ai")
public class StructuredOutputProperties {

    // 最大重试次数(默认2次)
    private int structuredMaxAttempts = 2;

    // 重试时是否包含上次错误信息
    private boolean structuredIncludeLastError = true;

    // 重试时是否使用修复提示词
    private boolean structuredRetryUseRepairPrompt = true;

    // 重试时是否追加严格的 JSON 格式说明
    private boolean structuredRetryAppendStrictJsonInstruction = true;

    // 错误消息最大长度
    private int structuredErrorMessageMaxLength = 200;

    // 是否启用指标监控
    private boolean structuredMetricsEnabled = true;
}

配置文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      base-url: https://api.openai.com

app:
  ai:
    structured-max-attempts: 2
    structured-include-last-error: true
    structured-retry-use-repair-prompt: true
    structured-retry-append-strict-json-instruction: true
    structured-error-message-max-length: 200
    structured-metrics-enabled: true

核心实现:StructuredOutputInvoker

设计思路

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
用户调用 invoke()
    ├── 第一次尝试
    │       │
    │       └── 成功 → 返回结果
    │       │
    │       └── 失败 → 构建重试提示词(含错误信息)
    ├── 第二次尝试
    │       │
    │       └── 成功 → 返回结果
    │       │
    │       └── 失败 → 记录指标,抛出业务异常
    └── 达到最大重试 → 抛出异常

完整代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
@Component
public class StructuredOutputInvoker {

    private static final String STRICT_JSON_INSTRUCTION = """
    请仅返回可被 JSON 解析器直接解析的 JSON 对象,并严格满足字段结构要求:
    1) 不要输出 Markdown 代码块(如 ```json)。
    2) 不要输出任何解释文字、前后缀、注释。
    3) 所有字符串内引号必须正确转义。
    """;

    private static final String METRIC_INVOCATIONS = "app.ai.structured_output.invocations";
    private static final String METRIC_ATTEMPTS = "app.ai.structured_output.attempts";
    private static final String METRIC_LATENCY = "app.ai.structured_output.latency";

    private final int maxAttempts;
    private final boolean includeLastErrorInRetryPrompt;
    private final boolean retryUseRepairPrompt;
    private final boolean retryAppendStrictJsonInstruction;
    private final int errorMessageMaxLength;
    private final boolean metricsEnabled;
    private final MeterRegistry meterRegistry;

    public StructuredOutputInvoker(
        StructuredOutputProperties properties,
        @Autowired(required = false) MeterRegistry meterRegistry
    ) {
        this.maxAttempts = Math.max(1, properties.getStructuredMaxAttempts());
        this.includeLastErrorInRetryPrompt = properties.isStructuredIncludeLastError();
        this.retryUseRepairPrompt = properties.isStructuredRetryUseRepairPrompt();
        this.retryAppendStrictJsonInstruction = properties.isStructuredRetryAppendStrictJsonInstruction();
        this.errorMessageMaxLength = Math.max(20, properties.getStructuredErrorMessageMaxLength());
        this.metricsEnabled = properties.isStructuredMetricsEnabled();
        this.meterRegistry = meterRegistry;
    }

    public <T> T invoke(
        ChatClient chatClient,
        String systemPromptWithFormat,
        String userPrompt,
        BeanOutputConverter<T> outputConverter,
        ErrorCode errorCode,
        String errorPrefix,
        String logContext,
        Logger log
    ) {
        long startNanos = System.nanoTime();
        String contextTag = normalizeContextTag(logContext);
        Exception lastError = null;

        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            // 构建提示词:首次尝试用原始提示词,重试时追加错误信息
            String attemptSystemPrompt = attempt == 1
                ? systemPromptWithFormat
                : buildRetrySystemPrompt(systemPromptWithFormat, lastError);

            try {
                T result = chatClient.prompt()
                    .system(attemptSystemPrompt)
                    .user(userPrompt)
                    .call()
                    .entity(outputConverter);

                recordAttempt(contextTag, STATUS_SUCCESS);
                recordInvocation(contextTag, STATUS_SUCCESS, startNanos);
                return result;
            } catch (Exception e) {
                lastError = e;
                recordAttempt(contextTag, STATUS_FAILURE);
                if (attempt < maxAttempts) {
                    log.warn("{}结构化解析失败,准备重试: attempt={}/{}, error={}",
                        logContext, attempt, maxAttempts, e.getMessage());
                } else {
                    log.error("{}结构化解析失败,已达最大重试次数: attempts={}, error={}",
                        logContext, maxAttempts, e.getMessage());
                }
            }
        }

        recordInvocation(contextTag, STATUS_FAILURE, startNanos);
        throw new BusinessException(
            errorCode,
            errorPrefix + (lastError != null ? lastError.getMessage() : "unknown")
        );
    }

    /**
     * 构建重试提示词
     * 在原始提示词基础上追加:
     * 1. 严格的 JSON 格式说明
     * 2. 上次失败的原因
     */
    private String buildRetrySystemPrompt(String systemPromptWithFormat, Exception lastError) {
        if (!retryUseRepairPrompt) {
            return systemPromptWithFormat;
        }

        StringBuilder prompt = new StringBuilder(systemPromptWithFormat)
            .append("\n\n");

        if (retryAppendStrictJsonInstruction) {
            prompt.append(STRICT_JSON_INSTRUCTION).append('\n');
        }
        prompt.append("上次输出解析失败,请仅返回合法 JSON。");

        if (includeLastErrorInRetryPrompt && lastError != null && lastError.getMessage() != null) {
            prompt.append("\n上次失败原因:")
                .append(sanitizeErrorMessage(lastError.getMessage()));
        }
        return prompt.toString();
    }

    private String sanitizeErrorMessage(String message) {
        // 错误信息转成单行,并限制长度
        String oneLine = message.replace('\n', ' ').replace('\r', ' ').trim();
        if (oneLine.length() > errorMessageMaxLength) {
            return oneLine.substring(0, errorMessageMaxLength) + "...";
        }
        return oneLine;
    }
}

使用示例

定义业务 DTO

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 简历分析响应
public record ResumeAnalysisResponse(
    int overallScore,           // 总分
    ScoreDetail scoreDetail,    // 各维度评分
    String summary,             // 摘要
    List<String> strengths,     // 优点列表
    List<Suggestion> suggestions // 改进建议
) {
    public record ScoreDetail(
        int contentScore,        // 内容完整性
        int structureScore,      // 结构清晰度
        int skillMatchScore,     // 技能匹配度
        int expressionScore,     // 表达专业性
        int projectScore         // 项目经验
    ) {}

    public record Suggestion(
        String category,         // 类别
        String priority,         // 优先级
        String issue,            // 问题描述
        String recommendation    // 建议
    ) {}
}

Service 中调用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@Service
@Slf4j
public class ResumeGradingService {

    private final ChatClient client;
    private final PromptTemplate sysPromptTemplate;
    private final PromptTemplate userPromptTemplate;
    private final BeanOutputConverter<ResumeAnalysisResponseDTO> outputConverter;
    private final StructuredOutputInvoker structuredOutputInvoker;

    public ResumeGradingService(
            ChatClient.Builder chatClientBuilder,
            ResourceLoader resourceLoader,
            ResumerAnalysisProperties properties,
            StructuredOutputInvoker structuredOutputInvoker
    ) throws IOException {
        this.client = chatClientBuilder.build();
        this.sysPromptTemplate = new PromptTemplate(
            resourceLoader.getResource(properties.getSystemPromptPath())
                .getContentAsString(StandardCharsets.UTF_8));
        this.userPromptTemplate = new PromptTemplate(
            resourceLoader.getResource(properties.getUserPromptPath())
                .getContentAsString(StandardCharsets.UTF_8));
        this.outputConverter = new BeanOutputConverter<>(ResumeAnalysisResponseDTO.class);
        this.structuredOutputInvoker = structuredOutputInvoker;
    }

    public ResumeAnalysisResponse analyzeResume(String content) {
        log.info("开始分析简历,文本长度: {} 字符", content.length());

        // 使用内部 DTO 接收 AI 响应
        ResumeAnalysisResponseDTO dto = structuredOutputInvoker.invoke(
            client,
            buildSystemPrompt(),          // 系统提示词(含 JSON 格式要求)
            buildUserPrompt(content),     // 用户提示词
            outputConverter,              // 输出转换器
            ErrorCode.AI_INVOKE_FAILED,   // 错误码
            "简历分析失败:",              // 错误前缀
            "resume-grading",             // 日志上下文
            log
        );

        // 转换为业务响应对象
        return toResponse(dto);
    }

    private String buildSystemPrompt() {
        return sysPromptTemplate.render() + "\n\n" + outputConverter.getFormat();
    }

    private String buildUserPrompt(String content) {
        return userPromptTemplate.render(Map.of("content", content));
    }
}

系统提示词示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
你是一个专业的简历评估专家。请分析以下简历,给出评分和改进建议。
请以 JSON 格式返回,字段必须严格遵循以下格式:
{
  "overallScore": 85,           // 总分,0-100
  "scoreDetail": {              // 各维度评分
    "contentScore": 20,         // 内容完整性,0-25
    "structureScore": 18,       // 结构清晰度,0-20
    "skillMatchScore": 22,      // 技能匹配度,0-25
    "expressionScore": 12,      // 表达专业性,0-15
    "projectScore": 13          // 项目经验,0-15
  },
  "summary": "这是一份结构清晰的简历...",
  "strengths": ["技术栈丰富", "项目经验详实"],
  "suggestions": [
    {
      "category": "内容",
      "priority": "中",
      "issue": "缺少量化的工作成果",
      "recommendation": "建议添加具体的数据指标"
    }
  ]
}

重试策略详解

首次尝试 vs 重试尝试

项目 首次尝试 重试尝试
系统提示词 原始提示词 原始提示词 + 修复提示词
JSON 格式说明 包含在原始提示词中 可选择追加
错误信息 可选择追加上次错误

重试提示词构建流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
1. 判断是否启用重试修复(retryUseRepairPrompt)
   └── 否:直接返回原始提示词

2. 追加严格 JSON 格式说明(可选)
   └── STRICT_JSON_INSTRUCTION:
       "请仅返回可被 JSON 解析器直接解析的 JSON 对象..."

3. 追加修复指令
   └── "上次输出解析失败,请仅返回合法 JSON。"

4. 追加上次错误原因(可选)
   └── "上次失败原因:{错误信息}"

指标监控

监控指标

指标名称 类型 标签 说明
app.ai.structured_output.invocations Counter context, status 调用次数
app.ai.structured_output.attempts Counter context, status 尝试次数(含重试)
app.ai.structured_output.latency Timer context, status 调用延迟

上下文标签规范化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
private String normalizeContextTag(String raw) {
    String source = (raw == null || raw.isBlank()) ? "unknown" : raw;
    // 转小写,空格替换为下划线
    String normalized = source.toLowerCase(Locale.ROOT).trim().replace(' ', '_');
    // 移除非字母数字下划线字符
    normalized = NON_ALNUM_PATTERN.matcher(normalized).replaceAll("_");
    // 压缩多个下划线为一个
    normalized = MULTI_UNDERSCORE.matcher(normalized).replaceAll("_");
    // 去除首尾下划线
    normalized = normalized.replaceAll("^_+|_+$", "");
    // 截断超长标签
    if (normalized.length() > MAX_CONTEXT_TAG_LENGTH) {
        normalized = normalized.substring(0, MAX_CONTEXT_TAG_LENGTH);
    }
    return normalized;
}

常见问题处理

1. LLM 输出 Markdown 代码块

问题:LLM 习惯性输出 ```json {...} ```,导致解析失败。

解决方案:在系统提示词中明确禁止,并在重试时追加:

1
2
3
4
5
private static final String STRICT_JSON_INSTRUCTION = """
请仅返回可被 JSON 解析器直接解析的 JSON 对象:
1) 不要输出 Markdown 代码块(如 ```json)。
2) 不要输出任何解释文字、前后缀、注释。
""";

2. 字符串引号未转义

问题:JSON 中字符串内容包含引号,导致解析失败。

解决方案:同上,严格要求输出格式。

3. 部分字段缺失

问题:LLM 省略了部分可选字段。

解决方案

  • DTO 字段使用包装类型(Integer 而非 int)
  • 提供完整的默认提示词模板

与 Spring AI BeanOutputConverter 结合

BeanOutputConverter 是 Spring AI 提供的结构化输出转换器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 创建转换器,指定目标类型
BeanOutputConverter<ResumeAnalysisResponseDTO> outputConverter =
    new BeanOutputConverter<>(ResumeAnalysisResponseDTO.class);

// 获取 JSON 格式说明(用于系统提示词)
String format = outputConverter.getFormat();
// 输出示例:
// {
//   "overallScore": int,
//   "scoreDetail": {
//     "contentScore": int,
//     ...
//   },
//   ...
// }

// 调用 AI 并自动转换
ResumeAnalysisResponseDTO dto = chatClient.prompt()
    .system(systemPrompt + "\n\n" + format)
    .user(userPrompt)
    .call()
    .entity(outputConverter);

最佳实践

1. 合理设置重试次数

1
2
3
app:
  ai:
    structured-max-attempts: 2  # 一般2-3次足够

2. 提示词设计要点

  • 明确输出格式,使用 BeanOutputConverter.getFormat()
  • 给出每个字段的具体说明和取值范围
  • 对复杂结构提供示例

3. 错误处理

1
2
3
4
5
6
7
try {
    return structuredOutputInvoker.invoke(...);
} catch (BusinessException e) {
    // 记录日志,返回友好错误或降级结果
    log.error("AI调用失败", e);
    return getDefaultResponse();
}

4. 指标监控

启用 Micrometer 后,可通过 Prometheus/Grafana 监控:

  • 调用成功率
  • 重试率
  • 平均延迟

总结

StructuredOutputInvoker 封装了:

  1. 调用逻辑:使用 BeanOutputConverter 实现结构化输出
  2. 重试策略:根据配置自动重试,追加修复提示词
  3. 错误处理:规范化错误信息,区分首次和重试场景
  4. 指标监控:记录调用次数、重试次数、延迟等指标

通过这一封装,我们可以稳定地从 LLM 获取结构化数据,无需关心底层重试逻辑。

使用 Hugo 构建
主题 StackJimmy 设计

发布了 29 篇文章 | 共 67213 字