深入解析 open-code-review 的混合架构:从ocr review回车到 JSON 落地的完整流水线
【免费下载链接】open-code-reviewFast, efficient, battle-tested at Alibaba's scale. Hybrid architecture code review tool: deterministic pipelines + LLM Agent, precise line-level comments, built-in multi-language ruleset (NPE, thread-safety, XSS, SQL injection), OpenAI & Anthropic compatible.项目地址: https://gitcode.com/GitHub_Trending/op/open-code-review
本文是一份面向源码读者的架构导览,完整梳理开源项目 open-code-review 在用户执行ocr review之后,内部如何完成「diff 加载 → 文件过滤 → 语义分组 → 按组并行子任务 → 评论处理 → 输出」的全链路。文章以 pages/src/content/docs/zh/architecture.md 为骨架,结合仓库源码与测试逐一印证各阶段实现细节,帮助读者建立足够的心智模型,从而有能力调试行为、调优参数,并有把握地直接阅读源码。
高层流水线:一次评审的六个阶段
从按下回车到 JSON 落在终端,ocr review内部遵循一条确定性的流水线:
- bootstrap——解析 LLM 端点(配置 → 环境变量 → rc 文件),加载模板、工具注册表与系统规则;
- diff provider——通过
git diff/ls-files/show产出[]model.Diff,支持 Workspace、Commit、Range 三种模式; - filter & rules——五重门过滤器(
preview.go)剔除二进制文件、排除路径、不支持扩展名,并为每个文件挑选规则; - semantic grouping——对文件元数据做一次 LLM 调用,把相关文件聚成组(每组最多 10 个文件);
- subtask dispatch——每组在独立 goroutine 中并行执行(并发度
--concurrency,默认 8):可选 Plan 阶段 → main 循环多轮 → 产出评论; - output writer——同步行号解析与评审过滤,按
--format/--audience渲染为 text 或 JSON。
编排逻辑位于 internal/agent/ 包,主要文件:agent.go(分发与按组编排)、grouping.go(语义文件分组)、preview.go(文件过滤)和util.go(辅助);工具调用循环与记忆压缩位于相邻的 internal/llmloop/。两个关键入口值得关注:Agent.Run(流水线顶部)与Agent.dispatchSubtasks(per-group 扇出)。
在 internal/agent/agent.go 中可以看到Run的实际执行顺序:先loadDiffs解析 diff(包在diff.parsetelemetry span 内),随后injectDiffMap构造只读 DiffMap 供file_read_diff工具查询,filterDiffs过滤,再进入dispatchSubtasks并行分发,最后finalizeManifest+session.Finalize落盘会话记录。若过滤后没有任何可评审文件,Run会直接打印[ocr] No supported files changed. Skipping review.并以 skipped 状态的 manifest 正常退出。
diff provider:三种模式与 diff 数据结构
internal/diff/git.go 定义了Provider结构,其未导出字段mode(类型为Mode,一个int枚举)选择与 CLI 参数对应的三种模式:
| 模式 | 触发方式 | 返回内容 |
|---|---|---|
Workspace | 无参数 | staged + unstaged + untracked 变更 |
Commit | --commit <sha>/-c <sha> | <sha>引入的变更(经git show <sha>,等价于<sha>^..<sha>diff) |
Range | --from <a> --to <b> | merge-base(a, b)..b |
每个 diff 携带:old/new path、old/new hunk、插入/删除计数、二进制标志、重命名检测。DiffContextLines固定为3——与 Git 默认一致(见 internal/diff/git.go)。
实现上三种模式使用不同 git 子命令:
- Range:先
git merge-base <from> <to>计算共同祖先,再git diff -U3 --find-renames <base> <to>; - Commit:使用
git show --diff-merges=first-parent。源码注释明确说明,对于 merge commit,普通git show会输出diff --cc组合格式,而ParseDiffText无法解析这种格式——所以特意与第一父提交比较,输出标准 unified 格式(internal/diff/git.go); - Workspace:先
git diff HEAD,若仓库尚无提交(HEAD不存在),回退到git diff --staged,保证首个 commit 前的工作区也能被评审;untracked 文件通过git ls-files --others --exclude-standard枚举后从磁盘读取,作为整文件新增(--- /dev/null→+++ b/<path>)拼入 diff。
注意一个边界细节:commit 模式对 merge commit 按第一父提交比较,ResolveInput在固化 manifest 输入时也会记录这一具体比较基准(parents[0]..head)。
五重门文件过滤:whyExcluded 与默认排除
diff 加载后,每个文件经过 internal/agent/preview.go 中的whyExcluded。该函数返回以下之一:
binary — file is binary user_exclude — matched a pattern in your `exclude` list unsupported_ext — extension is not in supported_file_types.json default_path — matched a built-in test-file exclude pattern……或文件被保留时返回空。deleted不由whyExcluded返回;它在Preview()中随后计算——当一个被保留文件的 diff 报告IsDeleted时。各门按以下顺序执行:
- binary——二进制文件先被丢弃;
- user_exclude——项目配置的
exclude总是优先; - user_include——若配置了 include 模式且文件匹配其一,立即保留(返回空),绕过下面的
unsupported_ext和default_path门; - unsupported_ext——按扩展名白名单过滤(依据
internal/config/allowlist/supported_file_types.json); - default_path——最后一道门:匹配内置测试文件排除模式(
**/*_test.go、**/*.test.{js,jsx,ts,tsx}、**/__tests__/**、**/*_test.py、**/*_spec.rb、**/*.test.ets……)。每个模式都以**/作为根前缀。
噪声目录过滤(vendor/、node_modules/、target/……)发生在更早的阶段,位于 diff-provider 层,通过 internal/diff/git.go 中的providerDirIgnoreDirs列表——这些目录的 diff 被解析后由filterDiffs剔除(internal/diff/git.go),永远不会到达 per-file 过滤器。此外isPathExcluded还会解析仓库根目录的.gitignore,遵循 git 的 last-match-wins 语义,支持!否定与**globstar 模式(经 doublestar 实现)。
运行ocr review --preview可不花 token 查看完整过滤结果,输出每个文件的WillReview与ExcludeReason。完整算法见 评审规则。
语义文件分组:一次 LLM 调用替代人工分类
过滤之后,OCR 先做一次GROUPING_TASKLLM 调用(internal/agent/grouping.go),只把文件元数据(路径、状态、+/-行数)发给模型——不含 diff 内容——请它把语义相关的文件聚成一组,一起评审。通常会归为一组的文件包括:同一模块 / 特性、存在生产者/消费者关系(接口与实现)、同一资源的 i18n / 配置变体、以及同目录下协作完成同一件事的文件。
约束与兜底:
- 每个文件恰好属于一个组;单个组最多
maxFilesPerGroup = 10个文件(internal/agent/grouping.go),超出由enforceMaxFilesPerGroup拆分为多个组; - 一组 diff 的合计 token 超过限制时,该组被拆成单文件组(
enforceGroupTokenBudget,见 internal/agent/grouping.go); - 分组调用失败、返回空或只有 1 个文件时,退化为每文件一组的分发方式。
分组并非总是调用 LLM。Template.GroupingPlan(internal/config/template/template.go)依据两个阈值决定策略:GROUPING_MIN_FILES = 4之下不值得发一次 LLM 调用(空间太小,调用买不到信息);随后GROUPING_BUNDLE_LINE_THRESHOLD = 200决定小变更集是否整体作为一组(bundle_all),超过上限则按文件分组(per_file)。parseGroupingResponse还会宽容地剥掉模型可能输出的 markdown 代码围栏,并跳过重复或未知的文件路径(internal/agent/grouping.go)。
每组子任务:plan + main 两阶段
对每个文件组,OCR 启动一个子 agent。每个子 agent 在自己的 goroutine 中运行,受--concurrency(默认8)约束,并有独立的 LLM 消息缓冲区。在 internal/agent/agent.go 中,并发通过带缓冲的 channel 信号量实现:concurrency <= 0时默认取 8。
一个子任务最多有两个阶段:
阶段 1——Plan(可选)
// template.PlanRequired(fileCount, totalChanged, maxFileChanged) PlanModeLineThreshold = 50 // 组内单文件最大变更行数 PlanModeGroupLineThreshold = 100 // 多文件组的合计变更行数 if maxFileChanged >= 50 { run plan } // 单文件大改 if fileCount >= 2 && totalChanged >= 100 { run plan } // 多个中等改动 otherwise { skip plan }两个阈值协同工作(见 internal/config/template/template.go 的PlanRequired):PLAN_MODE_LINE_THRESHOLD看组内最大的那个文件,PLAN_MODE_GROUP_LINE_THRESHOLD看整组的合计变更量;后者故意取更大的值,以免 plan 阶段变成无条件执行。
对小 diff,plan 只会增加延迟、没有价值,因此被静默跳过,main 循环直接运行。对较大 diff,OCR 做一次单次PLAN_TASKLLM 调用——不发送Tools字段,因此模型在 plan 期间不能调用工具。只读工具子集(code_search、file_read_diff、file_find——tools.json中plan_task标志为true的那三个)作为纯文本通过{{plan_tools}}占位符(由formatToolDefs渲染)嵌入,让模型知道后续可用什么。模型返回一份清单,作为 main prompt 中的{{plan_guidance}}。
阶段 2——main 循环(多轮)
main 循环组装MAIN_TASKprompt,与模型展开工具调用对话。完整工具集在 plan 阶段工具基础上加task_done、code_comment和file_read——完整清单见 工具。
整个 main 循环最多重复MAX_REVIEW_ROUNDS次(由--effort控制:low= 1 轮,medium= 2 轮(默认),high= 3 轮)。effort 的实现位于 internal/config/template/effort.go,ApplyEffort直接改写模板的MaxReviewRounds字段。第 2 轮起会剥离 plan 结果(避免它成为召回上限),并把上一轮已确认的评论作为「已发现」上下文回传,让模型去找新问题。某一轮没有新增发现,或已确认评论数达到上限时,提前停止。
main 循环的核心实现在 internal/llmloop/loop.go 的RunMainTask:
loop up to MAX_TOOL_REQUEST_TIMES (default 100): response = llm.complete(messages, tools) if response.toolCalls is empty: nudge model with "You did not successfully call any tools. Please try again or use task_done if finished." continue for each call: execute → collect result if any call was task_done: break addNextMessage(...) # may trigger compression循环有五个退出条件:
- 调用了
task_done; MAX_TOOL_REQUEST_TIMES(默认 100)耗尽;- 连续 3 轮未产生有效工具结果(
maxConsecutiveEmptyRounds = 3); - context 被取消;
addNextMessage返回 false——压缩无法把消息缓冲区压回警告阈值以下。
无论哪种情况,已收集的code_comment调用都成为评审评论。此外,当工具请求预算耗尽时(条件 2),RunMainTask会调用runGraceRound执行最后一轮「宽限轮」:只暴露code_comment与task_done两个工具,给模型一个提交已识别但尚未上报发现的最后机会(internal/llmloop/loop.go)。五种停止原因通过MainLoopStop枚举精确分类(StopMaxRounds/StopEmptyRounds/StopCompression等),并映射为稳定的、可直接写入机器可读输出的人读字符串。
记忆压缩:三分区策略
长的工具调用循环最终会溢出上下文窗口。OCR 用三分区策略管理,触发于MAX_TOKENS = 200000定义的 token 预算(见 internal/config/template/task_template.json)。注意MAX_TOKENS只是提示词上限;模型的输出上限由单独的MAX_COMPLETION_TOKENS = 16384控制(internal/config/template/task_template.json),因此用--max-tokens抬高提示词上限不会连带放大输出预算——CompletionTokenLimit()恒返回独立的输出上限(internal/config/template/template.go)。
| 阈值 | 常量 | 动作 |
|---|---|---|
| MAX_TOKENS 的 60% | tokenSoftThreshold | 启动异步后台压缩;当前循环不中断继续。 |
| MAX_TOKENS 的 80% | tokenWarningThreshold | 在发送下一个请求前同步运行压缩。 |
两个阈值常量定义于 internal/llmloop/compression.go,PromptTokenLimit即 80% 阈值,同时被 agent、scan 的前置检查与computeActiveZoneSize复用,保证全仓库对阈值只有单一来源。
三个区
frozen: 前 2 条消息(system + initial user),永不压缩 compress: 被压缩成一条 user 消息 active: 最近的 K 个完整轮次(保留在上下文中)一「轮」是一条 assistant 消息加上其后跟随的工具结果消息。partitionMessages(internal/llmloop/compression.go)从末尾向前遍历轮次,保留能装入(0.80 × MAX_TOKENS) - reservedTokens的尽可能多的轮。更早的内容成为compress 区。
compress 区被渲染为 XML(buildMessageXML产出<message id role><content>…</content></message>),用MEMORY_COMPRESSION_TASKprompt 交给模型;返回的摘要被追加到原始 user 消息内,包在<previous_review_summary>标签里。
压缩后:messages = frozen[2] + compressed_user_msg + active。核心逻辑见 internal/llmloop/compression.go 的runCompression:摘要为空或压缩失败时返回原消息(保留上下文比临时超限更好),成功时重建[frozen] + [summary 追加进 user msg] + [active]。
异步 vs 同步
异步路径让 main 循环在后台压缩运行时继续产出工具调用;当下一次 token 检查发生时,已就绪的摘要会通过tryApplyPendingCompression应用(internal/llmloop/compression.go)。异步任务带 5 分钟超时(context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)),并注册进bg sync.WaitGroup,由Runner.WaitBackground()在 run 边界汇合。若比例在异步任务完成前越过警告阈值,循环会停顿并同步运行runCompression——保证下一个请求总是装得下。注意compressionState是每个对话(一次RunMainTask)私有的:Runner 被多个并发子任务共享,若把 pending job 挂在 Runner 上,一个子任务就可能覆盖或取消另一个子任务的压缩任务。
评论处理流水线
每个code_comment工具调用产出一条或多条原始评论。它们经过一个CommentWorkerPool(固定大小 goroutine 池),使主工具调用循环永不阻塞在后处理上(internal/llmloop/loop.go):
- 行解析(worker 内)——
existing_code用滑动窗口算法与 diff 匹配以计算精确的start_line/end_line。匹配失败则两者默认为0——0行范围是「未锚定」评论的隐式信号,用户需手动定位(没有存储标志;下游消费者检查start_line == 0); - 重新定位任务(可选回退)——当行解析在较复杂的 diff 上失败时,OCR 运行
RE_LOCATION_TASKprompt,请模型重新锚定片段。对改写过的existing_code字符串有用; - 评审过滤——main 循环结束后(worker 池排空),
REVIEW_FILTER_TASKLLM 调用对照 diff 检查收集到的评论,移除可证明为错的评论。此处错误被记录并忽略; - 第二轮行解析——
Agent.Run返回后,顶层命令对完整评论集重跑diff.ResolveLineNumbers(见 cmd/opencodereview/review_cmd.go),以捕获existing_code跨多文件或被重新定位步骤更新的评论。跨文件场景下,RelocateAcrossFiles会把针对 A 文件但实际描述 B 文件代码的评论重新归档到 B(此时产生comment_refiled警告); - 渲染——按
--format渲染为 text 或 JSON。
异步路径使用context.WithoutCancel(ctx)派生上下文,确保父 context 取消后 worker 仍能完成已提交的解析任务;worker 的结论通过CommentCollector统一收集,CollectPendingComments在会话结束时汇合所有 worker。
token 预算守卫:fail-fast 与三道防线
在调用 LLM 之前,OCR 先做一个 fail-fast 检查(见 internal/agent/agent.go 与 llmloop 前置逻辑):
tokenLimit := MaxTokens * 4 / 5 // 80 % if countMessagesTokens(messages) > tokenLimit { record warning "token_threshold_exceeded" return nil // skip this group }这会在巨大 diff(自动生成的 lock 文件、触及数千行的重构)耗费请求之前把它们拦截下来。被跳过的那一组作为非致命警告在 stdout 报告,并加入 JSONwarnings数组。
除此之外还有两道守卫:第二个检查在filterLargeDiffs中运行——若单个 diff 单独超过MAX_TOKENS的 80%,它在分组与分发发生之前就被过滤掉,全部超大时打印[ocr] All changed files exceeded the token size limit. Skipping review.并以 skipped 状态退出;第三道守卫在分组内部运行——即前文提到的enforceGroupTokenBudget,把合计超限的组拆成单文件组。此外,若启用了--max-tokens-budget聚合预算,dispatchSubtasks会在获取信号量之前做 per-group 前瞻:已用 token 加上该组预估若超预算,则停止调度剩余组并置位BudgetExceeded(已派发的组允许跑完,超支被限制在并发数以内)。注意预算耗尽被设计为「受控的覆盖截断」,不设置 run-level 失败。
模板与占位符
internal/config/template/task_template.json 含六个 prompt:
| Key | 用途 |
|---|---|
GROUPING_TASK | 把变更文件聚成语义相关的组。 |
PLAN_TASK | plan 阶段——产出清单。 |
MAIN_TASK | main 评审循环——发出code_comment调用。 |
MEMORY_COMPRESSION_TASK | 摘要 compress 区。 |
REVIEW_FILTER_TASK | 循环后移除可证明为错评论的流程。 |
RE_LOCATION_TASK | 为existing_code无法匹配的评论重新锚定。 |
每个 prompt 是一个{role, prompt_file}引用列表,指向模板目录中的.md文件(如{"role": "system", "prompt_file": "main_task_system.md"})。加载时resolveConversation(internal/config/template/template.go)用embed.FS把这些文件读入内存中的{role, content}消息,随后模板占位符按组解析:
| 占位符 | 替换为 |
|---|---|
{{system_rule}} | 从四层链解析出的规则正文。 |
{{change_files}} | 本次变更中不属于当前组的其他文件的状态 + 路径。 |
{{diffs}} | 当前组内所有文件的 diff,逐个包在<file>元素中,整体放在<review_files>里。 |
{{file_list}} | (仅GROUPING_TASK)变更文件的元数据清单:路径、状态、+/-行数。 |
{{plan_guidance}} | plan 阶段的输出,plan 被跳过时移除。 |
{{confirmed_comments}} | 前几轮已确认的发现;第 1 轮为空并被移除。 |
{{plan_tools}} | plan 阶段工具定义的纯文本(由formatToolDefs渲染),用于PLAN_TASKsystem prompt。 |
{{requirement_background}} | --background或--background-file的有效内容(文件优先)。 |
{{current_system_date_time}} | 运行的本地时间戳,格式YYYY-MM-DD HH:MM(无秒或时区)。 |
{{context}} | (仅压缩)要摘要的 XML 渲染消息。 |
{{path}} | 组的 key(组内文件路径,逗号分隔),用于REVIEW_FILTER_TASK。 |
{{comments}} | 累积的评论(JSON),用于REVIEW_FILTER_TASK。 |
占位符替换位于 internal/agent/agent.go。模板本身不是 CLI 覆盖——要修改 prompt,你需要编辑 internal/config/template/task_template.json 并重新构建。--tools参数是工具注册表覆盖(它替换internal/config/toolsconfig消费的 JSON),不是模板覆盖——见 工具。
占位符语法注意。以上所有占位符都使用双花括号
{{…}}语法,除了RE_LOCATION_TASK,它替换单花括号的{diff}、{existing_code}和{suggestion_content}(见 internal/diff/relocation.go)。
另外,模板中的MAX_TOOL_REQUEST_TIMES = 100、GROUPING_MIN_FILES = 4、GROUPING_BUNDLE_LINE_THRESHOLD = 200、MAX_REVIEW_ROUNDS = 2等标量同样在此文件中定义,并由LoadDefault反序列化后供各阶段消费(internal/config/template/template.go)。
持久化:append-only JSONL 会话
每次评审以 JSONL 写入磁盘:
~/.opencodereview/sessions/<encoded-repo-path>/<session-id>.jsonl仓库路径不做 base64 编码;encodeRepoPath(在 internal/session/persist.go)把/和\替换为-、:替换为_,使路径对文件系统安全。
每行是一个事件:发送的 prompt、LLM 响应、工具调用、工具结果、发出的评论等。Web UI(ocr viewer)直接读这些文件——没有数据库,只有 append-only 日志。UI 导览与事件 schema 见 会话查看器。
遥测:span 与 event 的边界
启用遥测后,agent 发出三个流水线级 span(review.run包裹整个作业、diff.parse包裹 diff 加载、每个被评审的组一个subtask.execute.group.<group-key>),加上每个决策点一个短生命周期的event.<name>span(plan.skipped、token.threshold.exceeded、subtask.error……)。LLM 往返和工具调用仅作为 metrics 记录——不作为 span。prompt 与响应内容绝不附加到遥测;OCR_CONTENT_LOGGING标志已接入但目前是死代码。完整 schema 见 遥测。
哪些不自动化
一些决策有意保持手动,这让运行按组确定性,并让成本可预测:
- 端点发现没有回退。若你的 config + env + rc 文件给不出完整的
(URL, token, model)三元组,OCR 以非零码退出,而非猜测。对应实现见 internal/llm/resolver.go; - 子 agent 失败被隔离,不重试。一个失败的组产生一条警告;其余继续。重试属于包裹它的 CI 流水线,而非 agent。从源码看,
dispatchSubtasks中单组失败通过subtaskFailed原子计数与RecordReviewItemFailed记录,只有全部派发组都失败且无复用结果时才返回聚合错误; - 跨文件推理以组为边界。同一语义组内的文件共享一个 LLM 对话,因此 agent 可以直接在它们之间推理。其他组的文件只能通过
file_read_diff/code_search工具调用触达,不共享上下文,其中的发现也禁止作为评论目标——main_taskprompt 指示模型仅将上下文工具用于理解,并忽略在给定 diff 之外出现的问题。
源码地图
若你想对照阅读:
| 关注点 | 文件 |
|---|---|
| 顶层命令分发 | cmd/opencodereview/main.go |
review参数解析 | cmd/opencodereview/shared_flags.go |
| agent 编排 | internal/agent/(agent.go、util.go) |
| 语义文件分组 | internal/agent/grouping.go |
| 工具调用循环与记忆压缩 | internal/llmloop/(loop.go、compression.go) |
| effort 档位 | internal/config/template/effort.go |
| 文件过滤 / 预览 | internal/agent/preview.go |
| diff 加载(Git 模式) | internal/diff/git.go |
| 规则解析链 | internal/config/rules/system_rules.go |
| 工具注册表与实现 | internal/tool/ |
| LLM 端点解析器 | internal/llm/resolver.go |
| 会话 JSONL 写入器 | internal/session/persist.go |
| Web 查看器 | internal/viewer/server.go |
构建与测试说明见 贡献指南。
另见
- 工具——agent 循环调用的六种工具。
- 评审规则——按文件的规则文本如何解析。
- 会话查看器——检查此流水线写出的转录。
【免费下载链接】open-code-reviewFast, efficient, battle-tested at Alibaba's scale. Hybrid architecture code review tool: deterministic pipelines + LLM Agent, precise line-level comments, built-in multi-language ruleset (NPE, thread-safety, XSS, SQL injection), OpenAI & Anthropic compatible.项目地址: https://gitcode.com/GitHub_Trending/op/open-code-review
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考