StackWatch:用 Spring AI 2.0 给 Java 异常做根因分析

前言

这几年好久没写过博客了,最近沉迷AI coding, 工作之余,给大家分享一下我最近AI coding的成果,下面直接上干货

生产环境一崩,告警炸成雪花片------同一个 NPE 因为不同的参数、不同的 SQL 字面量,在告警群里被刷了几百遍。研发疲于奔命,真正要紧的那 1% 反而被淹没。

这是我开源的一个项目 StackWatch :AI 驱动的 Java 生产错误根因分析系统。它做了一件反直觉的事------虽然接入了 LLM,但只有约 1% 的异常真正调用大模型,其余 99% 在缓存层就免费归并掉了。

🔗 项目地址:https://github.com/AllenMuu/stackwatch (MIT 协议,欢迎 Star 🌟)

🔗 Gitee项目地址:https://gitee.com/AllenMoving/stackwatch

官网地址:https://allenmuu.github.io/stackwatch/


一、先说痛点:生产异常排查的"三宗罪"

做过线上保障的 Java 同学应该都经历过:

  1. 告警洪水:同一个根因,因为不同的入参字面量、不同的用户 ID,产生几百条语义相同、堆栈字面不同的异常。告警群瞬间被刷屏。
  2. 根因定位慢 :看到一个 NullPointerException,你得进日志、翻链路、看上下文,半个点过去了才知道是哪个对象为 null。MTTI(平均定位耗时)居高不下。
  3. 高频问题后知后觉:某个新上线的 bug 已经在线上炸了三天,直到有人手动统计才发现"原来这个异常这周出现了 800 次"------发现周期是"天"级,而不是"小时"级。

业界用 LLM 做 RCA(Root Cause Analysis)已经不是新鲜事,但直接把每条异常都丢给大模型有两个致命问题:

  • :生产异常量大,token 账单扛不住。
  • :异常 message 经常携带完整 SQL、响应体,甚至接了 SkyWalking/ARMS 后单条 trace 日志能到数万字符,上下文窗口直接爆满,幻觉风险飙升。

StackWatch 的解法就一句话:用"成本阶梯"把 LLM 调用压到约 1%,并在 LLM 前面加两道防线压住上下文。


二、项目简介

AI 驱动的 Java 生产错误根因分析 ------ 异常堆栈指纹归并 + LLM 根因定位 + 定时周报聚合。

把生产异常堆栈自动投喂 LLM 进行根因定位与分类归并,结合定时任务按周聚合高频错误并推送飞书周报。

两个量化目标:

  • 生产故障平均定位耗时缩短约 40%
  • 高频问题发现周期由 天级 缩短至 小时级

技术栈很新,全是 2025/2026 的版本:

领域 选型
框架 Spring Boot 4.1 + Java 21
LLM Spring AI 2.0(OpenAI 兼容协议,DashScope/DeepSeek 可切换)
L1 缓存 Caffeine(生产可换 Redis)
L2 向量 PgVector(默认关闭,渐进式解锁)
消息队列 Kafka(采集层第三入口,默认关闭)
弹性 Resilience4j
度量 Micrometer + Prometheus

三、架构总览:五层主链路 + 两层横切

