1. 为什么 Spring AI 不是“Spring 官方推出的 AI 框架”——从 1.0.2 版本定位说起

很多人第一次看到“Spring AI”这个名字,下意识会认为这是 Spring 官方继 Spring Boot、Spring Cloud 之后推出的又一个重量级全家桶成员,是 Spring 生态在 AI 时代的“正统继承者”。我最初也这么想,直到把 spring-ai-starter 的源码拉下来、把 Maven 依赖树展开、把官方 GitHub 仓库的 README 逐行读了三遍,才真正看清它的本质: Spring AI 是一个高度抽象的、面向 Java 开发者的 AI 能力接入层,不是模型训练平台,不托管算力,不提供私有化大模型,更不是替代 LangChain 或 LlamaIndex 的端到端框架。

这个认知偏差,直接导致大量团队在 2024 年底仓促引入 Spring AI 1.0.0 后陷入两个典型困境:一是以为加个 starter 就能自动对接本地 Ollama,结果发现连最基础的 ChatClient 初始化都报 No qualifying bean of type 'ChatModel' ;二是把 spring-ai-openai-spring-boot-starter 当成 OpenAI SDK 的简化版来用,结果在处理流式响应(SSE)时被 Flux<ChatResponse> 的背压机制绕晕,日志里全是 onErrorDropped 。这些坑,我在三个不同行业的项目中都踩过——金融风控的实时意图识别、制造业设备语音工单录入、教育 SaaS 的课件智能摘要生成。

Spring AI 1.0.2(发布于 2025 年 11 月)之所以值得单独拎出来讲,是因为它完成了从“实验性模块”到“生产可用组件”的关键跃迁。它不再只是对 Spring Boot 自动配置能力的炫技,而是通过一套稳定的 SPI(Service Provider Interface)契约,把 AI 调用中那些重复度极高、但各家 SDK 实现五花八门的环节做了标准化封装。比如模型调用前的 Prompt 模板渲染、调用后的 Response 解析、异常分类(限流、超时、内容安全拦截)、甚至最让人头疼的 Token 计数逻辑——过去每个团队都要自己写正则去算 gpt-4-turbo 的输入长度,现在 TokenCountEstimator 接口统一接管,你只需传入 Prompt 对象,它返回精确到子词(subword)级别的计数结果。

这背后的技术决策非常务实:Spring AI 不试图定义“什么是 AI 应用”,而是专注解决“Java 工程师在调用 AI 服务时,90% 场景下都会重复写的那 10% 代码”。它把 ChatModel EmbeddingModel AudioToTextModel ImageToTextModel 这四类核心能力抽象成接口,把 Message ChatResponse EmbeddingResponse 等数据结构标准化,把 RetryPolicy RateLimiter TracingSupport 这些横切关注点做成可插拔的 Bean。这种设计哲学,和 Spring Data JPA 抽象数据库访问、Spring Security 抽象认证授权一脉相承——它不造轮子,只造让轮子更好装的轴承。

所以当你看到热搜词里频繁出现 “spring ai alibaba”、“spring ai 2.0” 甚至 “spring ai dify”,要立刻意识到:这些都不是 Spring AI 本身的版本迭代,而是第三方厂商基于 Spring AI 提供的 SPI 接口开发的适配器(Adapter)。就像 spring-data-redis 是 Redis 的适配器, spring-cloud-alibaba-nacos-config 是 Nacos 的适配器一样,“spring-ai-alibaba” 只是阿里云百炼平台 API 的 Java 封装。它的价值在于降低接入成本,但绝不改变 Spring AI 的底层契约。这也是为什么我在客户现场做技术选型评审时,第一句话永远是:“先确认你们要对接的是哪家厂商的模型服务,再看它有没有提供符合 Spring AI 1.0.2 SPI 规范的 Starter。”

