基于本地LLM与Git的离线代码审查CLI工具实战

发布时间:2026/9/21 0:23:56
基于本地LLM与Git的离线代码审查CLI工具实战 1. 为什么我要自己动手做一个 open-code-review 工具代码审查这件事做过团队协作的人都有体会。提了 PR 之后等 reviewer 有空、reviewer 看漏了边界条件、同一个低级问题在三个文件里重复出现——这些场景几乎每天都在发生。市面上的商业代码审查服务不少但要么按席位收费贵得离谱要么把代码传到别人的服务器上公司安全规范直接卡死。我所在的团队就属于后者私有仓库连外部 CI 都不让接更别说把 diff 发给第三方 API 了。于是我开始琢磨能不能用本地能跑的 LLM配合 Git 的命令行能力做一个完全离线的代码审查工具这就是open-code-review的起点。它的定位很明确——一个跑在终端里的 CLI 工具读取git diff的输出把变更内容喂给本地或自建的 LLM Agent让它按预设规则逐文件、逐 hunk 地给出审查意见最后汇总成一份可读的报告。整个链路不依赖任何外部服务代码不出内网。这篇文章适合三类人看一是想给自己团队搭一套私有代码审查流水线的工程师二是正在研究 LLM Agent 怎么和 Git 工作流结合的技术爱好者三是单纯想搞清楚 CLI 工具怎么调用大模型、prompt 怎么设计、diff 怎么解析的开发者。我会把设计取舍、核心实现、踩过的坑全部摊开讲代码片段可以直接抄。先说清楚一个概念区分因为热词里很多人问agent、LLM、AI 模型到底啥关系。简单讲LLMLarge Language Model是底层的大语言模型比如 DeepSeek、GPT 系列、Claude 系列它们本质是输入文本、输出文本的函数。AI 模型是个更大的范畴LLM 只是其中一类。而Agent是在 LLM 之上加了一层感知—决策—行动的循环它能调用工具比如读文件、执行 git 命令、跑测试根据工具返回的结果决定下一步做什么。open-code-review 里的审查逻辑就是一个轻量 Agent——它不只是把 diff 丢给模型要一段评论而是会先解析 diff 结构、按文件分组、对每个 hunk 单独提问、再把结果聚合。这个分而治之的调度逻辑就是 Agent 和裸调 API 的区别。2. 整体架构CLI 外壳 Git 解析层 LLM 调度层2.1 三层职责划分我把整个工具拆成三层每层只干一件事方便单独测试和替换。第一层是CLI 外壳负责参数解析、配置加载、输出格式化。用户敲ocr review --staged或者ocr review main..feature这一层把命令翻译成内部调用。我选的是 Python 的argparse加rich做终端渲染没上 Click 是因为 argparse 零依赖、够用rich 负责把审查结果渲染成带颜色和表格的漂亮输出。第二层是Git 解析层负责调用 git 命令拿到 diff然后把 unified diff 格式解析成结构化的数据结构。这一层是整个工具的地基也是最容易出 bug 的地方后面单独展开讲。第三层是LLM 调度层负责把结构化的变更内容组装成 prompt调用模型解析模型返回做结果聚合。这一层要处理超长 diff 的分片、并发调用、失败重试、结果去重。三层之间用简单的 dataclass 传递数据不引入消息队列或复杂框架。理由很直接这是个单人维护的工具过度设计只会增加维护成本。2.2 为什么不用现成的 diff 解析库Python 生态里有unidiff、patch-ng这类库我一开始也用了unidiff但很快发现两个问题。一是它对二进制文件、重命名、模式变更file mode change的处理不够细而这些恰恰是代码审查里需要特别标注的地方——比如一个文件从 644 变成 755可能是误操作。二是它把整个 diff 一次性加载进内存遇到几千行的巨型 diff 会卡。所以我最后自己写了个流式解析器逐行扫描 diff 文本用状态机识别diff --git、index、---、、、、-、 这些前缀。核心逻辑大概是这样import re from dataclasses import dataclass, field dataclass class Hunk: old_start: int old_lines: int new_start: int new_lines: int lines: list field(default_factorylist) dataclass class FileDiff: old_path: str new_path: str is_new: bool False is_deleted: bool False is_renamed: bool False is_binary: bool False hunks: list field(default_factorylist) HUNK_HEADER re.compile(r^ -(\d)(?:,(\d))? \(\d)(?:,(\d))? ) def parse_diff(text: str) - list: files, current, hunk [], None, None for line in text.splitlines(): if line.startswith(diff --git): if current: files.append(current) parts line.split( b/, 1) current FileDiff(old_pathparts[0][11:], new_pathparts[1] if len(parts) 1 else ) hunk None elif line.startswith(new file mode): current.is_new True elif line.startswith(deleted file mode): current.is_deleted True elif line.startswith(rename from): current.is_renamed True elif line.startswith(Binary files): current.is_binary True elif HUNK_HEADER.match(line): m HUNK_HEADER.match(line) hunk Hunk(int(m.group(1)), int(m.group(2) or 1), int(m.group(3)), int(m.group(4) or 1)) current.hunks.append(hunk) elif hunk is not None and line[:1] in (, -, ): hunk.lines.append(line) if current: files.append(current) return files这段代码看着简单但有几个细节值得说。diff --git a/foo b/foo这行里路径可能带空格所以用split( b/, 1)而不是按空格切。头里的行数可能省略比如 -1 1 表示各一行所以正则里用了(?:,(\d))?并给默认值 1。二进制文件没有 hunk直接标记跳过。提示解析 diff 时一定要处理\ No newline at end of file这种特殊行它不属于任何 hunk 的内容但会影响行号计算。我一开始忽略了它导致审查意见里的行号全部偏移。2.3 数据流全景从用户敲命令到看到报告数据流是这样的CLI 解析参数 → 调用git diff拿到原始文本 → 解析器转成FileDiff列表 → 过滤器按规则剔除不需要审查的文件比如 lock 文件、图片→ 分片器把大 diff 切成模型能吃的块 → 调度器并发调用 LLM → 结果聚合器合并同一文件的意见 → 渲染器输出。这个链路里过滤器和分片器是两个容易被低估的环节。过滤器决定了审什么分片器决定了怎么审得动。下一节专门讲这两个。3. Git 变更获取与 diff 分片策略3.1 拿到正确的 diff 范围git diff的参数组合非常多工具要支持几种常见场景。我定义了三个入口ocr review --staged审查暂存区等价于git diff --cached适合提交前自查。ocr review --range main..feature审查两个分支之间的差异适合 PR 场景。ocr review --commit abc123审查单个提交等价于git show abc123。底层统一走一个函数根据参数拼 git 命令。这里有个坑git diff默认会调用外部 diff 工具或受.gitattributes影响输出格式可能不是标准 unified diff。所以我在命令里强制加了几个参数git -c diff.mnemonicprefixfalse -c core.quotepathfalse \ --no-optional-locks diff --no-color --no-ext-diff \ --unified3 --find-renames --find-copies main..feature逐个解释。diff.mnemonicprefixfalse保证路径前缀是标准的a/和b/而不是i/、w/这种随场景变化的助记前缀否则解析器要处理多种前缀。core.quotepathfalse让中文路径不被转义成八进制否则文件名会变成\346\226\207...这种鬼东西。--no-optional-locks避免在只读操作时去抢 index 锁多人同时跑不会互相阻塞。--no-ext-diff禁用外部 diff 工具。--unified3固定上下文行数方便后续按 hunk 处理。--find-renames和--find-copies让 git 识别重命名和复制这样审查时能知道这个文件只是改名了内容没动避免误报。注意如果你的仓库很大git diff本身可能就要跑好几秒。我加了个--no-optional-locks之后在 CI 环境里并发跑多个审查任务时不再出现 index.lock 冲突导致的失败。3.2 哪些文件不该送进模型不是所有变更都值得让 LLM 看。我维护了一个默认忽略列表同时允许用户在配置文件里覆盖类别匹配规则忽略理由锁文件*.lock,package-lock.json,poetry.lock机器生成无审查价值且极长压缩产物*.min.js,*.min.css,dist/**压缩后不可读浪费 token二进制图片、字体、.so,.dll模型读不了解析器已标记生成代码*_pb2.py,*.generated.*由工具生成改也是改生成器快照测试__snapshots__/**,*.snap内容随实现变审查意义低这个列表不是拍脑袋定的。我统计过团队三个月的 PR锁文件和 dist 产物占了 diff 总行数的 40% 以上全部剔除后 token 消耗直接砍半审查速度提升明显。但要注意忽略规则不能一刀切——有些团队把dist也纳入版本管理且需要审查所以必须可配置。配置用 YAML长这样ignore: - *.lock - package-lock.json - dist/** - **/*.min.js max_hunk_lines: 200 max_file_lines: 2000max_hunk_lines和max_file_lines是分片阈值下面讲。3.3 大 diff 怎么切才不丢上下文LLM 有上下文窗口限制一个几千行的 diff 塞进去要么超限要么模型注意力被稀释、审查质量下降。所以必须分片。但分片有个矛盾切得太碎模型看不到跨函数的调用关系切得太粗又超限。我的策略是按文件优先、按 hunk 兜底。具体规则单个文件的所有 hunk 加起来不超过max_file_lines默认 2000 行就整个文件作为一个审查单元。超过的话按 hunk 切但每个分片尽量包含连续的 hunk且分片之间保留 3 行重叠上下文。单个 hunk 超过max_hunk_lines默认 200 行的说明这是个巨型改动单独成片并标记oversized在报告里提示人工重点看。为什么按文件优先因为代码审查里很多问题是文件级的——比如这个文件新增了 500 行但没加任何测试这个模块的 import 顺序乱了。如果按 hunk 切碎模型就看不到文件全貌这类意见就提不出来。只有文件实在太大时才退化成 hunk 级。分片时还要注意保留 hunk 头里的行号信息因为最终报告要定位到具体行。我在每个分片的 prompt 里都会带上 -old_start,old_lines new_start,new_lines 这行让模型知道它看的是文件的哪一段。def split_file(fd: FileDiff, max_file_lines: int, max_hunk_lines: int): total sum(len(h.lines) for h in fd.hunks) if total max_file_lines: return [fd] # 整文件一片 chunks, buf, buf_lines [], [], 0 for h in fd.hunks: hlen len(h.lines) if hlen max_hunk_lines: if buf: chunks.append(buf); buf, buf_lines [], 0 chunks.append([h]) # 巨型 hunk 单独成片 continue if buf_lines hlen max_file_lines: chunks.append(buf); buf, buf_lines [], 0 buf.append(h); buf_lines hlen if buf: chunks.append(buf) return chunks这段逻辑我调了好几版。最早是简单按行数累加切结果把一个函数的开头和结尾切到两个片里模型对着半截函数提了一堆莫名其妙的意见。后来改成hunk 是不可分割的最小单位因为一个 hunk 通常对应一处连续改动切开就失去语义了。再后来加了巨型 hunk 的单独处理因为确实遇到过自动生成的迁移脚本单 hunk 上千行的情况。4. 把 diff 变成模型能懂的审查指令4.1 prompt 结构设计prompt 设计是这个工具的灵魂。我试过很多版本最后稳定下来的结构是四段式角色设定 审查规则 变更内容 输出格式。角色设定要具体不能只说你是一个代码审查员。我写的是你是一位有十年经验的资深工程师擅长发现并发问题、边界条件、资源泄漏和安全隐患。你的评论要具体、可操作指出问题所在行并给出修改建议。 这样模型输出的意见会明显更聚焦而不是泛泛地说代码可以更清晰。审查规则是可配置的默认包含这些维度正确性逻辑错误、边界条件、空指针、类型不匹配安全性注入风险、敏感信息硬编码、权限校验缺失性能不必要的循环、重复计算、N1 查询可维护性命名、重复代码、过长函数、缺失注释测试新增逻辑是否有对应测试变更内容部分我会把文件路径、hunk 头、具体增删行都带上并且用明确的标记区分新增和删除文件: src/auth/login.py 变更类型: 修改 -45,7 45,9 def verify_token(token): if not token: return None - payload jwt.decode(token, SECRET) payload jwt.decode(token, SECRET, algorithms[HS256]) if payload.get(exp, 0) time.time(): raise TokenExpired() return payload输出格式我要求模型返回 JSON字段固定file、line、severitycritical/warning/info、category、message、suggestion。用 JSON 是为了后续能程序化聚合和渲染而不是让模型自由发挥写一段散文。但这里有个坑——模型经常在 JSON 外面包一层 markdown 代码块或者加一句好的以下是审查结果。所以解析时要先剥离代码块标记再用容错的方式提取 JSON。import json, re def extract_json(text: str): text text.strip() text re.sub(r^(?:json)?\s*, , text) text re.sub(r\s*$, , text) try: return json.loads(text) except json.JSONDecodeError: m re.search(r\[.*\], text, re.DOTALL) if m: return json.loads(m.group(0)) return []提示让模型输出 JSON 时一定要在 prompt 里给一个完整的示例并且明确说只输出 JSON不要任何解释文字。即便如此仍要写容错解析因为模型偶尔会忘记。4.2 并发调用与限流一个 PR 可能有几十个文件串行调用模型太慢。我用concurrent.futures.ThreadPoolExecutor做并发默认 4 个 worker。为什么是 4 而不是更多因为本地跑的模型比如通过 Ollama 或 vLLM 部署的通常只有有限的并发能力开太多反而排队。如果是调用远程 API可以调到 8 到 16但要配合限流。限流我用的是简单的令牌桶每秒放行 N 个请求。这里踩过一个坑一开始没做限流20 个文件同时发出去远程 API 直接返回 429然后我的重试逻辑又同时重试形成雪崩。后来加了指数退避加随机抖动才稳定下来。import time, random def call_with_retry(fn, max_retries3): for i in range(max_retries): try: return fn() except RateLimitError: sleep (2 ** i) random.uniform(0, 1) time.sleep(sleep) raise RuntimeError(重试耗尽)指数退避的2 ** i保证每次等待翻倍随机抖动避免多个线程同时重试撞在一起。这个模式在任何调用外部服务的场景都适用不只是 LLM。4.3 结果聚合与去重并发调用回来的是一个个分片的审查结果需要合并。合并时有两个问题同一文件多个分片的意见要按行号排序不同分片可能对同一行提出重复意见因为分片有重叠上下文。去重策略是按(file, line, category)三元组做键保留 severity 最高的那条。如果两条意见 message 高度相似用简单的编辑距离或 Jaccard 相似度判断也合并。实测下来重叠上下文导致的重复意见大概占 5% 到 10%不去重的话报告会很啰嗦。聚合后按 severity 排序critical 在前info 在后。渲染时用 rich 的表格critical 标红warning 标黄info 标蓝。最后给一个统计摘要本次审查共发现 X 个 critical、Y 个 warning、Z 个 info涉及 N 个文件。5. 实测中暴露的问题与修复过程5.1 模型对删除行的误判最早版本里我把 diff 的增删行原样喂给模型结果模型经常对被删除的代码提意见。比如删掉了一行有 bug 的代码模型却说这行代码有风险。原因是它没理解-前缀的含义。修复方法是在 prompt 里明确说明 diff 格式并且在变更内容前加一段说明以下 diff 中以-开头的行是被删除的旧代码以开头的行是新增代码以空格开头的是未改动的上下文。请只对新增代码和上下文提出审查意见不要对被删除的代码提意见。 加了这段之后误判率从大概 15% 降到 2% 以下。这个坑的本质是模型不知道你的输入格式约定。任何把结构化数据喂给模型的场景都要显式说明格式不能假设模型应该懂。5.2 行号对不上的问题报告里的行号一开始经常对不上用户点过去发现是空行或者别的函数。排查后发现两个原因。一是模型返回的行号是相对于 hunk 的偏移而不是文件绝对行号。二是\ No newline at end of file这类特殊行影响了计数。修复方案是在 prompt 里明确要求模型返回文件绝对行号并且在变更内容里把每个 hunk 的起始行号标出来。同时在解析器里正确处理特殊行不把它们计入行号。为了验证我写了个测试构造一个已知行号的 diff跑一遍审查断言返回的行号落在预期范围内。这个测试后来成了回归测试的一部分。5.3 大文件导致的超时有个 PR 改了一个 3000 行的配置文件虽然我做了分片但每个分片仍然很大模型响应慢加上并发整体超时。后来我加了两条规则一是对配置文件、数据文件这类低审查价值的大文件默认只审查前 500 行并提示文件过大仅审查前 500 行二是给每个 LLM 调用设置独立的超时默认 60 秒超时就跳过该分片并在报告里标记未完成审查。这里的原则是宁可漏审不可卡死。一个审查工具如果跑十分钟还没结果用户就不会再用第二次。快速给出 80% 的价值比慢慢给出 100% 更有用。5.4 中文注释和字符串的处理团队代码里有大量中文注释早期版本模型对中文的处理不稳定有时把中文注释当成乱码。排查发现是core.quotepath的问题——git 默认会把非 ASCII 路径转义。加上-c core.quotepathfalse之后路径正常了但文件内容里的中文本来就没问题。另外在 prompt 里加一句代码中可能包含中文注释和字符串请正常理解模型的表现会更好。6. 配置、集成与日常使用心得6.1 配置文件长什么样工具的所有行为都通过一个.ocr.yaml配置放在仓库根目录。完整示例如下model: provider: openai_compatible base_url: http://localhost:8000/v1 api_key: ${OCR_API_KEY} name: deepseek-coder temperature: 0.2 max_tokens: 2048 timeout: 60 review: concurrency: 4 max_file_lines: 2000 max_hunk_lines: 200 rules: - correctness - security - performance - maintainability - testing ignore: - *.lock - dist/** - **/*.min.js output: format: table show_info: true fail_on_critical: truetemperature设 0.2 是因为代码审查需要稳定、可复现的输出太高会每次给出不同意见。fail_on_critical设为 true 时如果发现 critical 问题进程退出码非零可以直接接进 CI 流水线做门禁。api_key用${OCR_API_KEY}从环境变量读取避免密钥写进配置文件提交到仓库。这是基本的安全习惯但确实见过有人把 key 硬编码进去然后推到公开仓库。6.2 接进 CI 和 Git hooks最常见的两种集成方式。一是pre-commit hook提交前自动跑一次审查发现 critical 就阻止提交#!/bin/sh ocr review --staged --fail-on-critical放在.git/hooks/pre-commit并加执行权限即可。但要注意pre-commit 里跑 LLM 会拖慢提交速度如果模型响应慢开发者会烦。我的做法是 pre-commit 只跑快速规则比如正则匹配敏感信息完整审查放到 CI。二是CI 集成在流水线里加一步- name: Code Review run: | pip install open-code-review ocr review --range origin/main..HEAD --format markdown review.md env: OCR_API_KEY: ${{ secrets.OCR_API_KEY }}输出 markdown 后可以贴到 PR 评论里。这里的关键是模型服务要能被 CI 访问——如果模型跑在本地开发机CI 就够不着得部署一个内网可访问的推理服务。6.3 几个提升效果的小技巧第一给模型提供项目背景。在配置里加一个context字段写清楚项目是干什么的、用了什么框架、有哪些约定。比如这是一个 Django 项目所有数据库操作必须走 ORM禁止裸 SQL。模型知道这些之后提的意见会贴合项目规范而不是泛泛而谈。第二定期更新审查规则。团队踩过的坑应该沉淀成规则。比如曾经因为没做参数校验出过线上问题就把所有外部输入必须校验加进规则。规则越贴合团队实际工具越有用。第三不要迷信模型的每一条意见。LLM 会有幻觉会提出不存在的问题。我的做法是把 severity 为 info 的意见默认折叠只展开 critical 和 warning减少噪音。审查意见是辅助最终判断还是人来做。第四关注 token 成本。如果用的是按量计费的 API一个大 PR 可能花掉几块钱。我统计过剔除锁文件和产物后平均每个 PR 的审查成本在可接受范围内。但如果团队 PR 特别频繁建议用本地模型边际成本几乎为零。6.4 关于模型选型的实际对比我试过几种模型跑同一批 diff感受如下仅代表个人实测环境模型类型审查质量速度部署难度适合场景本地 7B 代码模型中等能抓明显问题快低日常快速自查本地 32B 代码模型较好边界条件也能抓中等中团队内网部署远程大模型 API最好理解力强取决于网络低对质量要求高的场景选型的核心权衡是质量、速度、隐私三者不可兼得。内网部署牺牲一点质量换隐私和成本远程 API 换质量但代码要出内网。没有标准答案看团队约束。7. 后续可以继续打磨的方向工具目前能稳定跑但还有几个我想做的改进。一是增量审查——记住上次审查到哪个 commit只审新增部分避免重复劳动。二是意见学习——记录开发者对每条意见的采纳或忽略用这些反馈微调 prompt让工具越来越懂团队的偏好。三是多语言规则包——不同语言Python、Go、Java的常见问题不一样做成可插拔的规则包会更专业。我在实际使用中最大的体会是代码审查工具的价值不在于替代人而在于把人从重复劳动里解放出来。格式问题、明显的空指针、忘记加校验这类低级错误交给工具架构设计、业务逻辑是否合理这类需要上下文判断的留给人。分工清楚了工具才真正有用而不是变成一个制造噪音的负担。