第 3 章 · 02 架构


第 3 章 · 02 架构

本节摘要:本节是从「你按下回车」到「JSON 落在终端」的端到端架构导览,帮你建立调试行为、调优参数、读源码所需的心智模型。我们走完整流水线:bootstrap 解析端点 → diff provider 出三种模式 → 五重门过滤 → per-file 子 agent(plan + main 两阶段)→ 评论处理(行解析、重新定位、评审过滤)→ 渲染输出;并讲清记忆压缩的三分区策略、token 预算守卫、模板占位符与持久化。读完你能解释「OCR 凭什么知道这里有缺陷」,并理解它的能力边界。

内容来源:原项目中文文档 pages/src/content/docs/zh/architecture.md,套用体系化模板改写。

学习目标

阅读完本节,你应当能够:

  1. 描述 OCR 的六段高层流水线(bootstrap → diff → filter → dispatch → output → persist)。
  2. 说明 per-file 子任务的两阶段(plan + main)及 plan 的跳过条件。
  3. 解释记忆压缩的三分区(frozen / compress / active)与异步/同步触发。
  4. 描述评论处理流水线的五步(行解析、重新定位、评审过滤、二轮行解析、渲染)。
  5. 说明 token 预算守卫的两道检查(80% fail-fast 与 filterLargeDiffs)。
  6. 列出 OCR 有意不自动化的设计选择及其原因。

一、高层流水线

编排逻辑位于 internal/agent/ 包,分布在四个文件:agent.go(主循环与分发)、compression.go(记忆压缩)、preview.go(文件过滤)和 util.go(辅助)。两个入口点值得关注:Agent.Run(流水线顶部)和 Agent.dispatchSubtasks(per-file 扇出)。

二、diff provider

internal/diff/git.go 定义了一个 Provider 结构,其未导出字段 mode 选择与 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 默认一致。untracked 文件从磁盘读取并作为整文件新增处理,以便 commit 前评审。

三、五重门文件过滤

diff 加载后,每个文件经过 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() 中随后计算。各门顺序与完整算法见本章评审规则一节,这里只强调一点:噪声目录(vendor/、node_modules/、target/……)的过滤发生在更早的 diff-provider 层,通过 providerDirIgnoreDirs 列表剔除,永远不会到达 per-file 过滤器。

四、per-file 子任务:plan + main

对每个通过过滤的文件,OCR 启动一个子 agent。每个子 agent 在自己的 goroutine 中运行,受 --concurrency(默认 8)约束,并有独立的 LLM 消息缓冲区。

一个子任务最多有两个阶段:

阶段 1——Plan(可选)

threshold := template.PlanModeLineThreshold // 50 changeLines := d.Insertions + d.Deletions if changeLines < threshold { skip plan } ​

对小 diff,plan 只会增加延迟、没有价值,因此被静默跳过,main 循环直接运行。对较大 diff,OCR 做一次单次 PLAN_TASK LLM 调用——不发送 Tools 字段,因此模型在 plan 期间不能调用工具。只读工具子集(code_search、file_read_diff、file_find)作为纯文本通过 {{plan_tools}} 占位符嵌入,让模型知道后续可用什么。模型返回一份清单,作为 main prompt 中的 {{plan_guidance}}。

阶段 2——main 循环

main 循环组装 MAIN_TASK prompt,与模型展开工具调用对话。完整工具集在 plan 阶段工具基础上加 task_done、code_comment 和 file_read——完整清单见第 4 章工具一节。

loop up to MAX_TOOL_REQUEST_TIMES (default 30): 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 ​

循环有五个退出条件:

  1. 调用了 task_done。
  2. MAX_TOOL_REQUEST_TIMES 耗尽。
  3. 连续 3 轮未产生有效工具结果(maxConsecutiveEmptyRounds = 3)。
  4. context 被取消。
  5. addNextMessage 返回 false——压缩无法把消息缓冲区压回警告阈值以下。

无论哪种情况,已收集的 code_comment 调用都成为评审评论。

五、记忆压缩

长的工具调用循环最终会溢出上下文窗口。OCR 用三分区策略管理,触发于 MAX_TOKENS = 58888 定义的 token 预算:

