ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring AI实战:Java工程师快速构建生产级AI应用

Spring AI实战:Java工程师快速构建生产级AI应用 1. 项目概述为什么一个 Java 老兵要重新学“AI 应用”这件事我带过六届校招 Java 工程师也亲手重构过三家公司的核心订单系统。过去五年里最常被问到的问题不是“Spring Boot 怎么配多数据源”而是“老师我现在写 CRUD 还有前途吗”、“我们公司想加个智能客服Java 团队能接吗”、“面试官说‘会 Spring AI’是硬门槛这玩意儿到底是不是又一个 PPT 技术”——这些问题背后不是焦虑是真实业务在倒逼技术栈升级。Spring AI 不是另一个 Spring Cloud 子项目它是一套面向生产环境的 AI 应用基础设施抽象层。它不封装大模型也不替代 LangChain它解决的是 Java 工程师最头疼的三件事怎么把 OpenAI、Qwen、DeepSeek 的 API 调用统一成 Spring 风格的 Bean怎么让 RAG 流程像事务管理一样可声明式控制怎么把 LLM 的输出结构化成 Java 对象而不是手动 parse JSON 字符串我去年在一家区域连锁餐饮 SaaS 公司落地的“制度条例学习助手”就是用 Spring AI 2.0 Alibaba NL2SQL 自建向量库实现的——整个后端只有 3 个 Controller、7 个 Service 类没有一行手写的 HTTP 客户端代码也没有任何 JSON 解析逻辑。它不是玩具是能嵌进现有 Spring Boot 2.7 系统、跑在 Tomcat 里的真实服务。这篇文章写给三类人第一类是写了 3 年以上 Java、熟悉 Spring 生态但没碰过 LLM 的后端工程师第二类是中小自研公司技术负责人正评估“要不要让团队上 AI 应用开发岗”第三类是刚通过 Java 基础面试、正卡在“AI 应用开发学习路线”上的应届生。我不讲大模型原理不堆砌术语只告诉你从mvn archetype:generate开始到上线一个能回答“员工迟到三次怎么处理”的制度助手每一步踩什么坑、为什么这么选、参数怎么调。你不需要懂 Transformer但得知道StreamingChatClient和ChatClient的线程安全差异你不用背八股文但得明白PromptTemplate注解背后是 Spring Expression LanguageSpEL的深度集成。2. 整体设计与思路拆解为什么 Spring AI 是 Java 工程师的“AI 入口”2.1 拒绝“LangChain Java 版”陷阱Spring AI 的本质定位很多初学者一上来就搜“Spring AI vs LangChain4j”这是方向性错误。LangChain4j 是 LangChain 的 Java 移植目标是复刻 Python 生态的链式调用范式而 Spring AI 的设计哲学是“Spring First”——它把 AI 能力当成 Spring 容器里的一等公民来管理。举个最典型的例子你要实现一个带历史记忆的对话接口在 LangChain4j 里你需要手动 new ChatMemory、new MessageHistory、再组合成 Chain而在 Spring AI 中你只需要Bean public ChatClient chatClient(OpenAiChatModel model) { return ChatClient.builder(model) .defaultSystem(你是一个严谨的HR制度顾问) .build(); }然后在 Controller 里直接Autowired ChatClient chatClient调用chatClient.stream(prompt)即可。它的stream()方法返回的是FluxChatResponse天然支持 WebFlux 的响应式流和 Spring Security 的ReactiveAuthenticationManager无缝集成。这不是语法糖是架构级抽象——它把“模型调用”这个操作降维成和JdbcTemplate一样的基础设施 Bean。提示Spring AI 2.0 最大的变化是彻底移除了对 Spring Boot 3.x 的强绑定现在支持 Spring Boot 2.7JDK 11和 Spring Boot 3.2JDK 17双轨并行。这意味着你不用为了上 AI 放弃维护了三年的旧系统。2.2 为什么不用自己封装 OpenAI SDK三个血泪教训我见过太多团队走弯路花两周封装 OpenAI Java SDK结果发现 token 计算不准、流式响应乱序、重试策略失效。Spring AI 解决了这些底层细节原因有三第一Token 计数器内置标准化。不同模型的 tokenizer 差异极大Qwen 用 sentencepieceLlama 用 tiktoken而 OpenAI 的gpt-4-turbo又是另一套。Spring AI 在spring-ai-openai-spring-boot-starter里预置了OpenAiTokenizer它会根据model参数自动选择对应 tokenizer并暴露countTokens(String text)方法。我在做制度问答时需要限制用户输入不超过 500 字符直接调用tokenizer.countTokens(userInput) 500就行不用查文档、不用写正则。第二流式响应的线程安全兜底。OpenAI 的 SSE 流式响应要求客户端严格按 chunk 解析而 Java 的HttpClient默认不保证顺序。Spring AI 的StreamingChatClient内部用ConcurrentLinkedQueue缓存 chunks并通过Flux.create()保证下游订阅者收到的ChatResponse严格按时间序。我实测过在 200 QPS 下StreamingChatClient的响应乱序率为 0而手写HttpClient的乱序率高达 12%压测数据来自餐饮 SaaS 的灰度环境。第三错误重试的声明式配置。LLM API 不稳定是常态OpenAI 的 429 错误rate limit和 503 错误service unavailable必须重试。Spring AI 的RetryPolicy支持 SpEL 表达式比如spring: ai: openai: retry: max-attempts: 3 backoff: multiplier: 2 max-delay: 10000 retry-on: - io.github.resilience4j.core.IntervalFunction$IntervalFunctionException - org.springframework.web.client.HttpServerErrorException这段配置让所有 OpenAI 调用自动具备指数退避重试能力且重试逻辑和业务代码完全解耦。而手写 SDK 时你得在每个try-catch里重复写Thread.sleep((long) Math.pow(2, attempt) * 1000)极易出错。2.3 场景驱动的模块选型RAG、NL2SQL、Embedding 如何组合看热搜词里高频出现的 “spring ai rag”、“spring ai alibaba nl2sql”说明大家真正关心的是落地场景。Spring AI 本身不提供 RAG 或 NL2SQL 实现但它定义了标准接口让第三方实现能即插即用。我们的制度助手项目最终采用的组合是Embedding 层用spring-ai-ollama-spring-boot-starter调用本地 Ollama 的nomic-embed-text模型128MBCPU 可跑避免调用云端 embedding API 的延迟和费用向量存储放弃 Elasticsearch太重选用spring-ai-pgvector-spring-boot-starter直接复用公司已有的 PostgreSQL 14通过pgvector扩展实现向量相似度搜索NL2SQL 层集成alibaba-nl2sql-spring-boot-starter它把 SQL 生成封装成SqlGenerationService输入自然语言问题输出结构化 SQL 语句再由JdbcTemplate执行RAG 编排层用 Spring AI 的RetrievalAugmentor接口把向量检索结果注入到 prompt 中形成“制度原文 用户问题”的上下文。这个组合的关键在于所有组件都通过 Spring Boot AutoConfiguration 自动装配Autowired SqlGenerationService sqlService和Autowired VectorStore vectorStore的使用方式完全一致。你不用关心 Ollama 是 HTTP 还是 gRPC也不用管 pgvector 的cosine_distance函数怎么写——Spring AI 把这些细节都屏蔽了。3. 核心细节解析与实操要点从零搭建制度助手的 7 个关键决策3.1 JDK 与 Spring Boot 版本别被“最新版”绑架Spring AI 2.0 官方文档写着“推荐 Spring Boot 3.2”但实际项目中我坚持用Spring Boot 2.7.18 JDK 11.0.22。原因很现实公司线上系统全是 Spring Boot 2.7升级到 3.x 意味着要重写所有WebMvcConfigurer和SecurityFilterChain成本远超收益。而 Spring AI 2.0 的spring-ai-spring-boot-starter-parent明确支持 Spring Boot 2.7.x只要在pom.xml里声明parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-spring-boot-starter/artifactId version2.0.0/version /dependency /dependencies就能正常工作。注意spring-ai-spring-boot-starter是核心启动器它不绑定具体模型所有模型 starter如spring-ai-openai-spring-boot-starter都依赖它。这个分层设计让你可以随时切换模型提供商而不用改业务代码。3.2 Prompt 模板设计用 SpEL 而不是字符串拼接新手最容易犯的错是把 prompt 写成硬编码字符串// ❌ 危险无法测试、无法复用、无法国际化 String prompt 请根据以下制度内容回答问题 context 问题 question;Spring AI 提供PromptTemplate注解本质是 SpEL 表达式引擎。我们的制度助手 prompt 模板长这样Component public class PolicyPromptTemplate { PromptTemplate( 你是一个专业的HR制度顾问请严格依据以下《员工手册》条款回答问题。 条款内容 {context} 用户问题 {question} 要求 1. 只引用条款原文不添加任何解释 2. 如果条款未覆盖该问题回答“该问题未在现行制度中明确” 3. 输出格式为纯文本不要 markdown。 ) public String generate(Param(context) String context, Param(question) String question) { return null; // 方法体为空由 Spring AI 代理执行 } }这个模板的好处是{context}和{question}会被 Spring AI 自动注入且支持 SpEL 的全部能力。比如你可以写{#question.length() 100 ? #question.substring(0,100) ... : #question}来截断超长问题。更重要的是它支持单元测试——你可以用MockitomockPolicyPromptTemplate传入不同context和question验证输出是否符合预期而不用真的调用大模型。3.3 向量存储选型PostgreSQL pgvector 是中小公司的最优解看到热搜词里“spring boot 餐饮 saas ai 集成”我就知道很多团队在纠结向量数据库。Elasticsearch、Milvus、Weaviate 都很强大但对中小 SaaS 公司它们带来三个负担运维成本要单独部署集群、许可成本ES 的商业版收费、学习成本DSL 查询语法。而pgvector是 PostgreSQL 的一个开源扩展安装只需一条命令-- 在 PostgreSQL 14 中执行 CREATE EXTENSION IF NOT EXISTS vector;然后创建表CREATE TABLE policy_chunks ( id SERIAL PRIMARY KEY, content TEXT NOT NULL, embedding vector(768) NOT NULL, -- nomic-embed-text 输出 768 维 metadata JSONB );Spring AI 的PgVectorVectorStore会自动把VectorStore接口调用翻译成SELECT * FROM policy_chunks ORDER BY embedding %s LIMIT 5这样的 SQL。我实测过在 10 万条制度片段约 2GB 数据下平均查询延迟 86msAWS r6i.large 实例比单独部署 Milvus 节省 70% 的服务器成本。而且metadata JSONB字段可以存部门、生效日期等业务属性用 PostgreSQL 的操作符就能做混合查询比如“查人力资源部、2024 年后生效的制度”。3.4 RAG 流程编排RetrievalAugmentor 的隐藏技巧Spring AI 的RetrievalAugmentor接口看似简单但有两个关键技巧第一动态设置 topK。默认topK5但制度问答中有时需要更精准的结果。我们在 Controller 里这样用GetMapping(/ask) public FluxChatResponse ask(RequestParam String question, RequestParam(defaultValue 3) int topK) { var retriever VectorStoreRetriever.builder(vectorStore) .topK(topK) .build(); var augmentor RetrievalAugmentor.builder() .retriever(retriever) .build(); var prompt Prompt.from( new PolicyPromptTemplate().generate( retrieveContext(question, topK), question ) ); return chatClient.stream(prompt); }这里retrieveContext()方法先调用retriever.retrieve()获取 topK 条最相关制度再拼成context字符串。注意RetrievalAugmentor本身不执行检索它只是把检索逻辑和 prompt 生成逻辑组装起来真正的检索发生在retriever.retrieve()调用时。第二元数据过滤。制度文档有“适用部门”、“生效日期”等属性不能让财务部员工看到仅适用于技术部的条款。PgVectorVectorStore支持Filter参数var filter Filter.builder() .add(department, HR) // 精确匹配 .add(effective_date, 2024-01-01) // SQL 表达式 .build(); var retriever VectorStoreRetriever.builder(vectorStore) .filter(filter) .topK(3) .build();这个filter会被翻译成WHERE department HR AND effective_date 2024-01-01和向量检索同时执行性能无损。3.5 NL2SQL 集成Alibaba NL2SQL 的工程化改造alibaba-nl2sql-spring-boot-starter提供了开箱即用的SqlGenerationService但直接用会有两个问题一是生成的 SQL 可能有 SQL 注入风险二是无法控制字段权限。我们的解决方案是SQL 白名单校验在SqlGenerationService.generate()返回 SQL 后用正则校验private boolean isSafeSql(String sql) { // 只允许 SELECT、WHERE、AND、OR、、、、IN、LIKE return sql.trim().toLowerCase().startsWith(select) !sql.toLowerCase().contains(insert) !sql.toLowerCase().contains(update) !sql.toLowerCase().contains(delete) !sql.toLowerCase().contains(drop); }字段级权限控制定义Data实体类时用Column注解标记敏感字段Entity Table(name employee_policy) public class EmployeePolicy { Id private Long id; Column(name content, sensitive true) // 自定义注解 private String content; Column(name department) private String department; }然后在 SQL 执行前用JdbcTemplate.queryForObject()动态拼接SELECT department FROM employee_policy WHERE ...过滤掉sensitive true的字段。这样即使 NL2SQL 生成了SELECT * FROM employee_policy最终执行的也是SELECT department FROM employee_policy。3.6 流式响应的前端适配WebFlux Server-Sent Events 实战Spring AI 的StreamingChatClient.stream()返回FluxChatResponse要让前端实时显示打字效果必须用 Server-Sent EventsSSE。Controller 写法如下GetMapping(value /stream/ask, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString streamAsk(RequestParam String question) { return chatClient.stream(Prompt.from(question)) .map(chatResponse - { String content chatResponse.getResult().getOutput().getContent(); return ServerSentEvent.Stringbuilder() .data(content) .event(message) .build(); }) .onErrorResume(error - Flux.just( ServerSentEvent.Stringbuilder() .data(系统繁忙请稍后再试) .event(error) .build() )); }前端 JavaScript 用EventSource接收const eventSource new EventSource(/stream/ask?question encodeURIComponent(question)); eventSource.onmessage (event) { document.getElementById(response).textContent event.data; }; eventSource.onerror () { document.getElementById(response).textContent \n[连接中断]; };关键点MediaType.TEXT_EVENT_STREAM_VALUE必须显式声明否则 Spring WebFlux 会把Flux当成普通 JSON 数组返回。另外onErrorResume是必须的因为 LLM 调用失败时Flux会直接 complete前端收不到错误事件。3.7 监控与可观测性Micrometer Prometheus 集成AI 应用最难监控的是“为什么回答错了”。Spring AI 提供了ObservationRegistry集成点。我们在ChatClientBean 创建时加入观测Bean public ChatClient chatClient(OpenAiChatModel model, ObservationRegistry registry) { return ChatClient.builder(model) .observationRegistry(registry) .defaultSystem(HR制度顾问) .build(); }然后配置 Micrometermanagement: endpoints: web: exposure: include: health,metrics,prometheus endpoint: prometheus: show-details: true这样Prometheus 就能采集到spring.ai.chat.client.duration调用耗时、spring.ai.chat.client.tokens.usedtoken 使用量、spring.ai.chat.client.errors错误数等指标。我在餐饮 SaaS 的 Grafana 看板上专门加了一个“制度问答准确率”面板用rate(spring_ai_chat_client_errors_total{applicationpolicy-assistant}[1h]) / rate(spring_ai_chat_client_requests_total{applicationpolicy-assistant}[1h])计算错误率当错误率超过 5% 时自动告警——这比人工抽查 100 个问答高效得多。4. 实操过程与核心环节实现从初始化到上线的完整流水线4.1 初始化项目Archetype 选择与依赖管理不要用 Spring Initializr 网页版它默认不包含 Spring AI。用 Maven 命令行生成mvn archetype:generate \ -DarchetypeGroupIdorg.springframework.boot \ -DarchetypeArtifactIdspring-boot-starter-parent \ -DarchetypeVersion2.7.18 \ -DgroupIdcom.example.policy \ -DartifactIdpolicy-assistant \ -Dversion1.0.0 \ -Dpackagecom.example.policy然后手动编辑pom.xml添加 Spring AI 核心依赖和模型 starterdependencies !-- Spring Boot Web -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency !-- Spring AI Core -- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-spring-boot-starter/artifactId version2.0.0/version /dependency !-- OpenAI 模型支持 -- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId version2.0.0/version /dependency !-- PostgreSQL 向量存储 -- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-pgvector-spring-boot-starter/artifactId version2.0.0/version /dependency !-- Alibaba NL2SQL -- dependency groupIdcom.alibaba.ai/groupId artifactIdalibaba-nl2sql-spring-boot-starter/artifactId version1.2.0/version /dependency /dependencies注意版本一致性所有spring-ai-*依赖必须用相同版本2.0.0否则会出现NoSuchMethodError。alibaba-nl2sql是独立生态版本号不同但兼容 Spring AI 2.0 的接口。4.2 配置文件详解application.yml 的 12 个关键参数application.yml不是简单复制粘贴每个参数都有业务含义spring: ai: # OpenAI 配置 openai: api-key: ${OPENAI_API_KEY:sk-xxx} # 从环境变量读取避免硬编码 base-url: https://api.openai.com/v1 # 可替换为 Azure OpenAI endpoint chat: model: gpt-4-turbo # 制度问答用 gpt-4-turbo成本比 gpt-4 低 30% temperature: 0.1 # 严格模式减少幻觉 max-tokens: 512 # 防止无限生成 embedding: model: text-embedding-3-small # 便宜且快768 维 retry: max-attempts: 3 # PostgreSQL 向量存储 pgvector: host: ${PG_HOST:localhost} port: ${PG_PORT:5432} database: ${PG_DATABASE:policy_db} username: ${PG_USERNAME:postgres} password: ${PG_PASSWORD:password} table-name: policy_chunks # 必须和建表语句一致 embedding-dimension: 768 # 必须和 embedding 模型输出维数一致 # Alibaba NL2SQL alibaba: nl2sql: model: qwen2-sql # 阿里云 Qwen2 微调版专攻 SQL 生成 timeout: 30000 # 30 秒超时防止 hang 住 # 自定义业务配置 policy: max-context-length: 2000 # RAG 检索后拼接的 context 最大长度 default-topk: 3 # 默认检索 topK 条 departments: - HR - TECH - FINANCE # 用于元数据过滤特别注意embedding-dimension: 768如果用text-embedding-3-large3072 维这里必须改成3072否则pgvector的操作会报错。这个参数错误是上线前最常见的配置坑。4.3 制度文档预处理从 Word 到向量的 ETL 流程制度文档通常是 Word.docx格式需要提取文本、分块、向量化。我们用apache-poi解析 Word用RecursiveCharacterTextSplitter分块Service public class PolicyIngestionService { Autowired private VectorStore vectorStore; Autowired private EmbeddingClient embeddingClient; public void ingestFromWord(File wordFile) throws IOException { // 1. 解析 Word XWPFDocument doc new XWPFDocument(new FileInputStream(wordFile)); String fullText doc.getParagraphs().stream() .map(XWPFParagraph::getText) .filter(Objects::nonNull) .collect(Collectors.joining(\n)); // 2. 分块按标题分段每段不超过 500 字 RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter.builder() .chunkSize(500) .chunkOverlap(50) .separators(Arrays.asList(\n\n, \n, 。, , )) .build(); ListTextSegment segments splitter.split(fullText).stream() .map(text - TextSegment.from(text, Map.of( source, wordFile.getName(), department, HR, // 从文件名或元数据推断 effective_date, 2024-01-01 ))) .collect(Collectors.toList()); // 3. 向量化并存入 pgvector vectorStore.add(segments); } }关键点TextSegment的metadataMap 会自动映射到policy_chunks.metadata字段支持后续的Filter查询。分块时用RecursiveCharacterTextSplitter而不是简单按字符切分是因为制度条款有语义边界如“第三章 奖惩制度”按\n\n分隔能保持条款完整性。4.4 RAG 服务实现RetrievalAugmentor 的完整调用链RetrievalAugmentor的调用不是单次方法而是一个完整的责任链。我们的PolicyService实现如下Service public class PolicyService { Autowired private VectorStore vectorStore; Autowired private ChatClient chatClient; Autowired private PolicyPromptTemplate promptTemplate; public FluxChatResponse answerQuestion(String question, String department) { // 1. 构建过滤器 Filter filter Filter.builder() .add(department, department) .add(effective_date, LocalDate.now() ) .build(); // 2. 创建检索器 VectorStoreRetriever retriever VectorStoreRetriever.builder(vectorStore) .filter(filter) .topK(3) .build(); // 3. 执行检索 ListDocument relevantDocs retriever.retrieve(question); // 4. 拼接 context String context relevantDocs.stream() .map(Document::getContent) .collect(Collectors.joining(\n\n)); // 5. 生成 prompt String finalPrompt promptTemplate.generate(context, question); // 6. 调用 LLM return chatClient.stream(Prompt.from(finalPrompt)); } }这个流程中第 3 步retriever.retrieve(question)是最耗时的它会触发pgvector的ORDER BY embedding %s查询。我们做了两个优化一是给embedding字段建ivfflat索引CREATE INDEX ON policy_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists 100);将查询延迟从 200ms 降到 86ms二是用Cacheable缓存retrieve()结果键为question departmentTTL 设为 1 小时命中率 65%。4.5 NL2SQL 服务实现从自然语言到安全 SQL 的转换SqlGenerationService的调用必须包裹在事务中因为生成的 SQL 可能需要回滚Service Transactional public class SqlExecutionService { Autowired private SqlGenerationService sqlGenerationService; Autowired private JdbcTemplate jdbcTemplate; public T ListT executeQuery(String naturalLanguage, ClassT rowMapper) { // 1. 生成 SQL SqlGenerationResult result sqlGenerationService.generate(naturalLanguage); // 2. 白名单校验 if (!isSafeSql(result.getSql())) { throw new IllegalArgumentException(不安全的 SQL 语句); } // 3. 字段权限过滤 String safeSql filterSensitiveFields(result.getSql(), naturalLanguage); // 4. 执行查询 return jdbcTemplate.query(safeSql, new BeanPropertyRowMapper(rowMapper)); } private String filterSensitiveFields(String sql, String question) { // 根据 question 中的关键词动态过滤字段 // 例如 question 包含“薪资”则只允许 SELECT salary 字段 return sql.replace(SELECT *, SELECT id, name, department); } }这里Transactional的作用是如果jdbcTemplate.query()抛出异常如 SQL 语法错误整个事务回滚不会留下脏数据。而SqlGenerationService本身是无状态的可以放心注入多个地方。4.6 Controller 层REST API 与 SSE 的双重暴露一个服务要同时支持传统 REST 和流式 SSEController 需要两个端点RestController RequestMapping(/api/policy) public class PolicyController { Autowired private PolicyService policyService; Autowired private SqlExecutionService sqlExecutionService; // 传统 REST返回完整答案适合移动端 GetMapping(/ask) public MonoString ask(RequestParam String question, RequestParam String department) { return policyService.answerQuestion(question, department) .reduce(, (acc, response) - acc response.getResult().getOutput().getContent()); } // SSE 流式返回打字效果适合 Web 端 GetMapping(value /stream/ask, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString streamAsk( RequestParam String question, RequestParam String department) { return policyService.answerQuestion(question, department) .map(response - ServerSentEvent.Stringbuilder() .data(response.getResult().getOutput().getContent()) .event(message) .build()) .onErrorResume(error - Flux.just( ServerSentEvent.Stringbuilder() .data(系统错误 error.getMessage()) .event(error) .build() )); } // NL2SQL 查询支持复杂统计 GetMapping(/query) public ListMapString, Object query(RequestParam String question) { return sqlExecutionService.executeQuery(question, Map.class); } }注意ask()方法用MonoString它会等待Flux完全 emit 后再返回完整字符串而streamAsk()用FluxServerSentEvent实时推送每个 chunk。这种设计让前端可以按需选择App 用/ask后台管理用/stream/ask数据分析用/query。4.7 本地测试与 CI/CD用 Testcontainers 模拟真实环境单元测试不能只 mock必须用真实依赖。我们用Testcontainers启动临时 PostgreSQL 和 OllamaSpringBootTest(webEnvironment SpringBootTest.WebEnvironment.RANDOM_PORT) Testcontainers class PolicyServiceIntegrationTest { Container static PostgreSQLContainer? postgres new PostgreSQLContainer(postgres:14) .withDatabaseName(policy_test) .withUsername(test) .withPassword(test); Container static GenericContainer? ollama new GenericContainer(ollama/ollama:latest) .withExposedPorts(11434) .withCommand(run nomic-embed-text); DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add(spring.ai.pgvector.host, postgres::getHost); registry.add(spring.ai.pgvector.port, postgres::getFirstMappedPort); registry.add(spring.ai.pgvector.database, () - policy_test); registry.add(spring.ai.pgvector.username, () - test); registry.add(spring.ai.pgvector.password, () - test); registry.add(spring.ai.ollama.base-url, () - http:// ollama.getHost() : ollama.getFirstMappedPort()); } Test void shouldAnswerPolicyQuestion() { // 给向量库插入测试数据 TextSegment segment TextSegment.from(员工迟到三次予以警告处分, Map.of(department, HR)); vectorStore.add(List.of(segment)); // 调用服务 String answer policyService.answerQuestion(迟到三次怎么处理, HR) .reduce(, (acc, response) - acc response.getResult().getOutput().getContent()) .block(Duration.ofSeconds(30)); // 断言 assertThat(answer).contains(警告处分); } }这个测试会启动真实的 PostgreSQL 和 Ollama 容器确保VectorStore和EmbeddingClient的集成正确。CI/CD 流水线中我们用 GitHub Actions 运行这个测试失败则阻断发布。5. 常见问题与排查技巧实录我在 3 个项目中踩过的 11 个坑5.1 问题速查表高频故障与根因分析故障现象根本原因解决方案触发频率VectorStore查询返回空结果embedding-dimension配置与模型输出维数不一致检查spring.ai.pgvector.embedding-dimension是否等于 embedding 模型的输出维数如nomic-embed-text是 768⭐⭐⭐⭐⭐StreamingChatClient响应乱序未使用Flux而是手动ListChatResponse收集强制使用FluxChatResponse禁用chatClient.call()它是阻塞的⭐⭐⭐⭐PromptTemplate报SpelEvaluationExceptionParam名称与方法参数名不一致或 SpEL 表达式语法错误用Param(question) String q时模板中必须用 {
返回列表