一、背景介绍
一次真实的线上排查记录。为了给 Agent 调用加日志切面(AOP),我们把内部方法调用改成了走代理,结果报出 chatModel is null 的 NPE。一开始以为是大模型 Bean 没注入,最后发现真正的坑藏在「代理 + private 方法」的组合里。
bash
ERROR ArticleAgentService : 阶段1:标题方案生成失败, taskId=...
java.lang.NullPointerException: Cannot invoke
"...DashScopeChatModel.call(...)" because "this.chatModel" is null
at ArticleAgentService.callLlm(ArticleAgentService.java:437)
at ArticleAgentService.agent1GenerateTitleOptions(ArticleAgentService.java:193)
at ArticleAgentService.executePhase1_GenerateTitles(ArticleAgentService.java:100)
...
at ArticleAgentService$$SpringCGLIB$$0.executePhase1_GenerateTitles(<generated>)
at ArticleAsyncService.executePhase1(ArticleAsyncService.java:106)
关键信息有两条:
chatModel(通过@Resource注入的DashScopeChatModel)是 null;- 调用栈里出现了
ArticleAgentService$$SpringCGLIB$$0------ 说明这个 Bean 被 Spring AOP 代理了。
第一反应通常是:DashScopeChatModel 是不是没注入成功?API Key 配错?Bean 没注册?
排查了一轮:application.yml 里 spring.ai.dashscope.api-key 配了、pom 依赖在、项目里也只有一个 ArticleAgentService 的定义...... 都不像是配置问题。最后把注意力放回「代理」这个异常特征上,才挖出真正的原因。
问题诊断摘要
- Spring AOP 的切面(注解)只在方法调用穿过代理对象时触发;
- 同一个 Bean 内部用
this.method()直接调用,穿不过代理 → 切面不生效,这是经典的「自调用陷阱」; - 解法之一是用
AopContext.currentProxy()拿到代理再调用(即下文getProxy()),前提是启动类开启@EnableAspectJAutoProxy(exposeProxy = true); - 但是!被注解的方法如果是
private,代理拦不住 ------private 方法无法被 CGLIB 子类覆盖,调用会直接在代理对象自身上执行; - 而 CGLIB 代理对象只是 Spring 额外 new 出来的一个"壳",字段全是 null (依赖注入只发生在真实 Bean 上)→ 于是在代理对象上跑业务方法,读到
chatModel == null→ NPE; - 解法:把被 AOP 注解的方法改成
public。
二、前置知识:Spring AOP 是基于代理的
Spring AOP 和 AspectJ 的编译期织入 不同,它是运行时基于代理的:当某个 Bean 上有切面匹配(比如方法上加了自定义注解)时,Spring 不会把你写的那个对象直接放进容器,而是:
java
// 真实业务对象(字段会被 Spring 注入)
ArticleAgentService target = new ArticleAgentService();
target.chatModel = ...; // 注入发生在 target 上
// 再包一层 CGLIB 代理(继承 target 的类,动态生成子类)
ArticleAgentService proxy = enhancer.create();
// proxy 里的字段...... 全是 null!
最终容器里存的是 proxy,外部拿到的也是 proxy。外部调用 proxy.executePhase1_GenerateTitles() 时,会先进入代理的拦截器链 (方法拦截器),切面逻辑在这里执行,然后再转发给真实的 target。
text
外部 Bean ──► proxy.method() ──► 拦截器链(切面) ──► target.method() 真实执行
(容器里的代理) (字段已注入)
关键点 1 :CGLIB 代理是 target 的一个子类实例 ,和 target 是两个不同的对象。Spring 只往 target 里注入依赖,代理对象上的字段全是 null/默认值。
关键点 2:切面只在方法调用"落在代理上"时才生效。谁发起调用、是不是外部 Bean,Spring AOP 根本不关心。
三、经典的「自调用」陷阱
既然外部调用会穿代理、切面能生效,那同一个类内部互相调用呢?
java
public void executePhase1_GenerateTitles(ArticleState state) {
// 直接调用,等价于 this.agent1GenerateTitleOptions(state)
agent1GenerateTitleOptions(state); // ❌ 切面不生效
}
这里的 this 是真实 target ,不是代理。方法调用根本没有经过代理对象,Spring AOP 连"发生了调用"都不知道,自定义注解切面自然不会触发。
这就是人尽皆知的 self-invocation(自调用)导致 Spring AOP 失效的问题。
打个比方:把代理想成一道安检门 。方法调用要从正门 过一遍才被安检(记录日志)。
真实 Bean 内部
this.method()相当于从门内侧 直接走,没出过门,安检形同虚设;而通过代理调用,等于先把自己带出门外、再从正门进来,安检才会生效。
四、为什么要加 getProxy()
因为要给每个 Agent 调用自动记录执行日志,我们加了一个切面:
java
@Around("@annotation(agentExecution)")
public Object aroundAgentExecution(ProceedingJoinPoint pjp, AgentExecution agentExecution) {
// ... 记录开始、成功、失败、耗时 ...
}
并给 Agent 方法打上注解:
java
@AgentExecution(value = "agent1_generate_titles", description = "生成标题方案")
public void agent1GenerateTitleOptions(ArticleState state) { ... }
可这些 Agent 方法是被同一个类 的 executePhase1_GenerateTitles 内部调用的 ------ 直接调就是自调用,切面永远不会触发,日志等于白写。
于是改成"先拿代理、再通过代理调用",让内部调用重新穿过代理:
java
private ArticleAgentService getProxy() {
return (ArticleAgentService) AopContext.currentProxy(); // 取出当前代理
}
public void executePhase1_GenerateTitles(ArticleState state) {
getProxy().agent1GenerateTitleOptions(state); // ✅ 穿过代理 → 切面触发
}
AopContext.currentProxy() 能取出"当前正在被代理执行的方法所对应的代理对象",前提是启动类开启:
java
@EnableAspectJAutoProxy(exposeProxy = true) // 关键!把当前代理放进 AopContext
这样内部调用就从:
text
target.executePhase1_GenerateTitles()
└── this.agent1() // 穿不过代理,切面不触发
变成了:
text
target.executePhase1_GenerateTitles() // 外层方法本身已穿过代理,AopContext 有值
└── proxy.agent1() ──► 拦截器链(切面) ──► target.agent1() 真实执行
从切面的视角看,这和方法"被外部 Bean 调用"完全一致 ------ AOP 不看调用者是谁,只看调用有没有落在代理上。
五、真正的坑:private 方法 + CGLIB 代理 = 在"空壳"上执行
按上面的改法,似乎已经解决了。但运行时还是 NPE:chatModel is null。为什么?
因为被 @AgentExecution 注解的那些方法,当初声明的是 private:
java
@AgentExecution(value = "agent1_generate_titles", description = "生成标题方案")
private void agent1GenerateTitleOptions(ArticleState state) { // private!
String content = callLlm(prompt); // 内部再调一个 private callLlm
...
}
private String callLlm(String prompt) {
ChatResponse response = chatModel.call(...); // this.chatModel → NPE
...
}
问题出在 CGLIB 代理的一个硬限制上:
private方法无法被 CGLIB 子类覆盖 。代理proxy是ArticleAgentService的子类,但子类不可能 override 父类的 private 方法------private 方法对子类都不可见。- 当你在
ArticleAgentService内部写proxy.agent1GenerateTitleOptions(...)时,编译器生成的是invokespecial(直接调用,不走虚分派),意思是"我就是在自己类内部调这个 private 方法",JVM 会直接把它当作在当前对象上执行。 - 于是这段代码在
proxy这个对象上执行了。而前面说过,proxy是一个字段全为 null 的"空壳" ------chatModel、imageServiceStrategy、cosService全都没注入。 callLlm里访问this.chatModel→this是proxy→ 字段是 null → NPE。
一句话概括这次 Bug:
想让 private 方法穿过代理触发切面,结果 private 方法穿不过代理,反而直接在字段全空的代理对象上执行了,于是所有
@Resource字段都是 null。
六、修复:把注解方法改成 public
把 6 个被 @AgentExecution 注解的 private 方法改成 public:
java
@AgentExecution(value = "agent1_generate_titles", description = "生成标题方案")
public void agent1GenerateTitleOptions(ArticleState state) { // public ✅
...
}
原理:
public方法可以被 CGLIB 子类覆盖,调用会走虚分派进入代理拦截器 → 切面触发;- 拦截器
proceed()后,由 Spring 反射到真实的 target 上执行 → 字段完整 → 不再 NPE。
顺带,日志切面也终于真正开始工作了(改之前因为 private 无法被切,日志从没写过一条)。
七、横向对比:三种做法的取舍
| 方案 | 切面是否触发 | 是否 NPE | 说明 |
|---|---|---|---|
this.method() 直接自调用 |
❌ | 否 | 简单,但 AOP 日志形同虚设 |
getProxy().method(),方法 private |
❌ | 是 | private 拦不住,且在代理空壳上执行 |
getProxy().method(),方法 public |
✅ | 否 | 本次修复方案,内部调用也能记日志 |
八、复盘清单
- 切面不生效 → 先看是不是同类自调用(
this.method())。是的话,要么拆独立 Bean,要么getProxy(); getProxy()拿不到代理 → 检查启动类是否开了@EnableAspectJAutoProxy(exposeProxy = true);- 被代理后字段全 null / NPE → 检查被
@Around注解匹配的方法是不是private。Spring AOP 只能代理public(及 CGLIB 下的 protected)方法; - 怀疑注入失败前 ,先看调用栈里有没有
$$SpringCGLIB$$/$Proxy------ 有代理参与时,"字段为 null"的第一嫌疑是代码跑在了代理空壳上,而不是 Bean 没注入。