SpringAI完整学习指南(四)
目录一、Structured Output / Output Converter 转换概念四种 ConverterStructuredOutputConverter 接口三大核心 Converter 对比BeanOutputConverter 详解定义 DTO基础用法ChatModel 层高级用法ChatClient.entity() 简化版MapOutputConverter 详解示例生成数字映射ChatClient 简化方式ListOutputConverter 详解示例冰淇淋口味列表ChatModel 层手动用法对比理解Format 指令的生成原理二、ETL 管道概念完整架构DocumentReader 列表DocumentTransformer 列表一行代码串联 ETL完整代码:PDF 到向量库切片策略选择三、Observability 可观测性为什么重要集成自动埋点的指标Trace 视图(在 Zipkin/Jaeger 中)Prometheus Grafana集成 Zipkin Prometheus四、Retry Resilience 弹性机制为什么重要RetryClient 工作流程Spring Retry 集成配置自动重试包装 ChatClientResilience4j 熔断断路器退避策略选择五、Evaluation 模型评估概念内置 Evaluator核心 API评估流程代码示例一、Structured Output / Output Converter 转换概念.entity()内部其实用的就是 Output Converter。Spring AI 提供 4 种,直接控制模型输出的格式与反序列化方式。核心原理两步走调用前转换器将期望的输出格式指令Format Instruction注入到 Prompt 中引导模型生成指定结构。调用后转换器将模型返回的文本解析并反序列化为 Java 对象。四种 ConverterConverter输出类型BeanOutputConverter单个 Java 对象(POJO/Record)ListOutputConverterListStringMapOutputConverterMapString,ObjectIntegerOutputConverter/BigDecimalOutputConverter单值StructuredOutputConverter 接口StructuredOutputConverterT 是顶层接口它同时继承了 Spring 的 ConverterString, T 和自定义的 FormatProviderFormatProvider.getFormat()— 返回格式指令字符串注入 Prompt 告诉模型你应该按什么格式输出ConverterString, T— 将模型的文本输出转换为目标类型 T 的实例三大核心 Converter 对比Converter目标类型格式策略典型场景BeanOutputConverterTJava Bean / RecordJSON SchemaDRAFT_2020_12业务 DTO、实体类映射适合字段固定的业务 DTOMapOutputConverterMapString, ObjectRFC8259 标准 JSON动态字段探索、快速原型适合字段不确定的探索场景ListOutputConverterListString逗号分隔文本关键词列表、标签提取适合关键词、标签等简单列表BeanOutputConverter 详解BeanOutputConverter是生产环境最常用的转换器。它根据 Java 类生成 JSON Schema引导模型输出符合结构的 JSON然后用 JacksonObjectMapper反序列化为 Java 对象。定义 DTO推荐使用 Java Record配合 Jackson 注解提供字段描述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(该作者创作的书籍列表) ListString books ) {}注解说明JsonProperty— 明确指定 JSON 键名避免因命名风格差异导致映射失败。JsonPropertyDescription— 提供字段语义描述Spring AI 会将其注入 Prompt帮助模型理解字段含义。基础用法ChatModel 层// 1. 创建转换器 BeanOutputConverterActorsBooks 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内部自动完成格式注入和结果转换// 单个对象 ActorsBooks result chatClient.prompt() .user(Generate the filmography of 5 books for Tom) .call() .entity(ActorsBooks.class); // 列表对象使用 ParameterizedTypeReference ListActorsBooks results chatClient.prompt() .user(Generate filmographies for 3 actors) .call() .entity(new ParameterizedTypeReferenceListActorsBooks() {});底层原理.entity()方法内部会调用converter.getFormat()获取格式指令、将格式指令追加到 system message 中调用模型获取文本响应调用converter.convert()反序列化为 Java 对象MapOutputConverter 详解MapOutputConverter引导模型生成 RFC8259 标准的 JSON然后转换为MapString, Object。适合字段不确定或探索阶段的场景示例生成数字映射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(); MapString, Object result converter.convert( generation.getOutput().getContent() ); // result {numbers: [1, 2, 3, 4, 5, 6, 7, 8, 9]}ChatClient 简化方式MapString, Object result chatClient.prompt() .user(List 5 popular programming languages with their key features) .call() .entity(new ParameterizedTypeReferenceMapString, Object() {});ListOutputConverter 详解ListOutputConverter引导模型生成逗号分隔的文本然后转换为ListString。适合提取关键词、标签、简短列表等场景示例冰淇淋口味列表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(); ListString list converter.convert( generation.getOutput().getContent() ); // list [Vanilla, Chocolate, Strawberry, Mint, Cookie Dough]ChatModel 层手动用法对比理解如果不使用 ChatClient 的.entity()快捷方法也可以手动在 ChatModel 层使用 ConverterAutowired private ChatModel chatModel; public ActorsFilms manualConvert(String input) { BeanOutputConverterActorsFilms 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 为例它内部会1. 根据目标 Java 类如 ActorsFilms.class生成 **JSON Schema**2. 将 JSON Schema 包装成一段格式指令文本3. 这段文本会告知模型你的回复必须是 JSON 格式不要包含解释不要包含 Markdown 代码块必须符合以下 Schema二、ETL 管道概念ETL(Extract-Transform-Load)是 RAG 的前置环节。Spring AI 把数据摄入抽象为三大组件:DocumentReader:从 PDF/Word/HTML/Markdown/JSON 读取,输出ListDocumentDocumentTransformer:切片、清洗、增强,例如TokenTextSplitterDocumentWriter:写入 VectorStore / 文件 / 数据库完整架构PDF ──┐ Word ─┼──► DocumentReader ──► DocumentTransformer ──► DocumentWriter ──► VectorStore HTML ─┘ (Extract) (Transform) (Load) (向量库)// DocumentReader — 从数据源提取文档 public interface DocumentReader extends SupplierListDocument { default ListDocument read() { return get(); } } // DocumentTransformer — 转换文档拆分、清理、丰富 public interface DocumentTransformer extends FunctionListDocument, ListDocument { default ListDocument transform(ListDocument docs) { return apply(docs); } } // DocumentWriter — 加载到目标存储 public interface DocumentWriter extends ConsumerListDocument { default void write(ListDocument docs) { accept(docs); } }DocumentReader 列表ReaderartifactId说明TextReaderspring-ai-core纯文本PagePdfDocumentReaderspring-ai-pdf-document-readerPDF 按页ParagraphPdfDocumentReaderspring-ai-pdf-document-readerPDF 按段落TikaDocumentReaderspring-ai-tika-document-readerWord/Excel/PPT/HTML/RTFMarkdownDocumentReaderspring-ai-markdown-document-readerMarkdownJsonReaderspring-ai-coreJSONDocumentTransformer 列表Transformer作用TokenTextSplitter按 Token 切片(默认,推荐)TextSplitter自定义切片基类ContentFormatTransformer添加 markdown/format 标记KeywordMetadataEnricher自动提取关键词到 metadataSummaryMetadataEnricher用 LLM 自动总结到 metadata一行代码串联 ETL// 函数式写法 vectorStore.accept(tokenTextSplitter.apply(pdfReader.get())); // 语义化写法 vectorStore.write(tokenTextSplitter.split(pdfReader.read()));完整代码:PDF 到向量库// 1. Maven 依赖 // pom.xml // dependency // groupIdorg.springframework.ai/groupId // artifactIdspring-ai-openai-spring-boot-starter/artifactId // /dependency // dependency // groupIdorg.springframework.ai/groupId // artifactIdspring-ai-pdf-document-reader/artifactId // /dependency // dependency // groupIdorg.springframework.ai/groupId // artifactIdspring-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(); ListDocument docs splitter.apply(reader.get()); docs.forEach(d - d.getMetadata().put(category, category)); vectorStore.add(docs); } // ---------- Demo3: 相似度检索 ---------- public ListDocument search(String query, int topK, String category) { return vectorStore.similaritySearch( SearchRequest.query(query) .withTopK(topK) .withSimilarityThreshold(0.7) .withFilterExpression(category category ) ); } }切片策略选择原文档 5000 tokens │ ├──► TokenTextSplitter(800, 200) │ └─ chunk1: [0-800], chunk2: [600-1400], chunk3: [1200-2000]... (overlap200) │ └──► ParagraphPdfDocumentReader └─ 按段落自然切分,语义完整但长度不均参数说明:defaultChunkSize: 单 chunk 目标 token(常用 500-1000)minChunkSizeChars: 最小字符数minChunkSizeToEmbed: 可嵌入的最小长度maxNumChunks: 防爆内存上限三、Observability 可观测性为什么重要AI 应用上线后必须能看到:每次调用的 token 用量、哪一步耗时、Advisor 是否生效、RAG 检索质量。Spring AI 原生集成 Micrometer,开箱即用天然对接 OpenTelemetry 协议实现 Tracing分布式追踪、Metrics指标监控、Logging 三位一体。集成dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency dependency groupIdio.micrometer/groupId artifactIdmicrometer-tracing-bridge-brave/artifactId /dependency dependency groupIdio.zipkin.reporter2/groupId artifactIdzipkin-reporter-brave/artifactId /dependencymanagement: 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.usageCountertoken 使用量(prompt/completion/total)gen_ai.client.operationTimer调用耗时gen_ai.client.tool.callCounterTool 调用次数Trace 视图(在 Zipkin/Jaeger 中)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_tokens120 │ │ └─ tag: gen_ai.usage.total_tokens300 │ └─ span: advisor MemoryAdvisor#after (3ms)自定义埋点Bean public ObservationHandlerChatClientObservationContext 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 Grafanamanagement: endpoints.web.exposure.include: prometheus metrics.export.prometheus.enabled: trueGrafana 推荐看板:Spring AI Dashboard(token 趋势、P95 延迟、错误率、Tool 命中率)。集成 Zipkin Prometheus// 1. Maven 依赖 // dependency // groupIdio.micrometer/groupId // artifactIdmicrometer-tracing-bridge-brave/artifactId // /dependency // dependency // groupIdio.zipkin.reporter2/groupId // artifactIdzipkin-reporter-brave/artifactId // /dependency // dependency // groupIdorg.springframework.boot/groupId // artifactIdspring-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. 启动 ZipkinDocker // 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 集成dependency groupIdorg.springframework.retry/groupId artifactIdspring-retry/artifactId /dependency配置自动重试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(); } }包装 ChatClientService 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 熔断断路器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(); }退避策略选择请求失败 │ ▼ 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(或人工对照)。内置 EvaluatorEvaluator评估什么RelevancyEvaluator答案是否相关于问题FactCheckingEvaluator答案是否基于事实(防幻觉)Evaluator自定义核心 API// 评估请求 public class EvaluationRequest { private final String userText; // 用户原始输入 private final ListContent 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); }评估流程测试集: List{question, expectedAnswer, docs} │ ▼ 对每个 case 调 ChatClient 生成 answer │ ▼ 调用 Evaluator 评分: {question, answer, docs} ─► score 0-1 │ ▼ 聚合统计:平均分、不合格率代码示例Service public class RagEvaluationService { private final ChatClient evalClient; // 专门用于评估的 client private final ChatClient ragClient; private final VectorStore store; public void evaluate(ListTestExample tests) { RelevancyEvaluator relevancy new RelevancyEvaluator(evalClient); FactCheckingEvaluator factCheck new FactCheckingEvaluator(evalClient); ListDouble relScores new ArrayList(); ListDouble factScores new ArrayList(); for (TestExample t : tests) { // 1. 拿到 RAG 答案 检索到的 docs String answer ragClient.prompt().user(t.question()).call().content(); ListDocument 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) {}作者筱白爱学习欢迎关注转发评论点赞沟通您的支持是筱白的动力