前段时间我重构一版内部业务系统时让 DeepSeek 帮忙写接口实现类发现一个特别典型的毛病它生成的代码经常引用不存在的类型、把泛型参数放错位置、甚至调用根本没定义过的方法。这不是模型能力不行而是它根本看不到整个工程的类型全貌。后来我用了javax.lang.model来解决这个问题思路很直接在编译期先把类、接口、泛型、注解这些结构全部梳理成上下文再交给 DeepSeek 生成代码最后用编译校验兜底。这套东西我内部代号叫 jlang-gen就是“DeepSeek javax.lang.model 实现”这个项目的复盘。如果你写过注解处理器或者想把 LLM 接进构建流程这篇文章值得看完。1. 为什么是 javax.lang.model1.1 大模型写 Java 代码的“类型盲区”先说痛点。我们让 LLM 写 Java 代码时通常喂给它的是几段零散的源码文件、一个需求描述然后希望它输出完整实现。问题在于Java 代码的正确性极度依赖类型信息而 prompt 里的文本根本无法完整表达这些信息。举例来说你让它“生成一个根据用户ID查询用户信息的分页接口”它不知道UserQuery里有哪些字段、UserRepository有没有findByCondition方法、ResultT的泛型约束是什么。它只能靠训练时见过的相似代码去猜猜对了一回猜错了十回。javax.lang.model 的存在就是为了在编译期描述 Java 程序的结构。它能看到源码中所有的类、方法、字段、泛型参数、注解、继承关系。把这些信息结构化之后喂给模型就等于给 AI 戴上了一副“类型眼镜”让它不再靠猜。1.2 反射、AST库、注解处理器三条路怎么选在考虑怎么给 LLM 提供类型上下文时我认真比较过三条技术路线。方案获取类型信息的时机能否拿到泛型与注解额外依赖生成代码后能否参与下一轮编译运行时反射运行时能拿到部分泛型但容易擦除无否JavaParser 等 AST 库编译前/运行时解析源码能拿到源码结构但类型解析弱需要引入第三方包否javax.lang.model 注解处理器编译期能拿到完整解析后的类型、泛型、注解无JDK 原生是最终我选了 javax.lang.model。原因有三第一它是 JDK 自带的标准 API不需要引入额外的解析库第二它跑在编译器的符号表之上拿到的类型是经过编译解析的和运行时反射拿到的那套“擦除后类型”完全是两码事第三注解处理器在编译轮次中天然支持生成源码文件生成的文件会参与到下一轮编译里这正好可以形成一个“解析 → 生成 → 校验”的闭环。2. javax.lang.model 的建模体系速览2.1 Element代码元素的“家谱”javax.lang.model 把 Java 源码中的基本单位抽象成Element接口它上面有几种核心子类型PackageElement包、TypeElement类或接口、ExecutableElement方法或构造器、VariableElement字段、局部变量、参数、TypeParameterElement类型参数。可以把它理解为代码的“家谱”包下面有类类里面有方法和字段方法又有参数和返回值类型。遍历这棵家谱树就能把整个类的结构完整提取出来。举个例子如果我们拿到一个TypeElement代表UserService那么getEnclosedElements()就能拿到它下面定义的所有方法、字段和内部类。2.2 TypeMirror类型系统的“指纹”Element 描述的是“有什么东西”TypeMirror 描述的是“这个东西长什么样”。element.asType()返回一个 TypeMirror它能表达完整类型信息是原始类型、数组类型、声明类型还是泛型变量、通配符。这里特别要注意DeclaredType里有一个getTypeArguments()方法它能拿到泛型实参。比如ListUser通过这个方法拿到的就是User这个 TypeMirror而不是一个被擦除的List。这一点对 AI 生成代码极其关键因为泛型错了编译一定过不了。2.3 Elements 与 Types 两个工具类的正确用法只靠 Element 和 TypeMirror 还不够真正的干活利器是Elements和Types这两个工具类。它们是 ProcessingEnvironment 直接提供的分别负责元素操作和类型判断。Elements.getAllMembers(typeElement)能拿到一个类型的所有成员包括从父类和接口继承过来的。Types.directSupertypes(typeMirror)能拿到直接的父类型列表从这个列表里可以判断一个类实现了哪些接口。Types.asMemberOf则可以解决“父类中的泛型 T 在子类中到底是什么类型”这类问题。下面这段代码演示了遍历一个类的所有方法并用 Types 补全类型信息的核心写法for (Element member : elementUtils.getAllMembers(serviceElement)) { if (member instanceof ExecutableElement method) { ExecutableElement inType (ExecutableElement) typeUtils.asMemberOf( serviceElement.asType(), method); for (VariableElement param : inType.getParameters()) { // 这里拿到的 param.asType() 已经是解析后的具体类型 paramTypeMap.put(param.getSimpleName().toString(), param.asType().toString()); } } }如果不经过asMemberOf这一层直接从getAllMembers拿到的父类方法其参数类型可能仍然是父类中定义的受类型变量而不是子类实例化后的具体类型。这是很多初学者踩过坑的地方。3. jlang-gen 的完整实现流程3.1 整体架构设计整个 jlang-gen 拆成了六个模块链路非常清楚先定义一个注解作为入口再写注解处理器去收集元素把类结构转成 JSON构建提示词调用 DeepSeek最后把生成的代码通过 Filer 写入工程。这六个模块分别是JGeneratedApi注解标注哪些类需要交给模型生成实现ApiGeneratorProcessor注解处理器入口负责收集被标注的元素TypeContextExtractor把 TypeElement 转成结构化 JSONPromptBuilder把 JSON 和业务需求组合成提示词LLMClient调用 DeepSeek 完成推理CodeWriter用 Filer 创建并写入生成的源码文件整个处理过程嵌入在编译周期里也就是说每次mvn compile的时候它会自动完成分析和生成。这一步是注解处理器最大的优势。3.2 处理器入口与元素收集注解处理器的入口很简单但有几个细节要处理对SupportedAnnotationTypes必须写全限定名SupportedSourceVersion要和你使用的 JDK 版本匹配。init 方法里必须把Elements、Types、Filer、Messager四个对象缓存下来后面每一步都要用。SupportedAnnotationTypes(com.jlang.gen.annotation.JGeneratedApi) SupportedSourceVersion(SourceVersion.RELEASE_17) public class ApiGeneratorProcessor extends AbstractProcessor { private Types typeUtils; private Elements elementUtils; private Filer filer; private Messager messager; Override public synchronized void init(ProcessingEnvironment processingEnv) { super.init(processingEnv); typeUtils processingEnv.getTypeUtils(); elementUtils processingEnv.getElementUtils(); filer processingEnv.getFiler(); messager processingEnv.getMessager(); } Override public boolean process(Set? extends TypeElement annotations, RoundEnvironment roundEnv) { for (Element element : roundEnv.getElementsAnnotatedWith( JGeneratedApi.class)) { if (element instanceof TypeElement typeElement) { handleTypeElement(typeElement, roundEnv); } } return false; } }return false意味着 Processor 只负责收集和生成不独占这些注解的处理权其他处理器依然可以处理它们。如果这里返回true其他注解处理器可能会拿不到相同的注解容易引起隐性问题。3.3 把类结构序列化为 LLM 上下文LLM 需要一个机器可读的结构化输入我选择用 JSON 作为中间格式。每种类型在 JSON 里都有对应的字段类名、类型种类、实现的接口、方法列表、字段列表、泛型参数、继承的父类。{ className: com.example.api.UserApi, type: interface, typeParameters: [], interfaces: [], methods: [ { name: findUserPage, returnType: com.example.common.Resultcom.example.vo.UserPageVO, parameters: [ { name: userQuery, type: com.example.query.UserQuery } ], modifiers: [public, abstract], throws: [] } ] }注意一点这里不能用toString()去兜底所有类型场景。TypeMirror.toString()有时候会丢失泛型约束或注解信息更稳妥的方式是手动遍历 TypeMirror 的层级结构把DeclaredType、TypeVariable、WildcardType都拆出来单独转换。用一个 Map 预先缓存已经遍历过的类型可以避免循环引用导致的栈溢出因为类型结构里经常出现互相引用的场景。3.4 提示词模板设计拿到 JSON 之后提示词的构建要点在于“给模型足够的约束”。我用的模板是你是一名 Java 资深工程师只输出代码不要输出任何解释。 以下是当前工程的类型上下文JSON {jsonContext} 需求{userRequirement} 要求 1. 只输出接口实现类不要输出额外符号。 2. 所有类型必须使用上下文中的类型不要发明不存在的类或方法。 3. 保持当前工程的代码风格使用 Lombok 风格简化 getter/setter。 4. 不要使用上下文未列出的第三方库。直接把类型上下文和需求一起塞进提示词里比单独调一次“查类型再生成”可靠得多。像 Lombok 这种能影响代码风格的信息也要明确写出来否则模型很容易按自己记忆中“最通用”的风格写。3.5 调用 DeepSeek 并落盘生成文件LLM 推理返回之后第一步先把回复里的 Markdown 代码块剥掉只保留纯 Java 代码。然后调用Filer.createSourceFile创建新源文件。JavaFileObject sourceFile filer.createSourceFile( com.example.impl.UserApiImpl, apiElement); try (Writer writer sourceFile.openWriter()) { writer.write(generatedCode); }这里有个关键细节createSourceFile的第二个参数apiElement是“创建该文件的源元素”它会被编译器记录为生成文件的来源。当同一个注解处理器在后续轮次再次运行时可以通过它判断文件是否已存在避免重复生成。不要直接把生成代码写进已有的源文件里而是让生成类作为一个新的.java文件参与下一轮编译。这样 Compiler 才能正确解析它依赖的所有类型并且保证 IDE 的索引也是最新的。4. 类型安全校验让 AI 生成的代码能编过4.1 内存编译校验流程DeepSeek 生成的代码不能直接信任即使给了类型上下文它依然可能在细节上犯错。因此生成之后必须走一遍真正的 Java 编译校验。我直接用javax.tools.JavaCompiler在内存里编译生成的字符串源码配合DiagnosticCollector把编译错误收集回来。如果出错把这些错误信息重新拼进提示词让模型再修一遍。JavaCompiler compiler ToolProvider.getSystemJavaCompiler(); DiagnosticCollectorJavaFileObject diagnostics new DiagnosticCollector(); StandardJavaFileManager fileManager compiler.getStandardFileManager(diagnostics, null, null);注意ToolProvider.getSystemJavaCompiler()在纯 JRE 环境下可能返回 null编译器只存在 JDK 的运行时环境里。如果你是跑在 IDE 内嵌的 JRE 里要先确认运行时用的是完整 JDK。4.2 三个我最看重的校验项多轮修复的经验让我总结出三个必须重点检查的问题前三位的错误率极高类型不存在AI 生成了一个UserServiceImpl但它引用的UserService并不存在。方法签名不匹配它调用了一个参数是String的方法但接口里定义的是Long。泛型实参不匹配接口要求返回ResultUser它返回了ResultObject。把这三类错误通过 Diagnostic 拿到的行号和列号信息回填给模型时记得同时附上当前工程的类型上下文否则模型无法根据错误信息定位问题根源。4.3 错误回喂与多轮修复错误回喂的提示词是最容易见效的修复方式。把编译器的原始错误信息、出错行以及出错位置的源码一起发给模型让它自查并重新生成。编译出现了以下错误 - 第 23 行找不到符号 UserService - 第 27 行类型不兼容无法将 ResultObject 转换为 ResultUser 请根据错误重新输出完整的实现类代码。不要解释只输出代码。我建议最多重试三次。根据实际效果第一次修复能解决大约一半错误第二次能解决剩下的一半第三次开始基本就是浪费时间了直接由人工介入更高效。4.4 避免循环生成的细节注解处理器的编译是分轮进行的。第一轮扫描到JGeneratedApi生成实现类第二轮这些新生成的实现类又会进入编译扫描。如果不加控制处理器可能在第三轮再次生成同一个文件最后抛Attempt to recreate a file for type异常。解决办法很简单用一个 Map 记录每个目标类对应的生成文件路径处理前先查缓存找到就直接跳过。同时配合RoundEnvironment.processingOver()判断当前是最后一轮还是中间轮。if (roundEnv.processingOver()) { return false; } if (generatedCache.containsKey(apiElement)) { return false; }5. 常见问题与排查技巧实录5.1 “Attempt to recreate a file”怎么办这是注解处理器开发里出现概率最高的问题。编译器在一个编译轮次内发现你尝试创建同一个源文件两次就会抛这个异常。原因一般有两种一是没有做缓存检查二是同一个TypeElement被多次传入JGeneratedApi。排查时先看生成的代码文件路径是不是完全一致再看 Processor 里有没有在process方法开头做防重处理。绝对路径、包名、类名完全一致就是重复创建的充分条件。5.2 泛型信息获取不完整有时候TypeMirror.toString()返回java.util.List而不是java.util.ListUser。这通常出现在获取父类方法时没有经过Types.asMemberOf处理。父类里定义的是T子类把它具体化成User但直接读父类方法签名拿到的依然是T。我建议在提取方法签名的地方统一调用asMemberOf方法避免这类问题。5.3 LLM 回复被 Markdown 代码块污染如果直接对 LLM 返回的字符串调用openWriter().write(...)大概率会写出一堆包含java的无效源码。我写了一个简单的提取方法先把代码块首尾的标记行剥掉再检查剩下内容里是否包含class关键字否则就不写入。private String extractJavaCode(String rawReply) { String code rawReply.replaceAll([a-zA-Z]*, ).trim(); return code; }5.4 AI 代码风格与工程不一致单个类的风格一致性对编译不产生影响但会让代码评审的人非常痛苦。用两个办法解决一是在提示词里写清楚当前工程的风格约定二是提供一段由工程中已有代码构成的 few-shot 示例。示例不在多一个方法就够效果远比十条文字要求好。6. 落地效果与实际心得6.1 在模拟项目 X 上的表现我在一个模拟项目 X 上做了完整的验证目标是生成一套接口实现层共涉及 12 个接口。用最原始的“直接让 DeepSeek 写”的方式能一次通过编译的只有 4 个接上 jlang-gen 的类型上下文后第一轮能通过编译的达到 10 个经过一轮错误回喂之后12 个全部通过编译。通过编译不代表业务逻辑完全正确但至少消灭了“类型瞎编”这一类低级问题剩下的就是业务细节的对齐。6.2 一个提高成功率的关键习惯我实际使用中总结出一条很管用的经验让 LLM 只写方法体不要让它发明方法签名。把方法名、参数列表、返回类型的定义权牢牢握在上下文提取器手里模型只需要根据需求填充逻辑。这样等于把确定性的部分从概率输出中剥离出来了自然会更可靠。后来我在 PromptBuilder 里明确要求“只输出方法体不要重复方法签名”生成正确率又上了一个台阶。这套实现的方向还可以继续扩展。下一步我准备把它做成一个 Gradle 插件让新项目通过一个插件声明就能接入而不是每个工程都手写一套注解处理器配置。谁有类似的类型感知生成需求我们可以多交流。