本节摘要:本节是从「你按下回车」到「JSON 落在终端」的端到端架构导览,帮你建立调试行为、调优参数、读源码所需的心智模型。我们走完整流水线:bootstrap 解析端点 → diff provider 出三种模式 → 五重门过滤 → per-file 子 agent(plan + main 两阶段)→ 评论处理(行解析、重新定位、评审过滤)→ 渲染输出;并讲清记忆压缩的三分区策略、token 预算守卫、模板占位符与持久化。读完你能解释「OCR 凭什么知道这里有缺陷」,并理解它的能力边界。
内容来源:原项目中文文档
pages/src/content/docs/zh/architecture.md,套用体系化模板改写。
阅读完本节,你应当能够:
编排逻辑位于 internal/agent/ 包,分布在四个文件:agent.go(主循环与分发)、compression.go(记忆压缩)、preview.go(文件过滤)和 util.go(辅助)。两个入口点值得关注:Agent.Run(流水线顶部)和 Agent.dispatchSubtasks(per-file 扇出)。
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 过滤器。
对每个通过过滤的文件,OCR 启动一个子 agent。每个子 agent 在自己的 goroutine 中运行,受 --concurrency(默认 8)约束,并有独立的 LLM 消息缓冲区。
一个子任务最多有两个阶段:
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}}。
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
循环有五个退出条件:
task_done。MAX_TOOL_REQUEST_TIMES 耗尽。maxConsecutiveEmptyRounds = 3)。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。
异步路径让 main 循环在后台压缩运行时继续产出工具调用;当下一次 token 检查发生时,已就绪的摘要会通过 tryApplyPendingCompression 应用。若比例在异步任务完成前越过警告阈值,循环会停顿并同步运行 runCompression——保证下一个请求总是装得下。
💡 技巧:想看压缩到底保留/丢弃了什么,在第 4 章会话查看器里看该文件的
memory_compression_task泳道,Response 窗格即结果摘要。
每个 code_comment 工具调用产出一条或多条原始评论。它们经过一个 CommentWorkerPool(固定大小 goroutine 池),使主工具调用循环永不阻塞在后处理上:
existing_code 用滑动窗口算法与 diff 匹配以计算精确的 start_line / end_line。匹配失败则两者默认为 0——0 行范围是「未锚定」评论的隐式信号,用户需手动定位。RE_LOCATION_TASK prompt,请模型重新锚定片段。对改写过的 existing_code 字符串有用。REVIEW_FILTER_TASK LLM 调用对照 diff 检查收集到的评论,移除可证明为错的评论。此处错误被记录并忽略。Agent.Run 返回后,顶层命令对完整评论集重跑 diff.ResolveLineNumbers,以捕获 existing_code 跨多文件或被重新定位步骤更新的评论。--format 渲染为 text 或 JSON。在调用 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 章遥测一节。
一些决策有意保持手动:
(URL, token, model) 三元组,OCR 以非零码退出,而非猜测。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 章贡献一节。
internal/agent/。task_done / 轮数耗尽 / 连续 3 轮空 / context 取消 / 压缩失败。filterLargeDiffs,超限文件作非致命警告跳过。{{system_rule}} 嵌入规则、{{diff}} 嵌入 diff、{{requirement_background}} 嵌入 --background。架构讲完了。接下来进入第 4 章——工具、MCP 与会话,看 agent 循环里那六个内置工具到底怎么用,以及如何用会话查看器回看每一次评审。