Spring AI ChatMemory 监控与排查:从指标到排错全链路
ChatMemory 上线就翻车?这份监控与排错手册能救你
前 6 篇,我们把 ChatMemory 从内存玩到 MySQL、Redis,做了性能对比、分布式部署,还接上了 RAG。功能都跑通了。
但"本地能跑"和"线上稳得住"之间,差的就是监控和排错这两件事。
我自己就在这套代码上踩了 8 个真实的坑:Bean 注入失败、Redis 反序列化炸了、智谱 embedding 返回 404、账户被限流 429……每一个都能让服务起不来或答非所问。
这一篇,我把监控怎么接、日志怎么读、坑怎么填一次讲透。建议收藏,排错时直接当手册查。
一、为什么要监控 ChatMemory?
很多同学以为"持久化到 MySQL 就完事了"。真上线你会发现,不监控迟早出事:
- 数据无限增长:每个会话都在写消息,三个月后表涨到几百万行,查询变慢、磁盘告警——你却毫不知情。
- 性能悄悄劣化:
MessageWindowChatMemory每次都读最近 N 条,会话一多,MySQL 慢查询把连接池打满。 - 依赖挂了无感知:Redis 宕机 / MySQL 主库切换,接口开始 500,但你的监控大盘一片"正常"。
- Token 预算失控:
maxMessages=20看起来不多,但并发 1000 个会话,每次请求上下文就是 2 万 token,账单爆炸。
监控的核心目的:把"看不见的对话状态"变成"看得见的指标",在用户投诉之前就发现问题。
二、监控的四个维度
| 维度 | 看什么 | 不监控的后果 | 工具 |
|---|---|---|---|
| 数据量 | 累计消息数、会话增长速率 | 存储膨胀、磁盘打满 | Micrometer Counter |
| 性能 | 读/写延迟、P95 耗时 | 慢查询、连接池耗尽 | Micrometer Timer |
| 依赖健康 | MySQL/Redis 是否可达 | 存储宕机无感知 | Actuator HealthIndicator |
| 调用量 | ChatMemory 被命中次数 | 无法评估负载、容量规划瞎猜 | Micrometer Counter |
四个维度对应一句话:数据量看"胀不胀"、性能看"快不快"、健康看"死没死"、调用量看"忙不忙"。
三、落地:Actuator + Micrometer 三步走
本项目用 Spring Boot Actuator 暴露端点,Micrometer 采集指标。全程零侵入业务逻辑。
Step 1:加依赖
pom.xml:
<!-- 监控:Actuator 暴露 health/metrics 端点,Micrometer 采集 ChatMemory 指标 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Actuator 自带 micrometer-core,MeterRegistry 自动可用,不用额外引。
Step 2:写指标装饰器
用装饰器模式包住真实的 ChatMemory,在 add / get / clear 时顺手记一笔指标。业务代码完全不改。
MeteredChatMemory.java:
public class MeteredChatMemory implements ChatMemory {
private final ChatMemory delegate;
private final Counter addedCounter;
private final Counter clearedCounter;
private final Counter getCounter;
private final Timer getTimer;
public MeteredChatMemory(ChatMemory delegate, MeterRegistry meterRegistry) {
this.delegate = delegate;
this.addedCounter = Counter.builder("chatmemory.messages.added")
.description("ChatMemory 累计写入的消息条数").register(meterRegistry);
this.clearedCounter = Counter.builder("chatmemory.messages.cleared")
.description("ChatMemory 累计清理的会话数").register(meterRegistry);
this.getCounter = Counter.builder("chatmemory.get.calls")
.description("ChatMemory 累计读取会话的次数").register(meterRegistry);
this.getTimer = Timer.builder("chatmemory.get.latency")
.description("ChatMemory 读取会话的耗时分布").register(meterRegistry);
}
public static ChatMemory of(ChatMemory delegate, MeterRegistry registry) {
return new MeteredChatMemory(delegate, registry);
}
@Override
public void add(String conversationId, Message message) {
delegate.add(conversationId, message);
addedCounter.increment();
}
@Override
public List<Message> get(String conversationId) {
getCounter.increment();
long start = System.nanoTime();
try {
return delegate.get(conversationId);
} finally {
getTimer.record(System.nanoTime() - start, TimeUnit.NANOSECONDS);
}
}
@Override
public void clear(String conversationId) {
delegate.clear(conversationId);
clearedCounter.increment();
}
}
注意:
get用finally手动计时,不要用Timer.recordCallable——它会把底层异常包成IllegalStateException,改变异常语义,排查时反而误导你。
Step 3:接入配置
在 ChatMemoryConfig 里把两个 Bean 用装饰器包起来:
@Bean
@ConditionalOnProperty(name = "spring.ai.chat.memory.type", havingValue = "mysql", matchIfMissing = true)
public ChatMemory jdbcChatMemory(JdbcChatMemoryRepository jdbcRepository, MeterRegistry meterRegistry) {
ChatMemory delegate = MessageWindowChatMemory.builder()
.chatMemoryRepository(jdbcRepository).maxMessages(20).build();
return MeteredChatMemory.of(delegate, meterRegistry);
}
@Bean
@ConditionalOnProperty(name = "spring.ai.chat.memory.type", havingValue = "redis")
public ChatMemory redisChatMemory(RedisChatMemoryRepository redisRepository, MeterRegistry meterRegistry) {
ChatMemory delegate = MessageWindowChatMemory.builder()
.chatMemoryRepository(redisRepository).maxMessages(20).build();
return MeteredChatMemory.of(delegate, meterRegistry);
}
再写一个健康指示器,用一次探针读取判断底层存储是否可达:
ChatMemoryHealthIndicator.java:
@Component
public class ChatMemoryHealthIndicator implements HealthIndicator {
private static final String PROBE = "__chat_memory_health_probe__";
private final ChatMemory chatMemory;
public ChatMemoryHealthIndicator(ChatMemory chatMemory) {
this.chatMemory = chatMemory;
}
@Override
public Health health() {
try {
chatMemory.get(PROBE); // 存储正常:返回空列表,不抛异常
return Health.up().withDetail("chatMemory", "repository reachable").build();
} catch (Exception e) {
return Health.down().withDetail("chatMemory", "repository unreachable").withException(e).build();
}
}
}
最后在 application.yml 暴露端点:
management:
endpoints:
web:
exposure:
include: health,metrics,info
endpoint:
health:
show-details: always
metrics:
tags:
application: hello-ai # 所有指标自动带应用名,多实例上报时能区分
四、怎么看监控?
起服务后,三个命令就够了:
# 1. 看存储健康(MySQL/Redis 是否可达)
curl http://localhost:8080/actuator/health
# => {"status":"UP","components":{"chatMemory":{"status":"UP","details":{"chatMemory":"repository reachable"}}}}
# 2. 看累计写入了多少消息(评估数据量增长)
curl http://localhost:8080/actuator/metrics/chatmemory.messages.added
# => {"name":"chatmemory.messages.added","measurements":[{"statistic":"COUNT","value":42.0}]}
# 3. 看读取耗时分布(性能,P95 是否超标)
curl http://localhost:8080/actuator/metrics/chatmemory.get.latency
# => 含 {"statistic":"MAX"}, {"statistic":"TOTAL_TIME"} 等
接上 Prometheus + Grafana(或任何支持 Micrometer 的监控后端),就能画曲线、配告警。比如"消息数 1 小时涨 10 万"或"get 延迟 P95 超过 200ms"自动报警。
配合性能对比篇的结论:MySQL 读快、Redis 写快。监控里看到
get.latency飙高,优先怀疑 MySQL 慢查询;看到写入瓶颈,考虑切 Redis。
五、排错手册:我踩过的 8 个真实坑 ⭐
这部分最重要,建议直接当字典查。症状 → 根因 → 修复。
| # | 症状(报错关键字) | 根因 | 修复 |
|---|---|---|---|
| 1 | No qualifying bean of type 'ChatMemory' |
@Qualifier("${...:mysql}") 占位符与 Bean 名不匹配 |
去掉 @Qualifier,改用 @Primary + 条件 Bean 互斥 |
| 2 | found 2: redisChatMemoryRepository,jdbcChatMemoryRepository |
自动配置的 Repository + 手动 Bean 冲突 | @Primary + @ConditionalOnProperty 保证同属性下只存在一个 |
| 3 | LinkedHashMap cannot be cast to class Message |
Redis 默认序列化器丢失类型信息 | RedisTemplate 用 GenericJackson2JsonRedisSerializer(带 @class) |
| 4 | Content must not be null for SYSTEM or USER messages |
反序列化读 content 字段,但 JSON 用的是 text |
手动提取 content 构造 new UserMessage(text) 等 |
| 5 | Could not find artifact spring-ai-starter-vector-store-simple |
Spring AI 1.0 没有这个 starter | 改用核心模块 spring-ai-vector-store |
| 6 | 找不到符号 类 QuestionAnswerAdvisor |
它在独立模块 spring-ai-advisors-vector-store,openai starter 不传递引入 |
pom.xml 补 spring-ai-advisors-vector-store 依赖 |
| 7 | HTTP 404 - Whitelabel Error Page(embedding) |
默认路径 /v1/embeddings + 智谱无 /v1 → 拼成 .../v4/v1/embeddings |
配 embeddings-path: /embeddings + options.model: embedding-3 |
| 8 | HTTP 429 账户已达到速率限制 / HTTP 500 网络错误 |
智谱上游限流 / 服务端抖动,非代码 Bug | 等限流窗口过期、慢速单发;生产加重试+熔断 |
下面挑几个高频的细说。
坑 7:embedding 返回 404(最容易卡住新手)
报错在 OpenAiEmbeddingModel.call,错误体是 Spring Boot 的 Whitelabel 404 页。
为什么:Spring AI 调 embedding 的默认 path 是 /v1/embeddings。而智谱的 endpoint 是 /embeddings(没有 /v1 前缀)。两者一拼,URL 变成 https://open.bigmodel.cn/api/paas/v4/v1/embeddings——智谱根本没有这个路径,直接 404。chat 那边靠 completions-path: /chat/completions 覆盖,embedding 同理要单独覆盖:
spring:
ai:
openai:
embedding:
base-url: https://open.bigmodel.cn/api/paas/v4
embeddings-path: /embeddings # 关键:去掉默认的 /v1 前缀
options:
model: embedding-3 # 模型名写 options.model 下,别写错层级
经验:所有国产 OpenAI 兼容接口,都要检查默认
/v1前缀。chat 用completions-path,embedding 用embeddings-path,模型名一律放options.model。
坑 8:429 / 500(最容易被误判为代码 Bug)
HTTP 429 - {"error":{"code":"1302","message":"您的账户已达到速率限制..."}}
HTTP 500 - {"error":{"code":"1234","message":"网络错误..."}}
判断依据:看异常栈顶部——如果卡在 OpenAiChatModel.call / OpenAiApi.chatCompletionEntity,说明请求压根没进你的业务逻辑,是打智谱那一步挂了。
429被 Spring AI 归为NonTransientAiException(不可重试)→ 重试 1 次直接抛。这是账户 QPS/并发被限流,本地重启、改代码都没用,只能等窗口过期(几十秒到 1 分钟)。500归为TransientAiException(可重试)→ 重试了 2 次仍 500,是智谱服务端网络抖动,重试也没意义,等恢复。
额外提醒:每次重启 RagConfig 都会调智谱 embedding 接口把知识库向量化(本例 5 次),这也在悄悄吃额度。频繁重启 + 连点,极易触发 429。
生产加固方向(思路给到,不展开代码):让 429 也走指数退避重试、客户端限速(bucket4j)、Resilience4j 熔断降级返回友好文案、embedding 结果缓存避免反复向量化。
坑 1 / 2:Bean 注入那对"双胞胎"
No qualifying bean 和 found 2 本质是同一类问题——Spring 容器里 ChatMemory / ChatMemoryRepository 的 Bean 定义冲突。排错口诀:
- “找不到 Bean” → 要么名字/类型不对(占位符
@Qualifier用错了),要么被条件注解排除了。 - “找到 2 个” → 自动配置和你手写的重复了,用
@Primary+@ConditionalOnProperty让两者互斥,同一时刻只留一个。
六、错误日志怎么读?(通用心法)
不用背堆栈,抓三个点:
- 看异常类型:
NoUniqueBeanDefinitionException(Bean 冲突)、ClassCastException(序列化)、HttpStatusCodeException(上游 HTTP)、UnsatisfiedDependencyException(依赖注入)——类型基本就告诉你是哪一类问题。 - 看异常栈顶部:最上面的
at xxx.call那行,决定问题是"你的代码"还是"上游/框架"。卡在OpenAiApi就是外部接口;卡在RagController就是你的 Controller。 - 看业务 code:智谱的错误体带
code字段,1302=限流、1234=网络错,比 HTTP 状态码更精确。
一句话:异常类型定方向,栈顶定位置,业务 code 定上游原因。
七、总结
- 监控是上线前置项,不是后补项:数据量、性能、健康、调用量四个维度一个都不能少。
- 零侵入落地:装饰器包
ChatMemory+HealthIndicator探活 + Actuator 暴露,业务代码一行不改。 - 排错有套路:8 个真实坑对应 8 类典型故障,记住"异常类型定方向、栈顶定位置、code 定上游"。
- 国产兼容接口要当心:默认
/v1前缀、模型名层级,是 404/编译失败的高发区。
本篇代码清单:
pom.xml:新增spring-boot-starter-actuatorMeteredChatMemory.java:ChatMemory 指标装饰器(Micrometer)ChatMemoryHealthIndicator.java:存储健康指示器ChatMemoryConfig.java:两个 ChatMemory Bean 包裹为带指标版本application.yml:新增management暴露 health/metrics
下一篇预告:ChatMemory 最佳实践总结(系列收官)
写在最后
我是一名8 年 Java 后端,正在转型 AI 应用开发。Spring AI 系列会持续更新,从 hello world 到 RAG 到 Agent,一路踩坑一路写。
如果你也在转型 AI,关注我,一起走。有问题评论区聊,我会逐条回复。
如果这篇文章帮到了你,点个赞就是对我最大的鼓励 ❤️
更多推荐


所有评论(0)