#mermaid-svg-lcps82nB5oKYsPIl{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lcps82nB5oKYsPIl .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lcps82nB5oKYsPIl .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lcps82nB5oKYsPIl .error-icon{fill:#552222;}#mermaid-svg-lcps82nB5oKYsPIl .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lcps82nB5oKYsPIl .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lcps82nB5oKYsPIl .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lcps82nB5oKYsPIl .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lcps82nB5oKYsPIl .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lcps82nB5oKYsPIl .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lcps82nB5oKYsPIl .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lcps82nB5oKYsPIl .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lcps82nB5oKYsPIl .marker.cross{stroke:#333333;}#mermaid-svg-lcps82nB5oKYsPIl svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lcps82nB5oKYsPIl p{margin:0;}#mermaid-svg-lcps82nB5oKYsPIl .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-lcps82nB5oKYsPIl .cluster-label text{fill:#333;}#mermaid-svg-lcps82nB5oKYsPIl .cluster-label span{color:#333;}#mermaid-svg-lcps82nB5oKYsPIl .cluster-label span p{background-color:transparent;}#mermaid-svg-lcps82nB5oKYsPIl .label text,#mermaid-svg-lcps82nB5oKYsPIl span{fill:#333;color:#333;}#mermaid-svg-lcps82nB5oKYsPIl .node rect,#mermaid-svg-lcps82nB5oKYsPIl .node circle,#mermaid-svg-lcps82nB5oKYsPIl .node ellipse,#mermaid-svg-lcps82nB5oKYsPIl .node polygon,#mermaid-svg-lcps82nB5oKYsPIl .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lcps82nB5oKYsPIl .rough-node .label text,#mermaid-svg-lcps82nB5oKYsPIl .node .label text,#mermaid-svg-lcps82nB5oKYsPIl .image-shape .label,#mermaid-svg-lcps82nB5oKYsPIl .icon-shape .label{text-anchor:middle;}#mermaid-svg-lcps82nB5oKYsPIl .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lcps82nB5oKYsPIl .rough-node .label,#mermaid-svg-lcps82nB5oKYsPIl .node .label,#mermaid-svg-lcps82nB5oKYsPIl .image-shape .label,#mermaid-svg-lcps82nB5oKYsPIl .icon-shape .label{text-align:center;}#mermaid-svg-lcps82nB5oKYsPIl .node.clickable{cursor:pointer;}#mermaid-svg-lcps82nB5oKYsPIl .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lcps82nB5oKYsPIl .arrowheadPath{fill:#333333;}#mermaid-svg-lcps82nB5oKYsPIl .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lcps82nB5oKYsPIl .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lcps82nB5oKYsPIl .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lcps82nB5oKYsPIl .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lcps82nB5oKYsPIl .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lcps82nB5oKYsPIl .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lcps82nB5oKYsPIl .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lcps82nB5oKYsPIl .cluster text{fill:#333;}#mermaid-svg-lcps82nB5oKYsPIl .cluster span{color:#333;}#mermaid-svg-lcps82nB5oKYsPIl div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-lcps82nB5oKYsPIl .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lcps82nB5oKYsPIl rect.text{fill:none;stroke-width:0;}#mermaid-svg-lcps82nB5oKYsPIl .icon-shape,#mermaid-svg-lcps82nB5oKYsPIl .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lcps82nB5oKYsPIl .icon-shape p,#mermaid-svg-lcps82nB5oKYsPIl .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lcps82nB5oKYsPIl .icon-shape .label rect,#mermaid-svg-lcps82nB5oKYsPIl .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lcps82nB5oKYsPIl .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lcps82nB5oKYsPIl .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lcps82nB5oKYsPIl :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ① 采集层 Collector

Logback / HTTP / Kafka
② 预处理层

指纹去重
③ 分析层 Analyzer

L1 / L2 / L3 三层级联
④ 聚合层

激增检测 + 按周聚合
⑤ 投递层

飞书告警 + 周报
横切A 度量层

耗时 · 准确率 · token 成本
横切B 反馈层

研发反馈飞轮

一条数据从左到右流过五层:

  1. 采集层 :三个入口------Logback Appender(无侵入接日志)/ HTTP /collect(标准上报)/ Kafka(默认关,做异步削峰)。
  2. 预处理层:生成指纹(SHA-256 + 框架帧过滤 + 版本化),相同指纹天然去重。
  3. 分析层系统中枢,L1→L2→L3 三层级联,下文重点讲。
  4. 聚合层:实时激增检测 + 按周 Top-N 聚合。
  5. 投递层:飞书实时告警 + 飞书周报。

两个横切层不参与主链路,但决定项目能不能上生产:

  • 横切 A 度量层 :Micrometer 打点,path=cache_hit|vector_merged|llm_new 这个 tag 是后面讲"95%/4%/1% 成本故事"的数据来源。
  • 横切 B 反馈层:研发纠正结果回流,注入下一次 prompt,构成自学习闭环。

四、核心设计:三层级联归并(成本阶梯)

