ChatMemory 上线就翻车?这份监控与排错手册能救你

前 6 篇,我们把 ChatMemory 从内存玩到 MySQL、Redis,做了性能对比、分布式部署,还接上了 RAG。功能都跑通了。

但"本地能跑"和"线上稳得住"之间,差的就是监控和排错这两件事。

我自己就在这套代码上踩了 8 个真实的坑:Bean 注入失败、Redis 反序列化炸了、智谱 embedding 返回 404、账户被限流 429……每一个都能让服务起不来或答非所问。

这一篇,我把监控怎么接、日志怎么读、坑怎么填一次讲透。建议收藏,排错时直接当手册查。


一、为什么要监控 ChatMemory?

很多同学以为"持久化到 MySQL 就完事了"。真上线你会发现,不监控迟早出事:

  1. 数据无限增长:每个会话都在写消息,三个月后表涨到几百万行,查询变慢、磁盘告警——你却毫不知情。
  2. 性能悄悄劣化:MessageWindowChatMemory 每次都读最近 N 条,会话一多,MySQL 慢查询把连接池打满。
  3. 依赖挂了无感知:Redis 宕机 / MySQL 主库切换,接口开始 500,但你的监控大盘一片"正常"。
  4. 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();
    }
}

注意:getfinally 手动计时,不要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 默认序列化器丢失类型信息 RedisTemplateGenericJackson2JsonRedisSerializer(带 @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.xmlspring-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 beanfound 2 本质是同一类问题——Spring 容器里 ChatMemory / ChatMemoryRepository 的 Bean 定义冲突。排错口诀:

  • “找不到 Bean” → 要么名字/类型不对(占位符 @Qualifier 用错了),要么被条件注解排除了。
  • “找到 2 个” → 自动配置和你手写的重复了,用 @Primary + @ConditionalOnProperty 让两者互斥,同一时刻只留一个。

六、错误日志怎么读?(通用心法)

不用背堆栈,抓三个点:

  1. 看异常类型:NoUniqueBeanDefinitionException(Bean 冲突)、ClassCastException(序列化)、HttpStatusCodeException(上游 HTTP)、UnsatisfiedDependencyException(依赖注入)——类型基本就告诉你是哪一类问题。
  2. 看异常栈顶部:最上面的 at xxx.call 那行,决定问题是"你的代码"还是"上游/框架"。卡在 OpenAiApi 就是外部接口;卡在 RagController 就是你的 Controller。
  3. 看业务 code:智谱的错误体带 code 字段,1302=限流1234=网络错,比 HTTP 状态码更精确。

一句话:异常类型定方向,栈顶定位置,业务 code 定上游原因


七、总结

  1. 监控是上线前置项,不是后补项:数据量、性能、健康、调用量四个维度一个都不能少。
  2. 零侵入落地:装饰器包 ChatMemory + HealthIndicator 探活 + Actuator 暴露,业务代码一行不改。
  3. 排错有套路:8 个真实坑对应 8 类典型故障,记住"异常类型定方向、栈顶定位置、code 定上游"。
  4. 国产兼容接口要当心:默认 /v1 前缀、模型名层级,是 404/编译失败的高发区。

本篇代码清单:

  • pom.xml:新增 spring-boot-starter-actuator
  • MeteredChatMemory.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,关注我,一起走。有问题评论区聊,我会逐条回复。

如果这篇文章帮到了你,点个赞就是对我最大的鼓励 ❤️

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