提示:Spring AI 1.0.2 的核心 jar 包 spring-ai-core 仅 127KB,没有任何第三方 HTTP 客户端依赖(不绑定 OkHttp、不绑定 WebClient),所有网络通信由具体的 Model Starter(如 spring-ai-openai-spring-boot-starter )自行实现。这意味着你可以用 Netty 写一个极轻量的 ChatModel 实现,只要它返回标准 ChatResponse ,就能无缝集成进整个 Spring AI 生态。

2. 四大核心模型接口的落地差异:从 ChatModel 到 AudioToTextModel 的工程实践

Spring AI 1.0.2 明确定义了四个顶层模型接口,它们看似平行,但在实际项目中的使用深度、错误处理复杂度、性能敏感度却天差地别。很多团队在 POC 阶段只验证了 ChatModel ,上线后才发现 AudioToTextModel 的延迟抖动会让客服系统超时熔断。下面我结合三个真实项目,拆解每个接口的落地要点。

2.1 ChatModel:不只是“发消息收回复”,而是状态管理的起点

ChatModel 是最常被使用的接口,但它的正确用法远不止 chatClient.call("你好") 。关键在于理解 ChatClient 背后封装的其实是 会话状态机(Session State Machine) 。Spring AI 1.0.2 引入了 ChatOptions 中的 temperature maxTokens 等参数,但更重要的是 systemMessage history 的协同。

在智慧校园系统的“学生事务问答机器人”项目中,我们遇到一个典型问题:学生问“我的奖学金申请进度如何?”,系统需要结合该学生的学号、历史申请记录来回答。如果每次请求都只传 UserMessage ,模型根本无法关联上下文。解决方案是使用 ChatClient withHistory() 方法构建带记忆的会话:

// 构建会话历史(从 Redis 缓存中加载)
List<Message> history = redisTemplate.opsForList()
    .range("chat:session:" + sessionId, 0, -1)
    .stream()
    .map(this::deserializeMessage)
    .collect(Collectors.toList());

// 创建带历史的 ChatClient 实例
ChatClient chatClient = ChatClient.builder(chatModel)
    .defaultSystemMessage("你是一名智慧校园助手,只能回答与教务、奖学金、宿舍相关的问题。")
    .defaultOptions(ChatOptions.builder()
        .temperature(0.3) // 降低创造性,保证答案准确
        .maxTokens(512)
        .build())
    .build();

// 发起调用(history 包含之前的所有 Message)
ChatResponse response = chatClient.call(
    new UserMessage("我的奖学金申请进度如何?"),
    history // 关键:显式传入历史
);

这里的关键细节是: history 必须是 Message 类型的列表,且顺序必须严格按时间倒序(最新消息在前)。Spring AI 不会帮你排序,也不会自动截断过长的历史——如果你传入 50 条历史消息,它会原封不动发给模型,极大增加 Token 消耗和响应延迟。我们在教育项目中实测,当 history 超过 8 条(约 1200 tokens)时,GPT-4-Turbo 的平均响应时间从 1.2s 涨到 4.7s。因此我们强制在 ChatClient 调用前插入一个 HistoryTruncator

public class HistoryTruncator {
    private final TokenCountEstimator estimator;
    
    public List<Message> truncate(List<Message> history, int maxTokens) {
        int totalTokens = 0;
        ListIterator<Message> iterator = history.listIterator(history.size());
        List<Message> truncated = new ArrayList<>();
        
        while (iterator.hasPrevious() && totalTokens < maxTokens) {
            Message msg = iterator.previous();
            int msgTokens = estimator.estimate(msg.getContent());
            if (totalTokens + msgTokens <= maxTokens) {
                truncated.add(0, msg); // 保持倒序
                totalTokens += msgTokens;
            } else {
                break;
            }
        }
        return truncated;
    }
}

这个 truncate 方法确保传给模型的历史永远控制在 800 tokens 以内,既保留关键上下文,又避免性能雪崩。这是 Spring AI 官方文档里不会写的实战技巧,却是生产环境的刚需。

2.2 EmbeddingModel:向量检索的“隐形瓶颈”,不是调用快就万事大吉

