以前做Code Review有一个根本性的局限:上下文窗口装不下整个仓库。
你只能把单个文件或几个相关文件喂给模型,让它孤立地看,它看不到调用关系,看不到接口约定,看不到历史变更的逻辑。给出的建议经常是"这个方法可以提取成工具类"——但它不知道你们项目里已经有一个类似的工具类了,只是在另一个包里。
DeepSeek V4支持1M token上下文,成本降到了V3.2的10%~27%。这件事让"把整个代码仓库放进上下文"从"烧钱的实验"变成了可以日常用的工程实践。
这篇文章就来做这件事:用Spring AI接入DeepSeek V4,构建一个能读整个仓库的Code Review Agent,把不同粒度的Review需求都支持到。
项目不大,核心代码200行左右。
项目结构和整体思路
先说清楚这个Agent要做什么:
递归读取指定目录下的所有代码文件 过滤掉不需要Review的文件(构建产物、配置文件等) 把代码内容组装成结构化的上下文 根据Review类型(全量/单文件/安全/性能)生成针对性的Prompt 调用DeepSeek V4,返回流式Review结果
整体架构很简单,没有向量数据库,没有RAG,就是直接把代码塞进1M上下文。这正是V4解锁的能力——小型到中型项目(10万行以内)完全可以全量送进去。
第一步:依赖和配置
pom.xml里加Spring AI的OpenAI starter(DeepSeek兼容OpenAI协议):
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
<version>1.1.4</version>
</dependency>
application.yml配置DeepSeek V4:
spring:
ai:
openai:
api-key:${DEEPSEEK_API_KEY}
base-url:https://api.deepseek.com
chat:
options:
model:deepseek-v4-flash# 日常Review用Flash,性价比高
temperature:0.3# Review场景要确定性,温度调低
max-tokens:8192
embedding:
enabled:false# DeepSeek不支持嵌入,关掉避免启动报错
为什么选V4-Flash而不是V4-Pro?Code Review不需要最强的推理能力,Flash在代码理解和审查上已经够用,而且便宜得多。复杂的架构分析再换Pro。
第二步:代码仓库读取器
这是整个项目最核心的基础设施,负责把代码文件转换成模型能理解的结构化文本。
@Component
publicclassRepositoryReader{
// 需要Review的文件扩展名
privatestaticfinal Set<String> SUPPORTED_EXTENSIONS = Set.of(
".java", ".kt", ".xml", ".yml", ".yaml", ".properties", ".sql", ".md"
);
// 排除目录:构建产物、依赖、IDE配置
privatestaticfinal Set<String> EXCLUDED_DIRS = Set.of(
"target", "build", ".git", ".idea", "node_modules", ".mvn", "__pycache__"
);
/**
* 读取整个仓库,返回结构化的代码上下文
* @param repoPath 仓库根目录
* @param maxTokenEstimate 预估最大token数,防止超出上下文限制
*/
public RepositoryContext readRepository(String repoPath, int maxTokenEstimate)throws IOException {
Path rootPath = Path.of(repoPath);
if (!Files.exists(rootPath)) {
thrownew IllegalArgumentException("目录不存在: " + repoPath);
}
List<CodeFile> codeFiles = new ArrayList<>();
int[] totalChars = {0};
Files.walkFileTree(rootPath, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs){
String dirName = dir.getFileName().toString();
if (EXCLUDED_DIRS.contains(dirName)) {
return FileVisitResult.SKIP_SUBTREE;
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)throws IOException {
String fileName = file.getFileName().toString();
String ext = getExtension(fileName);
if (!SUPPORTED_EXTENSIONS.contains(ext)) {
return FileVisitResult.CONTINUE;
}
// 粗略估算token:1个token约4个字符
// 超过预估上限就停止读取,防止超出V4的1M限制
if (totalChars[0] > maxTokenEstimate * 4) {
return FileVisitResult.TERMINATE;
}
String content = Files.readString(file, StandardCharsets.UTF_8);
String relativePath = rootPath.relativize(file).toString();
codeFiles.add(new CodeFile(relativePath, content, ext));
totalChars[0] += content.length();
return FileVisitResult.CONTINUE;
}
});
returnnew RepositoryContext(repoPath, codeFiles, totalChars[0]);
}
/**
* 把代码文件列表组装成模型能读懂的结构化文本
*/
public String buildContextText(RepositoryContext context){
StringBuilder sb = new StringBuilder();
sb.append("# 代码仓库概览\n");
sb.append("根目录: ").append(context.repoPath()).append("\n");
sb.append("文件总数: ").append(context.files().size()).append("\n");
sb.append("预估字符数: ").append(context.totalChars()).append("\n\n");
// 先列出文件目录,让模型有整体结构感
sb.append("## 文件列表\n");
context.files().forEach(f -> sb.append("- ").append(f.relativePath()).append("\n"));
sb.append("\n");
// 再逐文件输出内容
sb.append("## 文件内容\n");
context.files().forEach(f -> {
sb.append("\n### ").append(f.relativePath()).append("\n");
sb.append("```").append(getLanguageName(f.extension())).append("\n");
sb.append(f.content()).append("\n");
sb.append("```\n");
});
return sb.toString();
}
private String getExtension(String fileName){
int dotIndex = fileName.lastIndexOf('.');
return dotIndex >= 0 ? fileName.substring(dotIndex) : "";
}
private String getLanguageName(String ext){
returnswitch (ext) {
case".java" -> "java";
case".kt" -> "kotlin";
case".xml" -> "xml";
case".yml", ".yaml" -> "yaml";
case".sql" -> "sql";
default -> "";
};
}
// Records
public record CodeFile(String relativePath, String content, String extension){}
public record RepositoryContext(String repoPath, List<CodeFile> files, int totalChars){}
}
有几个细节值得说一下:
先输出文件目录列表,再逐文件展开内容。这个顺序很重要——模型先有整体的结构印象,再看细节,Review质量明显更好,不会出现"我注意到这里有个utils包"但其实它已经扫描过utils目录的情况。
token估算是粗略的,1个token约4个字符,V4的1M上下文换算过来大概是400万字符。超出就停止读取,不会因为个别超大仓库把请求搞崩。
第三步:Review类型和Prompt设计
不同的Review场景需要不同的Prompt,不能一个Prompt包打天下。这里定义四种:
publicenum ReviewType {
FULL_REVIEW("全量Review") {
@Override
public String buildSystemPrompt(){
return"""
你是一位有10年经验的Java架构师,正在对一个完整的代码仓库进行Code Review。
请从以下维度进行系统性评审:
1. 架构设计:层次划分是否清晰,模块职责是否单一,依赖方向是否合理
2. 代码质量:命名规范,方法长度,圈复杂度,重复代码
3. 潜在Bug:空指针风险,并发问题,资源未关闭,异常处理遗漏
4. 性能隐患:N+1查询,不必要的循环,缓存使用不当
5. 安全风险:SQL注入,未校验的外部输入,敏感信息硬编码
输出格式:
- 先给出整体评价(3-5句话)
- 按严重程度分级列出问题:🔴 严重 / 🟡 建议 / 🔵 优化
- 每个问题注明文件路径和行数(如果能确定的话)
- 每个问题给出具体的改进建议或示例代码
不要泛泛而谈,每个问题都要定位到具体代码。
""";
}
},
SECURITY_REVIEW("安全Review") {
@Override
public String buildSystemPrompt(){
return"""
你是一位专注Java安全的高级工程师,请对代码进行专项安全审查。
重点关注:
- SQL注入:JDBC拼接SQL,MyBatis中${}的使用
- XSS漏洞:未转义的用户输入直接输出到响应
- 越权访问:接口缺少权限校验,水平权限控制不足
- 敏感信息:硬编码的密码、Token、密钥
- 不安全的反序列化:ObjectInputStream,fastjson autoType
- 不安全的文件操作:路径穿越,任意文件上传
- 依赖漏洞:pom.xml中已知存在漏洞的依赖版本
对每个安全问题:
1. 说明漏洞类型和危害程度(高危/中危/低危)
2. 定位到具体文件和代码行
3. 给出修复后的代码示例
""";
}
},
PERFORMANCE_REVIEW("性能Review") {
@Override
public String buildSystemPrompt(){
return"""
你是一位专注Java性能优化的高级工程师,请对代码进行专项性能审查。
重点关注:
- 数据库访问:N+1查询,缺少索引提示,大事务,全表扫描
- 内存使用:大对象频繁创建,集合容量未预设,内存泄漏风险
- 并发问题:锁粒度过大,无谓的同步,线程池配置不合理
- 缓存使用:缓存击穿/穿透/雪崩风险,缓存粒度,过期时间
- 算法复杂度:循环嵌套,低效的数据结构选择
对每个性能问题,量化影响(如果能估算的话),并给出优化后的代码。
""";
}
},
SINGLE_FILE_REVIEW("单文件Review") {
@Override
public String buildSystemPrompt(){
return"""
你是一位经验丰富的Java工程师,正在Review一个代码文件。
请结合整个代码仓库的上下文,重点分析这个文件:
- 这个类的职责是否清晰,和其他模块的协作是否合理
- 代码逻辑是否正确,边界条件是否处理完整
- 是否有可以复用已有代码的地方(结合仓库中其他文件)
- 具体的改进建议,附示例代码
""";
}
};
privatefinal String displayName;
ReviewType(String displayName) { this.displayName = displayName; }
public String getDisplayName(){ return displayName; }
publicabstract String buildSystemPrompt();
}
每种ReviewType有自己独立的System Prompt,不要在一个巨型Prompt里塞所有维度——那样模型会平均用力,每个维度都浅尝辄止。专注一个维度,输出质量会高得多。
第四步:Code Review Agent主体
@Service
publicclassCodeReviewAgent{
privatefinal ChatClient chatClient;
privatefinal RepositoryReader repositoryReader;
// V4的1M上下文,预留20万token给模型的输出和系统提示,
// 剩余80万token给代码内容,换算成字符约320万
privatestaticfinalint MAX_TOKEN_ESTIMATE = 800_000;
publicCodeReviewAgent(ChatClient.Builder builder, RepositoryReader repositoryReader){
this.chatClient = builder
.defaultOptions(ChatOptions.builder()
.temperature(0.3)
.build())
.build();
this.repositoryReader = repositoryReader;
}
/**
* 对整个仓库进行Review,流式返回结果
*/
public Flux<String> reviewRepository(String repoPath, ReviewType reviewType)throws IOException {
// 读取仓库
RepositoryReader.RepositoryContext context =
repositoryReader.readRepository(repoPath, MAX_TOKEN_ESTIMATE);
// 组装代码上下文文本
String codeContext = repositoryReader.buildContextText(context);
String userPrompt = String.format("""
请对以下代码仓库进行%s。
%s
""", reviewType.getDisplayName(), codeContext);
return chatClient.prompt()
.system(reviewType.buildSystemPrompt())
.user(userPrompt)
.options(ChatOptions.builder()
.model("deepseek-v4-flash")
.build())
.stream()
.content();
}
/**
* 对单个文件进行Review,但携带整个仓库的上下文
* 这是V4长上下文真正发挥价值的地方:
* 模型能看到目标文件和整个仓库的关系
*/
public Flux<String> reviewSingleFile(String repoPath, String targetFile)throws IOException {
// 读取整个仓库(作为背景上下文)
RepositoryReader.RepositoryContext context =
repositoryReader.readRepository(repoPath, MAX_TOKEN_ESTIMATE);
String codeContext = repositoryReader.buildContextText(context);
// 单独读取目标文件内容
String targetContent = Files.readString(Path.of(repoPath, targetFile));
String userPrompt = String.format("""
我需要你重点Review这个文件:%s
文件内容:
```java
%s
```
以下是整个代码仓库的上下文,帮助你理解这个文件的位置和依赖关系:
%s
""", targetFile, targetContent, codeContext);
return chatClient.prompt()
.system(ReviewType.SINGLE_FILE_REVIEW.buildSystemPrompt())
.user(userPrompt)
.options(ChatOptions.builder()
.model("deepseek-v4-pro") // 单文件Review用Pro,质量更高
.build())
.stream()
.content();
}
/**
* 针对性提问:带着问题来Review
* 比如"这个项目的事务管理有没有问题"
*/
public Flux<String> askAboutCode(String repoPath, String question)throws IOException {
RepositoryReader.RepositoryContext context =
repositoryReader.readRepository(repoPath, MAX_TOKEN_ESTIMATE);
String codeContext = repositoryReader.buildContextText(context);
String userPrompt = String.format("""
关于这个代码仓库,我有一个具体问题:
问题:%s
代码仓库内容:
%s
""", question, codeContext);
return chatClient.prompt()
.system("你是一位资深Java工程师,请基于提供的代码仓库内容,准确回答开发者的问题。回答要具体,定位到代码,给出可操作的建议。")
.user(userPrompt)
.stream()
.content();
}
}
这里有几个设计决策说一下。
reviewRepository和reviewSingleFile用了不同的模型:全量Review用V4-Flash,单文件Review用V4-Pro。逻辑是——全量Review面对整个仓库,问题多但不需要每个都深挖,Flash够用;单文件Review是聚焦分析,模型要对一个类的逻辑做深度推理,这时候Pro的质量差异就体现出来了,值得多花一点钱。
reviewSingleFile里有一个细节:目标文件的内容被单独提取出来,放在Prompt的最前面,整个仓库的内容放在后面作为背景。不是随手的写法——模型对Prompt开头的内容注意力更集中,先把重点文件放前面,再铺背景,比把目标文件埋在几十个文件中间Review质量要好。
askAboutCode是三个方法里最灵活的,也是实际用下来最顺手的一个。"这个项目的分布式锁用对了吗"、"有没有可能触发OOM的地方"、"登录鉴权这块逻辑有没有漏洞"——带着具体问题去Review,比漫无目的的全量扫描效率高得多,模型的输出也更聚焦,不会洋洋洒洒写一堆你不关心的东西。
第五步:对外暴露接口
@RestController
@RequestMapping("/review")
publicclassCodeReviewController{
privatefinal CodeReviewAgent codeReviewAgent;
publicCodeReviewController(CodeReviewAgent codeReviewAgent){
this.codeReviewAgent = codeReviewAgent;
}
/**
* 全量仓库Review,流式返回
*/
@GetMapping(value = "/repository", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> reviewRepository(
@RequestParam String repoPath,
@RequestParam(defaultValue = "FULL_REVIEW") ReviewType reviewType) throws IOException {
return codeReviewAgent.reviewRepository(repoPath, reviewType);
}
/**
* 单文件Review(携带仓库上下文)
*/
@GetMapping(value = "/file", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> reviewFile(
@RequestParam String repoPath,
@RequestParam String targetFile)throws IOException {
return codeReviewAgent.reviewSingleFile(repoPath, targetFile);
}
/**
* 针对性提问
*/
@GetMapping(value = "/ask", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> askAboutCode(
@RequestParam String repoPath,
@RequestParam String question)throws IOException {
return codeReviewAgent.askAboutCode(repoPath, question);
}
}
跑起来测一下
启动项目,对一个Spring Boot项目做安全Review:
curl "http://localhost:8080/review/repository?\
repoPath=/path/to/your/project&reviewType=SECURITY_REVIEW"
针对性提问:
curl "http://localhost:8080/review/ask?\
repoPath=/path/to/your/project&\
question=这个项目的数据库事务管理有没有潜在问题"
几个值得注意的细节
关于文件读取的顺序
walkFileTree的遍历顺序会影响模型理解代码的效果。建议按照"配置文件 → 接口定义 → 实现类 → 测试文件"的顺序排列,让模型先看到架构全貌,再看实现细节。可以在readRepository里对codeFiles按扩展名和路径深度做一次排序。
关于大仓库
对于超过200个Java文件的大型项目,即使V4有1M上下文,一次性全量送进去Review质量也会下降——模型注意力的有效范围毕竟有限制。更好的做法是按模块拆分,分批Review,最后汇总。这个Agent作为单模块Review已经非常够用了。
关于费用
每次全量Review请求,Token消耗大概在10万到50万之间,取决于项目规模。V4-Flash的定价比V3便宜很多,实际成本完全可接受,但不建议在CI/CD里对每次commit都触发全量Review,那会消耗过多。更合理的用法是每次PR合并前触发一次,或者开发者手动触发。
这个项目能做的和做不到的
能做的:在整个仓库上下文里定位问题,发现跨文件的设计缺陷,给出带具体代码路径的建议,比传统只看单文件的AI Review深入得多。
做不到的:运行时行为分析,性能Profiling,依赖漏洞扫描(这些还是要专业工具来做)。这个Agent的定位是"有经验的同事帮你通读代码提建议",不是替代SonarQube或DAST工具。
两者配合着用,效果比单独用任何一个都好。
完整代码结构:RepositoryReader + ReviewType + CodeReviewAgent + CodeReviewController,四个文件,启动就能用。
说一个用下来的真实感受。
以前我做Code Review,最容易漏掉的是跨文件的问题——一个Service里的写法没问题,一个Repository里的写法也没问题,但两者叠在一起会有N+1查询,或者事务边界没对齐。这类问题在只看单文件的时候根本发现不了,Review完了还是有Bug上线。
V4的1M上下文把这个问题从根上解决了。它能同时"看着"整个项目,知道这个方法的调用链,知道那个接口的实现在哪,Review出来的问题质量明显不一样。
不是说有了这个就不需要人工Review了。人工Review有它不可替代的地方——业务逻辑对不对、产品需求理解有没有偏差、代码风格是否符合团队约定,这些AI还是很难判断。但那些纯技术层面的问题:潜在Bug、安全漏洞、性能隐患、违反设计原则的写法——用这个Agent先过一遍,再做人工Review,效率提升是实实在在的。
如果要在现有基础上继续扩展,最值得做的下一步是:Review结果持久化,按严重程度过滤,生成Markdown报告直接提交到PR评论区。CI/CD里接个Webhook,PR创建时自动触发一次安全Review,不需要人主动去跑。这些都是小改动,主体结构不用动。
代码完整可运行,拉下来改一下API Key和仓库路径就能跑。遇到问题欢迎留言。
最后宣传一下我的AI Agent一体化平台项目实战教程,有Go和Java两个版本,长期更新项目,持续添加最新的AI技术,通过这个项目已经有上百位成功转型AI Agent开发领域,薪资翻倍。有意向可以加我mszlu521了解。 如果没有渠道使用国外最好的大模型,可以试试这个mytoken.top
夜雨聆风