构建 LangGraph Code Review Agent(四):文件过滤与 AnalysisPackage 分包

一、前言

前面三个阶段完成了代码审查服务的确定性输入基础。

第一阶段完成:

  • FastAPI 项目骨架;
  • Pydantic 请求与响应模型;
  • 配置和执行预算;
  • pytest 与 Ruff;
  • LangGraph 依赖准备。

第二阶段完成:

  • 仓库允许根目录;
  • 规范路径校验;
  • 路径穿越防护;
  • 仓库与文件符号链接逃逸防护;
  • 结构化路径错误响应。

第三阶段完成:

  • 安全 Git 子进程;
  • Git 仓库与 Ref 校验;
  • 工作区和提交区间 Diff;
  • 未跟踪文件采集;
  • Unified Diff 解析;
  • 修改、新增、删除、重命名和二进制文件识别;
  • ChangedFileDiffHunk 结构化输出。

第三阶段结束后,接口已经可以返回真实变化:

text 复制代码
Git Repository
  ↓
GitClient
  ↓
UnifiedDiffParser
  ↓
ChangedFile[]

但是,ChangedFile[] 不能直接全部交给 LLM。

一个真实仓库的变更可能包含:

text 复制代码
业务源代码
单元测试
配置文件
依赖 Lock 文件
node_modules 或 vendor 内容
构建产物
缓存目录
生成代码
压缩后的 JavaScript
Source Map
图片和其他二进制文件
因为大小限制而没有读取正文的文件
单文件数千行的超大 Diff

如果把这些内容不加区分地发送给模型,会产生三个直接问题:

  1. 无意义文件占用上下文和调用成本;
  2. 相关源代码与测试可能被拆到不同请求,造成上下文割裂;
  3. 一个超大文件或文件组可能突破模型输入预算。

因此,第四阶段实现两个确定性模块:

text 复制代码
FileFilter
    决定哪些文件不进入模型,并记录原因

PackageBuilder
    将可以分析的相关文件组成大小受限的 AnalysisPackage

完整流程升级为:

text 复制代码
Git Diff
  ↓
ChangedFile[]
  ↓
FileFilter
  ├─ included_files
  └─ skipped_files
  ↓
PackageBuilder
  ↓
AnalysisPackage[]
  ↓
未来的 LangGraph 和 LLM 分析节点

本阶段仍然不调用 LLM,也不会产生真正的审查 Finding。


二、本篇目标与非目标

2.1 本篇目标

本篇需要实现:

  1. 定义结构化文件跳过原因;
  2. 记录每个被过滤文件的路径、原因和说明;
  3. 排除正文被省略的超大文件;
  4. 排除二进制文件;
  5. 排除依赖、缓存和构建目录;
  6. 排除常见依赖 Lock 文件;
  7. 排除生成文件、压缩文件和 Source Map;
  8. 使用文件类型白名单控制可分析文件;
  9. 支持 Dockerfile、Makefile 等无扩展名文件;
  10. 固定过滤优先级;
  11. 为可分析文件计算变更行数;
  12. 估算每个文件的输入 Token;
  13. 根据目录与文件名生成模块关联键;
  14. 将常见源码与测试文件分到同一关联组;
  15. 限制每个 Package 的文件数量;
  16. 限制每个 Package 的变更行数;
  17. 限制每个 Package 的估算 Token;
  18. 处理单文件本身超过预算的情况;
  19. 生成确定性的 Package ID 和分包顺序;
  20. 将过滤和分包接入 Review API;
  21. 返回 skipped_filespackages
  22. 使用自动化测试覆盖边界。

2.2 本篇暂时不做什么

本篇不会实现:

  • LangGraph StateGraph
  • LLM Provider;
  • Prompt 构建;
  • Agent 工具调用;
  • read_fileread_difffind_filessearch_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 的 fileschanged_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,
        )
    )

算法目标有两个:

  1. 先保护强相关的源码与测试;
  2. 再减少大量很小的单文件 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_callstool_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_tokenselapsed_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 所有过滤必须可解释

不静默丢弃文件,每个排除项都有 SkipReasondetail

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。

计划实现:

  1. 定义类型化 ReviewState
  2. 将输入准备结果放入 Graph State;
  3. 创建 prepare_input 节点;
  4. 创建 analyze_packages 节点;
  5. 创建 merge_findings 节点;
  6. 定义 Provider 无关的 Reviewer 接口;
  7. 定义结构化模型响应;
  8. 将模型输出转换为 Finding
  9. 限制 LLM 调用次数;
  10. 设置单次 LLM 超时和整图超时;
  11. 使用 Fake Reviewer 测试 Graph;
  12. 再通过配置启用真实 LLM Provider。

下一阶段初步流程:

text 复制代码
START
  ↓
prepare_input
  ↓
packages 是否为空?
  ├─ 是 → finalize
  └─ 否 → analyze_packages
              ↓
          merge_findings
              ↓
            finalize
              ↓
             END

上下文工具调用将在 Graph 与基本 Reviewer 跑通后接入,避免同时调试状态管理、模型协议和四个工具。


五十七、总结

第四阶段解决的不是"模型能不能审查代码",而是"应该把什么内容以什么大小交给模型"。

一个可靠输入准备层需要回答:

text 复制代码
哪些变化是完整事实?
哪些文件不适合文本模型?
为什么跳过?
源码和测试如何保持关联?
一个 Package 最多包含多少文件?
最多包含多少变更行?
如何估算 Token?
单文件本身超限怎么办?
输入顺序变化时结果是否稳定?
调用方能否解释每个过滤和分包决策?

本篇通过 FileFilterPackageBuilderReviewInputPreparer 给出了确定性答案。

现在项目已经从:

text 复制代码
能读取 Git Diff

推进到:

text 复制代码
能生成可解释、相关且预算受限的 AnalysisPackage

下一阶段将在这份稳定输入上接入 LangGraph 和真实 LLM Reviewer,开始产生第一批结构化 Finding

相关推荐
俊哥V1 小时前
每日 AI 研究简报 · 2026-07-23
人工智能·ai
a1117761 小时前
基于PyTorch的动物图像识别系统 开源
人工智能·pytorch·python
qetfw1 小时前
MWU:Vue 3 + FastAPI 的 MaaFramework 跨平台 WebUI 源码
前端·vue.js·python·fastapi·开源项目·效率工具
小白跃升坊1 小时前
WorkBuddy记忆管理全景指南
ai·三层架构·workbuddy
测试老哥1 小时前
接口自动化测试分层设计与实践总结
自动化测试·软件测试·python·测试工具·职场和发展·测试用例·接口测试
Summer-Bright1 小时前
消费者 AI 变现竞争:从“一家独大“到“iOS vs Android“,谁在为 AI 买单?
android·人工智能·ios·ai·自然语言处理·agi
m0_555762901 小时前
【无标题】
ai
大模型码小白2 小时前
企业级检索增强后端集成:Java 服务如何管理知识库版本
java·服务器·开发语言·人工智能·python·microsoft
农村小镇哥2 小时前
Python3开发快速获取图片的文字
开发语言·python