一、前言
前面三个阶段完成了代码审查服务的确定性输入基础。
第一阶段完成:
- FastAPI 项目骨架;
- Pydantic 请求与响应模型;
- 配置和执行预算;
- pytest 与 Ruff;
- LangGraph 依赖准备。
第二阶段完成:
- 仓库允许根目录;
- 规范路径校验;
- 路径穿越防护;
- 仓库与文件符号链接逃逸防护;
- 结构化路径错误响应。
第三阶段完成:
- 安全 Git 子进程;
- Git 仓库与 Ref 校验;
- 工作区和提交区间 Diff;
- 未跟踪文件采集;
- Unified Diff 解析;
- 修改、新增、删除、重命名和二进制文件识别;
ChangedFile与DiffHunk结构化输出。
第三阶段结束后,接口已经可以返回真实变化:
text
Git Repository
↓
GitClient
↓
UnifiedDiffParser
↓
ChangedFile[]
但是,ChangedFile[] 不能直接全部交给 LLM。
一个真实仓库的变更可能包含:
text
业务源代码
单元测试
配置文件
依赖 Lock 文件
node_modules 或 vendor 内容
构建产物
缓存目录
生成代码
压缩后的 JavaScript
Source Map
图片和其他二进制文件
因为大小限制而没有读取正文的文件
单文件数千行的超大 Diff
如果把这些内容不加区分地发送给模型,会产生三个直接问题:
- 无意义文件占用上下文和调用成本;
- 相关源代码与测试可能被拆到不同请求,造成上下文割裂;
- 一个超大文件或文件组可能突破模型输入预算。
因此,第四阶段实现两个确定性模块:
text
FileFilter
决定哪些文件不进入模型,并记录原因
PackageBuilder
将可以分析的相关文件组成大小受限的 AnalysisPackage
完整流程升级为:
text
Git Diff
↓
ChangedFile[]
↓
FileFilter
├─ included_files
└─ skipped_files
↓
PackageBuilder
↓
AnalysisPackage[]
↓
未来的 LangGraph 和 LLM 分析节点
本阶段仍然不调用 LLM,也不会产生真正的审查 Finding。
二、本篇目标与非目标
2.1 本篇目标
本篇需要实现:
- 定义结构化文件跳过原因;
- 记录每个被过滤文件的路径、原因和说明;
- 排除正文被省略的超大文件;
- 排除二进制文件;
- 排除依赖、缓存和构建目录;
- 排除常见依赖 Lock 文件;
- 排除生成文件、压缩文件和 Source Map;
- 使用文件类型白名单控制可分析文件;
- 支持 Dockerfile、Makefile 等无扩展名文件;
- 固定过滤优先级;
- 为可分析文件计算变更行数;
- 估算每个文件的输入 Token;
- 根据目录与文件名生成模块关联键;
- 将常见源码与测试文件分到同一关联组;
- 限制每个 Package 的文件数量;
- 限制每个 Package 的变更行数;
- 限制每个 Package 的估算 Token;
- 处理单文件本身超过预算的情况;
- 生成确定性的 Package ID 和分包顺序;
- 将过滤和分包接入 Review API;
- 返回
skipped_files和packages; - 使用自动化测试覆盖边界。
2.2 本篇暂时不做什么
本篇不会实现:
- LangGraph
StateGraph; - LLM Provider;
- Prompt 构建;
- Agent 工具调用;
read_file、read_diff、find_files、search_code;- Finding 生成;
- Finding 行号定位;
- 重复 Finding 过滤;
- Markdown 最终报告;
- GitHub PR 评论。
本篇只负责把"仓库发生了什么变化"转换成"哪些内容适合进入模型,以及应该分成几组"。
三、为什么不能逐文件调用 LLM
最直接的实现方式是:
python
for changed_file in changed_files:
result = llm.invoke(changed_file.diff)
这种方式代码很少,但会引入三个问题。
3.1 调用次数随文件数线性增长
假设一次变更包含 30 个文件:
text
30 个文件
↓
30 次模型请求
其中很多文件可能只有几行变化,单独调用会重复发送系统 Prompt、项目规则和输出格式说明。
3.2 相关文件上下文被拆开
例如:
text
src/auth/user.py
tests/auth/test_user.py
如果分成两次调用:
- 分析源码时看不到测试覆盖;
- 分析测试时看不到实现变化;
- 模型难以判断行为变化是否被测试验证。
3.3 跨文件契约无法比较
一次改动可能同时修改:
text
API Schema
Service
Repository
Unit Test
Configuration
逐文件审查容易只发现局部语法问题,无法检查接口字段、调用参数和异常行为是否在多个文件之间保持一致。
因此,本项目的模型调用单位不是"一个文件",而是:
text
AnalysisPackage
一个 Package 可以包含一组相关变化,但必须受到明确预算限制。
四、为什么也不能把所有文件放进一个 Package
另一个极端是:
text
所有 ChangedFile
↓
一个超大 Prompt
↓
一次 LLM 调用
这会带来:
- 输入可能超过模型上下文;
- 大量无关模块互相干扰;
- 模型注意力被稀释;
- 某个超大文件拖累整次请求;
- 失败后只能整体重试;
- 无法按 Package 记录耗时和 Token。
合理的方式是:
text
先过滤
↓
再按模块关联
↓
最后按预算切分
也就是在调用次数和上下文完整性之间取得平衡。
五、本篇代码变化总览
第四阶段新增和修改的文件如下:
text
langgraph-code-review-agent
├── src
│ └── code_review_agent
│ ├── analysis
│ │ ├── __init__.py
│ │ ├── file_filter.py
│ │ └── package_builder.py
│ ├── services
│ │ ├── __init__.py
│ │ └── review_input_preparer.py
│ ├── api
│ │ └── routes.py
│ ├── config.py
│ └── schemas.py
├── tests
│ ├── test_file_filter.py
│ ├── test_package_builder.py
│ ├── test_review_input_preparer.py
│ ├── test_review_route.py
│ └── test_schemas.py
├── .env.example
└── README.md
模块职责:
| 文件 | 职责 |
|---|---|
file_filter.py |
判断文件是否适合文本模型分析 |
package_builder.py |
关联、估算并切分分析包 |
review_input_preparer.py |
串联 Git 采集、过滤和分包 |
schemas.py |
定义跳过原因与响应合同 |
routes.py |
返回过滤结果和 Package 计划 |
config.py |
定义 Package 预算 |
依赖方向:
text
api/routes.py
↓
ReviewInputPreparer
├─→ GitChangeCollector
├─→ FileFilter
└─→ PackageBuilder
↓
AnalysisPackage
六、增加 Package Token 预算
第三阶段已有两个分包配置:
python
max_files_per_package: int = Field(default=8, gt=0)
max_changed_lines_per_package: int = Field(default=500, gt=0)
第四阶段增加:
python
max_estimated_tokens_per_package: int = Field(
default=6_000,
gt=0,
)
.env.example 对应:
text
MAX_FILES_PER_PACKAGE=8
MAX_CHANGED_LINES_PER_PACKAGE=500
MAX_ESTIMATED_TOKENS_PER_PACKAGE=6000
三个预算分别控制:
| 配置 | 默认值 | 作用 |
|---|---|---|
MAX_FILES_PER_PACKAGE |
8 | 防止一个 Package 包含过多文件 |
MAX_CHANGED_LINES_PER_PACKAGE |
500 | 防止变化范围过大 |
MAX_ESTIMATED_TOKENS_PER_PACKAGE |
6000 | 控制预计模型输入体积 |
为什么同时需要三种预算?
text
文件数少,不代表内容少
变更行数少,不代表每行字符少
Token 少,也不代表文件关系足够聚焦
例如一个只有两行变化的压缩 JavaScript 文件,字符数可能非常大;一个 Package 也可能包含很多只有一行变化的小文件。
三种预算从不同维度约束输入。
七、定义结构化跳过原因 SkipReason
文件:
text
src/code_review_agent/schemas.py
新增枚举:
python
class SkipReason(StrEnum):
"""表示变更文件未进入分析包的确定性原因。"""
binary_file = "binary_file"
content_omitted = "content_omitted"
excluded_directory = "excluded_directory"
dependency_lock_file = "dependency_lock_file"
generated_file = "generated_file"
unsupported_file_type = "unsupported_file_type"
package_budget_exceeded = "package_budget_exceeded"
每个值都对应一个明确决策:
| 原因 | 含义 |
|---|---|
binary_file |
文件不是普通文本 |
content_omitted |
文件正文因大小限制未加载 |
excluded_directory |
位于依赖、缓存或构建目录 |
dependency_lock_file |
常见依赖锁定文件 |
generated_file |
生成、压缩或映射文件 |
unsupported_file_type |
不在支持文件白名单中 |
package_budget_exceeded |
单文件无法装入任何合法 Package |
为什么使用枚举而不是一段自由文本?
后续可以稳定统计:
text
本次跳过了多少二进制文件?
多少文件因为目录规则跳过?
多少文件因为预算过大?
过滤规则是否过于激进?
如果只有自由文本,很难可靠聚合。
八、定义 SkippedFile
新增模型:
python
class SkippedFile(StrictModel):
"""表示未进入模型分析包的文件及其原因。"""
path: str
reason: SkipReason
detail: str = Field(min_length=1, max_length=500)
@field_validator("path")
@classmethod
def validate_path(cls, value: str) -> str:
return _validate_relative_repo_path(value)
示例:
json
{
"path": "package-lock.json",
"reason": "dependency_lock_file",
"detail": "Dependency lock files are excluded from model analysis."
}
这个模型有三层意义。
8.1 文件没有从事实记录中消失
被过滤文件仍然存在于:
text
changed_files
同时出现在:
text
skipped_files
它只是不进入:
text
packages
8.2 调用方可以解释结果
调用方可以明确知道:
text
文件发生了变化
但由于什么原因没有被模型分析
8.3 路径继续受到数据模型约束
SkippedFile.path 复用了仓库相对路径校验,不能使用:
text
../large.py
C:\secret.txt
/etc/passwd
即使文件被跳过,输出合同也不能包含越界路径。
九、三个列表的语义必须分开
第四阶段的响应包含:
text
changed_files
skipped_files
packages
它们不是重复字段。
9.1 changed_files
表示 Git 层观察到的完整变化事实。
9.2 skipped_files
表示没有进入模型分析包的文件及原因。
9.3 packages
表示未来允许送入模型的分组计划。
关系可以表示为:
text
changed_files
├─ eligible files ──→ packages
└─ excluded files ──→ skipped_files
注意,package_budget_exceeded 文件最初通过了文件类型过滤,但在分包阶段因为单文件过大进入 skipped_files。
十、设计 FileFilterResult
文件:
text
src/code_review_agent/analysis/file_filter.py
结果类型:
python
@dataclass(frozen=True, slots=True)
class FileFilterResult:
"""Eligible changes and explicit reasons for every excluded file."""
included_files: tuple[ChangedFile, ...]
skipped_files: tuple[SkippedFile, ...]
为什么不只返回 list[ChangedFile]?
只返回可分析文件会丢失过滤原因。调用方只能看到文件不见了,却不知道是:
- 二进制;
- 超大;
- 不支持;
- 生成文件;
- 依赖目录。
返回两个集合后,过滤器的每个决策都可以测试和展示。
使用 frozen=True 和元组,表示结果生成后不应被下游原地修改。
十一、过滤规则总体设计
FileFilter 使用保守白名单策略:
text
先排除明确不适合的文件
↓
再判断文件类型是否在允许列表
核心判断顺序:
text
content_omitted
↓
is_binary
↓
excluded directory
↓
lock file
↓
generated pattern
↓
supported extension or filename
任何文件只记录第一个命中的主要原因。
这样可以避免一个文件同时出现多个互相竞争的解释。
十二、第一优先级:正文被省略
判断代码:
python
if changed_file.content_omitted:
return self._skipped(
changed_file,
SkipReason.content_omitted,
"File content was omitted by the configured file-size limit.",
)
第三阶段中,未跟踪文件超过:
text
MAX_FILE_BYTES
时不会读取完整内容,只保留:
python
content_omitted=True
没有正文时,即使扩展名是 .py,也不能构造有效模型输入。
因此这个原因优先于其他规则。
例如一个超大二进制文件同时满足:
text
content_omitted = true
is_binary = true
项目记录主要原因:
text
content_omitted
因为在当前采集流程中,最先阻止分析的是内容没有被加载。
十三、第二优先级:二进制文件
判断代码:
python
if changed_file.is_binary:
return self._skipped(
changed_file,
SkipReason.binary_file,
"Binary files are not eligible for text-model analysis.",
)
二进制文件仍然是有效 Git 变化,例如:
text
assets/logo.png
fixtures/archive.zip
models/model.bin
但是本项目的审查模型处理文本代码,不应该将二进制数据拆成普通行。
所以二进制文件:
text
保留在 changed_files
记录到 skipped_files
不进入 AnalysisPackage
十四、排除依赖、缓存和构建目录
目录集合:
python
_EXCLUDED_DIRECTORIES = frozenset(
{
".cache",
".git",
".hg",
".mypy_cache",
".next",
".nuxt",
".pytest_cache",
".ruff_cache",
".svn",
".tox",
".venv",
"__pycache__",
"bin",
"build",
"coverage",
"dist",
"env",
"node_modules",
"obj",
"target",
"vendor",
"venv",
}
)
路径判断:
python
path = PurePosixPath(changed_file.path)
path_parts = {part.lower() for part in path.parts[:-1]}
if path_parts & self._EXCLUDED_DIRECTORIES:
return self._skipped(
changed_file,
SkipReason.excluded_directory,
"File is located in an excluded dependency, cache, or build directory.",
)
使用集合交集表示:
text
路径任意目录段
与
排除目录集合
存在交集
例如:
text
frontend/node_modules/library/index.js
目录段为:
text
frontend
node_modules
library
命中 node_modules 后被排除。
判断统一转成小写,避免 Windows 大小写差异导致:
text
Node_Modules
NODE_MODULES
node_modules
出现不同结果。
十五、排除常见依赖 Lock 文件
Lock 文件集合:
python
_LOCK_FILES = frozenset(
{
"bun.lockb",
"cargo.lock",
"composer.lock",
"gemfile.lock",
"go.sum",
"npm-shrinkwrap.json",
"package-lock.json",
"pdm.lock",
"pipfile.lock",
"pnpm-lock.yaml",
"poetry.lock",
"uv.lock",
"yarn.lock",
}
)
判断代码:
python
if filename in self._LOCK_FILES:
return self._skipped(
changed_file,
SkipReason.dependency_lock_file,
"Dependency lock files are excluded from model analysis.",
)
为什么默认排除 Lock 文件?
- 内容通常由包管理器生成;
- Diff 可能很大;
- 大量哈希和版本元数据不适合行级代码审查;
- 容易消耗上下文而产生低价值评论。
这不表示依赖变化不重要。
依赖安全扫描属于另一类确定性或专用安全工具。本项目当前只是不把 Lock 文件正文发送给通用代码审查模型。
同时,文件仍然保留在 changed_files 中,后续可以增加独立依赖风险节点。
十六、排除生成文件和压缩文件
模式集合:
python
_GENERATED_PATTERNS = (
"*.designer.cs",
"*.g.dart",
"*.generated.*",
"*.map",
"*.min.css",
"*.min.js",
"*_pb2.py",
"*_pb2_grpc.py",
"zz_generated.*",
)
匹配代码:
python
if any(
fnmatch(filename, pattern)
for pattern in self._GENERATED_PATTERNS
):
return self._skipped(
changed_file,
SkipReason.generated_file,
"Generated or minified files are excluded from model analysis.",
)
典型文件:
text
app.min.js
style.min.css
app.js.map
user_pb2.py
user_pb2_grpc.py
model.g.dart
Form.designer.cs
zz_generated.deepcopy.go
生成文件通常应该从源模板、Schema 或生成器配置处审查,而不是直接评论输出文件。
当前规则是显式模式集合,不会因为文件名中偶然包含 generated 就排除所有内容。
十七、使用支持文件类型白名单
过滤器定义 _SUPPORTED_EXTENSIONS,覆盖常见:
- Python;
- JavaScript 与 TypeScript;
- Java、Kotlin、Scala;
- Go、Rust;
- C、C++、C#;
- Ruby、PHP、Swift;
- Shell 与 PowerShell;
- SQL 与 GraphQL;
- Vue、Svelte、HTML、CSS;
- JSON、YAML、TOML、XML;
- Terraform;
- Protocol Buffers;
- Markdown 与 reStructuredText。
核心判断:
python
if (
filename not in self._SUPPORTED_FILENAMES
and path.suffix.lower() not in self._SUPPORTED_EXTENSIONS
):
return self._skipped(
changed_file,
SkipReason.unsupported_file_type,
"File type is not in the supported review allowlist.",
)
为什么采用白名单,而不是只列出 .png、.zip 等黑名单?
文件类型数量非常多,黑名单很难完整。
白名单的语义更明确:
text
只有项目明确知道如何作为文本代码处理的类型
才能进入模型输入
十八、支持没有普通扩展名的工程文件
一些重要文件没有标准后缀:
text
Dockerfile
Makefile
Jenkinsfile
Procfile
Gemfile
另一些点文件的 suffix 行为也不适合只按扩展名判断:
text
.gitignore
.dockerignore
.editorconfig
因此增加文件名白名单:
python
_SUPPORTED_FILENAMES = frozenset(
{
".dockerignore",
".editorconfig",
".gitignore",
"dockerfile",
"gemfile",
"jenkinsfile",
"justfile",
"makefile",
"procfile",
"rakefile",
"requirements.txt",
}
)
文件名转为小写后匹配,所以:
text
Dockerfile
dockerfile
DOCKERFILE
会得到一致结果。
十九、固定过滤优先级
一个文件可能同时满足多个条件。
例如:
text
node_modules/app.min.js
同时满足:
text
excluded_directory
generated_file
项目固定按照:
text
content_omitted
binary_file
excluded_directory
dependency_lock_file
generated_file
unsupported_file_type
返回第一个原因。
这样做的优点:
- 相同输入始终得到相同结果;
- 每个文件只有一个主要解释;
- 测试断言稳定;
- 统计不会重复计算一个文件。
过滤器不是风险分类器,不需要为一个文件返回所有可能标签。
二十、FileFilter.apply() 主流程
核心代码:
python
def apply(
self,
changed_files: tuple[ChangedFile, ...],
) -> FileFilterResult:
included: list[ChangedFile] = []
skipped: list[SkippedFile] = []
for changed_file in changed_files:
decision = self._skip_decision(changed_file)
if decision is None:
included.append(changed_file)
else:
skipped.append(decision)
return FileFilterResult(
included_files=tuple(included),
skipped_files=tuple(skipped),
)
流程很简单:
text
遍历 ChangedFile
↓
计算跳过决策
├─ None:进入 included_files
└─ SkippedFile:进入 skipped_files
过滤规则集中在 _skip_decision(),后续增加规则时不需要修改 API 和分包器。
二十一、回顾 AnalysisPackage 数据合同
第一阶段已经定义:
python
class AnalysisPackage(StrictModel):
package_id: str = Field(min_length=1, max_length=128)
files: list[str] = Field(min_length=1)
changed_lines: int = Field(ge=0)
estimated_tokens: int = Field(ge=0)
reason: str = Field(min_length=1, max_length=500)
字段含义:
| 字段 | 作用 |
|---|---|
package_id |
稳定标识当前分析包 |
files |
包含的仓库相对文件路径 |
changed_lines |
新增与删除行总数 |
estimated_tokens |
预计 Diff 输入 Token |
reason |
为什么这些文件被放在一起 |
AnalysisPackage 当前只保存文件路径和预算元数据,没有复制 Hunk 内容。
真正执行分析时,节点会通过 Package 的 files 从 changed_files 中选择对应结构化 Diff,避免在状态中重复保存大段内容。
二十二、设计 PackageBuildResult
文件:
text
src/code_review_agent/analysis/package_builder.py
结果类型:
python
@dataclass(frozen=True, slots=True)
class PackageBuildResult:
"""Packages ready for analysis and files that exceeded package budgets."""
packages: tuple[AnalysisPackage, ...]
skipped_files: tuple[SkippedFile, ...]
为什么分包器也会返回 skipped_files?
文件可能通过类型过滤,但单个文件自身已经超过 Package 预算。
例如:
text
src/legacy_service.py
变更行数:1200
MAX_CHANGED_LINES_PER_PACKAGE:500
这个文件不是二进制,也不是生成文件,但无法放进任何合法 Package。
因此它在分包阶段得到:
text
package_budget_exceeded
二十三、使用内部 _FileCandidate
分包前,ChangedFile 被转换为内部候选对象:
python
@dataclass(frozen=True, slots=True)
class _FileCandidate:
changed_file: ChangedFile
affinity: str
changed_lines: int
estimated_tokens: int
其中:
changed_file保存原始结构化变化;affinity表示模块关联键;changed_lines用于行数预算;estimated_tokens用于 Token 预算。
使用内部类型可以避免在排序、分组和预算判断时重复计算相同字段。
前导下划线表示这个类型只用于 package_builder.py 内部,不属于公共 API 合同。
二十四、初始化三重预算
构造函数:
python
def __init__(
self,
*,
max_files: int,
max_changed_lines: int,
max_estimated_tokens: int,
) -> None:
if min(
max_files,
max_changed_lines,
max_estimated_tokens,
) <= 0:
raise ValueError("Package budgets must be positive.")
self._max_files = max_files
self._max_changed_lines = max_changed_lines
self._max_estimated_tokens = max_estimated_tokens
所有预算必须大于 0。
如果允许:
text
max_files = 0
任何文件都无法进入 Package,算法也可能产生难以理解的空结果。
因此配置层和构造函数都执行正数校验。
二十五、计算变更行数
候选对象构建:
python
def _candidate(self, changed_file: ChangedFile) -> _FileCandidate:
return _FileCandidate(
changed_file=changed_file,
affinity=self._affinity_key(changed_file.path),
changed_lines=(
changed_file.additions
+ changed_file.deletions
),
estimated_tokens=self._estimate_tokens(changed_file),
)
变更行数使用:
text
additions + deletions
而不是只计算新增行。
原因是删除代码同样占用 Diff 内容,也可能包含重要行为变化。
例如:
text
新增 3 行
删除 2 行
当前 Package 预算记为:
text
5 changed lines
二十六、估算 Token
估算代码:
python
@staticmethod
def _estimate_tokens(changed_file: ChangedFile) -> int:
character_count = (
len(changed_file.path)
+ len(changed_file.status.value)
)
if changed_file.old_path:
character_count += len(changed_file.old_path)
for hunk in changed_file.hunks:
character_count += len(hunk.header)
character_count += sum(
len(line) + 1
for line in hunk.content
)
return max(
1,
math.ceil(character_count / 4) + 32,
)
估算内容包括:
- 文件路径;
- 文件状态;
- 重命名前路径;
- Hunk Header;
- Diff 行内容;
- 每个文件固定开销。
当前使用近似公式:
text
estimated_tokens = ceil(character_count / 4) + 32
26.1 为什么除以 4
英文代码场景下,约四个字符一个 Token 是常见粗略估算方式。
26.2 为什么增加 32
每个文件进入 Prompt 后还需要路径标签、结构分隔和说明文本。固定开销避免极短文件被估算成接近 0。
26.3 这不是实际 Token 账单
不同模型、中文、标识符和空白会影响 Token 数量。
因此这个字段只用于分包前的保守规划。真正调用 LLM 后,还需要记录 Provider 返回的实际 Input/Output Token。
二十七、处理单文件自身超过预算
在分组前先判断每个候选文件:
python
def _budget_exceeded(
self,
candidate: _FileCandidate,
) -> SkippedFile | None:
exceeded: list[str] = []
if candidate.changed_lines > self._max_changed_lines:
exceeded.append(
f"{candidate.changed_lines} changed lines exceeds "
f"{self._max_changed_lines}"
)
if candidate.estimated_tokens > self._max_estimated_tokens:
exceeded.append(
f"{candidate.estimated_tokens} estimated tokens exceeds "
f"{self._max_estimated_tokens}"
)
if not exceeded:
return None
return SkippedFile(
path=candidate.changed_file.path,
reason=SkipReason.package_budget_exceeded,
detail=(
"Single file cannot fit a package: "
+ "; ".join(exceeded)
+ "."
),
)
示例:
json
{
"path": "src/large.py",
"reason": "package_budget_exceeded",
"detail": "Single file cannot fit a package: 620 changed lines exceeds 500."
}
为什么不生成一个超预算 Package?
如果突破预算,配置就失去约束意义。
为什么当前不自动拆分单文件?
当前 AnalysisPackage 只记录文件路径,没有记录 Hunk 子集。把同一个文件拆成多个 Package 会无法表示每个 Package 应该分析哪些 Hunk。
后续如果需要支持超大文件,可以新增:
text
PackageFileSlice
path
hunk_indexes
line_ranges
在没有这个合同前,明确跳过比隐式超限更可靠。
二十八、什么是模块关联键 affinity
模块关联键用于回答:
text
哪些文件应该优先放在一起?
当前算法根据路径和常见测试命名计算,不解析语言 AST,也不分析 Import Graph。
根目录集合:
python
_ROOT_DIRECTORIES = frozenset(
{
"__tests__",
"app",
"apps",
"integration",
"lib",
"source",
"spec",
"specs",
"src",
"test",
"tests",
"unit",
}
)
这些目录常用于区分源码、测试或工程层级,不直接作为业务模块名。
二十九、关联源码与测试文件
关联算法:
python
@classmethod
def _affinity_key(cls, path: str) -> str:
parts = [
part.lower()
for part in PurePosixPath(path).parts
]
while (
len(parts) > 1
and parts[0] in cls._ROOT_DIRECTORIES
):
parts.pop(0)
if (
len(parts) > 2
and parts[0] in cls._CONTAINER_DIRECTORIES
):
return f"{parts[0]}/{parts[1]}"
if len(parts) > 1:
return parts[0]
return cls._normalized_stem(parts[0])
示例一:
text
src/auth/user.py
去掉 src 后:
text
auth/user.py
关联键:
text
auth
示例二:
text
tests/auth/test_user.py
去掉 tests 后:
text
auth/test_user.py
关联键同样是:
text
auth
因此两个文件会优先保留在同一关联组。
三十、处理根目录源码与测试命名
对于:
text
src/user.py
tests/test_user.py
去掉根目录后只剩文件名,需要标准化 Stem:
python
@staticmethod
def _normalized_stem(filename: str) -> str:
stem = PurePosixPath(filename).stem.lower()
for suffix in (
".spec",
".test",
"_spec",
"_test",
"-spec",
"-test",
):
if stem.endswith(suffix):
stem = stem[: -len(suffix)]
for prefix in ("spec_", "test_"):
if stem.startswith(prefix):
stem = stem[len(prefix) :]
return stem or "root"
可以统一:
text
user.py → user
test_user.py → user
user_test.py → user
user.spec.ts → user
user.test.ts → user
user-test.js → user
这是一种命名约定关联,不保证理解所有项目结构,但具有:
- 确定性;
- 无语言依赖;
- 无额外 I/O;
- 易于测试;
- 适合作为第一版。
三十一、处理 Monorepo 容器目录
容器目录集合:
python
_CONTAINER_DIRECTORIES = frozenset(
{
"components",
"modules",
"packages",
"plugins",
"services",
}
)
例如:
text
packages/billing/src/service.ts
packages/users/src/service.ts
如果只使用第一段 packages,两个完全不同的子项目会被认为属于同一模块。
所以当路径满足容器结构时使用两段:
text
packages/billing
packages/users
这减少 Monorepo 中不同服务被错误聚合的概率。
三十二、保证输入顺序不影响分包结果
候选文件先排序:
python
ordered_candidates = sorted(
candidates,
key=lambda item: (
item.affinity,
item.changed_file.path,
),
)
排序键为:
text
affinity
文件路径
为什么不能依赖 Git 返回顺序?
- 工作区 Diff 与提交区间可能有不同顺序;
- 未跟踪文件在 tracked 文件后追加;
- 测试或其他调用方可能使用不同输入顺序;
- 将来并行采集可能改变完成顺序。
确定性分包要求:
text
相同 ChangedFile 集合
无论输入排列如何
得到相同 Package 计划
三十三、先构建关联组
排序后按 affinity 分组:
python
affinity_groups: dict[
str,
list[_FileCandidate],
] = defaultdict(list)
for candidate in ordered_candidates:
affinity_groups[candidate.affinity].append(candidate)
示例:
text
auth
src/auth/user.py
tests/auth/test_user.py
billing
src/billing/invoice.py
tests/billing/test_invoice.py
readme
README.md
分组的目的不是保证每个 affinity 一定生成独立 Package,而是让相关文件在切分时保持相邻,并尽可能不被拆散。
三十四、在关联组内部按预算切块
核心代码:
python
def _split_affinity_group(
self,
candidates: list[_FileCandidate],
) -> list[tuple[_FileCandidate, ...]]:
chunks: list[tuple[_FileCandidate, ...]] = []
current: list[_FileCandidate] = []
for candidate in candidates:
if current and not self._fits(
(*current, candidate)
):
chunks.append(tuple(current))
current = []
current.append(candidate)
if current:
chunks.append(tuple(current))
return chunks
如果一个 auth 关联组有 12 个文件,而:
text
MAX_FILES_PER_PACKAGE=8
它会先被切成:
text
auth chunk 1:8 个文件
auth chunk 2:4 个文件
不会为了保持关联而突破预算。
三十五、统一判断三种预算
预算判断:
python
def _fits(
self,
candidates: tuple[_FileCandidate, ...],
) -> bool:
return (
len(candidates) <= self._max_files
and sum(
item.changed_lines
for item in candidates
) <= self._max_changed_lines
and sum(
item.estimated_tokens
for item in candidates
) <= self._max_estimated_tokens
)
必须同时满足:
text
文件数 <= max_files
AND
变更行数 <= max_changed_lines
AND
估算 Token <= max_estimated_tokens
任何一个条件失败,当前组合就不能进入同一 Package。
三十六、将小关联组继续装入 Package
如果每个关联组都单独产生一次 LLM 调用,根目录下的小文件仍可能造成很多请求。
因此主循环会在预算允许时,把相邻的小关联块继续装入当前 Package:
python
packages: list[AnalysisPackage] = []
current: list[_FileCandidate] = []
for affinity in sorted(affinity_groups):
for chunk in self._split_affinity_group(
affinity_groups[affinity]
):
if current and not self._fits(
(*current, *chunk)
):
packages.append(
self._create_package(
current,
len(packages) + 1,
)
)
current = []
current.extend(chunk)
if current:
packages.append(
self._create_package(
current,
len(packages) + 1,
)
)
算法目标有两个:
- 先保护强相关的源码与测试;
- 再减少大量很小的单文件 Package。
因此一个 Package 可能包含多个小 affinity,reason 会记录实际包含的关联键。
三十七、生成 Package ID 与原因
创建代码:
python
def _create_package(
self,
candidates: list[_FileCandidate],
index: int,
) -> AnalysisPackage:
affinities = list(
dict.fromkeys(
item.affinity
for item in candidates
)
)
affinity_text = ", ".join(affinities)
if len(affinity_text) > 350:
affinity_text = affinity_text[:347] + "..."
slug = re.sub(
r"[^a-z0-9]+",
"-",
affinities[0].lower(),
).strip("-") or "root"
return AnalysisPackage(
package_id=f"package-{index:03d}-{slug[:48]}",
files=[
item.changed_file.path
for item in candidates
],
changed_lines=sum(
item.changed_lines
for item in candidates
),
estimated_tokens=sum(
item.estimated_tokens
for item in candidates
),
reason=(
f"Grouped by module affinity ({affinity_text}) "
"within configured file, changed-line, "
"and token budgets."
),
)
ID 示例:
text
package-001-auth
package-002-billing
package-003-readme
数字序号保证唯一性,Slug 提供可读性。
reason 示例:
text
Grouped by module affinity (auth, config) within configured file,
changed-line, and token budgets.
AnalysisPackage.reason 最大 500 字符,因此 affinity 文本限制在 350 字符以内,防止极端长路径触发模型校验失败。
三十八、完整 build() 流程
PackageBuilder.build() 可以概括为:
text
ChangedFile[]
↓
计算 _FileCandidate
↓
单文件预算检查
├─ 超限 → SkippedFile(package_budget_exceeded)
└─ 合法 → candidates
↓
按 affinity + path 排序
↓
建立 affinity_groups
↓
组内按三重预算切块
↓
小块继续装入当前 Package
↓
生成 AnalysisPackage[]
返回:
python
PackageBuildResult(
packages=tuple(packages),
skipped_files=tuple(skipped),
)
整个过程不读取文件、不执行 Git、不调用模型,只消费第三阶段已经生成的结构化 ChangedFile。
三十九、创建 ReviewInputPreparer
文件:
text
src/code_review_agent/services/review_input_preparer.py
内部结果:
python
@dataclass(frozen=True, slots=True)
class PreparedReviewInput:
repository: Path
changed_files: tuple[ChangedFile, ...]
skipped_files: tuple[SkippedFile, ...]
packages: tuple[AnalysisPackage, ...]
warnings: tuple[str, ...]
collected_bytes: int
这就是未来 LangGraph 初始状态的重要输入来源。
它同时保留:
- 真实 Git 根目录;
- 完整变化;
- 跳过决策;
- Package 计划;
- 警告;
- 已采集字节数。
四十、在服务层注入预算
构造代码:
python
class ReviewInputPreparer:
__slots__ = (
"_collector",
"_filter",
"_package_builder",
)
def __init__(
self,
settings: Settings,
path_policy: PathPolicy,
) -> None:
self._collector = GitChangeCollector(
settings,
path_policy,
)
self._filter = FileFilter()
self._package_builder = PackageBuilder(
max_files=settings.max_files_per_package,
max_changed_lines=(
settings.max_changed_lines_per_package
),
max_estimated_tokens=(
settings.max_estimated_tokens_per_package
),
)
PackageBuilder 不直接读取环境变量。
它只接收构造参数,这样测试可以轻松设置:
python
PackageBuilder(
max_files=2,
max_changed_lines=10,
max_estimated_tokens=100,
)
配置解析属于 Settings,分包逻辑属于 PackageBuilder,职责保持分离。
四十一、串联 Git 采集、过滤和分包
核心代码:
python
def prepare(
self,
repository: Path,
request: ReviewRequest,
) -> PreparedReviewInput:
collection = self._collector.collect(
repository,
request,
)
filter_result = self._filter.apply(
collection.changed_files
)
package_result = self._package_builder.build(
filter_result.included_files
)
skipped_files = (
*filter_result.skipped_files,
*package_result.skipped_files,
)
顺序不能反过来:
text
先 Git 采集
再文件过滤
最后分包
如果先分包再过滤,二进制和 Lock 文件会先占用 Package 预算,过滤后还需要重新计算。
四十二、设计可解释警告
输入准备服务增加三类警告。
42.1 有文件被排除
python
if skipped_files:
warnings.append(
f"Excluded {len(skipped_files)} changed file(s) "
"from analysis packages."
)
42.2 有变化但没有可分析 Package
python
if collection.changed_files and not package_result.packages:
warnings.append(
"No changed files are eligible for model analysis."
)
42.3 请求范围没有 Git 变化
python
if not collection.changed_files:
warnings.append(
"No Git changes were found for the requested range."
)
三种情况含义不同:
text
没有 Git 变化
≠
有变化但全部被过滤
≠
有部分文件被过滤、其余正常分包
四十三、扩展 ReviewResponse
响应模型新增:
python
class ReviewResponse(StrictModel):
status: ReviewStatus
changed_files: list[ChangedFile] = Field(
default_factory=list
)
skipped_files: list[SkippedFile] = Field(
default_factory=list
)
packages: list[AnalysisPackage] = Field(
default_factory=list
)
findings: list[Finding] = Field(
default_factory=list
)
warnings: list[str] = Field(
default_factory=list
)
metrics: ReviewMetrics = Field(
default_factory=ReviewMetrics
)
error: ReviewError | None = None
当前状态仍然是:
text
accepted
因为完成的是输入准备,不是最终审查。
四十四、将输入准备服务接入 API
路由从第三阶段的:
python
GitChangeCollector(settings, policy).collect(
repository,
request,
)
升级为:
python
prepared_input = ReviewInputPreparer(
settings,
policy,
).prepare(
repository,
request,
)
成功响应:
python
return ReviewResponse(
status=ReviewStatus.accepted,
changed_files=list(prepared_input.changed_files),
skipped_files=list(prepared_input.skipped_files),
packages=list(prepared_input.packages),
warnings=[
*prepared_input.warnings,
"Analysis packages were prepared; "
"LangGraph analysis has not run yet.",
],
metrics=ReviewMetrics(
package_count=len(prepared_input.packages),
elapsed_ms=elapsed_ms,
),
)
package_count 不再固定为 0,而是使用真实 Package 数量。
llm_calls 和 tool_calls 仍然为 0,因为模型和 Agent 工具尚未运行。
四十五、Review API 响应示例
假设本次变更包含:
text
src/app.py
src/new.py
package-lock.json
其中:
- 两个 Python 文件适合分析;
package-lock.json被过滤;- 两个 Python 文件能够放进同一个 Package。
响应结构:
json
{
"status": "accepted",
"changed_files": [
{
"path": "src/app.py",
"status": "modified",
"old_path": null,
"additions": 1,
"deletions": 1,
"is_binary": false,
"content_omitted": false,
"hunks": [
{
"header": "@@ -1,2 +1,2 @@",
"old_start": 1,
"old_count": 2,
"new_start": 1,
"new_count": 2,
"content": [
" def value():",
"- return 1",
"+ return 2"
],
"new_changed_lines": [2]
}
]
},
{
"path": "src/new.py",
"status": "untracked",
"old_path": null,
"additions": 1,
"deletions": 0,
"is_binary": false,
"content_omitted": false,
"hunks": [
{
"header": "@@ -0,0 +1,1 @@",
"old_start": 0,
"old_count": 0,
"new_start": 1,
"new_count": 1,
"content": ["+NEW = True"],
"new_changed_lines": [1]
}
]
},
{
"path": "package-lock.json",
"status": "untracked",
"old_path": null,
"additions": 1,
"deletions": 0,
"is_binary": false,
"content_omitted": false,
"hunks": [
{
"header": "@@ -0,0 +1,1 @@",
"old_start": 0,
"old_count": 0,
"new_start": 1,
"new_count": 1,
"content": ["+{}"],
"new_changed_lines": [1]
}
]
}
],
"skipped_files": [
{
"path": "package-lock.json",
"reason": "dependency_lock_file",
"detail": "Dependency lock files are excluded from model analysis."
}
],
"packages": [
{
"package_id": "package-001-app",
"files": [
"src/app.py",
"src/new.py"
],
"changed_lines": 3,
"estimated_tokens": 100,
"reason": "Grouped by module affinity (app, new) within configured file, changed-line, and token budgets."
}
],
"findings": [],
"warnings": [
"Excluded 1 changed file(s) from analysis packages.",
"Analysis packages were prepared; LangGraph analysis has not run yet."
],
"metrics": {
"package_count": 1,
"tool_calls": 0,
"llm_calls": 0,
"elapsed_ms": 20
},
"error": null
}
其中 estimated_tokens 和 elapsed_ms 会随实际 Diff 与运行环境变化,示例数值不是固定断言。
四十六、测试 FileFilter
测试文件:
text
tests/test_file_filter.py
46.1 支持源码与特殊工程文件
python
def test_filter_keeps_supported_source_and_special_files() -> None:
result = FileFilter().apply(
(
changed_file("src/service.py"),
changed_file("Dockerfile"),
changed_file(".github/workflows/ci.yml"),
)
)
assert [
item.path
for item in result.included_files
] == [
"src/service.py",
"Dockerfile",
".github/workflows/ci.yml",
]
assert result.skipped_files == ()
46.2 每种排除规则返回正确原因
python
reasons = {
item.path: item.reason
for item in result.skipped_files
}
assert reasons == {
"src/large.py": SkipReason.content_omitted,
"assets/logo.png": SkipReason.binary_file,
"node_modules/library/index.js": (
SkipReason.excluded_directory
),
"package-lock.json": (
SkipReason.dependency_lock_file
),
"public/app.min.js": SkipReason.generated_file,
"notes.txt": SkipReason.unsupported_file_type,
}
46.3 验证优先级
同时设置:
python
content_omitted=True
is_binary=True
断言主要原因是:
text
content_omitted
四十七、测试源码与测试关联
测试文件:
text
tests/test_package_builder.py
测试输入:
python
files = (
changed_file("src/auth/user.py"),
changed_file("tests/auth/test_user.py"),
changed_file("README.md"),
)
设置:
python
result = builder(max_files=2).build(files)
断言:
python
assert result.packages[0].package_id == "package-001-auth"
assert result.packages[0].files == [
"src/auth/user.py",
"tests/auth/test_user.py",
]
assert result.packages[1].files == ["README.md"]
这验证了源码和测试优先保持在一起,而 README 因文件数预算进入下一个 Package。
四十八、测试三重预算与单文件超限
48.1 文件数和变更行预算
构造三个文件,每个文件:
text
新增 3 行
删除 2 行
共 5 changed lines
预算:
text
max_files = 2
max_changed_lines = 10
期望 Package:
text
Package 1:2 个文件,10 行
Package 2:1 个文件,5 行
断言:
python
assert [
len(package.files)
for package in result.packages
] == [2, 1]
assert [
package.changed_lines
for package in result.packages
] == [10, 5]
48.2 单文件超过行数预算
python
result = builder(max_changed_lines=5).build(
(
changed_file(
"src/large.py",
additions=5,
deletions=1,
),
)
)
总变化为 6 行,因此:
python
assert result.packages == ()
assert result.skipped_files[0].reason is (
SkipReason.package_budget_exceeded
)
48.3 单文件超过 Token 预算
使用长 Diff 内容和极小预算,断言同样得到:
text
package_budget_exceeded
四十九、测试确定性与模型字段边界
49.1 输入顺序不影响 Package
测试分别传入:
text
beta, test_beta, alpha
和:
text
alpha, test_beta, beta
断言:
python
assert forward.packages == reversed_result.packages
49.2 超长 affinity 不突破 reason 限制
构造极长文件名:
python
long_name = "feature" * 60
然后断言:
python
assert len(result.packages[0].reason) <= 500
这验证分包器不会因为解释文本过长而触发 Pydantic 校验错误。
五十、测试输入准备服务
测试文件:
text
tests/test_review_input_preparer.py
50.1 干净仓库
真实临时 Git 仓库没有工作区变化时:
python
assert result.changed_files == ()
assert result.packages == ()
assert result.skipped_files == ()
assert "No Git changes were found" in result.warnings[-1]
50.2 所有变化都被过滤
只创建:
text
package-lock.json
期望:
python
assert result.packages == ()
assert result.skipped_files[0].reason is (
SkipReason.dependency_lock_file
)
assert any(
"No changed files are eligible" in warning
for warning in result.warnings
)
这验证"没有变化"和"有变化但全部被过滤"是两个不同状态。
五十一、测试 Review API
API 集成测试制造:
text
src/app.py 修改
src/new.py 未跟踪
package-lock.json 未跟踪
断言完整变化:
python
assert {
item["path"]
for item in body["changed_files"]
} == {
"package-lock.json",
"src/app.py",
"src/new.py",
}
断言结构化过滤结果:
python
assert body["skipped_files"] == [
{
"path": "package-lock.json",
"reason": "dependency_lock_file",
"detail": (
"Dependency lock files are excluded "
"from model analysis."
),
}
]
断言 Package:
python
assert body["packages"][0]["files"] == [
"src/app.py",
"src/new.py",
]
断言指标:
python
assert body["metrics"]["package_count"] == 1
最后继续确认:
python
assert body["findings"] == []
assert "LangGraph analysis has not run" in body["warnings"][-1]
五十二、运行完整检查
进入项目:
powershell
cd D:\agent\langgraph-code-review-agent
conda activate agent
安装 editable 项目:
powershell
python -m pip install -e ".[dev]"
检查依赖:
powershell
python -m pip check
真实输出:
text
No broken requirements found.
运行 Ruff:
powershell
python -m ruff check --no-cache src tests
真实输出:
text
All checks passed!
运行完整测试:
powershell
python -m pytest
真实输出:
text
............................................................... [100%]
63 passed, 1 warning in 3.89s
唯一警告仍然来自 FastAPI TestClient 依赖链中的 Starlette 弃用提示,不影响第四阶段功能。
五十三、本阶段仍然存在的边界
53.1 过滤规则暂时是代码常量
当前支持扩展名、目录和 Lock 文件直接定义在 FileFilter 中。
优点是行为明确、容易测试;缺点是不同项目无法通过配置覆盖。
后续可以支持:
text
INCLUDE_PATTERNS
EXCLUDE_PATTERNS
ADDITIONAL_SUPPORTED_EXTENSIONS
但在没有稳定优先级和合并语义前,不应过早加入复杂规则 DSL。
53.2 关联算法不是语义依赖图
当前使用路径和命名约定,不会解析:
- Python Import;
- Java Package;
- TypeScript Alias;
- Go Module;
- 调用关系。
因此它是模块亲和性启发式,不是精确依赖分析。
53.3 小 affinity 可能合并到同一 Package
为了减少大量小请求,预算允许时会将多个小关联组装入同一 Package。
这在调用成本和模块纯度之间做了折中。后续可以为 affinity 增加权重或最大组数限制。
53.4 Token 只是近似值
当前公式没有调用真实模型 tokenizer。
未来 Provider 确定后,可以:
- 使用模型对应 tokenizer;
- 保留固定安全余量;
- 将系统 Prompt 和工具定义纳入预算;
- 记录估算值与真实值差异。
53.5 单文件超限暂时跳过
当前没有 Hunk Slice 合同,因此不能安全拆分同一个文件。
后续可以按 Hunk 或函数范围切片,但必须继续保留评论定位信息。
53.6 Lock 文件规则可能需要项目覆盖
某些团队希望审查依赖版本变化。
当前只是不把 Lock 正文交给通用代码模型,后续可以增加专用依赖风险检查,而不是简单删除过滤规则。
53.7 尚未调用 LangGraph 和 LLM
当前:
text
package_count > 0
llm_calls = 0
tool_calls = 0
findings = []
这是正确状态,不表示代码没有问题。
五十四、本篇关键设计决策
54.1 完整变化与模型输入分开
changed_files 保留事实,packages 只保存可分析输入,避免过滤导致审计信息丢失。
54.2 所有过滤必须可解释
不静默丢弃文件,每个排除项都有 SkipReason 和 detail。
54.3 白名单优于无限黑名单
只有明确支持的文本工程文件进入模型,未知类型默认跳过。
54.4 先保护关联,再考虑装箱效率
源码和测试先进入相同 affinity,再按预算切分和合并小组。
54.5 三重预算必须同时成立
文件数、变更行和估算 Token 从不同维度限制 Package,任何一个都不能被其他指标代替。
54.6 单文件不能绕过预算
超限文件返回 package_budget_exceeded,不会因为无法拆分就破坏全局限制。
54.7 分包结果必须确定
先按 affinity 和路径排序,确保输入顺序变化不会改变 Package 计划。
54.8 输入准备仍然不是审查完成
API 保持 accepted,并明确提示 LangGraph 尚未运行。
五十五、第四阶段完成了什么
本阶段完成清单:
text
[完成] 增加 MAX_ESTIMATED_TOKENS_PER_PACKAGE
[完成] 定义 SkipReason
[完成] 定义 SkippedFile
[完成] ReviewResponse 增加 skipped_files
[完成] 创建 analysis 包
[完成] 创建 FileFilterResult
[完成] 实现正文省略过滤
[完成] 实现二进制过滤
[完成] 实现依赖/缓存/构建目录过滤
[完成] 实现 Lock 文件过滤
[完成] 实现生成文件过滤
[完成] 实现支持扩展名白名单
[完成] 支持无普通扩展名工程文件
[完成] 固定过滤优先级
[完成] 创建 PackageBuildResult
[完成] 创建 _FileCandidate
[完成] 计算 additions + deletions
[完成] 估算 Diff Token
[完成] 单文件行数预算检查
[完成] 单文件 Token 预算检查
[完成] package_budget_exceeded 结果
[完成] 模块 affinity 算法
[完成] 源码与测试命名归一化
[完成] Monorepo 容器目录处理
[完成] 确定性候选排序
[完成] affinity 组内切块
[完成] 小关联组预算装箱
[完成] 确定性 Package ID
[完成] Package reason 长度保护
[完成] 创建 PreparedReviewInput
[完成] 创建 ReviewInputPreparer
[完成] 串联 Git 采集、过滤与分包
[完成] 无变化警告
[完成] 全部过滤警告
[完成] API 返回真实 packages
[完成] metrics.package_count
[完成] 过滤器测试
[完成] 分包预算测试
[完成] 确定性测试
[完成] 输入准备服务测试
[完成] API 集成测试
[完成] 63 项测试通过
[完成] Ruff 通过
[完成] pip check 通过
当前主流程:
text
FastAPI ReviewRequest
↓
PathPolicy
↓
GitChangeCollector
↓
ChangedFile[]
↓
FileFilter
├─ SkippedFile[]
└─ included_files
↓
PackageBuilder
↓
AnalysisPackage[]
↓
ReviewResponse
五十六、下一篇计划
下一阶段将第一次运行真正的 LangGraph 工作流和 LLM Reviewer。
计划实现:
- 定义类型化
ReviewState; - 将输入准备结果放入 Graph State;
- 创建
prepare_input节点; - 创建
analyze_packages节点; - 创建
merge_findings节点; - 定义 Provider 无关的 Reviewer 接口;
- 定义结构化模型响应;
- 将模型输出转换为
Finding; - 限制 LLM 调用次数;
- 设置单次 LLM 超时和整图超时;
- 使用 Fake Reviewer 测试 Graph;
- 再通过配置启用真实 LLM Provider。
下一阶段初步流程:
text
START
↓
prepare_input
↓
packages 是否为空?
├─ 是 → finalize
└─ 否 → analyze_packages
↓
merge_findings
↓
finalize
↓
END
上下文工具调用将在 Graph 与基本 Reviewer 跑通后接入,避免同时调试状态管理、模型协议和四个工具。
五十七、总结
第四阶段解决的不是"模型能不能审查代码",而是"应该把什么内容以什么大小交给模型"。
一个可靠输入准备层需要回答:
text
哪些变化是完整事实?
哪些文件不适合文本模型?
为什么跳过?
源码和测试如何保持关联?
一个 Package 最多包含多少文件?
最多包含多少变更行?
如何估算 Token?
单文件本身超限怎么办?
输入顺序变化时结果是否稳定?
调用方能否解释每个过滤和分包决策?
本篇通过 FileFilter、PackageBuilder 和 ReviewInputPreparer 给出了确定性答案。
现在项目已经从:
text
能读取 Git Diff
推进到:
text
能生成可解释、相关且预算受限的 AnalysisPackage
下一阶段将在这份稳定输入上接入 LangGraph 和真实 LLM Reviewer,开始产生第一批结构化 Finding。