在这里插入图片描述

Spring AI 企业增强版(含安全版与合规版)

基于 Spring AI 官方参考文档整理的中文实战教程。
参考版本:Spring AI 1.1.4,支持 Spring Boot 3.4.x / 3.5.x
官方文档入口:https://docs.spring.io/spring-ai/reference/index.html
学习记录

1. 这份文档适合谁

如果你已经会 Spring Boot,但第一次真正落地 AI 功能,这份文档可以直接带你走完一条典型路线:

  1. 接一个聊天模型
  2. 暴露一个 HTTP 接口
  3. 返回结构化结果
  4. 给模型加工具调用
  5. 加会话记忆
  6. 接入 PGvector 做 RAG
  7. 补齐日志、监控、超时、重试、审计与安全边界
  8. 补齐数据分级、模型供应商准入、留存删除与审批闭环

本文默认使用 OpenAI 作为示例模型供应商,因为官方文档示例最完整、上手最快。后续如果你换成 OllamaAnthropicAzure OpenAI,业务层代码大多可以复用,主要改 starter 和配置。

2. Spring AI 到底解决什么问题

Spring AI 的核心价值,不是“帮你调一次大模型接口”,而是把 AI 能力放进 Spring 的工程体系里:

  • 用统一抽象访问不同模型提供商
  • 用 Spring Boot 自动配置减少接线代码
  • ChatClientStructured OutputTool CallingAdvisorsChat MemoryRAG 这些组件搭业务链路
  • 让 AI 功能更容易接入现有的 Web、Data、Security、Actuator 体系

一句话理解:

Spring AI = 用 Spring Boot 的方式开发 AI 应用。

3. 实战目标

我们最终希望做出下面这种应用:

  • GET /ai/chat?message=...:普通聊天
  • GET /ai/plan?topic=...:返回结构化对象
  • GET /ai/tool?message=...:让模型自动调用本地工具
  • GET /ai/memory?...:支持多轮对话记忆
  • POST /rag/load:导入知识库文档到向量库
  • GET /rag/ask?question=...:基于私有知识库回答问题

这已经覆盖了大多数企业项目的第一版 AI 能力。

4. 创建项目

4.1 建议环境

  • JDK 21
  • Spring Boot 3.4.x3.5.x
  • Spring AI 1.1.4
  • Maven 或 Gradle
  • PostgreSQL + PGvector(如果要做 RAG)

4.2 推荐依赖

如果你已经有一个 Spring Boot 项目,最实用的方式是在现有项目里加入 Spring AI BOM 和需要的 starter。

下面是一个偏实战的 pom.xml 片段:

<properties>
    <java.version>21</java.version>
    <spring-ai.version>1.1.4</spring-ai.version>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>${spring-ai.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-advisors-vector-store</artifactId>
    </dependency>

    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
</dependencies>

这组依赖分别负责:

  • spring-boot-starter-web:暴露 REST 接口
  • spring-ai-starter-model-openai:接入 OpenAI Chat / Embedding
  • spring-ai-starter-vector-store-pgvector:接入 PGvector
  • spring-ai-advisors-vector-store:开箱即用的 RAG Advisor
  • spring-boot-starter-data-jpa:支撑审计落库、会话元数据等关系型存储
  • spring-boot-starter-actuator:暴露健康检查、指标、观测端点
  • micrometer-registry-prometheus:把指标输出给 Prometheus 抓取
  • spring-boot-starter-security:统一接入认证与权限控制
  • spring-boot-starter-oauth2-resource-server:把 AI 接口纳入 JWT/OAuth2 鉴权体系
  • spring-boot-starter-validation:对输入做参数校验和基础拦截

如果你暂时不做 RAG,可以先只保留:

  • spring-ai-starter-model-openai
  • spring-boot-starter-web

如果你暂时不做生产监控,也可以先不加:

  • spring-boot-starter-actuator
  • micrometer-registry-prometheus

如果你暂时不做审计落库,也可以先不加:

  • spring-boot-starter-data-jpa

如果你是在内网做 PoC,暂时也可以先不加:

  • spring-boot-starter-security
  • spring-boot-starter-oauth2-resource-server
  • spring-boot-starter-validation

5. 最小可用配置

5.1 application.yml

spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-4o-mini
          temperature: 0.2
  datasource:
    url: jdbc:postgresql://localhost:5432/postgres
    username: postgres
    password: postgres

几点说明:

  • spring.ai.openai.api-key 可以直接从环境变量读取
  • gpt-4o-mini 是一个比较适合开发期验证的默认选择
  • temperature 建议从 0.20.5 开始,不要一上来就太高
  • 如果你还没做 RAG,datasource 这段可以先不加

5.2 设置环境变量

PowerShell 示例:

$env:OPENAI_API_KEY="你的OpenAIKey"

如果要长期使用,可以写到系统环境变量或者本地 .env 管理方案中。

6. 第一个可运行接口

Spring AI 官方推荐业务层优先用 ChatClient,它比直接操作 ChatModel 更贴近应用开发。

package com.example.demo;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ChatController {

    private final ChatClient chatClient;

    public ChatController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @GetMapping("/ai/chat")
    public String chat(@RequestParam String message) {
        return chatClient.prompt()
                .system("你是一个简洁、专业的 Java 助手。")
                .user(message)
                .call()
                .content();
    }
}

请求示例:

curl "http://localhost:8080/ai/chat?message=请解释什么是Spring AI"

这个例子体现了 Spring AI 最基础的调用链:

  • 注入 ChatClient.Builder
  • 构造 prompt
  • 设置 systemuser
  • 调用 call().content() 取文本结果

7. 推荐先掌握的 API

真正写业务时,优先掌握下面几个点就够了:

  • ChatClient:应用层主入口
  • Prompt / 模板参数:组织提示词
  • entity():把输出转成对象
  • tools():让模型调用本地工具
  • advisors():挂记忆、RAG、增强逻辑

如果你只学一个 API,就先学 ChatClient

8. 结构化输出

企业项目里,很多场景不是要一段自然语言,而是要一个“可以继续进业务逻辑”的对象。

8.1 定义返回对象

package com.example.demo;

import java.util.List;

public record StudyPlan(
        String topic,
        List<String> steps,
        List<String> risks
) {
}

8.2 让模型直接返回对象

package com.example.demo;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class PlanController {

    private final ChatClient chatClient;

    public PlanController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @GetMapping("/ai/plan")
    public StudyPlan plan(@RequestParam String topic) {
        return chatClient.prompt()
                .system("你是一个技术学习规划助手,请返回清晰、可执行的学习计划。")
                .user(u -> u.text("请为主题 {topic} 生成 5 步学习计划,并补充常见风险。")
                        .param("topic", topic))
                .call()
                .entity(StudyPlan.class);
    }
}

8.3 什么时候该用结构化输出

下面这些场景,建议优先用 entity()

  • 任务拆解
  • 工单分类
  • 信息抽取
  • 生成审批建议
  • 生成可执行参数
  • 输出给前端渲染的 JSON 结构

不要等拿到一大段文本后再自己正则解析,那样维护成本会高很多。

9. Tool Calling:让模型调用本地能力

这是 Spring AI 很实用的一块。模型自己不能读你本地时间、数据库、业务系统,但它可以“请求调用工具”。

9.1 定义工具类

package com.example.demo;

import java.time.LocalDateTime;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;

@Component
public class TimeTools {

    @Tool(description = "返回当前系统时间,格式为 ISO-8601。")
    public String currentTime() {
        return LocalDateTime.now().toString();
    }
}

9.2 在请求时挂上工具

package com.example.demo;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ToolController {

    private final ChatClient chatClient;
    private final TimeTools timeTools;

    public ToolController(ChatClient.Builder builder, TimeTools timeTools) {
        this.chatClient = builder.build();
        this.timeTools = timeTools;
    }

    @GetMapping("/ai/tool")
    public String tool(@RequestParam String message) {
        return chatClient.prompt(message)
                .tools(timeTools)
                .call()
                .content();
    }
}

测试示例:

curl "http://localhost:8080/ai/tool?message=现在时间是什么?请顺便告诉我30分钟后是几点"

这个过程中:

  1. 模型判断自己需要当前时间
  2. Spring AI 执行 currentTime()
  3. 执行结果返回给模型
  4. 模型再组织最终回答

9.3 Tool Calling 的实战建议

  • 工具描述一定要写清楚,否则模型不容易正确调用
  • 工具返回值尽量稳定、可预测
  • 工具本质上是业务入口,注意权限与审计
  • 不要把所有内部方法都暴露给模型

10. Chat Memory:支持多轮对话

LLM 默认是无状态的。你要实现“上文下文记忆”,需要加内存层。

10.1 配置记忆 Bean

package com.example.demo;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AiConfig {

    @Bean
    ChatMemory chatMemory() {
        return MessageWindowChatMemory.builder()
                .maxMessages(10)
                .build();
    }

    @Bean
    ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
        return builder
                .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
                .build();
    }
}