这是整个项目的灵魂。分析层 ErrorAnalyzer.analyze 是架构枢纽,绝大多数异常在 L1/L2 免费归并,仅约 1% 真正调用 LLM。

层级 机制 token 成本
L1 指纹精确命中缓存(Caffeine) ≈ 0
L2 向量近似归并(PgVector) ≈ 0
L3 LLM 簇代表根因分析 真正调 LLM(约 1%)

核心流程示意(伪代码,展示思路而非源码):

java 复制代码
RootCauseAnalysis analyze(ErrorEvent event) {
    String fingerprint = fingerprinter.generate(event);   // 预处理:指纹
    String hash = sha256(fingerprint);

    // L1:指纹精确命中,零成本复用历史根因
    if (cache.has(hash)) {
        metrics.record(path = "cache_hit");
        return cache.get(hash);
    }

    // L2:向量近似归并(embedding == null 时跳过,即 L2 关闭的信号)
    if (event.embedding != null) {
        var hit = clusterRepo.findSimilar(event.embedding, threshold = 0.92);
        if (hit.isPresent()) {
            metrics.record(path = "vector_merged");
            cache.backfill(hash, hit.rootCause);   // 反向填充 L1,下文细讲
            return hit.rootCause;
        }
    }

    // L3:新簇,调用 LLM(约 1% 走到这里)
    var rootCause = callLlm(event);
    metrics.record(path = "llm_new");
    return rootCause;
}

为什么这样分层?

关键洞察是:对 RCA 来说,同一个根因会以大量"字面不同但语义相同"的堆栈实例反复出现。

比如同一个 NPE:

  • orderService.processuserId 为 null
  • orderService.processorderId 为 null
  • orderService.processtradeNo 为 null

这三条堆栈在指纹层面可能不完全一致(行号、上下文变量不同),但 embedding 相似度极高。L2 用向量把它们归到同一个簇,复用第一次 L3 算出来的根因------后面两条零 token。

这就是"成本阶梯":第一次出现的异常付 LLM 的钱,之后所有语义近似的异常都搭便车。


五、两个让人眼前一亮的巧思

1. 语义缓存 + 反向填充 L1

L2 不只是"近似归并",本质是语义缓存 :新错误 embedding 与已有簇相似度 ≥ 0.92 时,直接返回该簇的根因,零 LLM 调用。

更妙的是命中后的处理:结果反向填充 L1

复制代码
新异常 → L1 未命中 → L2 命中(相似度 0.92+)→ 返回根因
                                          ↓
                                   把 (指纹hash → 根因) 回填进 L1
                                          ↓
                          后续相同指纹 → L1 直接命中,连向量检索都省了

缓存梯度自洽:L2 命中越多,L1 命中率越高,整体越快越省。 用得越久越省钱,这是一个有"飞轮效应"的缓存设计。

2. LLM 前置两道防线,压住上下文窗口

直接喂异常给 LLM 会爆上下文------生产异常 message 可能携带完整 SQL / 响应体,接了 SkyWalking/ARMS 后 queryTraceContext 单条 trace 日志能到数万字符。

ContextOptimizer 在 Prompt 入参(exceptionMessagemdc)和每个 @Tool 返回值进入 LLM 之前 做截断。阈值走 stackwatch.context-optimizer.* 配置。没有这道闸,上下文窗口易爆满、幻觉风险上升。

这道防线和 L1/L2 缓存是互补的:缓存解决"调几次",上下文优化解决"单次调多贵"。


六、不止省钱:三档复核门控 + 反馈飞轮

省钱只是基础,StackWatch 在 L3 路径上还套了一层三档复核门控(借鉴阿里云 PagePilot 的置信度分档思想):

置信度 / 信号 复核级别 动作
≥ 高阈值(0.9)且有证据 AUTO_CONFIRMED 自动归因,不打扰研发
兜底阈值(0.6)与高阈值之间且有证据 NEEDS_CONFIRMATION 输出根因,标记待确认(低介入)
< 兜底阈值 / 无证据 / LLM 失败 NEEDS_HUMAN_REVIEW 转人工排查