EmbeddingModel 接口看似简单——输入文本,输出 float[] 向量。但它的性能陷阱藏在两个地方: 批量处理能力 向量维度一致性

在金融风控项目中,我们需要对每日新增的 5000+ 条客户投诉文本做语义聚类。如果用 embed(text) 单条调用,即使模型本身响应很快(<200ms),5000 次 HTTP 请求的网络开销、连接池竞争、线程切换也会让总耗时突破 20 分钟。而 EmbeddingModel 接口提供了 embed(List<String>) 批量方法,但并非所有实现都支持。OpenAI 的 spring-ai-openai-spring-boot-starter 1.0.2 版本已原生支持批量,但某些国产大模型的适配器仍只实现了单条。

更隐蔽的坑是向量维度。 EmbeddingModel getDimensions() 方法返回维度数,但这个值在运行时可能变化!例如某国产模型在 1.0.2 版本中, text-embedding-v1 返回 1024 维,但升级到 text-embedding-v2 后变成 2048 维。如果你的向量库(如 Milvus、Weaviate)表结构是按旧维度建的,新向量写入就会失败。我们在一次灰度发布中就因此导致 RAG 检索全部失效。

解决方案是: 在应用启动时强制校验维度 。我们在 @PostConstruct 方法中加入维度探测:

@Component
public class EmbeddingDimensionValidator {
    
    @Autowired
    private EmbeddingModel embeddingModel;
    
    @PostConstruct
    public void validateDimensions() {
        String testText = "Spring AI 1.0.2 dimension validation";
        EmbeddingResponse response = embeddingModel.embed(testText);
        int actualDim = response.getResults().get(0).getOutput().length;
        int expectedDim = embeddingModel.getDimensions();
        
        if (actualDim != expectedDim) {
            throw new IllegalStateException(
                String.format("Embedding dimension mismatch: expected %d, got %d. " +
                    "Check model version and adapter compatibility.", 
                    expectedDim, actualDim)
            );
        }
        log.info("Embedding dimension validated: {} dimensions", actualDim);
    }
}

这个简单的校验,在上线前就帮我们拦截了一次因模型服务商静默升级导致的维度不兼容事故。

2.3 AudioToTextModel:语音转文字的“三重延迟”,90% 的团队只优化了第一重

AudioToTextModel 是 Spring AI 1.0.2 新增的重要接口,用于对接 Whisper、Qwen-Audio 等语音模型。很多团队只关注模型本身的 ASR 准确率,却忽略了整个链路的三重延迟叠加:

  • 第一重:音频预处理延迟 —— Spring AI 默认将 InputStream 直接转发给模型服务,但实际场景中,用户上传的 MP3 文件往往包含大量静音段、背景噪音。如果不做前端降噪和静音切除,模型需要处理无效数据,徒增耗时。
  • 第二重:网络传输延迟 —— 音频文件体积大(1 分钟 MP3 约 1MB),HTTP 上传过程易受网络抖动影响。Spring AI 的 AudioToTextRequest 支持 MultipartFile ,但默认没有分块上传或断点续传。
  • 第三重:模型服务排队延迟 —— 语音模型计算密集,GPU 资源有限,高并发时请求会在服务端排队。

在制造业设备语音工单项目中,我们实测发现:10 秒语音从用户点击“提交”到拿到文字结果,平均耗时 8.2 秒,其中:

  • 预处理(降噪+切片)占 1.3 秒
  • 网络上传占 2.1 秒(内网千兆环境)
  • 模型服务排队+计算占 4.8 秒

优化方案是分层击破:

  • 预处理层 :用 ffmpeg 在 Java 中调用命令行进行静音切除( -af silenceremove=1:0.02:0.1 ),将 10 秒音频压缩到 6 秒有效语音,耗时降至 0.4 秒;
  • 传输层 :改用 WebClient bodyValue 流式上传,配合 HttpClient 的连接池复用,上传耗时稳定在 0.8 秒;
  • 服务层 :要求模型服务商提供独立的语音专用 GPU 实例,并配置 spring.ai.audio-to-text.rate-limiter 限制每秒请求数,避免排队。

