com.sun.source.util.Plugin这个接口,你在普通 Java 开发里基本不会碰到,但只要你写过一次自定义的 javac 插件,就会意识到它其实是 JDK 编译器的「后门」。它让你在编译过程中拿到所有.java文件的抽象语法树(AST),在字节码生成之前插一手。而把 DeepSeek 这类大模型接进去,就是把这扇后门拓宽成了一条 AI 辅助开发的专用通道——编译器不再只是报错,还能告诉你「这段代码为什么复杂、应该怎么改」。
我在试用这个方案之前,以为最麻烦的是写 AST 遍历逻辑,真正动手之后才发现,难点集中在这几件事:怎么在编译阶段优雅地调用外部 API、怎么从 AST 里提取出大模型能理解的上下文、怎么避免编译线程被网络请求卡死。这篇文章我会把整套思路和踩过的坑都写出来,适合已经会写 Java、想试试编译器插件,但还没真正上手过javac -Xplugin的开发者。
1. 为什么要在 javac 里塞进一个 DeepSeek?——问题与机会
1.1 编译器插件到底能做什么
JDK 从 1.6 开始就有 JSR 269 注解处理,但从 JDK 1.8 起,com.sun.source.util.Plugin这个接口才算真正给编译器插件开了绿灯。它跟注解处理器的区别是:注解处理器主要盯着注解,而Plugin能把整个 javac 的分析流程接过来,从词法、语法到语义分析,中间每个阶段你都能挂回调。换句话说,你能在编译的时候执行任意代码,直接接触 AST、符号表、诊断信息。
这听起来很吓人,但它能做的实事也很明确:
- 自定义编译器诊断。比如发现方法循环嵌套太深、字段命名不符合规范、某个工具类被错误地实例化了,直接输出警告甚至报错。
- 代码结构分析。在 CI 里编译的同时跑一遍自定义规则,比事后用 IDE 扫描要早一步。
- 高危代码拦截。某些敏感 API 调用,可以在编译期就拒绝通过。
传统做法里,这些规则要么靠 Checkstyle、PMD、SonarQube 这类外部工具在编译后扫描源码,要么靠 IDE 的实时检查。编译器插件的价值在于它发生在编译生命周期内,能访问编译器内部的语义信息,比如类型解析结果,这是纯源码扫描工具拿不到的。
1.2 我设想的应用场景:智能诊断与代码建议
大模型本质上是另一个「外部工具」,但它跟 Checkstyle 这类规则引擎完全不一样。规则引擎只能告诉你违反了第几条规则,大模型可以根据上下文自然语言地解释问题,还能给出重构后的代码。
我当时的目标场景很具体:项目里总有那么几个方法写得又长又绕,维护成本极高,我想在mvn compile的时候自动找出这些方法,把方法体摘出来发给 DeepSeek,然后拿到返回的重构建议,甚至直接生成一个优化版本。这个需求用传统插件做,只能做「圈复杂度检测」和「行数检测」,但做不到「为什么复杂」和「怎么改才不复杂」。接入 DeepSeek 之后,编译输出里就能出现一段人话:
警告: 方法 processOrder() 圈复杂度达到 24,建议拆分出 validateOrder()、calculateDiscount() 两个子方法。 DeepSeek 建议: 当前方法内嵌套了 6 层 if-else,其中前 12 行处理的是订单状态校验,可以整体提取; 第 40-56 行的折扣计算逻辑里,三个分支条件互斥,建议用表驱动替换。这就是我想要的效果:编译期的静态分析与生成式 AI 的解释能力合流。顺着这个想法往下走,就要先解决工程问题——怎么把一个编译器插件正确加载起来。
2. 动手前的工程准备:JDK 版本选择、模块依赖和插件加载机制
2.1 先选对 JDK 版本,否则接口都找不到
com.sun.source.util.Plugin从 JDK 8 就有了,但不同版本的行为和兼容性差异很大。我先说结论,然后解释为什么。
| JDK 版本 | 我的建议 | 主要问题 |
|---|---|---|
| JDK 8/11 | 能用,但别选 | javac源码模块化还没彻底,很多内部 API 通过tools.jar暴露,打包和 IDE 调试都比较别扭 |
| JDK 17 | 入门首选 | 长期支持版本,模块系统成熟,jdk.compiler模块导出规则清晰,网上资料也多 |
| JDK 21+ | 进阶试用 | 新增语言特性(比如虚拟线程)会带来 AST 新节点类型,插件代码要考虑兼容;如果编译目标代码包含 record、sealed 类,需要测试 |
我自己用的是 JDK 17 来写插件,编译目标代码也是 JDK 17。这样做的好处是语言层面的新特性相对稳定,AST 节点类型变化不大。如果你需要给一个旧项目做插件,但那个项目本身用的是 JDK 8,建议还是用 JDK 17 编译插件 jar,再用--release 8设编译目标,这样插件能在老版本编译器上跑,但同时又避开了tools.jar那套老式依赖方式。
2.2 打包插件并通过 javac -Xplugin 加载
插件加载入口非常简单。javac在启动时会查找-Xplugin参数后指定的插件名,然后从类路径或插件路径里实例化实现类。为了让编译器知道这个插件叫什么名字,标准的做法是在 jar 包里的META-INF/services/com.sun.source.util.Plugin这个文件中写入实现类的全限定名。
举个例子,我的插件实现类叫com.example.DeepSeekCompilerPlugin,那么 jar 的目录结构是:
myplugin.jar └── META-INF └── services └── com.sun.source.util.Plugin # 内容是 com.example.DeepSeekCompilerPlugin └── com/example/DeepSeekCompilerPlugin.class然后执行:
javac -Xplugin:DeepSeekPlugin -processorpath myplugin.jar \ -cp myplugin.jar \ -d target/classes \ src/main/java/com/example/*.java这里有个容易踩的地方:插件类本身既要在-processorpath里,也要在-cp里。因为Plugin接口是从jdk.compiler模块加载的,但实现类需要通过类路径加载,而且它在编译过程中还会创建自己的辅助类,这些辅助类默认用的是编译任务的类路径。我一开始只在-processorpath里放了 jar,结果插件能初始化,但一访问辅助类就抛ClassNotFoundException。
2.3 模块封装:--add-exports 到底在导出什么
JDK 17 对 JDK 内部 API 做了强封装。com.sun.source.tree和com.sun.source.util这两个包被定义在jdk.compiler模块里,但没有对外部模块完全导出。正常情况下,你的插件代码直接import com.sun.source.util.TreePathScanner会在编译期就报错:
package com.sun.source.util is not visible解决办法是给 javac 加上模块导出参数。注意,这个参数不止编译插件时要加,插件实际运行的时候同样要加。比如:
javac --add-exports jdk.compiler/com.sun.source.util=ALL-UNNAMED \ -Xplugin:DeepSeekPlugin ...很多人只在javac命令行里加了,结果 IDE 里跑插件调试时忘了在运行配置里加,导致明明能编译,运行时却报模块访问错误。我的经验是:直接把--add-exports jdk.compiler/com.sun.source.util=ALL-UNNAMED同时写到 Maven 编译插件的<compilerArgs>和 IDE 运行配置的 VM options 里,两边保持一致,就不会间歇性出问题。
还有一个隐藏点:com.sun.source.tree和com.sun.source.util需要分别导出,不能只导出util包。因为TreePathScanner的声明里大量引用了com.sun.source.tree下的类型,你 import util 包时,类型解析会连带访问 tree 包。我建议干脆三个包一起导出:
--add-exports jdk.compiler/com.sun.source.tree=ALL-UNNAMED --add-exports jdk.compiler/com.sun.source.util=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED最后一个com.sun.tools.javac.api是给JavacTask和Trees用的,后面写代码时你会需要它。这一步做完,插件才算真正跑起来。
3.com.sun.source.util.Plugin接口核心机制拆解:init 方法、JavacTask 与 Trees
3.1 Plugin 接口的注意点:名字与优先级
Plugin接口实际上非常小,核心方法只有两个:
public interface Plugin { String getName(); void init(JavacTask task, String... args); }JavacTask是编译器任务的主入口,它既可以用javacAPI 的方式在外部创建,也可以内部通过插件注入获取。args是你在-Xplugin:DeepSeekPlugin后面追加的参数,比如:
javac -Xplugin:DeepSeekPlugin:model=deepseek-chat:threshold=20 ...那么init收到的args[0]可能是"model=deepseek-chat",args[1]是"threshold=20",具体解析方式完全由你自己定义。这个设计让我很满意,因为它意味着同一个插件 jar 可以按项目配置阈值、模型名、API key,而不用重新编译。
有一点必须注意:多个插件同时注册时,javac会按getName()的结果排序后依次初始化,所以如果你的插件逻辑依赖另一个插件的输出,尽量不要做这种假设,编译器插件的执行顺序本质上就是未完全约定的。
3.2 JavacTask 与 Trees:编译器公开 AST 的两把钥匙
JavacTask里有两个方法,是插件访问 AST 的钥匙:
Iterable<? extends CompilationUnitTree> parse(); Iterable<? extends Element> analyze();parse()触发词法和语法分析,返回编译单元的根节点列表。analyze()触发语义分析,返回顶层元素(类、接口、枚举等)。
但光有根节点还不够,你需要在编译过程中进行「阶段化钩子」,以便拿到语义信息。比较好的做法是在init方法里注册监听器:
task.addTaskListener(new TaskListener() { @Override public void started(TaskEvent e) { // 可感知阶段开始 } @Override public void finished(TaskEvent e) { if (e.getKind() == TaskEvent.Kind.ANALYZE) { // 语义分析完成,此时可以安全地使用 TreePathScanner } } });Trees类则负责把编译单元树和符号表关联起来。比如你想知道某个MethodInvocationTree到底调用了哪个被解析过的方法,Trees能给你答案:
Trees trees = Trees.instance(task); TreePath path = TreePath.getPath(compilationUnit, tree); Element element = trees.getElement(path);这个能力是把 AI 建议落到真实代码逻辑的关键——只有当你知道一个方法调用对应的是项目里的哪个类、哪个方法,你给大模型的信息才不只是「字符串」,而是「结构化的语义」。
3.3 遍历 AST 的两种惯用法及我的选择
拿到CompilationUnitTree后,遍历 AST 有两种主流方式:
- 继承
TreePathScanner<Object, Object>,重写visitMethod、visitClass、visitIf等方法。 - 利用
TreeScanner或TreeVisitor手动派发。
我推荐第一种。TreePathScanner的好处是它维护了一个TreePath栈,你在扫描到任意节点时都能拿到从根到当前节点的完整路径。这意味着,当你定位到一个IfTree时,可以立刻向上追溯到它所属的类和方法,而不需要手动记录上下文。
下面是一段非常常见的骨架代码:
public class ComplexityScanner extends TreePathScanner<Object, Integer> { private int ifDepth = 0; private int maxDepth = 0; private MethodTree currentMethod = null; @Override public Object visitMethod(MethodTree node, Integer unused) { currentMethod = node; ifDepth = 0; maxDepth = 0; Object result = super.visitMethod(node, unused); if (maxDepth > 3) { String code = node.toString(); // 把 currentMethod 的源码片段写到一个收集器里,等待后续给 DeepSeek } return result; } @Override public Object visitIf(IfTree node, Integer unused) { ifDepth++; maxDepth = Math.max(maxDepth, ifDepth); Object result = super.visitIf(node, unused); ifDepth--; return result; } }这里我故意用了maxDepth来记录 if 嵌套层数,因为圈复杂度的完整算法需要CyclomaticComplexity这种外部库,而简单场景下嵌套深度已经能反映 80% 的问题。把不满足阈值的方法源码丢给 DeepSeek,就足够生成有价值的重构建议。
4. 把 AST 片段送去 DeepSeek:上下文构造、提示词设计与结果解析
4.1 从 code 到 prompt:控制 token 量的第一个要诀
AST 节点能直接toString(),但不意味着你应该把整个源码文件倒给大模型。我犯过的最大错误,就是把一个 500 行的类完整发给 DeepSeek,然后让它分析其中一个方法。这种行为有两个问题:token 迅速膨胀、返回质量反而下降。大模型的注意力会被无关代码分散,尤其是字段定义、注解、导入这些噪声。
我的原则是「只喂局部上下文,但给足关键信息」。一个方法的提示词建议包括:
- 方法签名(包括泛型、返回类型、参数名)。
- 方法体源码,控制在 200 到 300 行以内。
- 该方法涉及的类名、字段名,以及调用到的其它方法名(不用带方法体)。
- 一个明确的分析任务,例如「找出圈复杂度高于阈值的路径,并给出重构后的完整方法代码」。
控制 token 的另一个办法是:先扫描 AST,把方法体截断到只保留前 N 行。大部分重构建议关注控制流结构,而不是每一行赋值语句。我用过 50 行、100 行、200 行三种截断策略,100 行是最平衡的。
4.2 我使用的提示词结构与分级配置
DeepSeek 的 API 走的是 OpenAI 兼容格式,所以你既可以用官方 SDK,也可以用任何兼容 OpenAI 的 HTTP 客户端。我的提示词结构分三层:
- 系统指令:设定角色和输出格式。
- 参考资料:抽取出的方法源码、类名、相关字段。
- 用户问题:要求输出诊断报告和重构代码。
一个实际可用的 prompt 模板:
你是一名经验丰富的 Java 架构师。请分析下面的方法,指出: 1. 圈复杂度高的具体原因(哪些 if、for、while 分支导致); 2. 哪些逻辑可以提取为独立方法; 3. 给出重构后的完整 Java 方法代码,不要省略任何逻辑。 方法源码: ```java public void processOrder(Order order) { ... }类上下文:
- 类名: OrderService
- 字段: orderRepository, discountCalculator
- 相关方法: validateOrder(Order), sendNotification(Order)
注意,```` ```java ```` 这个标记在 prompt 里其实是可以用的,DeepSeek 能识别。但如果你用的是别的模型,最好改成纯文本格式,避免某些模型在代码块解析上不稳定。 输出解析上,我要求 DeepSeek 返回一段固定结构的回答,比如先给「问题定位」,再给「重构代码」,代码放在 ```` ```java ```` 代码块里。然后我在插件里用正则提取代码块内容。如果模型给的不是固定格式,你就得做容错解析,这个非常容易翻车。 ### 4.3 调用 DeepSeek API 的封装:超时、重试与同步阻塞问题 这是整个方案里最容易被低估的一环。编译器插件运行在 javac 的分析线程上,如果你直接同步调用 HTTP,那么一次编译可能因为网络延迟多出十几秒,甚至几分钟。CI 里的 `mvn compile` 会直接超时。 我给出的建议是分两步走: 第一,在插件本地做语法分析和知识提取,把要请求的数据准备成轻量 JSON。这一步必须在编译线程里完成,因为需要访问 AST。 第二,调用 DeepSeek API 放到异步执行。我有一个比较笨但可靠的实现:插件扫描阶段把「待分析任务」放进一个线程安全的队列,然后由一个专门的线程池去消费队列,调用 API,最后把生成的建议通过 `Messager` 打印到编译输出。但由于 javac 的编译任务可能在主线程等待,你必须考虑编译结束时机。最简单的做法是:在编译结束钩子里 `shutdown()` 线程池并 `awaitTermination`,给 API 请求一个有限的窗口期(比如 20 秒),超时就把未完成的任务丢进日志。 一个关键的 API 参数是 `timeout`。DeepSeek API 默认可能 60 秒没有返回,你可等不起。我把超时设置为 15 秒,重试 1 次,重试退避 2 秒。如果仍失败,就在编译输出里打一个警告,但不阻断编译。 下面是调用部分的骨架(用 Java 11+ HttpClient,不引额外依赖): ```java HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.deepseek.com/chat/completions")) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + apiKey) .timeout(Duration.ofSeconds(15)) .POST(BodyPublishers.ofString(payload)) .build();这里我特别提醒一个很容易犯的错误:不要在源码里写死 API key。你可以从-Xplugin参数传,也可以从环境变量读,甚至从~/.config/deepseek读取。把 key 打到 jar 里然后推到 Git 仓库,基本等于把密钥公开了。这一点在协作项目里尤其重要。
5. 一个完整的插件示例:扫描方法复杂度并让 DeepSeek 给出重构建议
5.1 插件主类与注册
下面这个示例,我尽量精简但保持可用。它实现的能力是:扫描每个方法,如果 if 嵌套深度超过指定阈值,就把方法源码发给 DeepSeek,并在编译结束后把建议打印出来。
public class DeepSeekCompilerPlugin implements Plugin { private static final String PLUGIN_NAME = "DeepSeekPlugin"; @Override public String getName() { return PLUGIN_NAME; } @Override public void init(JavacTask task, String... args) { PluginConfig config = PluginConfig.parse(args); Trees trees = Trees.instance(task); task.addTaskListener(new TaskListener() { @Override public void started(TaskEvent e) { } @Override public void finished(TaskEvent e) { if (e.getKind() == TaskEvent.Kind.ANALYZE) { CompilationUnitTree unit = e.getCompilationUnit(); ComplexityScanner scanner = new ComplexityScanner(config, trees); scanner.scan(unit, null); } } }); task.addTaskListener(new TaskListener() { @Override public void finished(TaskEvent e) { if (e.getKind() == TaskEvent.Kind.COMPILATION) { // 结束前等待异步任务完成 } } }); } }注册文件META-INF/services/com.sun.source.util.Plugin里写:
com.example.DeepSeekCompilerPlugin5.2 扫描逻辑:TreePathScanner 的实际代码
ComplexityScanner负责找嵌套过深的方法。和前面骨架相比,这里我在visitMethod中使用了Trees获取方法元素,这个元素信息会一起加入 prompt:
public class ComplexityScanner extends TreePathScanner<Object, Integer> { private final PluginConfig config; private final Trees trees; private int ifDepth = 0; private int maxDepth = 0; private MethodTree currentMethod; private CompilationUnitTree currentUnit; public ComplexityScanner(PluginConfig config, Trees trees) { this.config = config; this.trees = trees; } @Override public Object visitCompilationUnit(CompilationUnitTree node, Integer unused) { currentUnit = node; return super.visitCompilationUnit(node, unused); } @Override public Object visitMethod(MethodTree node, Integer unused) { currentMethod = node; ifDepth = 0; maxDepth = 0; Object result = super.visitMethod(node, unused); if (maxDepth > config.getMaxNestedIfDepth()) { MethodElementInfo info = extractMethodInfo(node); // 交给异步分析器 AsyncAnalyzerQueue.submit(info); } return result; } @Override public Object visitIf(IfTree node, Integer unused) { ifDepth++; maxDepth = Math.max(maxDepth, ifDepth); Object result = super.visitIf(node, unused); ifDepth--; return result; } private MethodElementInfo extractMethodInfo(MethodTree node) { TreePath path = TreePath.getPath(currentUnit, node); Element el = trees.getElement(path); String enclosingClass = ""; if (el != null) { enclosingClass = el.getEnclosingElement().getSimpleName().toString(); } return new MethodElementInfo(enclosingClass, node.getName().toString(), node.toString(), maxDepth); } }这里有一个体验上的细节:node.toString()能拿到方法的原始源码,但它在CompilationUnitTree上的格式化结果和源码基本一致,足以在 prompt 中作为输入。如果你的项目使用了 Lombok 这类注解处理器,可能 AST 中会出现一些由处理器生成的代码,插件遍历时要做好过滤——我一般只处理用户源码文件,不处理GENERATED代码,通过检查CompilationUnitTree对应的 URI 后缀来过滤。
5.3 DeepSeek 客户端封装以及如何把建议写回编译器诊断
异步队列的消费者,其实就是上一节提到的 HttpClient 调用。我在调用成功之后,通过 javac 的Messager往编译输出打消息:
public class DeepSeekAnalyzer { private static final Pattern CODE_BLOCK = Pattern.compile( "(?s)```java\\s*(.*?)```"); static void analyze(MethodElementInfo info, PluginConfig config) { String prompt = PromptBuilder.build(info); String response = DeepSeekClient.chat(prompt, config); String suggestion = extractJavaCode(response); if (suggestion.isEmpty()) { suggestion = "DeepSeek 未返回可解析的代码块,请查看 API 响应日志。"; } // 把结果交给编译诊断输出 DiagnosticOutput.printWarning( "方法 " + info.getMethodName() + " 嵌套深度过高(" + info.getMaxDepth() + ")," + "DeepSeek 建议重构:\n" + suggestion); } }DiagnosticOutput底层用什么?最直接的是通过JavacTask拿到com.sun.tools.javac.util.Log,或者更通用一点,直接用System.err.println配合-Xplugin的日志开关。严格来说,走Messager才能跟javac的-Xdiags格式统一,但在一个简化插件里,重定向到编译日志更省事。
这个示例完整跑通之后,你会看到像这样的编译输出:
警告: 方法 processOrder 嵌套深度过高(5),DeepSeek 建议重构: - 问题定位: 前 20 行是订单状态校验,多个 if 分支可提取为 validateOrder - 重构代码: private void validateOrder(Order order) { ... }注意,-Xplugin的输出默认不会带文件名和行号,因为这已经是编译诊断之外的自定义输出。如果你需要精确位置,可以在MethodTree上调用getStartPosition(),再通过JavacTask的SourcePositions算出行号。这一步补上后,输出会专业很多。
6. 实测中的坑与规避:javac 阶段、并发编译、模块导出和 IDE 兼容性
6.1 阶段问题:为什么必须在 ANALYZE 之后才能拿到完整语义信息
我在最初版本中犯过一个错误,在finished(e.getKind() == PARSE)阶段就尝试用Trees#getElement查符号,结果大量返回 null。原因是:Trees实例在语义分析阶段才会填充符号表。编译器分析阶段之前,AST 里只有语法结构,没有类型绑定。
所以正确做法是:只在TaskEvent.Kind.ANALYZE的finished事件里启动扫描,这时候类型信息、方法符号、字段符号基本都已经确定。如果你还需要在GENERATE阶段之前做修改,ANALYZE到GENERATE之间还有一小段窗口,足以读取符号表。
这和注解处理器的行为不同。注解处理器是在专门的 round 里拿到 Element 的,而 Plugin 直接接触 javac 生命周期,所以阶段判断必须自己来。
6.2 并发编译下 API 调用会卡死编译线程
Gradle 和 Maven 3 默认都会在多模块项目里并发编译多个模块。如果你让每个 javac 进程都在finished(ANALYZE)阶段同步调用 DeepSeek API,那么并发编译会被网络 IO 大量阻塞,整个构建体验会非常差。
我建议三个层次的缓解:
- 在插件内部做请求合并。比如 50 毫秒内收集到的方法分析任务合并成一个请求,一次发给大模型。DeepSeek 支持多轮对话,也支持一次 prompt 里包含多个代码片段。
- 使用异步线程池,并且把线程数限制为 2。不要起 10 个线程同时请求,因为编译结束时要
awaitTermination,起得多反而都在等网络响应。 - 设置开关。在本地开发时,默认关闭 AI 调用,只在 CI 环境变量里开启
DEEPSEEK_ANALYZE_ENABLED=true,避免开发人员每次编译都烧 token。
实测数据供你参考:一次全量编译 80 个模块,如果每个模块都触发 10 个 API 请求,总共 800 个请求,假设每个 2 秒,同步模式下耗时 1600 秒。异步线程池 2 线程 + 请求合并后,实际等待时间能控制在 60 秒左右。因此构建工具的超时设置一定要留足空间。
6.3 IDE 与构建工具兼容性:Maven/Gradle 里的最终配置样例
直接把插件接到 Maven 编译插件时,需要在pom.xml里配置下面这些参数。我经历过的坑是:Maven 编译用了 fork,--add-exports参数如果不传给 fork 的 javac 进程,插件运行时照样会缺少模块访问权限。
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.11.0</version> <configuration> <release>17</release> <compilerArgs> <arg>-Xplugin:DeepSeekPlugin:threshold=3</arg> <arg>--add-exports</arg> <arg>jdk.compiler/com.sun.source.util=ALL-UNNAMED</arg> <arg>--add-exports</arg> <arg>jdk.compiler/com.sun.source.tree=ALL-UNNAMED</arg> <arg>--add-exports</arg> <arg>jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg> </compilerArgs> <annotationProcessorPaths> <!-- 如果项目有其它注解处理器,要一并声明 --> </annotationProcessorPaths> </configuration> </plugin>如果你用 Gradle:
tasks.withType(JavaCompile).configureEach { options.compilerArgs += [ '-Xplugin:DeepSeekPlugin:threshold=3', '--add-exports', 'jdk.compiler/com.sun.source.util=ALL-UNNAMED', '--add-exports', 'jdk.compiler/com.sun.source.tree=ALL-UNNAMED', '--add-exports', 'jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED', ] }IDE 兼容性是我最后唠叨的重点。Eclipse 自带的编译器是 ECJ,不是 javac,所以-Xplugin在 Eclipse 里根本不会被执行。IntelliJ IDEA 在非默认情况下可能用自己的编译器做构建,也需要在Settings -> Build Tools -> Maven -> Runner里把「Delegate IDE build/run actions to Maven」打开,确保走 Maven 的 javac 流程。否则你会陷入「IDE 里不生效、命令行又没问题」的困惑。
7. 我的一些经验与扩展思路
这个项目最开始只是我想在 CI 上做实验,后来发现它真正能落地的方向比想象中多。除了方法级重构建议,你还可以把com.sun.source.util.Plugin这套机制扩展成团队代码规范的 AI 审查器:比如禁止使用某些java.util.Date的过期方法、发现System.out.println残留、检测循环依赖导致的问题。传统规则引擎处理这些要写一堆正则和结构匹配,而大模型只需要一段语义化的 prompt 就能理解规则,并且能定位到具体代码。
我把这个插件在内部项目里跑了大半年,最大的体会是:AI 编译期插件不适合做「分钟级」的交互,它更适合做「编译完成后的增量审查」。你想在编程时实时获得 AI 建议,IDE 插件是更好的选择;但你想让所有开发者提交代码时无感获得 AI 级诊断,编译器插件几乎是唯一的路。
另外有一个经验要分享:不要把大模型的 API key、项目代码片段、编译输出混在一起打到同一个日志文件。代码本身对企业是敏感资产,而 API key 泄漏会带来直接的经济损失。我给插件加了-Xplugin:DeepSeekPlugin:redactKey=true选项,在日志里把 key 替换成sk-***,并默认开启。这点看起来很小,但配合回归测试时看日志特别有用,不会不小心把密钥打印到 CI 日志里。
如果你也想试,建议从最小用例开始:先不接 DeepSeek,只写一个能打印出方法名的插件;然后加 AST 扫描;再加网络调用。每一步都单独验证,最后再拼起来。这样遇到问题的时候,你能分清到底是 javac 阶段的问题,还是大模型 prompt 的问题,而不是两头一起崩。
最后再多说一句:插件的启动参数args一定要认真解析,别图省事用全局变量。因为同一个 javac 进程在 Gradle daemon 里可能被复用,不同模块的编译参数会不同,如果全局变量被上一个模块污染,下一个模块就全错了。我当时在这个小细节上 DEBUG 了一个晚上,排查到最后才发现是静态字段没重置。