以企业智能客服为例,手把手搭建有记忆、能调工具、输出可靠的 AI 服务
环境说明: Spring AI 1.1.x(2025.11 GA)· Spring Boot 3.3+ · Java 17+ · 模型:私有部署 Qwen3.5(vLLM OpenAI 兼容接口)
前言:三行代码的 Demo 上了生产就崩
你可能见过 Spring AI 最简单的 Hello World:
java
String answer = chatClient.prompt()
.user("你好")
.call()
.content();
三行,优雅,跑通了。然后你兴冲冲搬到生产环境------
- 让模型返回 JSON,用
String接收后解析,上线第三天崩了。原因:模型心情好的时候返回json {...},带了 Markdown 代码块,直接JsonParseException。 - 注册了一个查询订单的工具,测试时好使,生产偶发"工具调用静默失败",模型直接编了个订单状态出来。
- 对话十分钟,用户说"帮我改一下上面那个订单的地址",模型问:什么订单?
这三个问题,对应三件套的三个价值:Structured Output 解决格式可靠性,Tool Calling 解决能力扩展,Memory 解决状态延续。它们不是三个孤立功能,是一个可信 Agent 的三条腿,缺一条就跛。
本文以企业智能客服 为主线场景,从 v1(仅 Structured Output)→ v2(加入 Tool Calling)→ v3(加入 Memory,生产可用),一步步组合落地。文章所有代码对接私有部署的 Qwen3.5(走 vLLM OpenAI 兼容接口),关于 vLLM 部署可参考我之前的文章:[vLLM 0.18 生产部署最佳实践]。
版本迁移速查(1.0 → 1.1 Breaking Changes)
很多读者照着旧文章写,报错了不知道为什么------因为 Spring AI 1.1 改了大量 API。先收藏这张表:
| 功能 | 1.0 旧写法 | 1.1 新写法 | 备注 |
|---|---|---|---|
| 工具注册 | FunctionCallback |
@Tool 注解 |
旧方式仍兼容但已标记废弃 |
| Structured Output | BeanOutputConverter 手动解析 |
.entity(MyRecord.class) |
1.1.1 新增原生支持 |
| Memory | ChatMemory 直接注入 |
Advisors API |
架构彻底重构 |
| 工具调用拦截 | 无 | ToolCallAdvisor hook |
1.1.1 新增 |
| 对话 ID | 无标准化方式 | CONVERSATION_ID_KEY 参数 |
多会话隔离关键 |
一、依赖配置
1.1 pom.xml
使用 BOM 统一管理版本,避免依赖冲突(这是 Spring AI 初学者最常踩的第一个坑):
java
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.1.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<!-- 注意:私有部署 Qwen 走 OpenAI 兼容接口,用这个 starter 即可 -->
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<!-- Memory 持久化用 Redis,生产环境必须,不能用 InMemory -->
</dependency>
</dependencies>
1.2 application.yml
XML
spring:
ai:
openai:
# 指向你私有部署的 vLLM 服务
base-url: http://your-vllm-host:8000/v1
api-key: EMPTY # vLLM 默认不校验 key,随便填
chat:
options:
model: Qwen3.5-7B-Instruct # 与你 vLLM 启动时的 --served-model-name 一致
temperature: 0.7
max-tokens: 2048
data:
redis:
host: localhost
port: 6379
⚠️ 踩坑记录 :
base-url必须包含/v1,很多人写到 host:port 就停了,会报 404。
二、Structured Output:让 LLM 返回你能用的数据
2.1 为什么字符串响应不够用
先看问题代码:
java
// ❌ 脆弱的写法
String raw = chatClient.prompt()
.system("返回 JSON 格式的订单状态")
.user("查询订单 ORD-20240401")
.call()
.content();
// 这行有一定概率崩溃
OrderStatus status = objectMapper.readValue(raw, OrderStatus.class);
崩溃原因:不同模型、不同版本,对"返回 JSON"的理解不同。Qwen 有时会返回:
java
好的,以下是订单状态:
```json
{"orderId": "ORD-20240401", "status": "SHIPPED"}
java
这带了自然语言前缀和 Markdown 代码块,`objectMapper.readValue` 直接抛异常。你可以写正则来处理,但这是跟模型行为"打补丁",不是根治。
### 2.2 三种 Structured Output 方式对比
| 方式 | 写法复杂度 | 稳定性 | 适用场景 |
|------|-----------|--------|---------|
| `.entity(Class)` | ⭐ 最简 | ⭐⭐⭐⭐ | 1.1.1+ 推荐,日常使用 |
| `BeanOutputConverter` | ⭐⭐⭐ | ⭐⭐⭐ | 需要自定义 Schema 描述时 |
| `StructuredOutputValidationAdvisor` | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 高可靠场景,校验失败自动重试 |
### 2.3 方式一:`.entity()` 原生结构化(推荐)
首先定义响应结构,用 Java Record:
```java
// 订单状态响应
public record OrderStatusResponse(
String orderId,
String status, // PENDING / SHIPPED / DELIVERED / CANCELLED
String estimatedArrival, // 预计到达时间,如 "2024-04-05"
List<String> trackingEvents // 物流轨迹列表
) {}
然后在 Service 中直接使用:
java
@Service
public class OrderQueryService {
private final ChatClient chatClient;
public OrderQueryService(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("""
你是一个订单查询助手。
请严格按照用户要求的格式返回信息,不要添加多余的解释。
""")
.build();
}
public OrderStatusResponse queryOrderStatus(String userMessage) {
return chatClient.prompt()
.user(userMessage)
.call()
.entity(OrderStatusResponse.class); // ✅ 类型安全,告别手动解析
}
}
Spring AI 内部会自动将 OrderStatusResponse 的结构生成 JSON Schema,注入到 System Prompt,并在拿到响应后自动反序列化。
2.4 方式二:StructuredOutputValidationAdvisor(高可靠场景)
对于关键业务场景(比如财务、合规),不能接受任何格式错误,可以加校验重试:
java
@Bean
public ChatClient reliableChatClient(ChatClient.Builder builder) {
return builder
.defaultAdvisors(
// 校验失败时最多重试 3 次,重试时将校验错误信息反馈给模型
StructuredOutputValidationAdvisor.builder()
.maxRetries(3)
.build()
)
.build();
}
⚠️ 踩坑记录 :加了
StructuredOutputValidationAdvisor后,每次重试都是一次完整 LLM 调用,Token 消耗最多增加 3 倍。建议只在关键路径上使用,不要全局加。
2.5 嵌套结构与 Optional 字段处理
java
// 支持嵌套 Record
public record CustomerServiceResponse(
String answer, // 回答内容
List<String> calledTools, // 调用了哪些工具
@Nullable String recommendation, // 可能为 null 的建议
ConfidenceLevel confidence // 枚举
) {}
public enum ConfidenceLevel { HIGH, MEDIUM, LOW }
⚠️ 踩坑记录 :枚举字段在某些模型上会被返回为小写(如
"high"),Spring AI 默认大小写不敏感,但如果你自定义了ObjectMapper,注意加MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS。
三、Tool Calling:给模型装上手和脚
3.1 Tool Calling 的完整生命周期
很多文章只讲"怎么注册工具",但不讲工具调用的完整循环------这是理解 Agent 的关键:
python
用户输入
↓
LLM 分析:需要调用工具?选哪个工具?参数是什么?
↓
返回 tool_calls(不是最终答案,是调用指令)
↓
Spring AI 拦截,执行工具方法(Java 代码)
↓
工具执行结果作为新消息返回 LLM
↓
LLM 结合工具结果生成最终自然语言回答
↓
用户看到答案
这个循环可能执行多次(比如查了订单,发现需要再查一次物流详情)。Spring AI 1.1 的 ToolCallAdvisor 负责管理这个循环。
3.2 注册工具:@Tool 注解(1.1 推荐方式)
java
@Component
public class CustomerServiceTools {
private final OrderRepository orderRepository;
private final PointsRepository pointsRepository;
// ✅ description 是工具调用的灵魂------模型靠这段文字决定要不要调用这个工具
@Tool(description = """
查询指定订单的当前状态和物流信息。
当用户询问"订单到哪了""发货了吗""什么时候到"时调用。
参数 orderId 必须是完整的订单号(格式:ORD-开头的字符串),
如果用户只说"我的订单",请先让用户提供订单号。
""")
public OrderStatusResponse queryOrderStatus(String orderId) {
return orderRepository.findById(orderId)
.map(this::convertToResponse)
.orElseThrow(() -> new ToolExecutionException(
"订单不存在:" + orderId + ",请确认订单号是否正确"
));
}
@Tool(description = """
修改订单的收货地址。
仅当用户明确要求修改地址时调用,不要在未经确认的情况下修改。
注意:只有状态为 PENDING(待发货)的订单可以修改地址,
已发货(SHIPPED)或已完成(DELIVERED)的订单无法修改。
需要完整地址:省/市/区/街道/门牌号。
""")
public String updateDeliveryAddress(String orderId, String newAddress) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new ToolExecutionException("订单不存在:" + orderId));
if (!"PENDING".equals(order.getStatus())) {
// ✅ 可预期的业务失败:返回字符串让 LLM 转述,而不是抛异常
return String.format(
"无法修改:订单 %s 当前状态为「%s」,只有待发货的订单可以修改地址",
orderId, order.getStatus()
);
}
order.setDeliveryAddress(newAddress);
orderRepository.save(order);
return "地址修改成功!新地址:" + newAddress;
}
@Tool(description = """
查询用户的积分余额和最近积分记录。
当用户询问"我有多少积分""积分够吗""积分怎么用"时调用。
userId 从系统上下文获取,无需用户提供。
""")
public PointsInfo queryPoints(String userId) {
return pointsRepository.findByUserId(userId);
}
private OrderStatusResponse convertToResponse(Order order) {
return new OrderStatusResponse(
order.getOrderId(),
order.getStatus(),
order.getEstimatedArrival(),
order.getTrackingEvents()
);
}
}
3.3 工具描述:好坏对比
这是最影响工具调用准确率的因素,却是大多数文章忽略的细节。
java
// ❌ 坏的 description:模型不知道什么时候该调用,参数怎么传
@Tool(description = "查询订单")
public OrderStatusResponse query(String id) { ... }
// ✅ 好的 description:触发时机 + 参数来源 + 边界条件都说清楚
@Tool(description = """
查询指定订单的当前状态和物流信息。
触发时机:用户询问订单进度、发货状态、预计到达时间时调用。
参数说明:orderId 是"ORD-"开头的订单号,从对话上下文或用户输入中提取。
如果用户没有提供订单号,先向用户索要,不要猜测。
""")
public OrderStatusResponse queryOrderStatus(String orderId) { ... }
实测对比:在 Qwen3.5-7B 上,好的 description 比坏的工具调用成功率高约 25-30%(10次测试中,坏的约 7 次正确,好的约 9-10 次正确)。
3.4 ToolCallAdvisor:拦截监控工具调用
Spring AI 1.1.1 新增了 ToolCallAdvisor 的 hook 机制,可以在工具调用前后插入自定义逻辑:
java
@Configuration
public class ToolCallConfig {
@Bean
public ToolCallAdvisor toolCallAdvisor(MeterRegistry meterRegistry) {
return ToolCallAdvisor.builder()
.beforeToolCall((toolDef, args) -> {
// 记录调用日志
log.info("[Tool调用开始] tool={}, args={}", toolDef.name(), args);
// 记录 Prometheus 指标
meterRegistry.counter("tool.call.total",
"tool", toolDef.name()).increment();
// 可以在这里做限流、鉴权等前置检查
})
.afterToolCall((toolDef, result) -> {
log.info("[Tool调用完成] tool={}, resultLen={}",
toolDef.name(), result.toString().length());
// 工具返回内容太长会撑爆上下文,在这里截断
// (下文踩坑部分会详细说)
})
.build();
}
}
3.5 踩坑记录
坑一:编译参数缺失导致工具参数名丢失
Spring AI 通过反射获取工具方法的参数名。如果没有加 -parameters 编译参数,参数名会被优化掉,变成 arg0、arg1,模型完全不知道怎么传参。
在 pom.xml 中加:
XML
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
坑二:工具返回内容过长导致上下文溢出
如果工具返回了一个 10KB 的 JSON(比如订单详情包含所有历史操作),会把上下文撑爆。解决方案:
java
@Tool(description = "...")
public String queryOrderDetail(String orderId) {
OrderDetail detail = orderRepository.findDetailById(orderId);
// ✅ 主动精简,只返回模型需要的字段
return String.format(
"订单号:%s, 状态:%s, 金额:%.2f元, 预计到达:%s",
detail.getOrderId(), detail.getStatus(),
detail.getAmount(), detail.getEstimatedArrival()
);
// ❌ 不要直接 return objectMapper.writeValueAsString(detail);
}
坑三:多个工具同时被调用时的并发问题
当用户问"我的订单状态和积分余额分别是多少",模型可能同时触发两个工具调用。Spring AI 1.1 默认串行执行,如果需要并行,需要配置:
java
@Bean
public ToolCallingManager toolCallingManager() {
return ToolCallingManager.builder()
.parallelToolCalls(true) // 开启并行工具调用
.build();
}
注意:并行工具调用要求工具方法本身是线程安全的。
四、Memory:让模型记住你是谁
4.1 Memory 的本质:上下文注入,不是魔法
先消除一个常见误解:LLM 本身是无状态的,每次调用都是全新的。Spring AI 的 Memory 做的事情是:在每次请求时,把历史对话消息塞回 Prompt。
python
第3轮对话时,实际发给 LLM 的 Prompt 是:
[System] 你是客服助手...
[User] 我的订单 ORD-001 到哪了? ← 第1轮(从Memory取出)
[Assistant] 您的订单已发货,预计明天到达 ← 第1轮
[User] 那能改地址吗? ← 第2轮(从Memory取出)
[Assistant] 已为您修改为新地址 ← 第2轮
[User] 谢谢,帮我再查一下积分余额 ← 第3轮(当前输入)
理解了这个,后面的 Token 消耗、窗口管理问题就自然明白了。
4.2 三种 Memory Advisor 对比
| Advisor | 历史如何注入 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|---|
MessageChatMemoryAdvisor |
以 Message 列表追加 | 模型感知好,支持角色区分 | Token 消耗略高 | 大多数场景,推荐 |
PromptChatMemoryAdvisor |
注入 System Prompt 文本 | Token 消耗低 | 部分模型对长 System Prompt 响应差 | 简单问答场景 |
VectorStoreChatMemoryAdvisor |
向量检索相关历史 | 支持超长会话 | 配置复杂,有检索延迟 | 客服历史数超长 |
4.3 Memory 存储:生产环境不能用 InMemory
java
// ❌ 只能在开发时用,服务重启数据全丢,多实例部署数据不共享
ChatMemory memory = new InMemoryChatMemory();
// ✅ 生产用 Redis(Spring AI 1.1 官方支持)
@Bean
public ChatMemory chatMemory(RedisTemplate<String, Object> redisTemplate) {
return RedisChatMemory.builder()
.redisTemplate(redisTemplate)
.defaultTtl(Duration.ofHours(24)) // 对话24小时后自动清理
.build();
}
4.4 Memory 窗口管理:上下文不能无限增长
随着对话轮次增加,每次注入的历史越来越长,Token 消耗线性增长。必须设置窗口:
java
// 方案一:按消息条数截断(简单,但可能截断一半的多轮对话)
MessageChatMemoryAdvisor.builder(chatMemory)
.windowSize(20) // 最多保留最近20条消息
.build()
// 方案二:按 Token 数截断(推荐,更精确)
// 需要配合 TokenCountingChatMemory
@Bean
public ChatMemory tokenAwareChatMemory(RedisTemplate<String, Object> redis,
TiktokenTokenCountEstimator tokenCounter) {
return TokenCountingChatMemory.builder()
.delegate(RedisChatMemory.builder().redisTemplate(redis).build())
.maxTokens(4096) // 保留最近 4096 Token 的历史
.tokenCounter(tokenCounter)
.build();
}
4.5 多会话隔离:最高频 Bug
这是生产中最常见的 Memory 问题,必须单独强调:
java
// ❌ 错误写法:conversationId 写死在 Bean 里
@Bean
public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
return builder
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory)
.conversationId("fixed-id") // ← 所有用户共享!!!
.build()
)
.build();
}
// ✅ 正确写法:在每次请求时动态覆盖 conversationId
@RestController
public class ChatController {
@PostMapping("/chat")
public CustomerServiceResponse chat(
@RequestBody ChatRequest request,
@RequestHeader("X-User-Id") String userId) {
return chatClient.prompt()
.user(request.message())
.advisors(advisor ->
// 关键:用用户ID作为会话ID,运行时动态覆盖
advisor.param(
AbstractChatMemoryAdvisor.CONVERSATION_ID_KEY,
userId
)
)
.call()
.entity(CustomerServiceResponse.class);
}
}
⚠️ 踩坑记录:我们曾经在测试环境用了固定 conversationId,测试了一下午,数据都是张冠李戴。发现问题时 Redis 里有 200 多条混杂的消息。上生产前一定要验证会话隔离。
4.6 踩坑记录
坑:Memory Advisor 和 RAG Advisor 的顺序问题
Advisor 的执行顺序由 getOrder() 决定,顺序不对会导致 Memory 里的历史信息被 RAG 检索结果覆盖:
javascript
// ✅ 正确顺序:Memory 先注入历史,RAG 再追加检索结果
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).order(10).build(),
QuestionAnswerAdvisor.builder(vectorStore).order(20).build(), // RAG
new ToolCallAdvisor(), // 默认 order 最低,最后执行
new SimpleLoggerAdvisor()
)
五、三件套组合:有记忆的智能客服 v3
现在把三件套组合起来,实现一个完整的生产级客服系统。
5.1 完整响应结构
java
// 客服响应:包含业务答案 + 调用信息(便于前端展示"AI使用了哪些工具")
public record CustomerServiceResponse(
String answer, // 给用户的回答
List<String> calledTools, // 本轮调用了哪些工具(可为空列表)
String nextSuggestion // 建议用户下一步可以做什么
) {}
5.2 工具 Bean
java
@Component
public class CustomerServiceTools {
// 见第三节的完整实现,这里省略
// 三个工具:queryOrderStatus / updateDeliveryAddress / queryPoints
}
5.3 核心配置:ChatClient 装配
java
@Configuration
public class CustomerServiceConfig {
@Bean
public ChatClient customerServiceChatClient(
ChatClient.Builder builder,
ChatMemory chatMemory,
CustomerServiceTools tools,
ToolCallAdvisor toolCallAdvisor) {
return builder
.defaultSystem("""
你是「XX商城」专属客服小助手。
你的能力:查询订单状态、修改配送地址、查询积分余额。
沟通原则:
1. 称呼用户为"您",语气友好专业
2. 如果需要订单号但用户没有提供,主动向用户索要
3. 不要编造不存在的订单信息,必须调用工具获取真实数据
4. 操作成功或失败都要明确告知用户
""")
.defaultAdvisors(
// 顺序说明:
// order=10: Memory 先执行,把历史消息注入 Prompt
// order=20: ToolCall 后执行,管理工具调用循环
// order=30: Logger 最后执行,记录完整请求响应
MessageChatMemoryAdvisor.builder(chatMemory)
.order(10)
.build(),
toolCallAdvisor, // order 见 ToolCallConfig Bean
new SimpleLoggerAdvisor() // 开发调试用,生产可去掉
)
.defaultTools(tools) // 注册工具
.build();
}
}
5.4 Controller 层
java
@RestController
@RequestMapping("/api/customer-service")
public class CustomerServiceController {
private final ChatClient chatClient;
@PostMapping("/chat")
public CustomerServiceResponse chat(
@RequestBody @Valid ChatRequest request,
@RequestHeader("X-User-Id") String userId) {
return chatClient.prompt()
.user(request.message())
.advisors(advisor ->
advisor.param(
AbstractChatMemoryAdvisor.CONVERSATION_ID_KEY,
"cs-" + userId // 加前缀避免与其他 Memory 冲突
)
)
.call()
.entity(CustomerServiceResponse.class);
}
// 清除会话历史(用户点击"开始新对话"时调用)
@DeleteMapping("/chat/{userId}/history")
public void clearHistory(@PathVariable String userId,
ChatMemory chatMemory) {
chatMemory.clear("cs-" + userId);
}
}
public record ChatRequest(@NotBlank String message) {}
5.5 端到端演示:Memory 的价值
用 CommandLineRunner 模拟真实多轮对话:
java
@Component
@Profile("demo")
public class MultiTurnChatDemo implements CommandLineRunner {
private final CustomerServiceController controller;
@Override
public void run(String... args) {
String userId = "demo-user-001";
System.out.println("=== 开始多轮对话演示 ===\n");
// 第一轮:查询订单
var r1 = controller.chat(
new ChatRequest("我的订单 ORD-20240401 发货了吗?"), userId);
System.out.println("用户:我的订单 ORD-20240401 发货了吗?");
System.out.println("客服:" + r1.answer());
System.out.println("工具:" + r1.calledTools());
// 输出:
// 客服:您好!您的订单 ORD-20240401 已于 4月2日 14:30 发货,
// 承运商为顺丰,预计 4月4日 送达您的地址。
// 工具:[queryOrderStatus]
System.out.println();
// 第二轮:用代词指代(Memory 的核心价值体现)
var r2 = controller.chat(
new ChatRequest("这个单还能改地址吗?我想改到公司"), userId);
System.out.println("用户:这个单还能改地址吗?我想改到公司");
System.out.println("客服:" + r2.answer());
System.out.println("工具:" + r2.calledTools());
// ✅ 模型从 Memory 中知道"这个单"指的是 ORD-20240401
// ✅ 查询状态后发现已发货,自动告知无法修改
// 输出:
// 客服:抱歉,您的订单 ORD-20240401 已处于配送中状态,
// 暂时无法修改收货地址。如有紧急情况可联系顺丰协商。
// 工具:[updateDeliveryAddress]
System.out.println();
// 第三轮:换个话题
var r3 = controller.chat(
new ChatRequest("好吧,那帮我查一下我的积分余额"), userId);
System.out.println("用户:好吧,那帮我查一下我的积分余额");
System.out.println("客服:" + r3.answer());
// 输出:您当前积分余额为 2,380 分,可兑换价值约 23.8 元的优惠券...
}
}
运行结果说明:第二轮对话中,用户说"这个单",模型从 Memory 中正确识别出 ORD-20240401,并调用了正确的工具------这就是三件套组合发挥作用的地方。
5.6 流式响应(Streaming)
对于较长的回答,可以开启流式响应,提升用户体验:
java
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(
@RequestParam String message,
@RequestHeader("X-User-Id") String userId) {
return chatClient.prompt()
.user(message)
.advisors(advisor ->
advisor.param(AbstractChatMemoryAdvisor.CONVERSATION_ID_KEY, "cs-" + userId)
)
.stream()
.content(); // 返回 Flux<String>,字符逐步输出
}
⚠️ 注意 :流式响应不支持
.entity()结构化输出,只能用.content()返回纯文本。如果既需要流式又需要结构化,需要在前端拼接完成后再解析,或者分两个接口。
六、生产上线 CheckList
6.1 上线前必查项
Token 消耗审计
javascript
// 在 ToolCallAdvisor 的 afterCall 中记录 Token 消耗
.afterToolCall((toolDef, result) -> {
// 通过 Spring AI 的 Usage 对象获取消耗
log.info("本次请求 Token 消耗 - 输入:{}, 输出:{}",
response.getMetadata().getUsage().getPromptTokens(),
response.getMetadata().getUsage().getGenerationTokens());
})
工具幂等性 :修改类工具(如 updateDeliveryAddress)必须是幂等的,同一请求重复调用不能产生副作用。
Memory TTL 设置:Redis 中的 Memory 数据必须设置过期时间,避免无限增长。
模型降级策略:私有 Qwen 服务不可用时,应有降级到云 API 的兜底方案。
6.2 可观测性接入
Spring AI 内置 Micrometer 指标,可以直接接入 Prometheus + Grafana:
XML
management:
metrics:
distribution:
percentiles-histogram:
spring.ai.chat.client.operation.seconds: true # 请求耗时分布
endpoints:
web:
exposure:
include: prometheus, health
关键监控指标:
spring.ai.chat.client.operation.seconds:端到端响应时间tool.call.total(自定义):各工具调用次数spring.ai.chat.client.error:错误率
更完整的 LLMOps 监控方案,后续会单独写一篇文章介绍 LangFuse 的接入实践。
6.3 Spring AI 2.0 展望
写这篇文章时,Spring AI 2.0-M1 已经发布(2025.12)。几个值得关注的变化:
- Recursive Advisors 正式化:支持工具调用循环、结构化输出校验失败自动重试,构建 Agentic Loop 更简洁
- MCP Annotations :用
@McpTool替代@Tool,一个注解同时暴露为 MCP Server 工具(关于 MCP,可参考我之前的文章:[MCP 协议实战:用 Java 开发自定义 MCP Server]) - Spring AI 2.0 API 会有 Breaking Changes,建议生产项目暂时锁定 1.1.x,观望 2.0 GA 后再迁移
总结
回到开头的三个问题:
| 问题 | 解决方案 | 关键 API |
|---|---|---|
| 模型返回 JSON 格式不稳定 | Structured Output | .entity(Class) + 可选 StructuredOutputValidationAdvisor |
| 工具调用静默失败 | Tool Calling 规范化 | @Tool + 好的 description + ToolCallAdvisor 监控 |
| 用户换 Tab 就失忆 | Memory 持久化 | MessageChatMemoryAdvisor + Redis + 动态 conversationId |
三件套的核心价值不在于各自的功能,而在于组合之后带来的可靠性:模型不再随机输出格式,不再编造数据,不再失忆------这才是从 Demo 走向生产的关键一步。