阈值 常量 动作
MAX_TOKENS 的 60% tokenSoftThreshold 启动异步后台压缩;当前循环不中断继续。
MAX_TOKENS 的 80% tokenWarningThreshold 在发送下一个请求前同步运行压缩。

三个区

一「轮」是一条 assistant 消息加上其后跟随的工具结果消息。partitionMessages 从末尾向前遍历轮次,保留能装入 (0.80 × MAX_TOKENS) - reservedTokens 的尽可能多的轮。更早的内容成为 compress 区。

compress 区被渲染为 XML,用 MEMORY_COMPRESSION_TASK prompt 交给模型;返回的摘要被追加到原始 user 消息内,包在 <previous_review_summary> 标签里。压缩后:messages = frozen[2] + compressed_user_msg + active。

异步 vs 同步

异步路径让 main 循环在后台压缩运行时继续产出工具调用;当下一次 token 检查发生时,已就绪的摘要会通过 tryApplyPendingCompression 应用。若比例在异步任务完成前越过警告阈值,循环会停顿并同步运行 runCompression——保证下一个请求总是装得下。

💡 技巧:想看压缩到底保留/丢弃了什么,在第 4 章会话查看器里看该文件的 memory_compression_task 泳道,Response 窗格即结果摘要。

六、评论处理流水线

每个 code_comment 工具调用产出一条或多条原始评论。它们经过一个 CommentWorkerPool(固定大小 goroutine 池),使主工具调用循环永不阻塞在后处理上:

  1. 行解析(worker 内)——existing_code 用滑动窗口算法与 diff 匹配以计算精确的 start_line / end_line。匹配失败则两者默认为 0——0 行范围是「未锚定」评论的隐式信号,用户需手动定位。
  2. 重新定位任务(可选回退)——当行解析在较复杂的 diff 上失败时,OCR 运行 RE_LOCATION_TASK prompt,请模型重新锚定片段。对改写过的 existing_code 字符串有用。
  3. 评审过滤——main 循环结束后(worker 池排空),REVIEW_FILTER_TASK LLM 调用对照 diff 检查收集到的评论,移除可证明为错的评论。此处错误被记录并忽略。
  4. 第二轮行解析——Agent.Run 返回后,顶层命令对完整评论集重跑 diff.ResolveLineNumbers,以捕获 existing_code 跨多文件或被重新定位步骤更新的评论。
  5. 渲染——按 --format 渲染为 text 或 JSON。

七、token 预算守卫

在调用 LLM 之前,OCR 先做一个 fail-fast 检查:

tokenLimit := MaxTokens * 4 / 5 // 80 % if countMessagesTokens(messages) > tokenLimit { record warning "token_threshold_exceeded" return nil // skip this file } ​

这会在巨大 diff(自动生成的 lock 文件、触及数千行的重构)耗费请求之前把它们拦截下来。被跳过的文件作为非致命警告在 stdout 报告,并加入 JSON warnings 数组。第二个检查在 filterLargeDiffs 中运行:若 diff 单独超过 MAX_TOKENS 的 80%,它在 per-file 分发器启动前就被过滤掉。

八、模板与占位符

internal/config/template/task_template.json 含五个 prompt:

Key 用途
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 文件。加载时 resolveConversation 把这些文件读入内存,随后模板占位符按文件解析:

占位符 替换为
{{system_rule}} 从四层链解析出的规则正文(本章评审规则一节)。
{{change_files}} PR 中其他每个变更文件的状态 + 路径。
{{diff}} 本文件的 diff(原始 git diff 输出)。
{{current_file_path}} 本文件的新路径。
{{plan_guidance}} plan 阶段的输出,plan 被跳过时移除。
{{plan_tools}} plan 阶段工具定义的纯文本,用于 PLAN_TASK system prompt。
{{requirement_background}} --background 参数内容。
{{current_system_date_time}} 运行的本地时间戳,格式 YYYY-MM-DD HH:MM。
{{context}} (仅压缩)要摘要的 XML 渲染消息。
{{path}} / {{comments}} 文件路径 / 累积评论(JSON),用于 REVIEW_FILTER_TASK。