LLM 不是黑盒拍板,而是按置信度 + 证据双判分流------高置信度自动归因省人力,低置信度老实转人工,不拿不靠谱的结论糊弄研发。

正负双向反馈飞轮

门控之外,还有一条正负双向飞轮让系统越用越准:

研发通过 POST /feedback 确认或纠正根因:

  • 正样本(few-shot,「该这么答」):研发确认了根因 → 作为示范注入下次 prompt。
  • 负样本(anti-pattern,「别这么答」) :研发携带 wrongRootCause 纠正 → 作为反例注入下次 prompt。

两者都注入下一次 L3 prompt------正样本引导、负样本警示。这是 PagePilot known-failures 思想在负样本侧的落地,与 few-shot 正样本侧对偶,构成完整的自学习闭环。

一句话:用得越多,知道"该怎么答"也知道"不该怎么答",准确率持续上升。


七、零基础设施启动:能跑起来再说

这点对想尝鲜的同学很友好------项目默认不依赖任何外部中间件就能启动:无 DB、无 Kafka、无向量库。

靠两个机制协同实现:

  1. application.yml 里的 spring.autoconfigure.exclude 列表(DataSourceAutoConfiguration / PgVectorStoreAutoConfiguration / KafkaAutoConfiguration)------总开关。
  2. 每个 Repository / Channel 通过 @ConditionalOnProperty 按需选择实现。

ClusterRepository 有两个互斥实现,由 stackwatch.l2.enabled 切换:

  • InMemoryClusterRepository(默认,matchIfMissing=true):findSimilar 永远返回空,强制走 L3。
  • PgVectorClusterRepositoryhavingValue=true):真正的向量归并。

想解锁 L2 或 Kafka,三处同步改(缺一即启动失败,Javadoc 里有明确说明):

  1. pom.xml 取消对应 starter 注释;
  2. spring.autoconfigure.exclude 移除对应 AutoConfiguration;
  3. spring.datasource / spring.kafka.bootstrap-servers,置 stackwatch.l2.enabled / stackwatch.collector.kafka.enabled = true

这种"渐进式解锁"的设计哲学是:先让你 5 分钟跑通看到效果,再按需上重型组件。


八、指纹算法:为什么能稳定归并

Fingerprinter 的输入是 exceptionType + 归一化后的 top-N 应用帧

关键在于框架帧过滤 :把 java. / javax. / org.springframework. / io.netty. 这类框架内部帧过滤掉。原因很实在------框架内部帧会随依赖版本抖动,而业务帧稳定。用业务帧算指纹,升级个 Spring 版本不会让历史归并关系断裂。

当应用帧不足 2 个时,回退到 top-N 原始帧,保证极端情况也有指纹。

另外指纹是版本化的FingerprintVersion)------算法升级后,老指纹的归并关系不会被破坏。buildEmbeddingText 喂给 L2 做 embedding,渲染策略由 EmbeddingRendering 记录(借鉴 PostHog 的做法,可追溯"这个 embedding 是怎么生成的")。


九、快速上手

bash 复制代码
# 1. 配置 LLM API Key(走环境变量,禁止写入代码)
export DASHSCOPE_API_KEY=sk-...

# 2. 构建(需 JDK 21+,Spring Boot 4.1 + Spring AI 2.0 强制要求)
mvn clean package

# 3. 运行
mvn spring-boot:run

# 4. 试用:输入异常堆栈,获取 LLM 根因
curl -X POST http://localhost:8080/analyze \
  -H "Content-Type: application/json" \
  -d '{
    "appName":"order-service",
    "exceptionType":"NullPointerException",
    "exceptionMessage":"Cannot invoke method on null",
    "stackTrace":[
      "com.foo.OrderService.process(OrderService.java:42)",
      "com.foo.OrderController.handle(OrderController.java:17)"
    ]
  }'

JDK 21 是硬要求(Spring Boot 4.1 + Spring AI 2.0 不支持 Java 8/11/17)。本地用 jenv 管理版本即可,无需手动 export JAVA_HOME。


十、当前状态 & 适合谁用