10.2 在请求中传入会话 ID

package com.example.demo;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class MemoryController {

    private final ChatClient chatClient;

    public MemoryController(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    @GetMapping("/ai/memory")
    public String memory(@RequestParam String conversationId, @RequestParam String message) {
        return chatClient.prompt()
                .user(message)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
                .call()
                .content();
    }
}

测试方式:

curl "http://localhost:8080/ai/memory?conversationId=u1&message=我叫张三"
curl "http://localhost:8080/ai/memory?conversationId=u1&message=你还记得我叫什么吗"

10.3 Memory 的边界

要特别区分两件事:

  • Chat Memory:给模型提供当前会话上下文
  • Chat History:你业务上真正需要长期保存的全部对话记录

Spring AI 的 ChatMemory 更偏前者,不要把它直接当正式消息库。

11. RAG:让模型基于你的知识库回答

很多项目真正要的不是“会聊天”,而是“基于公司资料回答问题”。这就是 RAG。

11.1 启动本地 PGvector

官方文档给出了最直接的本地启动方式:

docker run -it --rm --name postgres -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres pgvector/pgvector

11.2 RAG 配置

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/postgres
    username: postgres
    password: postgres
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-4o-mini
          temperature: 0.2
    vectorstore:
      pgvector:
        index-type: HNSW
        distance-type: COSINE_DISTANCE
        dimensions: 1536
        initialize-schema: true

几点注意:

  • initialize-schema: true 需要你显式开启,Spring AI 1.x 不再默认帮你建表
  • dimensions: 1536 是一个常见示例值
  • 如果你使用的 Embedding 维度不是 1536,要保持一致

11.3 导入文档

先写一个最简单的导入接口:

package com.example.demo;

import java.util.List;
import java.util.Map;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class RagLoadController {

    private final VectorStore vectorStore;

    public RagLoadController(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    @PostMapping("/rag/load")
    public String load(@RequestBody List<String> texts) {
        List<Document> documents = texts.stream()
                .map(text -> new Document(text, Map.of("source", "manual")))
                .toList();

        vectorStore.add(documents);
        return "ok";
    }
}

请求示例:

Invoke-RestMethod -Method Post `
  -Uri "http://localhost:8080/rag/load" `
  -ContentType "application/json" `
  -Body '["Spring AI是Spring生态下的AI应用开发框架","ChatClient是推荐的应用层入口"]'

11.4 基于知识库提问

package com.example.demo;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class RagAskController {

    private final ChatClient chatClient;
    private final QuestionAnswerAdvisor qaAdvisor;

    public RagAskController(ChatClient.Builder builder, VectorStore vectorStore) {
        this.chatClient = builder.build();
        this.qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
                .searchRequest(SearchRequest.builder()
                        .similarityThreshold(0.8d)
                        .topK(6)
                        .build())
                .build();
    }

    @GetMapping("/rag/ask")
    public String ask(@RequestParam String question) {
        return chatClient.prompt()
                .user(question)
                .advisors(qaAdvisor)
                .call()
                .content();
    }
}

测试示例:

curl "http://localhost:8080/rag/ask?question=Spring AI 推荐用什么作为应用层入口?"

11.5 RAG 质量为什么经常不稳定

很多人以为 RAG 效果差是模型不行,实际常见问题在这里:

  • 文档切分太粗或太碎
  • 导入时没清洗脏数据
  • 检索阈值不合适
  • topK 太小或太大
  • Prompt 没限制“找不到就别瞎编”

也就是说,RAG 是一个“数据工程 + 检索工程 + Prompt 工程”的组合问题,不只是模型调用。

12. 推荐的项目结构

一个干净的 Spring AI 项目,建议至少这样分层:

src/main/java/com/example/demo
├─ config
├─ controller
├─ service
├─ tool
├─ rag
└─ model

可以按下面方式落地:

  • configChatClientChatMemoryVectorStore 相关配置
  • controller:HTTP 接口
  • service:AI 编排逻辑
  • tool:给模型调用的工具
  • rag:文档导入、检索增强
  • model:结构化输出对象、DTO

13. 实战时最值得遵守的 8 条建议

  1. 业务代码优先用 ChatClient,不要一开始就沉到过低层 API。
  2. 需要稳定结果时优先用结构化输出,而不是解析自然语言。
  3. 工具调用只暴露必要能力,不要把内部服务一股脑交给模型。
  4. 多轮对话一定要传 conversationId,否则记忆会串。
  5. RAG 先从小规模文档验证,不要一开始就全量导库。
  6. temperature 先低后高,业务系统通常更需要稳定而不是发散。
  7. 日志里要记录模型、耗时、token、错误类型和命中的知识来源。
  8. 把 AI 看成“不稳定外部依赖”,加超时、重试、降级和兜底提示。

14. 企业增强:可观测性

Spring AI 官方文档明确提供了观测能力,覆盖:

  • ChatClient
  • Advisor
  • ChatModel
  • EmbeddingModel
  • VectorStore

如果你准备上线,建议第一天就把可观测性接上,而不是等问题出现后再补。

14.1 最小监控依赖

上面依赖中的这两个组件就是最常用组合:

  • spring-boot-starter-actuator
  • micrometer-registry-prometheus

14.2 推荐配置

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      show-details: when_authorized

spring:
  ai:
    chat:
      observations:
        log-prompt: false
        log-completion: false
        include-error-logging: true
      client:
        observations:
          log-prompt: false
          log-completion: false
    vectorstore:
      observations:
        log-query-response: false

这里有两个关键点:

  • Prompt 和 Completion 默认不要落日志,避免敏感信息泄漏
  • 错误日志可以打开,但要配合脱敏规则

14.3 上线后重点看什么指标

结合 Spring AI 的观测模型,建议至少盯住这些维度:

  • gen_ai_client_operation:模型调用耗时
  • gen_ai_client_token_usage:输入、输出、总 token 用量
  • gen_ai_chat_client_operation:应用层 ChatClient 调用耗时
  • db_vector_client_operation:向量库查询和写入耗时

最有业务价值的看板通常是:

  • 每分钟请求量
  • 平均耗时和 P95/P99 耗时
  • 每模型 token 消耗
  • 工具调用次数与失败率
  • RAG 检索命中率
  • 兜底返回比例

15. 企业增强:超时、重试与降级

AI 调用和普通数据库调用最大的区别,是它更慢、更贵、也更不稳定。企业系统里最好把模型调用当成外部依赖来治理。

15.1 先定超时策略

最实用的做法不是一味延长等待时间,而是按场景分层:

  • 用户同步问答:宁可快失败,也不要无上限等待
  • 后台批处理:可以给更长超时
  • RAG 链路:要单独考虑向量检索和模型生成两段耗时

一个简单思路是把 AI 编排放到 service 层,再做超时包裹:

package com.example.demo.service;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class AiGatewayService {

    private final ChatClient chatClient;

    public AiGatewayService(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    public String askWithTimeout(String message) {
        return CompletableFuture.supplyAsync(() ->
                chatClient.prompt()
                        .user(message)
                        .call()
                        .content())
                .orTimeout(8, TimeUnit.SECONDS)
                .exceptionally(ex -> "当前 AI 服务较忙,请稍后重试。")
                .join();
    }
}

这个示例不是唯一实现方式,但它表达了一个很重要的原则:

超时和兜底应该放在应用层明确控制,而不是完全交给用户等待。

15.2 重试要克制

AI 调用不是所有错误都适合重试。更合理的策略通常是:

  • 网络抖动、瞬时 5xx:可有限重试
  • 参数错误、提示词错误、权限错误:不要重试
  • 超时后是否重试:要看接口是否是强交互场景

建议原则:

  • 最多 12 次重试
  • 使用指数退避
  • 把每次重试记录到审计日志
  • 流式响应场景谨慎重试

15.3 给出明确降级策略

企业项目不能只返回 500。至少要准备三种兜底:

  • 文本兜底:当前服务繁忙,请稍后再试
  • 规则兜底:改走关键词检索或固定模板
  • 人工兜底:进入工单或人工审核

16. 企业增强:审计、日志与敏感信息治理

很多团队上线后第一个问题不是“模型答得准不准”,而是“出了问题到底是谁、在什么时候、用哪个模型、对哪条数据做了什么”。

16.1 审计日志至少记录这些字段

  • traceId
  • conversationId
  • userId
  • model
  • provider
  • latencyMs
  • inputToken
  • outputToken
  • toolNames
  • knowledgeSources
  • resultStatus
  • failureReason

16.2 不要直接记录原始 Prompt

官方文档也明确提示:Prompt 和 Completion 可能包含敏感信息,默认不应该直接导出。企业里更建议这么做:

  • 日志记录摘要和长度,不记录全量正文
  • 对手机号、身份证号、邮箱、地址做脱敏
  • 对内部文档片段只记录 sourceIddocumentId
  • 把完整会话单独进入受控审计存储,而不是普通应用日志

16.3 一个简单的审计对象示例

package com.example.demo.audit;

import java.time.Instant;
import java.util.List;

public record AiAuditLog(
        Instant timestamp,
        String traceId,
        String userId,
        String conversationId,
        String model,
        long latencyMs,
        Integer inputTokens,
        Integer outputTokens,
        List<String> toolNames,
        List<String> knowledgeSources,
        String resultStatus,
        String failureReason
) {
}

这个对象不复杂,但足够支撑大部分排障和审计诉求。

17. 企业增强:内容安全与权限边界

Spring AI 官方文档里的 Advisors 里专门提到了 SafeGuardAdvisor。这很适合做第一层输入拦截。

17.1 用 SafeGuardAdvisor 做敏感词拦截

package com.example.demo.config;

import java.util.List;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.SafeGuardAdvisor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;

@Configuration
public class GuardrailConfig {

    @Bean
    ChatClient guardedChatClient(ChatClient.Builder builder) {
        SafeGuardAdvisor safeGuardAdvisor = new SafeGuardAdvisor(
                List.of("银行卡密码", "身份证原件", "绕过审核", "导出全部客户数据"),
                "请求中包含敏感内容,已拒绝处理,请联系管理员。",
                Ordered.HIGHEST_PRECEDENCE);

        return builder
                .defaultAdvisors(safeGuardAdvisor)
                .build();
    }
}

这里的重点不是“敏感词列表有多全”,而是先建立一条明确边界:

  • 先拦危险请求
  • 再决定是否调用模型
  • 最后再决定是否触发工具

17.2 工具权限要单独控制

企业里最容易出事的不是聊天本身,而是工具调用。建议至少做到:

  • 工具和普通聊天接口分开授权
  • 高风险工具按角色控制
  • 工具调用落审计日志
  • 对写操作类工具加人工确认或审批

尤其是下面这些工具,不能只靠模型自己判断:

  • 导出数据
  • 修改订单
  • 发消息
  • 调资金
  • 删除记录

18. 企业增强:RAG 结果校验与幻觉治理

只做 RAG 还不够,生产环境里还要关心“答得像不像真的”。Spring AI 官方文档提供了 EvaluatorRelevancyEvaluator 这条路线。

18.1 为什么要加评估

RAG 常见失败方式不是完全答错,而是:

  • 检索到了相关片段,但模型扩写过度
  • 检索上下文不够,模型开始脑补
  • 文档过期,但回答看起来很自信

18.2 可行的治理方式

  • 没检索到结果时,明确要求返回“未找到依据”
  • 把引用来源返回给前端
  • 对高风险问答增加 RelevancyEvaluator
  • 低分回答走二次确认或人工审核

如果是法规、财务、医疗、合同类场景,这一步尤其重要。

19. 企业增强:推荐的生产分层

如果准备长期维护,建议把原来的基础结构再细分一点:

src/main/java/com/example/demo
├─ config
├─ controller
├─ service
├─ tool
├─ rag
├─ audit
├─ security
├─ observability
├─ fallback
└─ model

推荐职责:

  • service:AI 编排主流程
  • audit:审计日志与调用留痕
  • security:权限、脱敏、输入校验、内容安全
  • observability:指标、trace、日志统一封装
  • fallback:超时、重试、降级、人工接管逻辑

20. 企业安全版:接口认证与角色鉴权

企业里最先要收住的,不是模型能力,而是“谁能访问哪些 AI 接口”。

一个比较稳妥的默认原则是:

  • 普通问答接口:登录即可访问
  • RAG 查询接口:需要业务角色
  • 文档导入接口:只允许知识库管理员
  • 工具调用接口:只允许明确授权的角色
  • 高风险工具:只允许后台服务账号或审批后执行

20.1 一个基础的 Spring Security 配置

下面这个示例把普通聊天、RAG 导入、管理接口分开了权限边界:

package com.example.demo.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/actuator/health", "/actuator/info").permitAll()
                        .requestMatchers("/ai/chat", "/ai/plan", "/ai/memory").authenticated()
                        .requestMatchers("/ai/tool").hasAnyRole("AI_TOOL_USER", "ADMIN")
                        .requestMatchers("/rag/ask").hasAnyRole("KNOWLEDGE_USER", "ADMIN")
                        .requestMatchers("/rag/load").hasAnyRole("KNOWLEDGE_ADMIN", "ADMIN")
                        .anyRequest().denyAll())
                .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
                .build();
    }
}

这个示例最重要的不是写法本身,而是这三个原则:

  • AI 接口不要默认裸奔
  • 工具调用权限要比普通问答更严格
  • 写操作和导入操作要单独隔离

20.2 方法级权限更适合业务控制

有些权限不适合只写在 URL 层,尤其是服务复用时。可以继续加方法级控制:

package com.example.demo.service;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
public class RagAdminService {

    @PreAuthorize("hasRole('KNOWLEDGE_ADMIN')")
    public void loadDocuments() {
        // 导入向量库
    }
}

这样即使 controller 之外有别的入口调用,也不会绕过权限。

21. 企业安全版:会话隔离、租户隔离与数据权限

AI 系统最容易被忽略的问题,是“回答对了,但回答给错人了”。

21.1 不要让前端自由传 conversationId

前面的 demo 为了便于理解,直接把 conversationId 作为请求参数传入。但企业系统里更安全的做法通常是:

  • 从登录态里拿 userId
  • 再拼上租户、应用、会话标识
  • 由服务端生成最终会话 ID

例如:

package com.example.demo.security;

import java.security.Principal;
import org.springframework.stereotype.Component;

@Component
public class ConversationIdFactory {

    public String create(String tenantId, Principal principal, String sessionId) {
        return tenantId + ":" + principal.getName() + ":" + sessionId;
    }
}

这样可以避免用户伪造别人的会话上下文。

21.2 RAG 检索要带上数据权限条件

如果你的知识库按租户、部门、项目隔离,向量检索不能只做“语义最相近”,还要做“当前用户能看见”。

推荐做法:

  • 文档入库时写入 tenantIddepartmentIdvisibility 等 metadata
  • 查询时按当前用户权限拼检索过滤条件
  • 返回引用来源时也不要泄露其他租户信息

哪怕模型本身回答得再准,只要越权命中了一段资料,这就是安全事故。

22. 企业安全版:高风险工具授权与二次确认

工具调用的真正风险,不在“模型会不会调用”,而在“调用后会不会造成真实业务影响”。

22.1 把工具分级

建议最少分成三类:

  • 只读工具:查天气、查状态、查配置
  • 低风险写工具:创建草稿、生成建议、写临时记录
  • 高风险写工具:发消息、改订单、导数据、调资金、删数据

22.2 高风险工具不要直接暴露

一个比较稳妥的做法,是让模型只输出“意图”,真正执行前必须经过业务授权层:

package com.example.demo.security;

import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;

@Service
public class ToolAuthorizationService {

    public void checkCanExecute(String toolName, Authentication authentication) {
        boolean isAdmin = authentication.getAuthorities().stream()
                .anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority()));

        if ("exportCustomerData".equals(toolName) && !isAdmin) {
            throw new IllegalStateException("当前用户无权执行高风险工具");
        }
    }
}

你可以把它理解为:

  • 模型负责提出“想调用什么”
  • Spring AI 负责调用链编排
  • 业务授权层负责决定“准不准执行”

22.3 对高风险操作增加确认票据

企业里更建议把高风险动作拆成两步:

  1. 模型生成操作建议
  2. 用户点击确认或审批通过后再执行

这比“模型一决定就直接写库”安全得多。

23. 企业安全版:输入校验、脱敏与提示词注入防护

很多 AI 安全问题并不是传统漏洞,而是“输入太自由,模型被带偏了”。

23.1 先做普通输入校验

先把最基础的校验补上,这一步不要省:

package com.example.demo.api;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record ChatRequest(
        @NotBlank(message = "message不能为空")
        @Size(max = 4000, message = "message长度不能超过4000")
        String message
) {
}

controller 示例:

package com.example.demo.controller;

import com.example.demo.api.ChatRequest;
import jakarta.validation.Valid;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class SecureChatController {

    private final ChatClient chatClient;

    public SecureChatController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @PostMapping("/ai/chat")
    public String chat(@Valid @RequestBody ChatRequest request) {
        return chatClient.prompt()
                .system("你是企业知识助手,只回答授权范围内的问题。")
                .user(request.message())
                .call()
                .content();
    }
}

23.2 不要把用户输入直接拼进 system 提示词

这是非常常见的错误写法:

  • 把用户问题拼进 system
  • 把数据库原文不加处理塞进 system prompt
  • 让用户输入覆盖你的规则提示

更稳妥的原则是:

  • 固定规则放 system
  • 用户问题放 user
  • RAG 上下文走专门的 advisor 或受控模板

23.3 脱敏要发生在进入模型之前

如果用户输入里可能含有手机号、身份证、银行卡号、合同编号,可以先做脱敏或替换标记,再进入模型。

例如:

  • 13800138000 -> [PHONE]
  • 310xxxxxxxxxxxxx -> [ID_CARD]
  • 6222xxxxxxxxxxxx -> [BANK_CARD]

这样即使日志、监控、模型提供商链路中有暴露风险,也能明显降低影响面。

24. 企业安全版:审计落库

前面的增强版里提过审计字段,这里把它进一步落成“可持久化”的最小结构。

24.1 一个简单的审计实体

package com.example.demo.audit;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.time.Instant;

@Entity
public class AiAuditRecord {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Instant createdAt;
    private String traceId;
    private String tenantId;
    private String userId;
    private String conversationId;
    private String provider;
    private String model;
    private String requestType;
    private String promptDigest;
    private Integer promptLength;
    private Integer responseLength;
    private Long latencyMs;
    private Integer inputTokens;
    private Integer outputTokens;
    private String resultStatus;
    private String toolNames;
    private String knowledgeSources;
    private String failureReason;

    protected AiAuditRecord() {
    }
}

上面的实体为了突出字段结构,省略了 getter/setter、构造器、索引和表注解细节。真实项目里你可以:

  • 手写 getter/setter
  • 使用 Lombok
  • 或者在 repository 层改成构造方式写入

24.2 一个最小仓库接口

package com.example.demo.audit;

import org.springframework.data.jpa.repository.JpaRepository;

public interface AiAuditRecordRepository extends JpaRepository<AiAuditRecord, Long> {
}

24.3 审计落库时机

建议至少在下面这些节点记录:

  • 请求进入前
  • 模型调用完成后
  • 工具调用前后
  • RAG 检索完成后
  • 发生异常和降级时

这会让你之后追一条链路时轻松很多。

24.4 推荐的实现思路

审计最常见的失败方式不是“不会写库”,而是“写得太散,最后串不起来”。比较稳妥的做法是把整个流程固定成一条链:

  1. 请求进入 controller 或 service 时创建审计上下文
  2. 提前拿到 traceIduserIdtenantIdconversationId
  3. 对原始输入做脱敏和摘要,不直接保存全文
  4. 调用模型、工具、RAG 检索
  5. 成功时统一落一条成功审计
  6. 失败时统一落一条失败审计
  7. 高风险工具和关键检索命中再补充事件日志

这里最重要的原则是:

  • 审计要围绕一次完整请求聚合
  • 审计和普通应用日志分开
  • 审计写失败不能影响主业务结果

24.5 审计上下文对象

先用一个上下文对象把请求期间的关键信息收拢起来,会比在各层到处传零散参数更稳。

package com.example.demo.audit;

import java.time.Instant;

public record AiAuditContext(
        Instant startedAt,
        String traceId,
        String tenantId,
        String userId,
        String conversationId,
        String provider,
        String model,
        String requestType,
        String promptDigest,
        Integer promptLength
) {
}

这个对象的作用很简单:

  • start 时生成
  • 整个调用链往下传
  • 成功或失败时统一补全并落库

24.6 先做脱敏和摘要

审计不是把原始输入“原封不动存下来”,而是先处理再存。一个实用做法是:

  • 保存脱敏后的摘要
  • 保存长度
  • 保存摘要哈希
  • 不保存原始正文

下面是一个简化版脱敏与摘要工具:

package com.example.demo.audit;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.regex.Pattern;
import org.springframework.stereotype.Component;

@Component
public class SensitiveDataMasker {

    private static final Pattern PHONE = Pattern.compile("1\\d{10}");
    private static final Pattern ID_CARD = Pattern.compile("\\d{17}[\\dXx]");
    private static final Pattern BANK_CARD = Pattern.compile("\\d{16,19}");

    public String mask(String text) {
        if (text == null || text.isBlank()) {
            return "";
        }

        String masked = PHONE.matcher(text).replaceAll("[PHONE]");
        masked = ID_CARD.matcher(masked).replaceAll("[ID_CARD]");
        masked = BANK_CARD.matcher(masked).replaceAll("[BANK_CARD]");
        return masked;
    }

    public String digest(String text) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            byte[] bytes = md.digest(mask(text).getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(bytes);
        } catch (NoSuchAlgorithmException ex) {
            throw new IllegalStateException("SHA-256 not available", ex);
        }
    }
}

这个工具不复杂,但已经能满足大多数审计最小需求:

  • 同一段输入可稳定生成相同摘要
  • 不直接暴露原始敏感内容
  • 后续仍然可以基于摘要做排查和比对

24.7 审计服务怎么封装

比较推荐的方式,是让所有 AI 请求都先经过一个统一的审计服务,而不是每个 controller 自己写一遍。

package com.example.demo.audit;

import java.security.Principal;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;

@Service
public class AiAuditService {

    private final AiAuditRecordRepository repository;
    private final SensitiveDataMasker masker;

    public AiAuditService(AiAuditRecordRepository repository, SensitiveDataMasker masker) {
        this.repository = repository;
        this.masker = masker;
    }

    public AiAuditContext start(
            String tenantId,
            Principal principal,
            String conversationId,
            String provider,
            String model,
            String requestType,
            String rawPrompt) {

        String traceId = readTraceId();
        String masked = masker.mask(rawPrompt);

        return new AiAuditContext(
                Instant.now(),
                traceId,
                tenantId,
                principal == null ? "anonymous" : principal.getName(),
                conversationId,
                provider,
                model,
                requestType,
                masker.digest(masked),
                masked.length());
    }

    public void success(
            AiAuditContext context,
            String responseContent,
            List<String> toolNames,
            List<String> knowledgeSources,
            Integer inputTokens,
            Integer outputTokens) {

        AiAuditRecord record = new AiAuditRecord();
        record.setCreatedAt(context.startedAt());
        record.setTraceId(context.traceId());
        record.setTenantId(context.tenantId());
        record.setUserId(context.userId());
        record.setConversationId(context.conversationId());
        record.setProvider(context.provider());
        record.setModel(context.model());
        record.setRequestType(context.requestType());
        record.setPromptDigest(context.promptDigest());
        record.setPromptLength(context.promptLength());
        record.setResponseLength(responseContent == null ? 0 : responseContent.length());
        record.setLatencyMs(Duration.between(context.startedAt(), Instant.now()).toMillis());
        record.setInputTokens(inputTokens);
        record.setOutputTokens(outputTokens);
        record.setToolNames(String.join(",", toolNames));
        record.setKnowledgeSources(String.join(",", knowledgeSources));
        record.setResultStatus("SUCCESS");

        saveQuietly(record);
    }

    public void failure(AiAuditContext context, Throwable ex) {
        AiAuditRecord record = new AiAuditRecord();
        record.setCreatedAt(context.startedAt());
        record.setTraceId(context.traceId());
        record.setTenantId(context.tenantId());
        record.setUserId(context.userId());
        record.setConversationId(context.conversationId());
        record.setProvider(context.provider());
        record.setModel(context.model());
        record.setRequestType(context.requestType());
        record.setPromptDigest(context.promptDigest());
        record.setPromptLength(context.promptLength());
        record.setLatencyMs(Duration.between(context.startedAt(), Instant.now()).toMillis());
        record.setResultStatus("FAILED");
        record.setFailureReason(ex.getClass().getSimpleName() + ":" + ex.getMessage());

        saveQuietly(record);
    }

    private void saveQuietly(AiAuditRecord record) {
        try {
            repository.save(record);
        } catch (Exception ignored) {
            // 审计失败不能反向打挂主业务
        }
    }

    private String readTraceId() {
        String traceId = MDC.get("traceId");
        return traceId == null || traceId.isBlank() ? UUID.randomUUID().toString() : traceId;
    }
}

上面这个实现里,有几个点很关键:

  • startsuccess/failure 分开,便于统一收口
  • saveQuietly 保证审计失败不会拖垮主流程
  • traceId 优先从链路上下文取,没有再自动生成
  • promptDigestpromptLength 替代原始正文

24.8 在 AI service 里怎么接

最适合接审计的位置,通常不是 controller,而是你真正编排 ChatClient、工具和 RAG 的 service。

package com.example.demo.service;

import com.example.demo.audit.AiAuditContext;
import com.example.demo.audit.AiAuditService;
import java.security.Principal;
import java.util.List;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class EnterpriseChatService {

    private final ChatClient chatClient;
    private final AiAuditService auditService;

    public EnterpriseChatService(ChatClient.Builder builder, AiAuditService auditService) {
        this.chatClient = builder.build();
        this.auditService = auditService;
    }

    public String chat(String tenantId, Principal principal, String conversationId, String message) {
        AiAuditContext audit = auditService.start(
                tenantId,
                principal,
                conversationId,
                "openai",
                "gpt-4o-mini",
                "CHAT",
                message);

        try {
            String result = chatClient.prompt()
                    .system("你是企业知识助手,只回答授权范围内的问题。")
                    .user(message)
                    .call()
                    .content();

            auditService.success(
                    audit,
                    result,
                    List.of(),
                    List.of(),
                    null,
                    null);

            return result;
        } catch (Exception ex) {
            auditService.failure(audit, ex);
            throw ex;
        }
    }
}

这里示例里把 token 先传成 null,是因为不同模型提供商、不同响应路径的取值方式会不同。企业里更实用的做法通常是:

  • 优先通过 Spring AI 的 observability 指标看 token
  • 如果 provider 元数据好取,再补到审计表
  • 不要因为 token 还没接上,就卡住整个审计体系

24.9 工具调用和 RAG 检索怎么记

如果你的 AI 请求里会发生多个关键事件,建议不要只记最终结果,还要把关键节点补进去。最常见的有两类:

  • 工具调用事件
  • RAG 检索事件

最简单的做法有两种:

  1. 先把工具名、命中文档 ID 聚合到主表字段里
  2. 如果链路更复杂,再单独建一张事件表

一个常见的事件表思路是:

  • traceId
  • eventType
  • eventName
  • status
  • durationMs
  • payloadDigest

事件类型可以先约定成:

  • MODEL_REQUEST
  • TOOL_CALL
  • TOOL_RESULT
  • RAG_RETRIEVAL
  • MODEL_RESPONSE
  • FAILURE

如果现在还不想加第二张表,至少先保证主表里能记录:

  • toolNames
  • knowledgeSources
  • resultStatus
  • failureReason

24.10 controller 层最好补哪些字段

controller 层最适合补的是“身份和租户”相关信息,例如:

  • tenantId
  • conversationId
  • Principal
  • 请求来源系统

最不适合在 controller 层做的,是把整套审计组装逻辑写死在那里。更推荐:

  • controller 只收集身份上下文
  • service 负责业务编排
  • AiAuditService 负责审计聚合与落库

24.11 审计表设计的几个实战建议

  • traceIdcreatedAtuserIdconversationId 建索引
  • failureReason 不要无限长,建议截断
  • toolNamesknowledgeSources 如果很长,优先存摘要或事件表
  • 高并发场景下,审计落库可以异步化或写消息队列
  • 审计表要有归档和定期清理策略

24.12 审计失败时怎么办

企业里更稳的处理方式通常是:

  • 主业务成功,审计失败:记录错误日志并报警,但不回滚主请求
  • 主业务失败,审计成功:保留失败审计,便于排障
  • 主业务失败,审计也失败:至少保证普通应用日志里还有 traceId

换句话说:

审计很重要,但审计系统本身不应该成为 AI 主链路的单点故障。

25. 企业安全版:流量控制与滥用防护

AI 接口比普通 CRUD 更容易被滥用,因为:

  • 成本更高
  • 响应更慢
  • 一次请求可能触发模型、向量库、工具三段开销

25.1 至少限制这三类维度

  • 按用户限流
  • 按租户限额
  • 按接口分级限频

建议:

  • /ai/chat 可以相对宽松
  • /ai/tool/rag/load 要更严格
  • 管理类接口要有更低频率和更强审计

25.2 把成本控制也纳入安全治理

企业安全不只是“防攻击”,还包括“防失控成本”。建议至少做:

  • 单用户每日 token 配额
  • 单租户月度预算
  • 高成本模型白名单
  • 超预算后的自动降级

这类策略对 AI 系统尤其重要。

26. 企业合规版:先定义治理边界

安全解决的是“能不能安全运行”,合规解决的是“这样运行是否符合公司制度、客户承诺和监管要求”。

这里先给一个很重要的前提:

这部分内容适合作为企业内部落地参考,但不能替代你们公司的法务、合规、审计结论。

也就是说,Spring AI 可以帮你把能力接起来,但下面这些边界必须先由组织定义清楚:

  • 哪些数据允许进入外部模型
  • 哪些数据只能走本地模型或私有部署
  • 哪些场景必须人工审批
  • 哪些输出必须留痕和可追溯
  • 哪些日志允许保存,保存多久,谁能查看

如果这些边界没定好,技术实现再漂亮,后面也很容易返工。

27. 企业合规版:数据分级与最小化原则

AI 项目里最常见的合规问题,不是“模型答错”,而是“本不该发出去的数据被发出去了”。

27.1 先做数据分级

建议至少把进入模型的数据分成四档:

  • P0:公开信息,可以进入外部模型
  • P1:内部一般信息,允许在受控条件下进入外部模型
  • P2:敏感业务信息,只允许脱敏后进入模型
  • P3:高度敏感信息,只允许本地模型、专有环境或完全禁止进入模型

典型例子:

  • P0:公开产品说明、帮助文档
  • P1:内部流程说明、非敏感 FAQ
  • P2:订单片段、工单内容、客户沟通记录
  • P3:身份证号、银行卡号、完整合同、财务底账、核心源代码密钥

27.2 最小化原则

进入模型前,优先遵循这三件事:

  • 能不传的字段就不传
  • 能摘要的内容不要传原文
  • 能脱敏的内容不要裸传

这条原则对 Prompt、RAG 上下文、工具参数、日志内容都成立。

27.3 一个简单的数据分级配置对象

package com.example.demo.compliance;

import java.util.Set;
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "app.ai.compliance")
public class AiComplianceProperties {

    private boolean allowExternalModel = true;
    private Set<String> blockedTags = Set.of("P3", "SECRET");
    private Set<String> approvalRequiredTags = Set.of("P2", "FINANCE", "LEGAL");
    private int defaultRetentionDays = 30;

    public boolean isAllowExternalModel() {
        return allowExternalModel;
    }

    public void setAllowExternalModel(boolean allowExternalModel) {
        this.allowExternalModel = allowExternalModel;
    }

    public Set<String> getBlockedTags() {
        return blockedTags;
    }

    public void setBlockedTags(Set<String> blockedTags) {
        this.blockedTags = blockedTags;
    }

    public Set<String> getApprovalRequiredTags() {
        return approvalRequiredTags;
    }

    public void setApprovalRequiredTags(Set<String> approvalRequiredTags) {
        this.approvalRequiredTags = approvalRequiredTags;
    }

    public int getDefaultRetentionDays() {
        return defaultRetentionDays;
    }

    public void setDefaultRetentionDays(int defaultRetentionDays) {
        this.defaultRetentionDays = defaultRetentionDays;
    }
}

对应配置示意:

app:
  ai:
    compliance:
      allow-external-model: true
      blocked-tags: [P3, SECRET]
      approval-required-tags: [P2, FINANCE, LEGAL]
      default-retention-days: 30

这类配置的价值,在于把“制度要求”落成“系统可执行规则”。

28. 企业合规版:模型供应商准入与第三方管理

企业 AI 系统不是只选“哪个模型更聪明”,还要评估“这个供应商能不能进生产”。

28.1 供应商准入至少看这些项

  • 数据是否会被用于模型训练
  • 是否支持关闭训练或关闭数据保留
  • 数据存储和处理区域在哪里
  • 是否支持企业合同、DPA、SLA
  • 是否支持专有网络、私有化或区域隔离
  • 是否提供调用审计、费用明细和权限管理

28.2 给每个供应商建立准入档案

建议为每个模型供应商维护一个最小档案:

  • 供应商名称
  • 允许使用的业务场景
  • 禁止输入的数据类型
  • 默认保留策略
  • 是否允许生产使用
  • 是否允许敏感场景使用
  • 是否允许跨境传输

你可以把这件事理解成“模型供应商白名单”。

28.3 一个简单的供应商合规档案对象

package com.example.demo.compliance;

public record ModelVendorProfile(
        String vendor,
        boolean productionAllowed,
        boolean crossBorderAllowed,
        boolean trainingOptOutSupported,
        boolean sensitiveDataAllowed,
        String retentionPolicy,
        String approvedUseCases
) {
}

这个对象本身不复杂,但它能帮助团队把“采购/法务结论”变成“系统配置输入”。

29. 企业合规版:留存、删除与可追溯

很多团队会做审计,却忘了“审计数据也属于需要治理的数据”。

29.1 哪些内容需要定义留存策略

建议至少明确下面这些对象的保存期限:

  • 原始用户请求
  • 脱敏后的 Prompt 摘要
  • 模型响应结果
  • 工具调用记录
  • RAG 命中文档引用
  • 审计日志
  • 成本和 token 统计

29.2 留存策略不要一刀切

比较常见的实践是:

  • 普通问答日志:短期保存
  • 高风险操作审计:中长期保存
  • 敏感内容正文:尽量不保存或只保存脱敏摘要
  • 训练/评估样本:单独走审批与标识

29.3 一个最小保留策略对象

package com.example.demo.compliance;

public record RetentionPolicy(
        String dataType,
        int retentionDays,
        boolean storeRawContent,
        boolean requireMaskedStorage,
        boolean allowUserDeletionRequest
) {
}

29.4 删除流程要可执行

企业里更实际的问题往往是:

  • 用户要求删除某次会话怎么办
  • 客户要求删除某租户历史数据怎么办
  • 员工离职后相关会话如何处理

所以你最好提前设计:

  • userId 删除
  • tenantId 删除
  • conversationId 删除
  • 按日期批量清理

删除本身也要记审计日志,否则后面追不回来。

30. 企业合规版:人工审批与责任链

不是所有 AI 输出都能直接执行。很多企业场景里,模型最多只能做到“建议生成”,最后决定仍然需要人来签字。

30.1 哪些场景建议强制审批

  • 对外发送正式文本
  • 涉及合同、价格、财务、法务内容
  • 导出客户数据
  • 修改订单、库存、资金、权限
  • 生成可能影响客户权益的结论

30.2 把 AI 输出区分成三类

  • assist:仅供参考,不直接生效
  • review:需要人工复核后才能继续
  • execute:经过授权和确认后才能执行

这类分层非常适合接到你的工作流或 BPM 系统里。

30.3 一个简单的审批状态枚举

package com.example.demo.compliance;

public enum AiDecisionMode {
    ASSIST,
    REVIEW,
    EXECUTE
}

这个枚举很简单,但很适合做系统内约束:

  • 默认 ASSIST
  • 高风险场景强制 REVIEW
  • 只有极少数白名单场景进入 EXECUTE

31. 企业合规版:输出声明、用户告知与使用边界

很多合规问题不在模型内部,而在“用户是不是知道这是一段 AI 生成内容”。

31.1 建议明确告知用户的内容

  • 当前内容由 AI 生成或辅助生成
  • 回答可能存在不完整或不准确情况
  • 高风险问题需要人工复核
  • 输入内容可能进入模型处理链路
  • 哪些内容不允许输入

31.2 高风险场景建议追加引用和免责声明

尤其是在这些场景:

  • 政策解读
  • 合同条款说明
  • 财务建议
  • 医疗建议
  • 合规判断

建议至少返回:

  • 引用来源
  • 更新时间
  • 审核状态
  • “最终以人工/正式制度/原始文件为准”的说明

32. 企业合规版:上线前检查清单

如果你准备把 Spring AI 服务真正推进生产,至少确认下面这些问题已经有答案:

  1. 输入给模型的数据是否做了分级和脱敏。
  2. 不同模型供应商的允许场景和禁止场景是否有书面结论。
  3. 是否存在必须走本地模型或私有部署的场景。
  4. 审计日志是否可查到用户、会话、模型、工具和结果状态。
  5. 是否定义了日志和会话的保留与删除策略。
  6. 高风险工具是否有角色控制和二次确认。
  7. RAG 命中的文档是否有权限过滤。
  8. 是否能对外证明某次结果经过了哪些流程和谁审批。
  9. 是否向用户明确告知了 AI 生成、限制和责任边界。
  10. 法务、合规、审计、业务方是否都已确认上线边界。

这 10 条做完,你的系统不一定“完全没风险”,但通常已经从 demo 阶段走到了可治理阶段。

33. 常见坑

33.1 只会调用模型,不会设计输出

很多项目一开始只会:

  • 拼字符串 Prompt
  • 拿一段大文本回来
  • 再手动拆字段

这很快会变难维护。正确思路通常是:

  • 能对象化就对象化
  • 能工具化就工具化
  • 能检索增强就别纯靠模型记忆

33.2 把工具调用当成万能 Agent

Tool Calling 很强,但不代表适合所有流程。下面这类场景更稳:

  • 明确工具清单
  • 明确输入输出
  • 明确调用边界
  • 明确失败处理

如果业务链路非常复杂,建议先把流程拆成确定性步骤,再让模型参与其中一部分,而不是一开始就做全自动 Agent。

33.3 向量库维度不匹配

这是最常见的 RAG 问题之一。Embedding 模型变了,维度也可能变。你需要确认:

  • 向量表的维度
  • 当前 Embedding 模型输出维度
  • 老数据是否需要重建

否则你会遇到插入失败或检索异常。

33.4 没有观测就直接上线

没有监控的 AI 服务通常会遇到这些问题:

  • 用户说“很慢”,你不知道慢在模型、向量库还是工具
  • 成本突然上涨,你不知道是哪类请求导致
  • 出现幻觉,你拿不到当时的上下文和命中来源

企业里这不是“优化项”,而是基本盘。

33.5 会话 ID 和用户身份脱钩

如果 conversationId 由前端自由传入,又没有绑定用户和租户,很容易出现:

  • 读到别人的上下文
  • 会话串线
  • 审计记录无法还原真实操作者

所以生产环境里最好由服务端生成和校验会话 ID。

33.6 把高风险工具直接交给模型

如果模型能直接执行“导出数据”“修改订单”“删除记录”这类操作,而没有角色控制和二次确认,这基本就是埋雷。

33.7 只做安全,不做合规边界

很多团队会把鉴权、脱敏、日志做得不错,但没有回答这些问题:

  • 哪些数据可以进外部模型
  • 哪些场景必须人工复核
  • 哪些结果允许自动执行
  • 哪些日志必须定期清理

这会导致系统“技术上能跑”,但组织上没人敢真正承担责任。

34. 一条最实用的学习顺序

如果你要真正学会 Spring AI,建议按这个顺序来:

  1. 跑通最小聊天接口
  2. 把返回值改成结构化对象
  3. 给模型加一个工具
  4. 再做会话记忆
  5. 给接口接入认证和角色控制
  6. 再做 RAG
  7. 补审计、限流、降级和安全治理
  8. 最后把数据分级、供应商准入、审批链和留存删除制度补完整

这条路线最接近真实项目推进顺序,也最不容易一开始就把自己绕晕。

35. 参考链接

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