⚠️ 注意:模板本身不是 CLI 覆盖——要修改 prompt,你需要编辑 task_template.json 并重新构建。--tools 参数是工具注册表覆盖,不是模板覆盖。另外,以上所有占位符都使用双花括号 {{…}} 语法,除了 RE_LOCATION_TASK,它替换单花括号的 {diff}、{existing_code} 和 {suggestion_content}。

九、持久化与遥测

持久化

每次评审以 JSONL 写入磁盘:

~/.opencodereview/sessions/<encoded-repo-path>/<session-id>.jsonl ​

仓库路径不做 base64 编码;encodeRepoPath 把 / 和 \ 替换为 -、: 替换为 _,使路径对文件系统安全。每行是一个事件:发送的 prompt、LLM 响应、工具调用、工具结果、发出的评论等。Web UI(ocr viewer)直接读这些文件——没有数据库,只有 append-only 日志。

遥测

启用遥测后,agent 发出三个流水线级 span(review.run 包裹整个作业、diff.parse 包裹 diff 加载、每个被评审文件一个 subtask.execute.<file>),加上每个决策点一个短生命周期的 event.<name> span(plan.skipped、token.threshold.exceeded、subtask.error……)。LLM 往返和工具调用仅作为 metrics 记录——不作为 span。prompt 与响应内容绝不附加到遥测。完整 schema 见第 4 章遥测一节。

十、哪些不自动化

一些决策有意保持手动:

  • 端点发现没有回退。 若你的 config + env + rc 文件给不出完整的 (URL, token, model) 三元组,OCR 以非零码退出,而非猜测。
  • 子 agent 失败被隔离,不重试。 一个失败文件产生一条警告;其余继续。重试属于包裹它的 CI 流水线,而非 agent。
  • 无跨文件推理。 每个文件在它自己的 LLM 对话中评审。跨文件问题通过 file_read_diff / code_search 工具调用,而非共享上下文。那些其他文件中的发现也禁止作为评论目标——main_task prompt 指示模型仅将上下文工具用于理解,并忽略在当前 diff 之外文件中出现的问题。

💡 技巧:这些选择让运行按文件确定性,并让成本可预测——这也是为什么 OCR 的退出码语义是「有成功子 agent 即 0」(见第 2 章 CLI 参考)。

十一、源码地图

若你想对照阅读:

关注点 文件
顶层命令分发 cmd/opencodereview/main.go
review 参数解析 cmd/opencodereview/flags.go
agent 编排与压缩 internal/agent/(agent.go、compression.go、util.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

构建与测试说明见第 6 章贡献一节。

本节要点回顾

  1. 六段流水线:bootstrap → diff → filter → dispatch → output → persist,核心编排在 internal/agent/。
  2. 两阶段子任务:plan(可选,变更 ≥ 50 行触发,只读无工具)→ main 循环(工具调用对话)。
  3. main 循环五退出:task_done / 轮数耗尽 / 连续 3 轮空 / context 取消 / 压缩失败。
  4. 记忆压缩三分区:frozen(前 2 条)+ compress(摘要)+ active(近 K 轮),60% 异步、80% 同步。
  5. 评论处理五步:行解析 → 重新定位(回退)→ 评审过滤 → 二轮行解析 → 渲染,经 CommentWorkerPool 异步。
  6. token 守卫:80% fail-fast + filterLargeDiffs,超限文件作非致命警告跳过。
  7. 五个模板占位符体系:{{system_rule}} 嵌入规则、{{diff}} 嵌入 diff、{{requirement_background}} 嵌入 --background。
  8. 有意不自动化:端点不猜测、子 agent 失败不重试、无跨文件推理——换来确定性与可预测成本。

架构讲完了。接下来进入第 4 章——工具、MCP 与会话,看 agent 循环里那六个内置工具到底怎么用,以及如何用会话查看器回看每一次评审。


作者与出处
原作者: 灏天文库
来源:alibaba
许可证:Apache-2.0
整理: 灏天文库整理
由灏天文库结构化整理,提供目录导航、全文检索与在线阅读,便于系统化学习
发布者: 作者: 灏天文库 转发
评论区 (0)
U