DeepSeek V4 实战:用 Spring AI 打造 Java 项目源码分析助手
1. 为什么单文件问答救不了源码阅读如果你维护过一个超过三年的 Spring Boot 项目大概会有这种体验想搞清楚一个接口从 Controller 到数据库到底经过了几层得在 IDEA 里按住 Ctrl 点十几次跳转记录堆满一屏。更麻烦的是接手别人的遗留系统Transactional加在 Service 上但调用方是同类内部方法事务根本没生效这种坑只有把整条调用链摊开才看得见。单文件问答式 AI 助手在这里帮不上太多忙。你贴一个 Controller 给它它能告诉你这个类干了什么但它不知道这个 Controller 依赖的 Service 里藏着一个循环依赖也不知道某个 Mapper 的 SQL 在分页参数为负时会全表扫描。源码分析真正需要的是跨文件的全局视野而不是把每个文件孤立地解释一遍。DeepSeek V4 把上下文窗口拉到 1M token 之后这件事有了新的解法。一个中型 Spring Boot 项目去掉 target、test、build 目录后Java 源码通常在 1.5 万到 3 万行之间折算成 token 大概 30 万到 60 万完全塞得进一次请求。这意味着你可以把整个项目的源码一次性交给模型让它从架构分层、模块职责、潜在缺陷三个维度给出报告。这篇要做的就是把这个思路落成一个能跑起来的工具Spring Boot 3 JDK 21 Spring AI后端扫描本地项目路径前端一个静态页面输入路径点分析DeepSeek V4 返回结构化 JSON 报告。适合谁适合正在做代码审查、接手遗留系统、或者想给自己团队搭一个内部源码体检工具的 Java 开发者。下面从依赖配置一路写到 Controller/Service 层的解析验证每一步都能直接复制。2. 用 TaoToken 接入 DeepSeek V4 的前置准备在写代码之前先把模型调用这条链路打通。Spring AI 的 OpenAI 兼容 starter 需要一个 base-url 和 api-keyDeepSeek 官方接口是兼容 OpenAI 协议的所以配置上可以直接复用。我这边习惯用 TaoToken 做统一入口原因是它把 DeepSeek V4 系列和 Claude、GPT 这些模型的调用方式统一了切换模型只改一个 model 字段不用动代码结构。对源码分析这种需要反复试不同模型对比效果的场景省事不少。具体要准备三样东西第一一个可用的 API Key。到 TaoToken 控制台的 API Keys 页面创建一个复制出来形如sk-xxxx的字符串。这个 Key 只显示一次建议直接存到环境变量里别写死在代码里提交到 Git。第二确认 Base URL。TaoToken 的 API 入口是https://taotoken.net/api注意这里不带任何查询参数Spring AI 会在后面自动拼/v1/chat/completions。如果你用的是 DeepSeek 官方那就是https://api.deepseek.com两者协议一致换 base-url 即可。第三确认 Model ID。DeepSeek V4 常见的有deepseek-v4-pro和deepseek-v4-flash两个档位。Pro 适合全量分析Flash 适合摘要模式或者做降级备用。Model ID 必须和平台文档里写的完全一致大小写、连字符都不能错否则会返回 model not found。把 Key 设进环境变量Linux/macOS 下export DEEPSEEK_API_KEYsk-你的真实key export DEEPSEEK_BASE_URLhttps://taotoken.net/apiWindows PowerShell 下$env:DEEPSEEK_API_KEYsk-你的真实key $env:DEEPSEEK_BASE_URLhttps://taotoken.net/api这里有个容易踩的点Spring AI 读取spring.ai.openai.api-key时如果环境变量名和配置里的占位符对不上启动不会报错但第一次调用会返回 401。建议在 application.yml 里用${DEEPSEEK_API_KEY}这种显式占位启动时如果没设会直接抛异常比运行到一半才发现要好。另外提醒一句源码分析会把你本地项目的完整代码发到模型侧如果项目涉及商业机密先确认合规再操作。测试阶段建议拿开源项目练手比如 Spring Security、Spring Cloud Gateway 这类既能验证效果又不涉及敏感信息。3. 可复制的 Spring AI 配置与核心代码这一节是全文的重点把 pom 依赖、application.yml、Prompt 模板、扫描服务、分析服务、Controller 全部给全。你照着建工程改一下包名就能跑。3.1 pom.xml 依赖JDK 21 是硬要求虚拟线程和文本块都会用到。Spring AI 用 1.0.5 版本BOM 方式引入避免版本冲突。properties java.version21/java.version spring-ai.version1.0.5/spring-ai.version /properties dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version${spring-ai.version}/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-client-chat/artifactId /dependency dependency groupIdcommons-io/groupId artifactIdcommons-io/artifactId version2.16.1/version /dependency dependency groupIdcom.knuddels/groupId artifactIdjtokkit/artifactId version1.0.0/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependenciesspring-ai-starter-model-openai是核心它自动装配ChatClient.Builder我们后面注入ChatClient直接用。commons-io负责递归扫描文件jtokkit用来估算 token 数避免请求超限。3.2 application.yml 配置spring: ai: openai: api-key: ${DEEPSEEK_API_KEY} base-url: ${DEEPSEEK_BASE_URL:https://taotoken.net/api} chat: options: model: ${DEEPSEEK_MODEL:deepseek-v4-pro} temperature: 0.1 max-tokens: 8192 deepseek: models: primary: ${DEEPSEEK_MODEL_PRIMARY:deepseek-v4-pro} fallback: ${DEEPSEEK_MODEL_FALLBACK:deepseek-v4-flash} retry: max-attempts: 2 backoff-millis: 1000 server: port: 8080temperature 设 0.1 是因为源码分析要的是稳定输出不是创意。max-tokens 8192 够放一份完整报告。primary/fallback 双模型配置是为了降级Pro 调用失败时自动切 Flash避免整个请求挂掉。3.3 Prompt 模板与 JSON 输出约束Prompt 设计是这类工具成败的关键。如果只写「分析这个项目」模型会返回一大段散文你没法解析。必须用 system prompt 强制它输出 JSON并且给出明确的 schema。private static final String SYSTEM_PROMPT 你是资深 Java 架构师。分析源码后按以下 JSON 输出不要有其他内容 { architecture: { summary: , coreModules: [], patterns: [] }, securityRisks: [ {file: , line: 0, risk: } ], potentialBugs: [ {file: , line: 0, bug: } ], suggestions: [] } ;注意这里用了 JDK 21 的文本块三引号包裹可读性比字符串拼接好太多。schema 里line字段要求模型给出具体行号虽然模型给的行号不一定百分百准但能定位到大致位置人工复核时省时间。3.4 源码扫描服务扫描逻辑要处理三件事递归找 .java 文件、排除 target/test/build 目录、把文件内容拼成 prompt。Slf4j Service public class SourceCodeCollector { public ListSourceFile scanJavaFiles(String projectPath) { File rootDir new File(projectPath); if (!rootDir.exists() || !rootDir.isDirectory()) { throw new IllegalArgumentException(路径无效: projectPath); } ListSourceFile result new ArrayList(); CollectionFile javaFiles FileUtils.listFiles(rootDir, new String[]{java}, true); for (File f : javaFiles) { String absPath f.getAbsolutePath(); if (absPath.contains(/target/) || absPath.contains(/test/) || absPath.contains(/build/)) { continue; } try { String relative rootDir.toURI().relativize(f.toURI()).getPath(); String content FileUtils.readFileToString(f, StandardCharsets.UTF_8); result.add(new SourceFile(relative, content)); } catch (Exception e) { log.warn(读取文件失败: {}, f.getPath(), e); } } log.info(扫描完成共 {} 个 Java 文件, result.size()); return result; } public String buildPrompt(ListSourceFile files, String mode) { StringBuilder sb new StringBuilder(); sb.append(下面是项目的全部 Java 源码请分析。\n\n); for (SourceFile file : files) { sb.append(### 文件: ).append(file.getRelativePath()).append(\n); if (full.equals(mode)) { sb.append(java\n).append(file.getContent()).append(\n\n\n); } else { sb.append(摘要:\n).append(extractSummary(file.getContent())).append(\n\n); } } return sb.toString(); } private String extractSummary(String content) { StringBuilder sb new StringBuilder(); for (String line : content.split(\n)) { String trimmed line.trim(); if (trimmed.startsWith(package ) || trimmed.startsWith(import ) || trimmed.matches(.*\\b(class|interface|enum)\\b.*) || trimmed.matches(.*\\bpublic\\b.*\\(.*\\).*)) { sb.append(line).append(\n); } } return sb.toString(); } }extractSummary是摘要模式的核心只保留 package、import、类声明、public 方法签名这几类行。一个 1500 文件的 Spring Security 项目全量模式 token 消耗巨大摘要模式能压到 1 万多 token先跑摘要看整体架构再针对可疑模块跑全量这是省钱又高效的做法。3.5 分析服务与降级逻辑Slf4j Service RequiredArgsConstructor public class ProjectAnalyzerService { private final ChatClient basicChatClient; private final SourceCodeCollector collector; private final ObjectMapper objectMapper; private final DeepSeekProperties deepSeekProperties; public AnalysisReport analyze(String projectPath, String mode) throws Exception { ListSourceFile files collector.scanJavaFiles(projectPath); if (files.isEmpty()) { throw new IllegalArgumentException(未找到任何 Java 源文件); } String userContent collector.buildPrompt(files, mode); log.info(发送内容长度: {} 字符, userContent.length()); String primaryModel deepSeekProperties.getModels().getPrimary(); String fallbackModel deepSeekProperties.getModels().getFallback(); int maxAttempts deepSeekProperties.getRetry().getMaxAttempts(); long backoff deepSeekProperties.getRetry().getBackoffMillis(); Exception lastException null; for (int attempt 1; attempt maxAttempts; attempt) { String modelToUse (attempt 1) ? primaryModel : fallbackModel; try { log.info(第 {} 次尝试使用模型: {}, attempt, modelToUse); String response callDeepSeek(userContent, modelToUse); return objectMapper.readValue(extractJson(response), AnalysisReport.class); } catch (Exception e) { log.warn(模型 {} 调用失败: {}, modelToUse, e.getMessage()); lastException e; if (attempt maxAttempts backoff 0) { Thread.sleep(backoff); } } } throw new RuntimeException(所有模型均调用失败, lastException); } private String callDeepSeek(String userContent, String model) { return basicChatClient.prompt() .system(SYSTEM_PROMPT) .user(userContent) .options(OpenAiChatOptions.builder() .model(model) .temperature(0.1) .maxTokens(8192) .build()) .call() .content(); } private String extractJson(String resp) { int start resp.indexOf({); int end resp.lastIndexOf(}); if (start ! -1 end start) { return resp.substring(start, end 1); } throw new RuntimeException(AI 响应未包含有效 JSON); } }extractJson这一步很关键。即使 system prompt 说了「不要有其他内容」模型偶尔还是会加一句「好的以下是分析结果」再跟 JSON。用首尾大括号截取能兜住这种情况比直接readValue整个响应健壮得多。3.6 Controller 与虚拟线程启动类Slf4j RestController RequestMapping(/api) RequiredArgsConstructor public class ApiController { private final ProjectAnalyzerService analyzerService; PostMapping(/analyze) public AnalysisReport analyze(RequestBody AnalysisRequest req) throws Exception { log.info(分析请求: path{}, mode{}, req.getProjectPath(), req.getMode()); return analyzerService.analyze(req.getProjectPath(), req.getMode()); } }启动类里开启虚拟线程因为源码分析是 IO 密集型任务等模型响应时线程大部分时间在阻塞虚拟线程能让 Tomcat 用少量平台线程扛住并发请求。SpringBootApplication public class AnalyzerApplication { public static void main(String[] args) { SpringApplication.run(AnalyzerApplication.class, args); } Bean public TomcatProtocolHandlerCustomizer? protocolHandlerVirtualThreadExecutorCustomizer() { return protocolHandler - protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); } }到这里后端就齐了。前端一个静态 HTML一个输入框填项目路径一个下拉选 summary/full一个按钮发 POST 到/api/analyze把返回的 JSON 渲染成卡片即可不是本文重点略过。4. 验证请求解析 Controller 与 Service 层代码写完得验证它真的能干活。我拿一个自己写的 Spring Boot 小项目做测试结构是标准的 Controller → Service → Mapper 三层大概 40 个 Java 文件。启动应用后先确认服务起来了curl -X POST http://localhost:8080/api/analyze \ -H Content-Type: application/json \ -d {projectPath:D:/test/demo-project,mode:summary}如果返回 401说明 Key 没配对往下看第 5 节的排查。正常返回应该是一段 JSON结构如下节选真实输出{ architecture: { summary: 项目采用经典三层架构Controller 层负责 HTTP 路由与参数校验Service 层承载业务逻辑并管理事务边界Mapper 层通过 MyBatis 访问数据库。, coreModules: [user, order, common], patterns: [分层架构, DTO 转换, 统一异常处理] }, securityRisks: [ { file: src/main/java/com/demo/user/controller/UserController.java, line: 42, risk: 查询接口直接返回 User 实体包含 password 字段建议改用 VO 脱敏。 } ], potentialBugs: [ { file: src/main/java/com/demo/order/service/OrderService.java, line: 88, bug: createOrder 方法内部调用同类 this.calcPrice()Transactional 未生效价格计算异常时订单不会回滚。 } ], suggestions: [ 建议为分页查询统一封装 PageResult避免各 Controller 重复处理。, common 模块的日期工具类建议改用 java.time替换过时的 SimpleDateFormat。 ] }这份报告里最有价值的是第二条 potentialBugs。this.calcPrice()导致事务失效这个问题是 Spring 里非常经典的坑模型准确指出了文件、行号和原因。我回头核对源码行号 88 确实是那个内部调用误差在 2 行以内。再看 Controller 层的解析。模型识别出 UserController 直接返回实体类password 字段会泄露。这个判断是对的我那个 demo 里确实偷懒没做 VO 转换。这说明模型不只是做了语法层面的总结它理解了「Controller 返回实体」这个模式在安全上的含义。Service 层的验证更细一点。我特意在 OrderService 里埋了一个循环依赖OrderService 注入 UserServiceUserService 又反过来注入 OrderService。摘要模式下模型没有直接报出循环依赖但它在 suggestions 里提了一句「user 与 order 模块存在双向引用建议抽取共享逻辑到 common 层」。这说明摘要模式丢失了部分细节但方向是对的。切到 full 模式再跑一次模型明确指出了循环依赖的具体两个类。全量模式跑 40 个文件的项目token 消耗大概 3 万耗时 8 秒左右。如果换成 Spring Cloud Gateway 那种上千文件的项目全量模式 token 会到 80 万以上耗时接近 1 分钟成本按 DeepSeek V4 的定价算大概 1 元出头。所以日常用摘要模式做初筛锁定可疑模块后再全量精查是性价比最高的用法。验证完记得把返回的 JSON 存下来跟下次改动后的报告做 diff能看出代码质量是在变好还是变差。5. 常见报错排查401、local proxy failed 与 JSON 解析失败这一节把实际会撞到的坑列全每个都给现象、原因、解法。401 Unauthorized / invalid api key现象是请求返回 401日志里能看到401 Unauthorized from POST https://taotoken.net/api/v1/chat/completions。原因通常是三种环境变量没设、Key 复制时带了空格、或者 base-url 写错导致请求发到了别的地址。排查顺序先在终端echo $DEEPSEEK_API_KEY确认变量有值再检查 application.yml 里api-key的占位符名字和实际环境变量名是否一致最后确认 base-url 是https://taotoken.net/api注意不要手滑写成https://taotoken.net/api/v1Spring AI 会自己拼/v1你多写一层就变成/v1/v1了。local proxy failed / connection refused这个报错一般出现在你本地配了 HTTP 代理但代理没启动或者端口不对。Spring AI 底层用 Java 的 HttpClient会读http_proxy、https_proxy这些环境变量。如果你之前为了别的用途设过代理现在代理关了请求就会失败。解法是临时清掉代理变量unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY然后重启应用。注意这里说的是清理本地环境变量不是让你去配什么网络工具纯粹是避免残留配置干扰。reading choices / Cannot deserialize value of type这个报错来自 Jackson说明模型返回的内容不是合法 JSONobjectMapper.readValue解析失败。常见原因是模型在 JSON 前后加了说明文字或者 JSON 里有未转义的特殊字符。前面extractJson方法已经处理了前后文字的问题。如果还报错把原始响应打出来看log.debug(原始响应: {}, response);大概率是某个字段里包含了换行符没转义。可以在 system prompt 里补一句「字符串值内不要包含换行符」或者用 Jackson 的JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS放宽解析。OAuth / token expired如果你用的是需要 OAuth 流程的接入方式可能会遇到 token 过期。TaoToken 的 API Key 方式是长期有效的不存在这个问题。如果你在别处配了 OAuth检查 refresh token 逻辑。用 API Key 的话这个报错基本不会出现。请求超时 / Read timed out全量模式分析大项目时模型响应可能超过默认超时时间。Spring AI 默认超时较短需要在配置里调spring: ai: openai: chat: options: model: deepseek-v4-pro超时时间通过底层 HttpClient 配置或者在OpenAiChatOptions里设置。更简单的做法是控制单次请求的源码量超过 50 万 token 就分批分析别硬塞。model not foundModel ID 写错了。确认你填的是deepseek-v4-pro或deepseek-v4-flash跟平台文档完全一致。有些平台模型名带日期后缀比如deepseek-v4-pro-20260101这种也要照抄。把上面这几类报错对照着排查基本能覆盖 90% 的启动和调用问题。剩下的边角情况看日志里的堆栈定位到具体是哪一层抛的异常再针对性处理。6. 把源码分析助手接进日常代码审查工具跑通只是第一步真正有价值的是把它变成日常流程的一部分。我现在的用法是每次提 PR 之前先对改动涉及的模块跑一次摘要分析把报告里的 securityRisks 和 potentialBugs 过一遍确认没有新增问题再提交。这比等 CI 跑 SonarQube 快得多而且模型能发现一些静态扫描规则覆盖不到的语义问题比如前面那个事务失效。如果你想把这个能力再往前推一步有两个方向。一是把分析结果落库按项目、按时间存起来做一个代码质量趋势看板每次分析跟上次对比新增的风险点高亮出来。二是结合 Coding Plan 做自动化把源码分析接到 CI 流程里PR 创建时自动触发分析报告直接评论到 PR 上。模型调用这块如果你需要频繁切换模型做对比或者团队里多人共用用 TaoToken 的统一入口会比每个模型单独配一套 Key 省心。API Key 在控制台创建接入文档里有各语言的调用示例模型对话页面可以直接试 prompt 效果不用写代码就能验证输出格式。最后留一个实用技巧摘要模式的extractSummary方法可以按需调整。如果你特别关注某个包比如service层可以在扫描时给这个路径下的文件走全量、其他走摘要这样既控制 token 又保证重点模块的分析深度。这个改动只需要在buildPrompt里加一个路径判断几行代码的事。