目录
[一、Structured Output / Output Converter 转换](#一、Structured Output / Output Converter 转换)
[四种 Converter](#四种 Converter)
[StructuredOutputConverter 接口](#StructuredOutputConverter 接口)
[三大核心 Converter 对比](#三大核心 Converter 对比)
[BeanOutputConverter 详解](#BeanOutputConverter 详解)
[定义 DTO](#定义 DTO)
[基础用法(ChatModel 层)](#基础用法(ChatModel 层))
[高级用法(ChatClient.entity() 简化版)](#高级用法(ChatClient.entity() 简化版))
[MapOutputConverter 详解](#MapOutputConverter 详解)
[ChatClient 简化方式](#ChatClient 简化方式)
[ListOutputConverter 详解](#ListOutputConverter 详解)
[ChatModel 层手动用法(对比理解)](#ChatModel 层手动用法(对比理解))
[Format 指令的生成原理](#Format 指令的生成原理)
[二、ETL 管道](#二、ETL 管道)
[DocumentReader 列表](#DocumentReader 列表)
[DocumentTransformer 列表](#DocumentTransformer 列表)
[一行代码串联 ETL](#一行代码串联 ETL)
[完整代码:PDF 到向量库](#完整代码:PDF 到向量库)
[三、Observability 可观测性](#三、Observability 可观测性)
[Trace 视图(在 Zipkin/Jaeger 中)](#Trace 视图(在 Zipkin/Jaeger 中))
[Prometheus + Grafana](#Prometheus + Grafana)
[集成 Zipkin + Prometheus](#集成 Zipkin + Prometheus)
[四、Retry & Resilience 弹性机制](#四、Retry & Resilience 弹性机制)
[RetryClient 工作流程](#RetryClient 工作流程)
[Spring Retry 集成](#Spring Retry 集成)
[包装 ChatClient](#包装 ChatClient)
[Resilience4j 熔断(断路器)](#Resilience4j 熔断(断路器))
[五、Evaluation 模型评估](#五、Evaluation 模型评估)
[内置 Evaluator](#内置 Evaluator)
[核心 API](#核心 API)
一、Structured Output / Output Converter 转换
概念
.entity() 内部其实用的就是 Output Converter。Spring AI 提供 4 种,直接控制模型输出的格式与反序列化方式。
核心原理(两步走):
-
调用前:转换器将期望的输出格式指令(Format Instruction)注入到 Prompt 中,引导模型生成指定结构。
-
调用后:转换器将模型返回的文本解析并反序列化为 Java 对象。
四种 Converter
| Converter | 输出类型 |
|---|---|
BeanOutputConverter |
单个 Java 对象(POJO/Record) |
ListOutputConverter |
List<String> |
MapOutputConverter |
Map<String,Object> |
IntegerOutputConverter / BigDecimalOutputConverter |
单值 |
StructuredOutputConverter 接口
java
StructuredOutputConverter<T>` 是顶层接口,它同时继承了 Spring 的 `Converter<String, T>` 和自定义的 `FormatProvider



-
FormatProvider.getFormat()--- 返回格式指令字符串,注入 Prompt 告诉模型"你应该按什么格式输出" -
Converter<String, T>--- 将模型的文本输出转换为目标类型 T 的实例
三大核心 Converter 对比
| Converter | 目标类型 | 格式策略 | 典型场景 |
|---|---|---|---|
BeanOutputConverter<T> |
Java Bean / Record | JSON Schema(DRAFT_2020_12) | 业务 DTO、实体类映射,适合字段固定的业务 DTO |
MapOutputConverter |
Map<String, Object> |
RFC8259 标准 JSON | 动态字段探索、快速原型,适合字段不确定的探索场景 |
ListOutputConverter |
List<String> |
逗号分隔文本 | 关键词列表、标签提取,适合关键词、标签等简单列表 |
BeanOutputConverter 详解
BeanOutputConverter 是生产环境最常用 的转换器。它根据 Java 类生成 JSON Schema,引导模型输出符合结构的 JSON,然后用 Jackson ObjectMapper 反序列化为 Java 对象。
定义 DTO
推荐使用 Java Record,配合 Jackson 注解提供字段描述:
java
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import java.util.List;
public record ActorsBooks(
@JsonProperty("actor")
@JsonPropertyDescription("作者姓名")
String actor,
@JsonProperty("books")
@JsonPropertyDescription("该作者创作的书籍列表")
List<String> books
) {}
注解说明:
@JsonProperty --- 明确指定 JSON 键名,避免因命名风格差异导致映射失败。 @JsonPropertyDescription --- 提供字段语义描述,Spring AI 会将其注入 Prompt,帮助模型理解字段含义。
基础用法(ChatModel 层)
java
// 1. 创建转换器
BeanOutputConverter<ActorsBooks> converter =
new BeanOutputConverter<>(ActorsBooks.class);
// 2. 获取格式指令
String format = converter.getFormat();
// 3. 构造 Prompt(将 format 注入模板)
String template = """
Generate the filmography of 5 movies for {actor}.
{format}
""";
Prompt prompt = new Prompt(
new PromptTemplate(template,
Map.of("actor", "Tom",
"format", format))
.createMessage()
);
// 4. 调用模型
Generation generation = chatClient.call(prompt).getResult();
// 5. 转换结果
ActorsBooks result = converter.convert(
generation.getOutput().getContent()
);
高级用法(ChatClient.entity() 简化版)
Spring AI 的 `ChatClient` 提供了更简洁的 `.entity()` API,内部自动完成格式注入和结果转换
java
// 单个对象
ActorsBooks result = chatClient.prompt()
.user("Generate the filmography of 5 books for Tom")
.call()
.entity(ActorsBooks.class);
// 列表对象(使用 ParameterizedTypeReference)
List<ActorsBooks> results = chatClient.prompt()
.user("Generate filmographies for 3 actors")
.call()
.entity(new ParameterizedTypeReference<List<ActorsBooks>>() {});
底层原理 :.entity() 方法内部会:
-
调用
converter.getFormat()获取格式指令、 -
将格式指令追加到 system message 中
-
调用模型获取文本响应
-
调用
converter.convert()反序列化为 Java 对象
MapOutputConverter 详解
MapOutputConverter 引导模型生成 RFC8259 标准的 JSON,然后转换为 Map<String, Object>。适合字段不确定 或探索阶段的场景
示例:生成数字映射
java
MapOutputConverter converter = new MapOutputConverter();
String format = converter.getFormat();
String template = """
Provide me a list of {subject}
{format}
""";
PromptTemplate promptTemplate = new PromptTemplate(template,
Map.of(
"subject", "an array of numbers from 1 to 9 under the key name 'numbers'",
"format", format
));
Prompt prompt = new Prompt(promptTemplate.createMessage());
Generation generation = chatClient.call(prompt).getResult();
Map<String, Object> result = converter.convert(
generation.getOutput().getContent()
);
// result => {"numbers": [1, 2, 3, 4, 5, 6, 7, 8, 9]}
ChatClient 简化方式
java
Map<String, Object> result = chatClient.prompt()
.user("List 5 popular programming languages with their key features")
.call()
.entity(new ParameterizedTypeReference<Map<String, Object>>() {});
ListOutputConverter 详解
ListOutputConverter 引导模型生成逗号分隔 的文本,然后转换为 List<String>。适合提取关键词、标签、简短列表等场景
示例:冰淇淋口味列表
java
ListOutputConverter converter =
new ListOutputConverter(new DefaultConversionService());
String format = converter.getFormat();
String template = """
List five {subject}
{format}
""";
PromptTemplate promptTemplate = new PromptTemplate(template,
Map.of("subject", "ice cream flavors",
"format", format));
Prompt prompt = new Prompt(promptTemplate.createMessage());
Generation generation = chatClient.call(prompt).getResult();
List<String> list = converter.convert(
generation.getOutput().getContent()
);
// list => ["Vanilla", "Chocolate", "Strawberry", "Mint", "Cookie Dough"]
ChatModel 层手动用法(对比理解)
如果不使用 ChatClient 的 .entity() 快捷方法,也可以手动在 ChatModel 层使用 Converter
java
@Autowired
private ChatModel chatModel;
public ActorsFilms manualConvert(String input) {
BeanOutputConverter<ActorsFilms> converter =
new BeanOutputConverter<>(ActorsFilms.class);
String format = converter.getFormat();
SystemMessage systemMessage = new SystemMessage(format);
UserMessage userMessage = new UserMessage(input);
String text = chatModel
.call(new Prompt(systemMessage, userMessage))
.getResult().getOutput().getText();
return converter.convert(text);
}
Format 指令的生成原理
`getFormat()` 是 Structured Output 的关键。以 `BeanOutputConverter` 为例,它内部会:
-
根据目标 Java 类(如 `ActorsFilms.class`)生成 **JSON Schema**
-
将 JSON Schema 包装成一段格式指令文本
-
这段文本会告知模型:"你的回复必须是 JSON 格式,不要包含解释,不要包含 Markdown 代码块,必须符合以下 Schema"
二、ETL 管道
概念
ETL(Extract-Transform-Load)是 RAG 的前置环节。Spring AI 把数据摄入抽象为三大组件:
-
DocumentReader :从 PDF/Word/HTML/Markdown/JSON 读取,输出
List<Document> -
DocumentTransformer :切片、清洗、增强,例如
TokenTextSplitter -
DocumentWriter:写入 VectorStore / 文件 / 数据库
完整架构
java
PDF ──┐
Word ─┼──► DocumentReader ──► DocumentTransformer ──► DocumentWriter ──► VectorStore
HTML ─┘ (Extract) (Transform) (Load) (向量库)
java
// DocumentReader --- 从数据源提取文档
public interface DocumentReader extends Supplier<List<Document>> {
default List<Document> read() { return get(); }
}
// DocumentTransformer --- 转换文档(拆分、清理、丰富)
public interface DocumentTransformer extends Function<List<Document>, List<Document>> {
default List<Document> transform(List<Document> docs) { return apply(docs); }
}
// DocumentWriter --- 加载到目标存储
public interface DocumentWriter extends Consumer<List<Document>> {
default void write(List<Document> docs) { accept(docs); }
}
DocumentReader 列表
| Reader | artifactId | 说明 |
|---|---|---|
TextReader |
spring-ai-core | 纯文本 |
PagePdfDocumentReader |
spring-ai-pdf-document-reader | PDF 按页 |
ParagraphPdfDocumentReader |
spring-ai-pdf-document-reader | PDF 按段落 |
TikaDocumentReader |
spring-ai-tika-document-reader | Word/Excel/PPT/HTML/RTF |
MarkdownDocumentReader |
spring-ai-markdown-document-reader | Markdown |
JsonReader |
spring-ai-core | JSON |
DocumentTransformer 列表
| Transformer | 作用 |
|---|---|
TokenTextSplitter |
按 Token 切片(默认,推荐) |
TextSplitter |
自定义切片基类 |
ContentFormatTransformer |
添加 markdown/format 标记 |
KeywordMetadataEnricher |
自动提取关键词到 metadata |
SummaryMetadataEnricher |
用 LLM 自动总结到 metadata |
一行代码串联 ETL
java
// 函数式写法
vectorStore.accept(tokenTextSplitter.apply(pdfReader.get()));
// 语义化写法
vectorStore.write(tokenTextSplitter.split(pdfReader.read()));
完整代码:PDF 到向量库
java
// ========== 1. Maven 依赖 ==========
// pom.xml
// <dependency>
// <groupId>org.springframework.ai</groupId>
// <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
// </dependency>
// <dependency>
// <groupId>org.springframework.ai</groupId>
// <artifactId>spring-ai-pdf-document-reader</artifactId>
// </dependency>
// <dependency>
// <groupId>org.springframework.ai</groupId>
// <artifactId>spring-ai-milvus-store-spring-boot-starter</artifactId>
// </dependency>
// ========== 2. application.yml ==========
// spring:
// ai:
// openai:
// api-key: ${DEEPSEEK_API_KEY}
// base-url: https://api.deepseek.com
// embedding:
// options:
// model: text-embedding-ada-002
// vectorstore:
// milvus:
// host: localhost
// port: 19530
// collection-name: rag_docs
// embedding-dimension: 1536
// ========== 3. ETL Service ==========
package com.example.demo.etl;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.DocumentReader;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.ai.transformer.DocumentTransformer;
import org.springframework.ai.transformer.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.List;
@Service
public class EtlPipelineService {
private final VectorStore vectorStore;
public EtlPipelineService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
// ---------- Demo1: PDF 文档入库 ----------
public void ingestPdf(String filePath) {
PagePdfDocumentReader reader = new PagePdfDocumentReader(
filePath,
PdfDocumentReaderConfig.builder()
.withPageTopMargin(0)
.withPagesPerDocument(1)
.build()
);
TokenTextSplitter splitter = new TokenTextSplitter(
512, // defaultChunkSize
64, // chunkOverlap
5, // minChunkSizeChars
10000, // maxNumChunks
true // keepSeparator
);
vectorStore.write(splitter.split(reader.read()));
}
// ---------- Demo2: 文本文件入库 ----------
public void ingestText(Resource file, String category) throws IOException {
TextReader reader = new TextReader(file);
reader.getCustomMetadata().put("source", file.getFilename());
TokenTextSplitter splitter = new TokenTextSplitter();
List<Document> docs = splitter.apply(reader.get());
docs.forEach(d -> d.getMetadata().put("category", category));
vectorStore.add(docs);
}
// ---------- Demo3: 相似度检索 ----------
public List<Document> search(String query, int topK, String category) {
return vectorStore.similaritySearch(
SearchRequest.query(query)
.withTopK(topK)
.withSimilarityThreshold(0.7)
.withFilterExpression("category == '" + category + "'")
);
}
}
切片策略选择
java
原文档 5000 tokens
│
├──► TokenTextSplitter(800, 200)
│ └─ chunk1: [0-800], chunk2: [600-1400], chunk3: [1200-2000]... (overlap=200)
│
└──► ParagraphPdfDocumentReader
└─ 按段落自然切分,语义完整但长度不均
参数说明:
-
defaultChunkSize: 单 chunk 目标 token(常用 500-1000) -
minChunkSizeChars: 最小字符数 -
minChunkSizeToEmbed: 可嵌入的最小长度 -
maxNumChunks: 防爆内存上限
三、Observability 可观测性
为什么重要
AI 应用上线后必须能看到:每次调用的 token 用量、哪一步耗时、Advisor 是否生效、RAG 检索质量。Spring AI 原生集成 Micrometer,开箱即用,天然对接 OpenTelemetry 协议,实现 Tracing(分布式追踪)、Metrics(指标监控)、Logging 三位一体。
集成
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
<groupId>io.zipkin.reporter2</groupId>
<artifactId>zipkin-reporter-brave</artifactId>
</dependency>
java
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
tracing:
sampling:
probability: 1.0 # 100% 采样(生产建议 0.1)
metrics:
tags:
application: spring-ai-demo
自动埋点的指标
Spring AI 自动采集以下指标(Micrometer Meter):
| Meter Name | 类型 | 含义 |
|---|---|---|
gen_ai.client.token.usage |
Counter | token 使用量(prompt/completion/total) |
gen_ai.client.operation |
Timer | 调用耗时 |
gen_ai.client.tool.call |
Counter | Tool 调用次数 |
Trace 视图(在 Zipkin/Jaeger 中)
java
trace-id: a1b2c3
├─ span: chat-client "prompt" (1.2s)
│ ├─ span: advisor "MemoryAdvisor#before" (5ms)
│ ├─ span: advisor "RAGAdvisor#before" (50ms)
│ │ └─ span: vector-store similaritySearch (45ms)
│ ├─ span: chat-model "openai.call" (1.1s)
│ │ └─ tag: gen_ai.usage.prompt_tokens=120
│ │ └─ tag: gen_ai.usage.total_tokens=300
│ └─ span: advisor "MemoryAdvisor#after" (3ms)
自定义埋点
java
@Bean
public ObservationHandler<ChatClientObservationContext> myHandler() {
return context -> {
if (context.getError().isPresent()) {
log.error("AI call failed", context.getError().get());
}
ChatResponse resp = context.getResponse();
if (resp != null) {
metrics.counter("custom.ai.tokens",
"model", resp.getMetadata().getModel()
).increment(resp.getMetadata().getUsage().getTotalTokens());
}
};
}
Prometheus + Grafana
java
management:
endpoints.web.exposure.include: prometheus
metrics.export.prometheus.enabled: true
Grafana 推荐看板:Spring AI Dashboard(token 趋势、P95 延迟、错误率、Tool 命中率)。
集成 Zipkin + Prometheus
java
// ========== 1. Maven 依赖 ==========
// <dependency>
// <groupId>io.micrometer</groupId>
// <artifactId>micrometer-tracing-bridge-brave</artifactId>
// </dependency>
// <dependency>
// <groupId>io.zipkin.reporter2</groupId>
// <artifactId>zipkin-reporter-brave</artifactId>
// </dependency>
// <dependency>
// <groupId>org.springframework.boot</groupId>
// <artifactId>spring-boot-starter-actuator</artifactId>
// </dependency>
// ========== 2. application.yml ==========
// management:
// endpoints:
// web:
// exposure:
// include: health,metrics,prometheus
// zipkin:
// tracing:
// endpoint: http://localhost:9411/api/v2/spans
// tracing:
// sampling:
// probability: 1.0
// metrics:
// export:
// prometheus:
// enabled: true
// ========== 3. 启动 Zipkin(Docker) ==========
// docker run -d --name zipkin -p 9411:9411 openzipkin/zipkin:latest
// ========== 4. 查看指标 ==========
// GET /actuator/metrics/spring.ai.chat.client.operation
// GET /actuator/metrics/gen_ai.client.token.usage
// ========== 5. 敏感数据控制 ==========
// 默认:Prompt/Completion 不导出(防泄露)
// spring:
// ai:
// chat:
// observations:
// include-prompt: false # true 有泄露风险
// include-completion: false
// include-error-logging: true
**安全警告:**include-prompt/completion 设为 true 会将完整对话导出到观测后端,生产环境请谨慎评估!
四、Retry & Resilience 弹性机制
为什么重要
LLM API 经常:
-
429 限流(尤其免费档)
-
502/503 网关错误
-
超时(流式响应慢)
-
上下文超限
RetryClient 工作流程

Spring Retry 集成
java
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
配置自动重试
java
@Configuration
@EnableRetry
public class RetryConfig {
@Bean
public RetryTemplate chatRetryTemplate() {
return RetryTemplate.builder()
.maxAttempts(3)
.exponentialBackoff(Duration.ofMillis(500), 2, Duration.ofSeconds(10))
.retryOn(NonTransientAiException.class)
.retryOn(WebClientResponseException.ServiceUnavailable.class)
.retryOn(WebClientResponseException.TooManyRequests.class)
.build();
}
}
包装 ChatClient
java
@Service
public class ResilientChatService {
private final ChatClient client;
private final RetryTemplate retry;
public String chat(String input) {
return retry.execute(context -> {
log.info("attempt={}", context.getRetryCount());
return client.prompt().user(input).call().content();
});
}
}
Resilience4j 熔断(断路器)
java
@Configuration
public class CircuitConfig {
@Bean
public CircuitBreaker aiCircuit() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.slowCallRateThreshold(80)
.waitDurationInOpenState(Duration.ofSeconds(30))
.slidingWindowSize(20)
.build();
return CircuitBreaker.of("aiCircuit", config);
}
}
// 使用
public String chatSafe(String input) {
return CircuitBreaker.decorateSupplier(aiCircuit,
() -> client.prompt().user(input).call().content()
).get();
}
退避策略选择
java
请求失败
│
▼
attempt 1 ──► 429 ──► 等 500ms
│
▼
attempt 2 ──► 429 ──► 等 1000ms (指数退避)
│
▼
attempt 3 ──► 429 ──► 等 2000ms
│
▼
失败 ──► 触发 CircuitBreaker
│
▼
fallback(默认答案 / 转人工)
限流原理 :RateLimiter 基于令牌桶算法 平滑控制请求速率。例如 OpenAI 免费账号限制 3 RPM,可通过此机制主动控制。RetryClient 用装饰器模式包装 AiClient,添加重试能力。
五、Evaluation 模型评估
概念
AI 应用最大痛点:回答对不对?RAG 检索准不准? Spring AI 提供 Evaluator 接口,用 LLM 评估 LLM(或人工对照)。
内置 Evaluator
| Evaluator | 评估什么 |
|---|---|
RelevancyEvaluator |
答案是否相关于问题 |
FactCheckingEvaluator |
答案是否基于事实(防幻觉) |
Evaluator |
自定义 |
核心 API
java
// 评估请求
public class EvaluationRequest {
private final String userText; // 用户原始输入
private final List<Content> dataList; // RAG 检索到的上下文
private final String responseContent; // AI 模型响应
}
// 评估响应
public class EvaluationResponse {
private final boolean passing; // 是否通过
private final float score; // 得分(0.0~1.0)
private final String feedback; // 反馈信息
}
// 评估器函数接口
@FunctionalInterface
public interface Evaluator {
EvaluationResponse evaluate(EvaluationRequest request);
}
评估流程
java
测试集: List<{question, expectedAnswer, docs}>
│
▼
对每个 case 调 ChatClient 生成 answer
│
▼
调用 Evaluator 评分: {question, answer, docs} ─► score 0-1
│
▼
聚合统计:平均分、不合格率
代码示例
java
@Service
public class RagEvaluationService {
private final ChatClient evalClient; // 专门用于评估的 client
private final ChatClient ragClient;
private final VectorStore store;
public void evaluate(List<TestExample> tests) {
RelevancyEvaluator relevancy = new RelevancyEvaluator(evalClient);
FactCheckingEvaluator factCheck = new FactCheckingEvaluator(evalClient);
List<Double> relScores = new ArrayList<>();
List<Double> factScores = new ArrayList<>();
for (TestExample t : tests) {
// 1. 拿到 RAG 答案 + 检索到的 docs
String answer = ragClient.prompt().user(t.question()).call().content();
List<Document> docs = store.similaritySearch(
SearchRequest.builder().query(t.question).topK(4).build());
// 2. 评估相关性
EvaluationRequest req = new EvaluationRequest(t.question(), docs, answer);
EvaluationResponse relResp = relevancy.evaluate(req);
relScores.add(relResp.score());
// 3. 评估事实性
EvaluationResponse factResp = factCheck.evaluate(req);
factScores.add(factResp.score());
}
double avgRel = relScores.stream().mapToDouble(d -> d).average().orElse(0);
double avgFact = factScores.stream().mapToDouble(d -> d).average().orElse(0);
log.info("Relevancy: {}, FactCheck: {}", avgRel, avgFact);
}
}
record TestExample(String question, String expectedAnswer) {}
作者:筱白爱学习!!
欢迎关注转发评论点赞沟通,您的支持是筱白的动力!