最终端到端延迟压到 3.5 秒以内,达到产线工人可接受的交互体验。

2.4 ImageToTextModel:视觉理解的“格式战争”,PNG/JPEG/WebP 的兼容性雷区

ImageToTextModel 接口用于多模态理解(如 OCR、图像描述生成),但它的最大痛点不是模型能力,而是 图像格式兼容性 。Spring AI 1.0.2 的 ImageToTextRequest 接收 byte[] ,看似无格式要求,实则暗藏玄机。

我们对接阿里云百炼的 qwen-vl-plus 模型时,发现一个诡异现象:同一张截图,用 PNG 格式上传能正确识别文字,换成 JPEG 就返回空结果。排查后发现,百炼的 API 对 JPEG 的 EXIF 元数据(尤其是方向标记)极其敏感,而 Spring Boot 默认的 MultipartFile.getBytes() 会剥离 EXIF。更麻烦的是,某些国产模型要求 WebP 格式,但 Java 原生 ImageIO 不支持 WebP 编码。

解决方案是: ImageToTextRequest 构建前,强制统一为 PNG 格式并清除 EXIF

public byte[] normalizeImage(MultipartFile file) throws IOException {
    BufferedImage image = ImageIO.read(file.getInputStream());
    // 清除 EXIF(避免方向标记干扰)
    if (image instanceof BufferedImage) {
        BufferedImage normalized = new BufferedImage(
            image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_ARGB);
        Graphics2D g2d = normalized.createGraphics();
        g2d.drawImage(image, 0, 0, null);
        g2d.dispose();
        image = normalized;
    }
    
    // 写入 PNG(无损,兼容性最好)
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    ImageIO.write(image, "png", baos);
    return baos.toByteArray();
}

// 使用
ImageToTextRequest request = ImageToTextRequest.builder()
    .image(normalizeImage(multipartFile)) // 关键:先归一化
    .prompt("请提取图中所有可见文字,按行返回,不要解释。")
    .build();

这个看似简单的归一化步骤,解决了我们在三个不同客户项目中遇到的图像识别失败问题。它提醒我们:AI 模型的“智能”背后,是无数工程细节的堆砌。

3. 动态模型路由的实战方案:如何在同一个 Spring Boot 应用中自由切换 OpenAI、百炼、本地 Ollama

“Spring AI 2.0 动态设置模型”、“spring ai alibaba 动态加载模型配置”——这些热搜词背后,是企业级应用的真实需求:业务场景不同,对模型的要求也不同。客服对话需要高准确率(GPT-4),内部知识库检索需要低成本(Qwen1.5-7B),实时语音转写需要低延迟(Whisper.cpp)。硬编码多个 ChatModel Bean 不仅臃肿,更难以按业务规则动态路由。

Spring AI 1.0.2 本身不提供开箱即用的“模型路由”功能,但它的 ChatModel SPI 设计天然支持此扩展。我们的方案是: 基于 Spring 的 ObjectProvider @ConditionalOnProperty ,构建一个可配置的模型工厂

3.1 模型注册中心:用 @ConfigurationProperties 管理所有模型实例

首先,定义一个 ModelProperties 类,集中管理所有模型的配置:

# application.yml
spring:
  ai:
    models:
      openai:
        enabled: true
        api-key: ${OPENAI_API_KEY}
        base-url: https://api.openai.com/v1
        model-name: gpt-4-turbo
        temperature: 0.2
      qwen:
        enabled: true
        api-key: ${QWEN_API_KEY}
        base-url: https://dashscope.aliyuncs.com/api/v1
        model-name: qwen-max
        temperature: 0.5
      ollama:
        enabled: false # 本地开发用
        base-url: http://localhost:11434
        model-name: qwen:7b

对应的 Java 配置类:

@ConfigurationProperties(prefix = "spring.ai.models")
@Data
public class ModelProperties {
    private OpenAi openai = new OpenAi();
    private Qwen qwen = new Qwen();
    private Ollama ollama = new Ollama();
    
    @Data
    public static class OpenAi {
        private boolean enabled = false;
        private String apiKey;
        private String baseUrl;
        private String modelName;
        private Double temperature;
    }
    
    // Qwen 和 Ollama 类同理...
}

3.2 条件化 Bean 注册:让 Spring 容器按需加载

为每个模型类型编写条件化配置类。以 OpenAI 为例:

@Configuration
@ConditionalOnProperty(name = "spring.ai.models.openai.enabled", havingValue = "true")
public class OpenAiAutoConfiguration {
    
    @Bean
    @Primary // 默认主模型
    public ChatModel openAiChatModel(ModelProperties properties) {
        return OpenAiChatModel.builder()
            .apiKey(properties.getOpenai().getApiKey())
            .baseUrl(properties.getOpenai().getBaseUrl())
            .modelName(properties.getOpenai().getModelName())
            .options(ChatOptions.builder()
                .temperature(properties.getOpenai().getTemperature())
                .build())
            .build();
    }
    
    @Bean
    public EmbeddingModel openAiEmbeddingModel(ModelProperties properties) {
        return OpenAiEmbeddingModel.builder()
            .apiKey(properties.getOpenai().getApiKey())
            .baseUrl(properties.getOpenai().getBaseUrl())
            .modelName("text-embedding-3-small")
            .build();
    }
}

同理,为 Qwen 和 Ollama 编写 QwenAutoConfiguration OllamaAutoConfiguration 。这样,Spring 容器会根据 application.yml 中的 enabled 配置,只加载启用的模型 Bean。

3.3 动态路由引擎:基于业务上下文选择模型

最关键的路由逻辑,放在一个 ModelRouter 服务中。它不依赖任何具体模型实现,只通过 ObjectProvider 获取可用的 ChatModel

@Service
public class ModelRouter {
    
    @Autowired
    private ObjectProvider<ChatModel> chatModelProvider;
    
    @Autowired
    private ModelProperties modelProperties;
    
    /**
     * 根据业务场景和用户等级,动态选择 ChatModel
     * @param scenario 业务场景:customer_service, internal_knowledge, real_time_chat
     * @param userLevel 用户等级:vip, normal, guest
     */
    public ChatModel routeChatModel(String scenario, String userLevel) {
        // 优先级:VIP 客服 > 内部知识 > 实时聊天 > 默认
        if ("customer_service".equals(scenario) && "vip".equals(userLevel)) {
            return getChatModelByType("openai");
        } else if ("internal_knowledge".equals(scenario)) {
            return getChatModelByType("qwen");
        } else if ("real_time_chat".equals(scenario)) {
            return getChatModelByType("ollama");
        } else {
            // 返回容器中第一个可用的 ChatModel(@Primary)
            return chatModelProvider.getIfAvailable();
        }
    }
    
    private ChatModel getChatModelByType(String type) {
        // Spring 不支持按名称获取 Bean,我们用 ApplicationContext
        String beanName = type + "ChatModel";
        try {
            return (ChatModel) applicationContext.getBean(beanName);
        } catch (NoSuchBeanDefinitionException e) {
            // 降级到默认模型
            return chatModelProvider.getIfAvailable();
        }
    }
}

3.4 在 Controller 中使用路由

最终,在业务 Controller 中,按需调用:

@RestController
public class AiController {
    
    @Autowired
    private ModelRouter modelRouter;
    
    @PostMapping("/chat")
    public ChatResponse chat(@RequestBody ChatRequest request) {
        // 从业务参数中提取场景和用户等级
        String scenario = determineScenario(request);
        String userLevel = determineUserLevel(request.getUserId());
        
        ChatModel selectedModel = modelRouter.routeChatModel(scenario, userLevel);
        
        ChatClient chatClient = ChatClient.builder(selectedModel)
            .defaultSystemMessage(getSystemMessage(scenario))
            .build();
            
        return chatClient.call(new UserMessage(request.getMessage()));
    }
}