MVP 阶段,五层主链路 + 两层横切均已实现:

状态
domain 数据结构(不可变 record)
② 预处理层 - 指纹生成(SHA-256 + 框架帧过滤 + 版本化)
③ 分析层 - L1 缓存 + L2 向量归并 + L3 LLM 根因(结构化输出 + Function Calling)
上下文优化 - ContextOptimizer
① 采集层 - Logback Appender + HTTP + Kafka
④ ⑤ 聚合层 / 投递层 - 实时激增检测 + 飞书周报
横切 A/B - Micrometer 度量 + 正负双向反馈飞轮

适合这几类同学关注:

  • Java 后端 / SRE:被生产告警洪水折磨,想用 AI 提效但又怕 token 账单爆炸。
  • AI 应用落地实践者:想看一个"LLM 不是无脑调用,而是按成本阶梯编排"的真实工程案例。
  • Spring AI 学习者 :项目用了很新的 Spring AI 2.0 + Spring Boot 4.1 + JDK 21,结构化输出(.entity())、Function Calling(.tools())、ChatClient 链式调用都有实战用法。
  • 想做类似系统的同学:指纹算法、语义缓存反向填充、三档复核门控、正负反馈飞轮,这些设计模式都可以直接借鉴到自己的项目里。

十一、设计借鉴与致谢

项目站在巨人的肩膀上,借鉴了几个优秀开源项目的设计思想:

  • PostHog error_tracking:指纹算法版本化、embedding rendering 元数据、周报结构
  • Arvo-AI/aurora:RCA 后动作自动化、知识库积累思想
  • salesforce/PyRCA:RCA 评估方法论
  • PagePilot(支付宝商家中心测试团队):置信度分档门控 + known-failures 失败模式自学习库;本项目的 L3 三档复核 + 正负双向反馈飞轮即借鉴于此

写在最后

做这个项目,我最想表达的一个观点是:

在工程系统里接入 LLM,"调用大模型"应该是最后一步,而不是第一步。

先用指纹精确去重(L1),再用向量语义归并(L2),把重复的、相似的异常都挡在 LLM 之外;只有真正新出现的、需要推理的,才走到 L3。再用上下文优化压住单次成本,用三档门控保证结论可信,用反馈飞轮让系统越用越准。

这样一套下来,LLM 的"贵"和"乱"两个痛点都被结构性地解决了,而不是靠堆 prompt 技巧。

如果你也在做 AI 落地、或者被生产异常排查困扰,欢迎来看看、提 issue、提 PR。

🔗 GitHub:https://github.com/AllenMuu/stackwatch

📐 详细设计文档、🧭 选型分析、🚀 演进路线都在 repo 的 docs/ 目录下。

如果对你有启发,给个 ⭐ Star 是对我最大的鼓励。


MIT 协议开源,欢迎交流。

相关推荐
雨辰AI3 小时前
全集实战:企业级大模型服务化部署全栈指南|FastAPI 封装 + Nginx 负载均衡 + 高可用架构 从单机到生产一步到位
人工智能·ai·负载均衡·fastapi·ai编程
spencer_tseng4 小时前
Redis + Nacos.bat
java·windows·dos
troyzhxu5 小时前
列表查询的 GraphQL —— 一行代码终结你的 if-else 地狱!
java·springboot·graphql
孫治AllenSun5 小时前
【DataX】生产环境搭建DataX集群案例
java·开发语言·jvm
好好沉淀6 小时前
@NotBlank(message = “{xxx}“) 注解中花括号的含义
java
fīɡЙtīиɡ ℡6 小时前
内存泄漏产生的原因
java·spring·servlet
oil欧哟7 小时前
我做了一个 Vibe Coding 术语学习站:VibeHub
前端·ai·agent·独立开发·vibe coding
NPE~7 小时前
[AI]Agent开发——ADK框架使用
人工智能·python·ai·教程·adk·agent开发
码智社8 小时前
Java实现RSA密钥生成、加密解密、加签验签
java
wear工程师8 小时前
ThreadLocal 在线程池里为什么会串数据:别只会答内存泄漏
java·后端