这套方案的优势在于: 零侵入现有代码 。所有模型切换逻辑集中在 ModelRouter ,业务代码只关心“我要什么场景的模型”,不关心具体是哪家厂商。当需要接入新模型(如百度文心)时,只需新增一个 WenxinAutoConfiguration ,修改 application.yml ,无需改动任何业务逻辑。我们在某银行项目中,用此方案在两周内完成了从 GPT-4 到文心一言的平滑迁移,零停机。

注意: ObjectProvider 是 Spring 5.3 引入的轻量级 Bean 查找工具,比 ApplicationContext.getBean() 更安全,它不会在 Bean 不存在时抛异常,而是返回 null 或默认值,非常适合动态场景。

4. RAG 实战避坑指南:从 TokenTextSplitter 到向量库选型的全链路经验

“Spring AI RAG”、“spring ai rag的tokentextsplitter”——这些热搜词揭示了一个残酷现实:90% 的 RAG 项目失败,不是因为模型不行,而是因为数据预处理和检索环节的工程缺陷。Spring AI 1.0.2 提供了 TokenTextSplitter ,但它只是一个工具,如何用好它,才是成败关键。

4.1 TokenTextSplitter 的三大误用:切得越细,效果越差?

TokenTextSplitter 的设计初衷是按 Token 数量切分文本,避免单个 Chunk 超过模型上下文限制。但很多团队把它当成“万能切刀”,无脑设置 maxTokenSize=512 ,结果导致:

  • 语义断裂 :一段完整的操作步骤(如“1. 登录系统;2. 进入订单页;3. 点击导出按钮”)被切成两半,检索时只匹配到“点击导出按钮”,丢失前置条件;
  • 信息稀疏 :技术文档中,一个关键参数说明(如 spring.ai.chat.options.temperature )被切到不同 Chunk,模型无法关联上下文;
  • 噪声放大 :HTML 页面的 <div> <p> 标签被当作普通文本切分,Chunk 中充斥着无意义的标签字符。

在智慧校园系统的 RAG 项目中,我们对比了三种切分策略对检索准确率的影响(测试集:1000 条学生常见问题):

切分方式 Chunk 大小 平均召回率 语义完整率 备注
TokenTextSplitter(maxTokenSize=512) ~512 tokens 68.2% 41.5% 标签噪声多,步骤类问题召回差
RecursiveCharacterTextSplitter(chunkSize=500, chunkOverlap=50) ~500 chars 72.1% 63.8% 保留段落结构,但技术术语易被切开
自定义 MarkdownTextSplitter # / ## / - 符号切分 85.7% 89.2% 严格按语义单元切分

我们的解决方案是: 放弃通用切分器,为每种文档类型定制切分逻辑 。针对学校官网的 Markdown 文档,我们编写了 MarkdownTextSplitter

public class MarkdownTextSplitter implements TextSplitter {
    
    @Override
    public List<String> splitText(String text) {
        List<String> chunks = new ArrayList<>();
        // 按一级、二级标题切分
        String[] sections = text.split("(?=#\\s+)|(?=##\\s+)");
        
        for (String section : sections) {
            if (section.trim().isEmpty()) continue;
            
            // 每个 section 再按列表项切分(避免长段落)
            String[] items = section.split("(?=-\\s+)");
            for (String item : items) {
                if (item.trim().length() > 100) { // 长段落再按句子切
                    chunks.addAll(splitBySentence(item.trim()));
                } else {
                    chunks.add(item.trim());
                }
            }
        }
        return chunks;
    }
    
    private List<String> splitBySentence(String text) {
        // 使用正则按中文句号、英文句号、问号、感叹号切分
        return Arrays.stream(text.split("[。!?.!?]"))
            .filter(s -> s.trim().length() > 20) // 过滤过短句子
            .map(String::trim)
            .collect(Collectors.toList());
    }
}

这个切分器确保每个 Chunk 都是一个语义完整的单元(如一个 FAQ 条目、一个操作步骤列表),极大提升了检索的相关性。

4.2 向量库选型:Milvus、Weaviate、PGVector,谁才是 Spring AI 的最佳拍档?

Spring AI 1.0.2 的 VectorStore 接口是 RAG 的基石,但官方只提供了内存版 InMemoryVectorStore 和一个简陋的 RedisVectorStore 。生产环境必须选型第三方向量库。我们实测了三种主流方案:

方案 优势 劣势 Spring AI 集成难度 适用场景
Milvus 2.4 性能最强(亿级向量毫秒检索),分布式架构成熟 运维复杂(需 Kafka/Pulsar/ZooKeeper),Java SDK 文档少 ★★★★☆(需自研 MilvusVectorStore 大型企业,海量知识库
Weaviate 1.23 语义搜索+关键词搜索融合,REST API 友好,Schema 灵活 单机版性能一般,集群版 License 限制 ★★★☆☆(社区有 weaviate-spring-ai Starter) 中小型项目,需混合搜索
PGVector 0.5 无缝集成 PostgreSQL,ACID 事务,运维零成本 百万级向量后性能下降明显,不支持 HNSW 索引 ★★☆☆☆(官方 spring-ai-pgvector Starter 已支持) 初创公司,已有 PG,知识库 < 50 万条

我们的选择是 PGVector 。原因很实在:客户已有成熟的 PostgreSQL 运维体系,DBA 拒绝为 RAG 单独部署一套 Milvus。而 spring-ai-pgvector Starter 在 1.0.2 版本中已完善,只需三步:

  1. 在 PostgreSQL 中启用 pgvector 扩展: CREATE EXTENSION vector;
  2. 添加依赖: implementation 'org.springframework.ai:spring-ai-pgvector-spring-boot-starter'
  3. 配置 application.yml
spring:
  ai:
    pgvector:
      host: localhost
      port: 5432
      database: rag_db
      username: rag_user
      password: ${PG_PASSWORD}
      table-name: embeddings

PGVectorVectorStore 会自动创建表、索引,并提供 add() similaritySearch() 等标准方法。我们在教育项目中,用它支撑了 32 万条课程文档的实时检索,P95 延迟稳定在 120ms 以内。

4.3 RAG 的“最后一公里”:如何让模型不胡说八道?

即使切分精准、检索高效,模型仍可能“幻觉”——编造不存在的政策条款、虚构未发布的系统功能。Spring AI 1.0.2 提供了 RetrievalAugmentor ,但默认行为是简单拼接检索结果和用户问题。我们的增强方案是:

  • 来源可信度加权 :为每个检索到的 Chunk 打上来源标签(如 official_policy.md: 0.95 , faq_draft.md: 0.6 ),在 Prompt 中明确要求模型“仅依据可信度 > 0.8 的来源作答”;
  • 引用标注强制 :在 System Prompt 中加入:“你必须在回答末尾用 [1][2] 标注所引用的 Chunk 编号,未标注视为违规”;
  • 后置验证 :对模型输出进行正则匹配,检查是否包含 [数字] 格式引用,若无则触发重试或返回兜底提示。

这套组合拳,将教育项目中“政策类问题”的幻觉率从 23% 降至 1.8%,真正让 RAG 从玩具变成了生产工具。

5. 生产环境监控与埋点:如何追踪每一次模型调用的“健康度”

“Spring AI 模型调用埋点”、“spring ai sessionapi方案”——这些热搜词指向一个被严重低估的领域:AI 服务的可观测性。在传统微服务中,我们监控 QPS、P95 延迟、错误率;但在 AI 场景中,这些指标远远不够。一次“成功”的模型调用,可能返回毫无价值的废话;一次“失败”的调用,可能只是模型拒绝回答敏感问题(这是安全合规的表现)。

Spring AI 1.0.2 内置了 ObservationRegistry 支持,但默认只记录基础指标。我们要做的是: 构建一个覆盖“输入-处理-输出”全链路的埋点体系

5.1 输入层埋点:捕捉 Prompt 的“毒性”与“模糊度”

Prompt 质量直接决定模型输出质量。我们定义了两个关键输入指标:

  • Prompt 毒性分(Toxicity Score) :使用 Google 的 Perspective API 或开源的 detoxify 库,对 UserMessage.getContent() 进行实时扫描,分值 > 0.7 标记为高风险;
  • Prompt 模糊度(Ambiguity Score) :基于关键词密度计算。例如,一个合格的客服 Prompt 应包含“用户ID”、“问题类型”、“发生时间”等实体。我们用正则匹配关键字段缺失率:
public double calculateAmbiguity(String prompt) {
    int requiredFields = 0;
    int foundFields = 0;
    
    if (prompt.contains("用户ID") || prompt.contains("学号")) {
        requiredFields++; foundFields++;
    }
    if (prompt.contains("问题类型") || prompt.contains("故障")) {
        requiredFields++; foundFields++;
    }
    if (prompt.contains("时间") || prompt.contains("日期")) {
        requiredFields++; foundFields++;
    }
    
    return requiredFields == 0 ? 0.0 : (double) (requiredFields - foundFields) / requiredFields;
}

模糊度 > 0.5 的 Prompt,系统自动追加引导:“请提供您的学号、问题发生的具体时间,以便我们快速定位。”

5.2 处理层埋点:不只是延迟,更要关注 Token 效率

传统监控只看 responseTime ,但 AI 场景中, inputTokens outputTokens 同样关键。我们扩展了 Micrometer 的 Timer ,记录每个调用的 Token 效率比:

@Bean
public ObservationHandler<Observation.Context> tokenEfficiencyHandler(
        MeterRegistry meterRegistry) {
    return context -> {
        if (context.getLowCardinalityKeyValues().containsKey("model.name")) {
            long inputTokens = context.getOrDefault("inputTokens", 0L);
            long outputTokens = context.getOrDefault("outputTokens", 0L);
            String modelName = context.getLowCardinalityKeyValues()
                .get("model.name").getValue();
            
            // 计算效率比:输出 Token / 输入 Token
            double efficiency = inputTokens > 0 ? (double) outputTokens / inputTokens : 0.0;
            
            Timer.builder("ai.token.efficiency")
                .tag("model", modelName)
                .register(meterRegistry)
                .record(Duration.ofMillis(1), 
                    Tags.of(Tag.of("efficiency", String.valueOf(efficiency))));
        }
    };
}

这个指标帮助我们发现:GPT-4-Turbo 在处理长文档摘要时,效率比(output/input)稳定在 0.3~0.4;而 Qwen1.5-7B 在相同任务下只有 0.15,意味着它需要更多输入才能产出同等信息量的输出——这直接影响了我们的模型选型决策。

5.3 输出层埋点:用 LLM-as-a-Judge 评估回答质量

最硬核的埋点,是对模型输出本身进行质量评估。我们采用“LLM-as-a-Judge”模式:用另一个更强大的模型(如 GPT-4)对当前回答打分。

定义评估 Prompt:

你是一个严格的 QA 评估员。请根据以下标准,对助手的回答打分(1-5 分):
1. 准确性:回答是否与提供的参考信息一致?有无事实性错误?
2. 完整性:是否回答了用户问题的所有子问题?
3. 有用性:回答是否提供了可操作的步骤或明确结论?
4. 安全性:是否拒绝回答非法、有害、隐私相关问题?

参考信息:{retrieved_chunks}
用户问题:{user_question}
助手回答:{assistant_answer}

请只输出一个数字:1/2/3/4/5

ChatClient 调用后,异步触发评估:

public void asyncEvaluateResponse(ChatResponse response, String userQuestion, List<String> retrieved
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