总 序言:从"工程黑魔法"到"论文谱系"
读 OCR 源码时,会反复撞见一些"看起来很 magic"的工程细节:
- 为什么 token 阈值是 60% 和 80%?
- 为什么评论定位先用文本滑动窗口,失败了才请 LLM 重写?
- 为什么 compression state 要 per-conversation 而不是放在 Runner 上?
- 为什么 plan 阈值正好是 50 行?
这些数字与选择不是拍脑袋。每一个都能挂回一篇论文或一个经典思想原型。本篇的任务就是把"工程黑魔法"还原为"论文谱系" ------让每一个看起来神秘的工程决策,都变得可解释、可推导、可记忆。

八种机制一览表
| # | 机制 | 核心论文/思想 | OCR 落点 |
|---|---|---|---|
| ① | 分治式并行子任务调度 | LLM×MapReduce (2024) | agent.go::dispatchSubtasks |
| ② | 三区记忆压缩 | Reflexion (2023), Lost-in-the-Middle (2023) | compression.go::partitionMessages |
| ③ | 异步压缩的快照-合并协议 | Copy-on-Write, Snapshot Isolation (1995) | compression.go::triggerAsyncCompression |
| ④ | 评论行号解析 + LLM 反思式 Re-location | ReAct (2023), Self-Consistency (2023) | resolver.go::ResolveComment + relocation.go |
| ⑤ | Plan-then-Main 两阶段 + Review Filter | Plan-and-Solve (2023), Self-Refine (2023) | agent.go::executeSubtask + executeReviewFilter |
| ⑥ | 批处理分组 + Token 预算前瞻门控 | Adaptive Computation, Speculative Decoding (2023) | scan/batch.go + agent.go::dispatchBatch |
| ⑦ | Prompt Caching 三协议适配 | Anthropic Prompt Caching (2024), OpenAI Responses API | client.go + responses_client.go |
| ⑧ | 离线 BPE + 安全防御组合拳 | BPE for NMT (2016), Saltzer & Schroeder (1975) | embedded_loader.go + pathutil/path.go |
| ⑨ (彩蛋) | CommentWorkerPool 的 per-key WaitGroup | sync.WaitGroup hazard | pool.go |
配图说明:这张主图把 OCR 的八种机制可视化为一张学术知识图谱------中心是 OCR 内核,向外辐射八条分支,每条分支末端挂着一篇代表性论文。配色用柔和的八色(青、珊瑚、薰衣草、芥末等)区分不同机制家族。整体氛围像研究论文里那种"作者亲手画的总览图",让人一眼就能记住"原来 OCR 是这么多论文的工程收束"。
下面我们一个一个展开。
分-1 机制①:分治式并行子任务调度
原理
代码审查面对的不是一个"任务",而是一组相关但独立的任务------每个文件都需要被审。OCR 把"审 N 个文件"建模为"N 个独立子任务",每文件一个独立的 LLM 上下文,并发执行(默认 8 并发)。这是经典的 divide-and-conquer 模式在 LLM 时代的复现。
对应论文
- LLM×MapReduce (Zhou et al., 2024, arXiv:2410.09342)------把长文本切 chunk,每个 chunk 独立 LLM 处理(map),再 reduce 聚合。论文核心洞察:分治最大的挑战不是切,而是切完之后如何处理 chunk 间依赖与冲突,作者用 "structured information protocol" 解决依赖、"in-context confidence calibration" 解决冲突。
- BriefContext: MapReduce for Long Context (Zhang et al., 2024, arXiv:2412.15271)------医学 QA 场景下用 MapReduce 解决 "lost-in-the-middle" 问题。
- Lost in the Middle (Liu et al., 2023)------长上下文里,关键信息放中间会被忽略;分治让每个子任务"短上下文 + 焦点信息靠前"。
OCR 用分治解决了两个根本问题:
- 大 PR 不能塞一个 context------50 文件 diff 塞进一个 prompt,LLM 会"挑食"漏审;
- 文件之间天然独立 ------审查
a.go不需要看b.go的完整内容,只需要知道"b.go 改了什么"作为上下文。
OCR 落点
internal/agent/agent.go(file:///workspace/internal/agent/agent.go) 的 dispatchSubtasks:
go
sem := make(chan struct{}, concurrency) // 默认 8
var wg sync.WaitGroup
for i := range diffs {
wg.Add(1)
go func(idx int) {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
return
}
defer func() { <-sem }()
// ... per-file subtask with isolated message buffer
}(i)
}
wg.Wait()
每个文件拿到独立的 sessionID := uuid.NewString(),作为 OpenAI Responses API 的 prompt_cache_key,让同一文件的多轮工具调用共享 cache。
例子:50 文件 PR 的执行轨迹
假设 50 个文件,concurrency=8:
#mermaid-svg-W8mAVctGL3NrB3FC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-W8mAVctGL3NrB3FC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-W8mAVctGL3NrB3FC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-W8mAVctGL3NrB3FC .error-icon{fill:#552222;}#mermaid-svg-W8mAVctGL3NrB3FC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-W8mAVctGL3NrB3FC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-W8mAVctGL3NrB3FC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-W8mAVctGL3NrB3FC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-W8mAVctGL3NrB3FC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-W8mAVctGL3NrB3FC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-W8mAVctGL3NrB3FC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-W8mAVctGL3NrB3FC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-W8mAVctGL3NrB3FC .marker.cross{stroke:#333333;}#mermaid-svg-W8mAVctGL3NrB3FC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-W8mAVctGL3NrB3FC p{margin:0;}#mermaid-svg-W8mAVctGL3NrB3FC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-W8mAVctGL3NrB3FC .cluster-label text{fill:#333;}#mermaid-svg-W8mAVctGL3NrB3FC .cluster-label span{color:#333;}#mermaid-svg-W8mAVctGL3NrB3FC .cluster-label span p{background-color:transparent;}#mermaid-svg-W8mAVctGL3NrB3FC .label text,#mermaid-svg-W8mAVctGL3NrB3FC span{fill:#333;color:#333;}#mermaid-svg-W8mAVctGL3NrB3FC .node rect,#mermaid-svg-W8mAVctGL3NrB3FC .node circle,#mermaid-svg-W8mAVctGL3NrB3FC .node ellipse,#mermaid-svg-W8mAVctGL3NrB3FC .node polygon,#mermaid-svg-W8mAVctGL3NrB3FC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-W8mAVctGL3NrB3FC .rough-node .label text,#mermaid-svg-W8mAVctGL3NrB3FC .node .label text,#mermaid-svg-W8mAVctGL3NrB3FC .image-shape .label,#mermaid-svg-W8mAVctGL3NrB3FC .icon-shape .label{text-anchor:middle;}#mermaid-svg-W8mAVctGL3NrB3FC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-W8mAVctGL3NrB3FC .rough-node .label,#mermaid-svg-W8mAVctGL3NrB3FC .node .label,#mermaid-svg-W8mAVctGL3NrB3FC .image-shape .label,#mermaid-svg-W8mAVctGL3NrB3FC .icon-shape .label{text-align:center;}#mermaid-svg-W8mAVctGL3NrB3FC .node.clickable{cursor:pointer;}#mermaid-svg-W8mAVctGL3NrB3FC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-W8mAVctGL3NrB3FC .arrowheadPath{fill:#333333;}#mermaid-svg-W8mAVctGL3NrB3FC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-W8mAVctGL3NrB3FC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-W8mAVctGL3NrB3FC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-W8mAVctGL3NrB3FC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-W8mAVctGL3NrB3FC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-W8mAVctGL3NrB3FC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-W8mAVctGL3NrB3FC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-W8mAVctGL3NrB3FC .cluster text{fill:#333;}#mermaid-svg-W8mAVctGL3NrB3FC .cluster span{color:#333;}#mermaid-svg-W8mAVctGL3NrB3FC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-W8mAVctGL3NrB3FC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-W8mAVctGL3NrB3FC rect.text{fill:none;stroke-width:0;}#mermaid-svg-W8mAVctGL3NrB3FC .icon-shape,#mermaid-svg-W8mAVctGL3NrB3FC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-W8mAVctGL3NrB3FC .icon-shape p,#mermaid-svg-W8mAVctGL3NrB3FC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-W8mAVctGL3NrB3FC .icon-shape .label rect,#mermaid-svg-W8mAVctGL3NrB3FC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-W8mAVctGL3NrB3FC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-W8mAVctGL3NrB3FC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-W8mAVctGL3NrB3FC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 50 isolated sub-agents
(each with own LLM context)
a.go
sessionID=uuid1
b.go
sessionID=uuid2
...
z.go
sessionID=uuid50
Semaphore (cap=8)
slot 1
slot 2
slot 3
slot 4
slot 5
slot 6
slot 7
slot 8
50 files diff
a.go
b.go
c.go
...
z.go
配图说明:50 个文件 → 8 个 slot → 50 个独立子 agent。每文件拿到自己的 UUID,互不干扰。这种"分治 + 独立上下文"让 OCR 可以稳定审 200 文件级别的大 PR------通用 Agent 在这种规模下会开始"挑食"。
与论文的差异
OCR 没有显式的 reduce 阶段------因为代码审查不需要把多文件结果合成一个答案。但 OCR 仍然要处理 chunk 间依赖:
buildChangeFilesExcept(currentPath)------把"其他改了什么"作为上下文注入 MAIN_TASK 的 prompt;injectDiffMap------file_read_diff工具能读其他文件的 diff,让 LLM 在审a.go时能看到b.go改了什么。
这就是 OCR 对 LLM×MapReduce 论文里"inter-chunk dependency"问题的工程化答案。
分-2 机制②:三区记忆压缩(Frozen / Compress / Active)
原理
LLM 的 context window 是有限的。OCR 一个文件可能跑 30 轮工具调用,每轮都 append 一条 assistant message + 一条 tool result,token 累积很快。当 token 接近上限时,OCR 把对话历史切成三段:
[frozen] [compress] [active]
0 : 2 2 : N-K N-K : N
↑ ↑ ↑
系统提示 压缩成 summary 保留最近 K 轮完整
+ 首条user (LLM 总结)
- Frozen(冻结尾):系统提示 + 第一条 user message。永不被压缩,作为"自我认知"的基础。
- Compress(压缩中段):中间的 assistant + tool_result 轮次,序列化为 XML 喂给 LLM,让 LLM 生成一份 summary。
- Active(保留活跃段):最近的 K 轮完整保留,保证 LLM 看得到"刚刚做了什么"。
对应论文
- Reflexion (Shinn et al., NeurIPS 2023)------提出 verbal reinforcement memory:agent 把"上次为什么失败"用语言写下来,下次启动时加载这份"反思记忆"作为 system prompt 的一部分。OCR 的
MEMORY_COMPRESSION_TASK生成的<previous_review_summary>就是这种反思记忆。 - Self-Refine (Madaan et al., NeurIPS 2023)------生成-评价-修正的迭代式自我精炼。OCR 的压缩可以看作"对自己历史对话的精炼"。
- Lost in the Middle (Liu et al., 2023)------长上下文里中间位置的信息容易被忽略。OCR 的"只保近期"原则直接呼应这个洞察------不指望 LLM 能从一堆压缩过的中间消息里捞回细节,干脆只让它看最近 K 轮。
- LLM-based Agents Survey (Wang et al., FCS 2024)------把 agent 的记忆分为 short-term(上下文)与 long-term(外部存储)。OCR 的 frozen+active 是 short-term,compress 等于把短期记忆压缩成"长期摘要"再放回短期。
OCR 落点
internal/llmloop/compression.go(file:///workspace/internal/llmloop/compression.go) 的 partitionMessages + computeActiveZoneSize。
两个关键阈值:
go
const (
tokenSoftThreshold = 0.60 // 触发异步压缩
tokenWarningThreshold = 0.80 // 触发同步压缩
)
func PromptTokenLimit(maxTokens int) int {
return int(float64(maxTokens) * tokenWarningThreshold) // 80%
}
- 60% 软阈值:触发后台异步压缩,不阻塞主循环;
- 80% 警戒阈值:触发同步压缩(主循环必须等它完成),且 pre-flight gate 也用这个值(≥80% 直接拒绝跑)。
computeActiveZoneSize 从最新一轮往前扫,累加 token 直到预算耗尽:
go
for i := len(rounds) - 1; i >= 0; i-- {
roundTokens := CountTokens(assistant) + Σ CountTokens(toolResults)
if tokensUsed + roundTokens > budget { break }
tokensUsed += roundTokens
count++
}
例子:一段对话的压缩前后
假设 max_tokens = 8000,跑了 5 轮工具调用,每轮约 1500 token:
压缩前(共 7500 token,接近 80% 阈值):
messages[0]: system prompt (1500)
messages[1]: user (diff+rule) (800)
messages[2]: assistant turn 1 (1500)
messages[3]: tool result 1 (1500)
messages[4]: assistant turn 2 (1500)
messages[5]: tool result 2 (1500)
... 共 10 条,7500 token
压缩后(重构为):
messages[0]: system prompt (1500) ← frozen
messages[1]: user (diff+rule) (800) ← frozen
+ <previous_review_summary>
"已审 a.go 第 1-30 行,发现 2 个 bug:
1. nil pointer at line 47
2. unused import" (300)
messages[2]: assistant turn 5 (1500) ← active (最近 1 轮)
messages[3]: tool result 5 (1500)
─────────
总计 5600 token
token 从 7500 降到 5600,LLM 仍然看得到"最近做了什么",且通过 summary 知道"前面发现了什么"。
#mermaid-svg-CfeC6T8UiQSC0gqO{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-CfeC6T8UiQSC0gqO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CfeC6T8UiQSC0gqO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CfeC6T8UiQSC0gqO .error-icon{fill:#552222;}#mermaid-svg-CfeC6T8UiQSC0gqO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CfeC6T8UiQSC0gqO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CfeC6T8UiQSC0gqO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CfeC6T8UiQSC0gqO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CfeC6T8UiQSC0gqO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CfeC6T8UiQSC0gqO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CfeC6T8UiQSC0gqO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CfeC6T8UiQSC0gqO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CfeC6T8UiQSC0gqO .marker.cross{stroke:#333333;}#mermaid-svg-CfeC6T8UiQSC0gqO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CfeC6T8UiQSC0gqO p{margin:0;}#mermaid-svg-CfeC6T8UiQSC0gqO .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-CfeC6T8UiQSC0gqO .cluster-label text{fill:#333;}#mermaid-svg-CfeC6T8UiQSC0gqO .cluster-label span{color:#333;}#mermaid-svg-CfeC6T8UiQSC0gqO .cluster-label span p{background-color:transparent;}#mermaid-svg-CfeC6T8UiQSC0gqO .label text,#mermaid-svg-CfeC6T8UiQSC0gqO span{fill:#333;color:#333;}#mermaid-svg-CfeC6T8UiQSC0gqO .node rect,#mermaid-svg-CfeC6T8UiQSC0gqO .node circle,#mermaid-svg-CfeC6T8UiQSC0gqO .node ellipse,#mermaid-svg-CfeC6T8UiQSC0gqO .node polygon,#mermaid-svg-CfeC6T8UiQSC0gqO .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-CfeC6T8UiQSC0gqO .rough-node .label text,#mermaid-svg-CfeC6T8UiQSC0gqO .node .label text,#mermaid-svg-CfeC6T8UiQSC0gqO .image-shape .label,#mermaid-svg-CfeC6T8UiQSC0gqO .icon-shape .label{text-anchor:middle;}#mermaid-svg-CfeC6T8UiQSC0gqO .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-CfeC6T8UiQSC0gqO .rough-node .label,#mermaid-svg-CfeC6T8UiQSC0gqO .node .label,#mermaid-svg-CfeC6T8UiQSC0gqO .image-shape .label,#mermaid-svg-CfeC6T8UiQSC0gqO .icon-shape .label{text-align:center;}#mermaid-svg-CfeC6T8UiQSC0gqO .node.clickable{cursor:pointer;}#mermaid-svg-CfeC6T8UiQSC0gqO .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-CfeC6T8UiQSC0gqO .arrowheadPath{fill:#333333;}#mermaid-svg-CfeC6T8UiQSC0gqO .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-CfeC6T8UiQSC0gqO .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-CfeC6T8UiQSC0gqO .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CfeC6T8UiQSC0gqO .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-CfeC6T8UiQSC0gqO .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CfeC6T8UiQSC0gqO .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-CfeC6T8UiQSC0gqO .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-CfeC6T8UiQSC0gqO .cluster text{fill:#333;}#mermaid-svg-CfeC6T8UiQSC0gqO .cluster span{color:#333;}#mermaid-svg-CfeC6T8UiQSC0gqO div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-CfeC6T8UiQSC0gqO .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-CfeC6T8UiQSC0gqO rect.text{fill:none;stroke-width:0;}#mermaid-svg-CfeC6T8UiQSC0gqO .icon-shape,#mermaid-svg-CfeC6T8UiQSC0gqO .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CfeC6T8UiQSC0gqO .icon-shape p,#mermaid-svg-CfeC6T8UiQSC0gqO .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-CfeC6T8UiQSC0gqO .icon-shape .label rect,#mermaid-svg-CfeC6T8UiQSC0gqO .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CfeC6T8UiQSC0gqO .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-CfeC6T8UiQSC0gqO .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-CfeC6T8UiQSC0gqO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 压缩后 (5600 token)
sys prompt
user + summary
(MEMORY_COMPRESSION_TASK 生成)
turn 5 + tool 5
partitionMessages
frozen = msg0:2
compress = msg2:6
active = msg6:7 (last 1 round)
压缩前 (7500 token, 5 轮)
sys prompt
user (diff+rule)
turn 1 + tool 1
turn 2 + tool 2
turn 3 + tool 3
turn 4 + tool 4
turn 5 + tool 5 (recent)
配图说明:压缩前三区结构清晰------frozen 永不变,compress 被吃掉重生成 summary,active 完整保留。压缩后 token 下降 25%,但 LLM 仍然知道"前面审过哪些 + 最近在做什么"。这种"留头去尾保中"的策略,是 Reflexion 思想在 OCR 里的工程化收束。
分-3 机制③:异步压缩的快照-合并协议
原理
机制②讲的是"压缩什么"。机制③讲的是"怎么在不阻塞主循环的前提下压缩"。
挑战在于------压缩需要时间(一次 LLM 调用,可能几秒到几十秒)。如果主循环等压缩完,整个文件审查就被卡住。如果主循环不等,那等压缩完,主循环可能已经 append 了新消息,新消息会被压缩任务丢掉。
OCR 的解法借鉴了操作系统的两个经典思想:
- Copy-on-Write(写时复制)------压缩任务启动时对消息切片做一份浅拷贝,主循环后续 append 不影响压缩任务的输入。
- Snapshot Isolation(快照隔离,Berenson et al., SIGMOD 1995)------压缩任务看到的是"启动那一刻的快照",主循环看到的是"实时视图",两者隔离。等压缩完,把"快照后新增的消息"merge 进压缩结果。
对应思想
- Copy-on-Write:操作系统教科书概念,Linux fork() 用它。
- Snapshot Isolation (Berenson et al., SIGMOD 1995):数据库事务隔离级别,事务看到启动时刻的快照,不受其他事务影响。
OCR 落点
internal/llmloop/compression.go(file:///workspace/internal/llmloop/compression.go) 的 triggerAsyncCompression + tryApplyPendingCompression。
关键设计:compressionState 必须 per-conversation,不能放在共享的 Runner 上。
"The Runner is shared by concurrent per-file goroutines, so this state must not live on the Runner: a shared slot lets one file apply, cancel, or replace another file's compression job (#384)."
这是 issue #384 的修复注释。曾经因为 state 放在 Runner 上,并发审查多个文件时,文件 A 的压缩结果会覆盖文件 B 的 conversation------一次难以调试的灾难。
go
st := &compressionState{} // loop.go:157, 每文件新建
defer r.cancelPendingCompression(st) // loop.go:158
异步流程:
LLM 后台 goroutine compressionState per-conv 主循环 RunPerFile LLM 后台 goroutine compressionState per-conv 主循环 RunPerFile #mermaid-svg-CVaCdqWzDQvRQHjM{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-CVaCdqWzDQvRQHjM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CVaCdqWzDQvRQHjM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CVaCdqWzDQvRQHjM .error-icon{fill:#552222;}#mermaid-svg-CVaCdqWzDQvRQHjM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CVaCdqWzDQvRQHjM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CVaCdqWzDQvRQHjM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CVaCdqWzDQvRQHjM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CVaCdqWzDQvRQHjM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CVaCdqWzDQvRQHjM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CVaCdqWzDQvRQHjM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CVaCdqWzDQvRQHjM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CVaCdqWzDQvRQHjM .marker.cross{stroke:#333333;}#mermaid-svg-CVaCdqWzDQvRQHjM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CVaCdqWzDQvRQHjM p{margin:0;}#mermaid-svg-CVaCdqWzDQvRQHjM .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-CVaCdqWzDQvRQHjM text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-CVaCdqWzDQvRQHjM .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-CVaCdqWzDQvRQHjM .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-CVaCdqWzDQvRQHjM .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-CVaCdqWzDQvRQHjM .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-CVaCdqWzDQvRQHjM #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-CVaCdqWzDQvRQHjM .sequenceNumber{fill:white;}#mermaid-svg-CVaCdqWzDQvRQHjM #sequencenumber{fill:#333;}#mermaid-svg-CVaCdqWzDQvRQHjM #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-CVaCdqWzDQvRQHjM .messageText{fill:#333;stroke:none;}#mermaid-svg-CVaCdqWzDQvRQHjM .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-CVaCdqWzDQvRQHjM .labelText,#mermaid-svg-CVaCdqWzDQvRQHjM .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-CVaCdqWzDQvRQHjM .loopText,#mermaid-svg-CVaCdqWzDQvRQHjM .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-CVaCdqWzDQvRQHjM .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-CVaCdqWzDQvRQHjM .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-CVaCdqWzDQvRQHjM .noteText,#mermaid-svg-CVaCdqWzDQvRQHjM .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-CVaCdqWzDQvRQHjM .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-CVaCdqWzDQvRQHjM .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-CVaCdqWzDQvRQHjM .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-CVaCdqWzDQvRQHjM .actorPopupMenu{position:absolute;}#mermaid-svg-CVaCdqWzDQvRQHjM .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-CVaCdqWzDQvRQHjM .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-CVaCdqWzDQvRQHjM .actor-man circle,#mermaid-svg-CVaCdqWzDQvRQHjM line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-CVaCdqWzDQvRQHjM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 无 pending job 保留 snapshot 后主循环新增的消息 round N 完成 1 tryApplyPendingCompression (尝试应用已完成的) 2 检查 token 超过 60% 软阈值 3 triggerAsyncCompression 4 snapshot = shallow copy messages 5 pendingJob = 新建 job snapshotLen=len messages 6 go 启动后台 goroutine 压缩 snapshot 7 继续 round N+1 (不等) 8 append assistant + tool_result 9 MEMORY_COMPRESSION_TASK snapshot 10 summary 11 job.rebuilt = rebuilt_msgs 12 job.done 发送完成信号 13 round N+2 开始 14 tryApplyPendingCompression 15 select 等待 job.done 后应用 16 ownership check job 是否还是 pendingJob 17 rebuilt 追加 snapshot 之后的新消息 18 返回新 messages 19 用新 messages 继续 20
配图说明 :这张时序图最关键的箭头是 rebuilt = append(rebuilt, messages[snapshotLen:]...)。压缩任务在 t0 看到的是 messages0:N,但等它完成时主循环已经把 messages 扩展到 0:M(M > N)。合并时只替换 0:N 部分,保留 N:M------这就是"snapshot 后新增消息"的合并协议。
例子:#384 bug 复盘
旧版本(state 在 Runner 上):
File A: triggerAsyncCompression at t0 (snapshot len=10)
File B: triggerAsyncCompression at t1 (snapshot len=8) → 覆盖了 A 的 pendingJob
File A: tryApplyPendingCompression at t2 → 应用的是 B 的 summary!
→ 文件 A 的对话里被塞进了文件 B 的压缩结果
新版本(state per-conversation):
File A: st_A.triggerAsyncCompression at t0 (snapshot len=10)
File B: st_B.triggerAsyncCompression at t1 (snapshot len=8) → 不影响 A
File A: st_A.tryApplyPendingCompression at t2 → 应用 A 自己的 summary
这种 per-上下文 的状态隔离,是 OCR 在 #384 教训后形成的硬约束------共享 Runner 上不能放任何"会话级"状态。
安全设计细节
context.WithoutCancel(ctx):后台压缩任务脱离父 context 的取消(主循环可能已结束),但仍受 5min 硬超时限制。cancelPendingCompression:主循环结束时主动取消未完成的压缩任务,避免 goroutine 泄漏。- 失败回退:压缩 LLM 调用失败时,保留原始 messages(不截断到 frozenEnd------那会丢光所有上下文,更糟)。
分-4 机制④:评论行号解析 + LLM 反思式 Re-location
原理
LLM 产出的评论带 existing_code(用户原代码片段),但没有行号。OCR 需要把 existing_code 反向匹配回文件,得到 StartLine/EndLine。
策略是两段式:
- 第一段(确定性) :用文本滑动窗口把
existing_code匹配到 hunk 或文件内容,得到行号。 - 第二段(动态) :如果文本匹配失败,让 LLM 重新生成一个更精准的
existing_codesnippet,再试一次。失败则回滚到原始 snippet。
对应论文
- ReAct (Yao et al., ICLR 2023)------Reason-Act-Observe 三元组:先推理(thought),再行动(action),观察结果(observation)。OCR 的"先文本匹配→失败再让 LLM 重写"就是 Reason(看到匹配失败)→Act(调 LLM)→Observe(重试匹配)。
- Self-Consistency (Wang et al., ICLR 2023)------多次采样后选最一致的。OCR 没有"多次采样",但"失败重试"是简化版的 self-correction。
- Reflexion (Shinn et al., NeurIPS 2023)------verbal reinforcement:失败后用语言描述"为什么失败",下次重试。OCR 的
RE_LOCATION_TASK给 LLM 的 prompt 就是"前面这个 existing_code 没匹配上,请重新生成一个能匹配的"。
OCR 落点
internal/diff/resolver.go(file:///workspace/internal/diff/resolver.go) 的 ResolveComment + internal/diff/relocation.go(file:///workspace/internal/diff/relocation.go) 的 ReLocateComment。
文本匹配算法:
go
func ResolveComment(cm *LlmComment, d *Diff) {
if cm.StartLine > 0 || cm.ExistingCode == "" { return } // 已有行号或无 snippet
if resolveFromHunk(cm, d) { return } // 1a. hunk 匹配
resolveFromFileContent(cm, d) // 1b. 全文件 fallback
}
func resolveFromHunk(cm *LlmComment, d *Diff) bool {
// 先试 new side(context + added → new file 行号)
if start, end := matchConsecutive(extractSideLines(hunk, "new"), targetLines); match {
cm.StartLine, cm.EndLine = start, end
return true
}
// 再试 old side(context + deleted → old file 行号)
if start, end := matchConsecutive(extractSideLines(hunk, "old"), targetLines); match {
cm.StartLine, cm.EndLine = start, end
return true
}
return false
}
matchConsecutive 是滑动窗口------对每个起点 i,检查 sideLines[i:i+len(target)] 是否与 target 逐元素相等。
关键细节 :normalizeLine 去掉首尾空格 + 去掉 diff marker(+/-),空行在 file-content fallback 里被跳过------这样"两个相邻非空行"才算 consecutive,避免被空行打断。
例子:模拟一条评论的解析
LLM 产出:
json
{
"content": "nil pointer dereference if user is nil",
"existing_code": "if err != nil {\n return err\n}",
"category": "bug",
"severity": "high"
}
目标文件 main.go 的 hunk:
@@ -40,5 +45,7 @@
func Handle(req *Request) error {
user, err := parseUser(req)
if err != nil {
+ return err
+ }
- if user == nil {
- return ErrInvalid
- }
+ processUser(user)
return nil
}
OCR 的解析:
normalizeLine("if err != nil {")→"if err != nil {"normalizeLine(" return err")→"return err"normalizeLine("}")→"}"- 在 hunk 的 new side(context + added)扫描:
- 第 47-49 行:
if err != nil {、return err、}← 匹配!
- 第 47-49 行:
StartLine = 47, EndLine = 49✅
#mermaid-svg-oWrHwmMWwfahjKdY{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oWrHwmMWwfahjKdY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oWrHwmMWwfahjKdY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oWrHwmMWwfahjKdY .error-icon{fill:#552222;}#mermaid-svg-oWrHwmMWwfahjKdY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oWrHwmMWwfahjKdY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oWrHwmMWwfahjKdY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oWrHwmMWwfahjKdY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oWrHwmMWwfahjKdY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oWrHwmMWwfahjKdY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oWrHwmMWwfahjKdY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oWrHwmMWwfahjKdY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oWrHwmMWwfahjKdY .marker.cross{stroke:#333333;}#mermaid-svg-oWrHwmMWwfahjKdY svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oWrHwmMWwfahjKdY p{margin:0;}#mermaid-svg-oWrHwmMWwfahjKdY .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-oWrHwmMWwfahjKdY .cluster-label text{fill:#333;}#mermaid-svg-oWrHwmMWwfahjKdY .cluster-label span{color:#333;}#mermaid-svg-oWrHwmMWwfahjKdY .cluster-label span p{background-color:transparent;}#mermaid-svg-oWrHwmMWwfahjKdY .label text,#mermaid-svg-oWrHwmMWwfahjKdY span{fill:#333;color:#333;}#mermaid-svg-oWrHwmMWwfahjKdY .node rect,#mermaid-svg-oWrHwmMWwfahjKdY .node circle,#mermaid-svg-oWrHwmMWwfahjKdY .node ellipse,#mermaid-svg-oWrHwmMWwfahjKdY .node polygon,#mermaid-svg-oWrHwmMWwfahjKdY .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oWrHwmMWwfahjKdY .rough-node .label text,#mermaid-svg-oWrHwmMWwfahjKdY .node .label text,#mermaid-svg-oWrHwmMWwfahjKdY .image-shape .label,#mermaid-svg-oWrHwmMWwfahjKdY .icon-shape .label{text-anchor:middle;}#mermaid-svg-oWrHwmMWwfahjKdY .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-oWrHwmMWwfahjKdY .rough-node .label,#mermaid-svg-oWrHwmMWwfahjKdY .node .label,#mermaid-svg-oWrHwmMWwfahjKdY .image-shape .label,#mermaid-svg-oWrHwmMWwfahjKdY .icon-shape .label{text-align:center;}#mermaid-svg-oWrHwmMWwfahjKdY .node.clickable{cursor:pointer;}#mermaid-svg-oWrHwmMWwfahjKdY .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-oWrHwmMWwfahjKdY .arrowheadPath{fill:#333333;}#mermaid-svg-oWrHwmMWwfahjKdY .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-oWrHwmMWwfahjKdY .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-oWrHwmMWwfahjKdY .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oWrHwmMWwfahjKdY .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-oWrHwmMWwfahjKdY .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oWrHwmMWwfahjKdY .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-oWrHwmMWwfahjKdY .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-oWrHwmMWwfahjKdY .cluster text{fill:#333;}#mermaid-svg-oWrHwmMWwfahjKdY .cluster span{color:#333;}#mermaid-svg-oWrHwmMWwfahjKdY div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-oWrHwmMWwfahjKdY .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-oWrHwmMWwfahjKdY rect.text{fill:none;stroke-width:0;}#mermaid-svg-oWrHwmMWwfahjKdY .icon-shape,#mermaid-svg-oWrHwmMWwfahjKdY .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oWrHwmMWwfahjKdY .icon-shape p,#mermaid-svg-oWrHwmMWwfahjKdY .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-oWrHwmMWwfahjKdY .icon-shape .label rect,#mermaid-svg-oWrHwmMWwfahjKdY .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oWrHwmMWwfahjKdY .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-oWrHwmMWwfahjKdY .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-oWrHwmMWwfahjKdY :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 匹配成功
匹配失败
匹配成功
匹配失败
成功
失败
LLM 评论
existing_code: 'if err != nil {
return err
}'
- 文本匹配
(resolveFromHunk)
StartLine=47, EndLine=49
1b. fallback
(resolveFromFileContent)
拿到行号
2. LLM 重定位
(RE_LOCATION_TASK)
LLM 重新生成
existing_code
extractCodeBlock
取第一个 fenced block
重试文本匹配
拿到行号
回滚到原始 existing_code
(不阻塞,下游处理)
配图说明 :这张图把评论定位的两段式策略画得清清楚楚------第一段是纯文本工程(sliding window),第二段是 LLM 动态补救(re-location)。先确定后动态------这就是 OCR 一贯的"硬约束先上,Agent 兜底"思路。第一段解决 80% 的评论,第二段解决剩下 20% 的疑难案例。
失败回滚的重要性
relocation.go(file:///workspace/internal/diff/relocation.go) 第 73 行:
go
defer func() {
if !success {
cm.ExistingCode = originalExistingCode // 回滚
}
}()
为什么重要?因为 re-location LLM 可能产出更糟的 snippet(比如完全不相关的代码)。回滚到原始 snippet 至少保证下游看到的是 LLM 一开始的真实输出,而不是 re-location 的失败品。
分-5 机制⑤:Plan-then-Main 两阶段 + Review Filter 反思
原理
OCR 的每个文件审查其实有两个独立的 LLM 调用阶段 + 一个事后反思阶段:
- Plan 阶段(可选):让 LLM 先看 diff,输出一份 JSON checklist("重点审哪些地方")。
- Main 阶段 :把这份 plan 作为
{``{plan_guidance}}注入主任务 prompt,进入 tool-use 循环。 - Review Filter 阶段:主循环结束后,把所有评论 + diff 再喂给 LLM,让它删掉明显错的评论。
对应论文
- Plan-and-Solve Prompting (Wang et al., ACL 2023)------把 CoT 拆成两步:先让模型"plan"出解题步骤,再"solve"。论文证明这能显著提升数学推理准确率。OCR 的 plan-then-main 是这个思想在代码审查场景的复现。
- Chain of Thought (Wei et al., NeurIPS 2022)------让模型显式推理而不是直接给答案。plan 阶段就是"先想清楚要审什么"。
- Self-Refine (Madaan et al., NeurIPS 2023)------生成→评价→修正。review-filter 是"生成→评价(哪些是错的)→修正(删掉)"。
OCR 落点
internal/agent/agent.go(file:///workspace/internal/agent/agent.go) 的 executeSubtask + executeReviewFilter。
Plan 阶段的跳过条件:
go
threshold := a.args.Template.PlanModeLineThreshold // = 50
changeLines := d.Insertions + d.Deletions
if planTask defined && threshold > 0 && changeLines < int64(threshold) {
// SKIP plan phase --- 小 diff 不值得 plan 开销
telemetry.Event("plan.skipped", ...)
} else if planTask defined {
planResult, err = a.executePlanPhase(...)
if err != nil {
planResult = "" // plan 失败回退到 plan-less
}
}
为什么阈值是 50 行?------经验值。变更 < 50 行的 diff,LLM 一眼就能看完,不需要 plan;变更 ≥ 50 行时,plan 能帮 LLM 聚焦"哪几段值得重点看",减少无效 tool-use 轮次。
Plan 失败不阻塞:
go
if err != nil {
planResult = "" // 回退到 v1 (plan-less) 行为
}
这就是机制③的"失败优雅降级"原则在 plan 上的体现。
Plan 输出示例
json
{
"summary": "本次变更给 handler 增加了用户认证逻辑,并修改了错误处理",
"checkpoints": [
{"focus": "认证逻辑", "lines": "12-30", "why": "新加的 JWT 校验,需检查 token 解析"},
{"focus": "错误处理", "lines": "45-52", "why": "改了 if-else 分支,可能有 nil 路径"},
{"focus": "测试覆盖", "lines": "-", "why": "看是否补了测试"}
]
}
这份 JSON 作为 {``{plan_guidance}} 注入 MAIN_TASK 的 prompt。
Review Filter 示例
主循环产出了 5 条评论:
json
[
{"id": "c-0", "content": "nil pointer at line 47", "existing_code": "if err != nil {"},
{"id": "c-1", "content": "unused import", "existing_code": "import \"fmt\""},
{"id": "c-2", "content": "should use context.WithTimeout", "existing_code": "http.Get(url)"},
{"id": "c-3", "content": "magic number 42", "existing_code": "if x > 42 {"},
{"id": "c-4", "content": "should add comment", "existing_code": "func Foo()"}
]
REVIEW_FILTER_TASK 拿到这些评论 + diff,判断哪些是"provably incorrect"(基于 diff 能直接证伪):
json
["c-1", "c-3"]
c-1删:import "fmt"实际上在 diff 里被使用了(filter 看到 diff 第 5 行用了 fmt.Println),所以"unused import"是错的。c-3删:diff 第 8 行显示 42 是个配置常量const MaxRetries = 42,"magic number"是错的。
剩下 c-0、c-2、c-4 保留。
#mermaid-svg-JpGnMFV2ztsp7NHp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JpGnMFV2ztsp7NHp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JpGnMFV2ztsp7NHp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JpGnMFV2ztsp7NHp .error-icon{fill:#552222;}#mermaid-svg-JpGnMFV2ztsp7NHp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JpGnMFV2ztsp7NHp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JpGnMFV2ztsp7NHp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JpGnMFV2ztsp7NHp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JpGnMFV2ztsp7NHp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JpGnMFV2ztsp7NHp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JpGnMFV2ztsp7NHp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JpGnMFV2ztsp7NHp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JpGnMFV2ztsp7NHp .marker.cross{stroke:#333333;}#mermaid-svg-JpGnMFV2ztsp7NHp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JpGnMFV2ztsp7NHp p{margin:0;}#mermaid-svg-JpGnMFV2ztsp7NHp .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-JpGnMFV2ztsp7NHp .cluster-label text{fill:#333;}#mermaid-svg-JpGnMFV2ztsp7NHp .cluster-label span{color:#333;}#mermaid-svg-JpGnMFV2ztsp7NHp .cluster-label span p{background-color:transparent;}#mermaid-svg-JpGnMFV2ztsp7NHp .label text,#mermaid-svg-JpGnMFV2ztsp7NHp span{fill:#333;color:#333;}#mermaid-svg-JpGnMFV2ztsp7NHp .node rect,#mermaid-svg-JpGnMFV2ztsp7NHp .node circle,#mermaid-svg-JpGnMFV2ztsp7NHp .node ellipse,#mermaid-svg-JpGnMFV2ztsp7NHp .node polygon,#mermaid-svg-JpGnMFV2ztsp7NHp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-JpGnMFV2ztsp7NHp .rough-node .label text,#mermaid-svg-JpGnMFV2ztsp7NHp .node .label text,#mermaid-svg-JpGnMFV2ztsp7NHp .image-shape .label,#mermaid-svg-JpGnMFV2ztsp7NHp .icon-shape .label{text-anchor:middle;}#mermaid-svg-JpGnMFV2ztsp7NHp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JpGnMFV2ztsp7NHp .rough-node .label,#mermaid-svg-JpGnMFV2ztsp7NHp .node .label,#mermaid-svg-JpGnMFV2ztsp7NHp .image-shape .label,#mermaid-svg-JpGnMFV2ztsp7NHp .icon-shape .label{text-align:center;}#mermaid-svg-JpGnMFV2ztsp7NHp .node.clickable{cursor:pointer;}#mermaid-svg-JpGnMFV2ztsp7NHp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-JpGnMFV2ztsp7NHp .arrowheadPath{fill:#333333;}#mermaid-svg-JpGnMFV2ztsp7NHp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-JpGnMFV2ztsp7NHp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-JpGnMFV2ztsp7NHp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JpGnMFV2ztsp7NHp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-JpGnMFV2ztsp7NHp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JpGnMFV2ztsp7NHp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-JpGnMFV2ztsp7NHp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-JpGnMFV2ztsp7NHp .cluster text{fill:#333;}#mermaid-svg-JpGnMFV2ztsp7NHp .cluster span{color:#333;}#mermaid-svg-JpGnMFV2ztsp7NHp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-JpGnMFV2ztsp7NHp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-JpGnMFV2ztsp7NHp rect.text{fill:none;stroke-width:0;}#mermaid-svg-JpGnMFV2ztsp7NHp .icon-shape,#mermaid-svg-JpGnMFV2ztsp7NHp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JpGnMFV2ztsp7NHp .icon-shape p,#mermaid-svg-JpGnMFV2ztsp7NHp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-JpGnMFV2ztsp7NHp .icon-shape .label rect,#mermaid-svg-JpGnMFV2ztsp7NHp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JpGnMFV2ztsp7NHp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JpGnMFV2ztsp7NHp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JpGnMFV2ztsp7NHp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 阶段 3: Review Filter (反思)
阶段 2: Main (主循环)
阶段 1: Plan (可选)
删 provably incorrect
diff
PLAN_TASK
(LLM 出 JSON checklist)
MAIN_TASK
(tool-use 循环)
5 条原始评论
REVIEW_FILTER_TASK
(LLM 看评论+diff)
3 条最终评论
配图说明 :这张图把 plan-main-filter 三阶段流水线画清。注意 plan 是可选的(小 diff 跳过),filter 是 best-effort 的(失败不阻塞)。只有 main 是必经的。这种"主路径硬 + 两端软"的设计,是 OCR 在"质量"与"稳定"之间的平衡。
Plan 失败的优雅降级
stripEmptyPlanBlock(internal/agent/util.go(file:///workspace/internal/agent/util.go) 第 22-34 行)用正则把空的 ### Review Plan 包装块去掉,避免 {``{plan_guidance}} 字面量泄漏到 prompt:
go
var planBlockPattern = regexp.MustCompile(
`(?m)^### [^\n]*(?:Review Plan|审查计划)[^\n]*\n\{\{plan_guidance\}\}\n\n?`)
正则同时匹配英文 "Review Plan" 和中文 "审查计划"------这是过去的一个 bug 修复(旧版本硬编码中文,与英文模板不匹配)。
分-6 机制⑥:批处理分组 + Token 预算前瞻门控
原理
ocr scan 模式要审整个仓库的所有文件。一个仓库可能有上千文件,全跑完 token 可能爆。OCR 的策略:
- 分批:按语言或目录分组(同语言文件相邻处理,提高 prompt cache 命中率)。
- 批内并发:批内最多 8 文件并发。
- 批间串行:批与批之间串行(让前批的 dedup 跑完再开下一批)。
- 预算前瞻:每文件 acquire slot 前,先算"已用 + 本文件预估"是否超预算,超了就跳过剩余。
对应论文/思想
- Adaptive Computation------根据任务难度动态调整计算量。OCR 的 plan 跳过小 diff、budget look-ahead 跳过预算超限文件,都是 adaptive。
- Speculative Decoding (Leviathan et al., 2023) 的 look-ahead 思想------在执行下一步前先"前瞻"一下,决定要不要继续。OCR 在 acquire slot 前 look-ahead 一次 token 预算。
- Token Budget-Aware Reasoning------给 LLM 一个 token budget,让它在预算内分配计算。OCR 用工程方式实现了这个------不让 LLM 自己管 budget,由工程代码 look-ahead 决定要不要让它继续。
OCR 落点
internal/scan/batch.go(file:///workspace/internal/scan/batch.go) + internal/scan/agent.go(file:///workspace/internal/scan/agent.go) 的 dispatchBatch。
三种 batch 策略:
go
BatchNone // v1: 每文件一批
BatchByLanguage // 按扩展名分组 (.go / .py / <no-ext>)
BatchByDirectory // 按首层目录分组
groupBatches 排序后按 BatchSize 切片,保证确定性顺序(重要!同语言文件相邻 → prompt cache hit rate 高)。
预算 look-ahead:
go
if a.args.MaxTokensBudget > 0 {
used := a.runner.TotalTokensUsed()
projected := used + estimateFileTokens(batch[i], a.planEnabled())
if projected > a.args.MaxTokensBudget {
budgetHit = true
break // 跳过本批剩余文件
}
}
estimateFileTokens(internal/scan/estimate.go(file:///workspace/internal/scan/estimate.go))用经验常数:
go
promptOverheadTokens = 2000 // system prompt + 模板 + tool defs
avgMainRoundsPerFile = 7 // 观察到的均值
avgOutputTokensPerRound = 700
每文件预估 = (fileTokens + 2000) × 7 + 700 × 7(加上 plan 阶段就是再加 2000+400)。
例子:100 文件仓库的批次执行
100 files, BatchByLanguage, BatchSize=20, MaxTokensBudget=500K
[batch 0] lang=.go (23 files)
├ file_0 (estimate 5K, used 0K, projected 5K < 500K → run)
├ file_1 (estimate 4K, used 5K, projected 9K < 500K → run)
...
├ file_22 (estimate 6K, used 110K, projected 116K < 500K → run)
└ drain pool → dedup (3 comments merged into 1)
[batch 1] lang=.py (35 files)
├ file_23 (estimate 4K, used 116K, projected 120K < 500K → run)
...
├ file_70 (estimate 5K, used 480K, projected 485K < 500K → run)
├ file_71 (estimate 8K, used 485K, projected 493K < 500K → run)
├ file_72 (estimate 7K, used 493K, projected 500K >= 500K → skip!)
└ budget_hit=true, break (本批剩余 + 后续批次都跳过)
#mermaid-svg-zkRpYVZgsV2OXhel{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zkRpYVZgsV2OXhel .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zkRpYVZgsV2OXhel .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zkRpYVZgsV2OXhel .error-icon{fill:#552222;}#mermaid-svg-zkRpYVZgsV2OXhel .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zkRpYVZgsV2OXhel .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zkRpYVZgsV2OXhel .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zkRpYVZgsV2OXhel .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zkRpYVZgsV2OXhel .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zkRpYVZgsV2OXhel .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zkRpYVZgsV2OXhel .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zkRpYVZgsV2OXhel .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zkRpYVZgsV2OXhel .marker.cross{stroke:#333333;}#mermaid-svg-zkRpYVZgsV2OXhel svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zkRpYVZgsV2OXhel p{margin:0;}#mermaid-svg-zkRpYVZgsV2OXhel .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zkRpYVZgsV2OXhel .cluster-label text{fill:#333;}#mermaid-svg-zkRpYVZgsV2OXhel .cluster-label span{color:#333;}#mermaid-svg-zkRpYVZgsV2OXhel .cluster-label span p{background-color:transparent;}#mermaid-svg-zkRpYVZgsV2OXhel .label text,#mermaid-svg-zkRpYVZgsV2OXhel span{fill:#333;color:#333;}#mermaid-svg-zkRpYVZgsV2OXhel .node rect,#mermaid-svg-zkRpYVZgsV2OXhel .node circle,#mermaid-svg-zkRpYVZgsV2OXhel .node ellipse,#mermaid-svg-zkRpYVZgsV2OXhel .node polygon,#mermaid-svg-zkRpYVZgsV2OXhel .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zkRpYVZgsV2OXhel .rough-node .label text,#mermaid-svg-zkRpYVZgsV2OXhel .node .label text,#mermaid-svg-zkRpYVZgsV2OXhel .image-shape .label,#mermaid-svg-zkRpYVZgsV2OXhel .icon-shape .label{text-anchor:middle;}#mermaid-svg-zkRpYVZgsV2OXhel .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zkRpYVZgsV2OXhel .rough-node .label,#mermaid-svg-zkRpYVZgsV2OXhel .node .label,#mermaid-svg-zkRpYVZgsV2OXhel .image-shape .label,#mermaid-svg-zkRpYVZgsV2OXhel .icon-shape .label{text-align:center;}#mermaid-svg-zkRpYVZgsV2OXhel .node.clickable{cursor:pointer;}#mermaid-svg-zkRpYVZgsV2OXhel .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zkRpYVZgsV2OXhel .arrowheadPath{fill:#333333;}#mermaid-svg-zkRpYVZgsV2OXhel .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zkRpYVZgsV2OXhel .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zkRpYVZgsV2OXhel .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zkRpYVZgsV2OXhel .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zkRpYVZgsV2OXhel .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zkRpYVZgsV2OXhel .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zkRpYVZgsV2OXhel .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zkRpYVZgsV2OXhel .cluster text{fill:#333;}#mermaid-svg-zkRpYVZgsV2OXhel .cluster span{color:#333;}#mermaid-svg-zkRpYVZgsV2OXhel div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zkRpYVZgsV2OXhel .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zkRpYVZgsV2OXhel rect.text{fill:none;stroke-width:0;}#mermaid-svg-zkRpYVZgsV2OXhel .icon-shape,#mermaid-svg-zkRpYVZgsV2OXhel .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zkRpYVZgsV2OXhel .icon-shape p,#mermaid-svg-zkRpYVZgsV2OXhel .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zkRpYVZgsV2OXhel .icon-shape .label rect,#mermaid-svg-zkRpYVZgsV2OXhel .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zkRpYVZgsV2OXhel .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zkRpYVZgsV2OXhel .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zkRpYVZgsV2OXhel :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
100 files
groupBatches
(by-language)
batch 0: .go (23 files)
batch 1: .py (35 files)
batch 2: .md (42 files)
批内 8 并发
dedup
批内 8 并发
used + estimate
> 500K?
继续
skip 剩余文件
- 跳过 batch 2
结束
配图说明 :这张图展示 batch 的串行 + 文件并发 + budget look-ahead 三层控制。批间串行 是为了让 dedup 能在批边界跑;文件并发 是为了吞吐;budget look-ahead 是为了不爆预算。三者合起来回答了"怎么在有限预算下扫完仓库"------答案就是"该跳过时就跳过"。
Dedup 的安全门
maybeRunDedup(internal/scan/agent.go(file:///workspace/internal/scan/agent.go) 第 699-752 行)调 LLM 让它给"近重复评论"分组:
json
{
"groups": [
{"members": ["c-0", "c-3"], "merged_content": "..."},
{"members": ["c-1"], "merged_content": ""}
]
}
applyDedupGroups 第 782-833 行有严格的安全门 :parsed groups 必须覆盖每个输入 id 恰好一次 。任何 id 未知、缺失、重复、空 members,都返回 (nil, false),保留所有原始评论。
"Dedup is a best-effort optimization, never a correctness gate."
这是 OCR 的核心原则------优化永远不能成为正确性的门。
分-7 机制⑦:Prompt Caching 三协议适配
原理
LLM 调用最贵的是 prompt 处理。如果同一份 system prompt + tool definitions 被反复发,Provider 通常提供 prefix cache------第一次写入 cache(cache_write),后续命中 cache(cache_read),后者便宜很多(Anthropic 约 1/10 价)。
OCR 支持三种 protocol,每种触发 cache 的方式不同:
| Protocol | SDK | Cache 触发方式 |
|---|---|---|
anthropic |
anthropic-sdk-go |
显式 CacheControl: ephemeral 标记 |
openai (chat completions) |
openai-go/v3 |
隐式 prefix cache(无显式 key) |
openai-responses |
Responses API | 显式 prompt_cache_key |
对应文档
- Anthropic Prompt Caching (Anthropic, 2024)------
cache_control: {type: "ephemeral"}标记,breakpoint 必须在特定位置(system prompt 末尾、tool list 末尾等)。 - OpenAI Responses API (OpenAI, 2024)------
prompt_cache_key字段,同 key 的请求共享 cache。 - GPT-OSS Tokenizer (OpenAI)------
o200k_base用于 o1/o3/o4 系列,cl100k_base用于其他。
OCR 落点
Anthropic 的 cache 策略
internal/llm/client.go(file:///workspace/internal/llm/client.go) 的 buildAnthropicParams:
go
// 最后一个 system block 标记 ephemeral cache
if len(systemBlocks) > 0 {
systemBlocks[len(systemBlocks)-1].CacheControl = NewCacheControlEphemeralParam()
}
// 最后一个 tool 标记 ephemeral cache
if len(tools) > 0 {
tools[len(tools)-1].CacheControl = NewCacheControlEphemeralParam()
}
为什么是"最后一个"?因为 Anthropic 的 cache 是 prefix-based------标记点之前的所有内容都会被 cache。把标记放在最后一个 system block / tool 上,让前面所有内容都进入 cache。
OpenAI Responses 的 cache 策略
internal/llm/responses_client.go(file:///workspace/internal/llm/responses_client.go):
go
req := responses.ResponseNewParams{
Model: responses.String(model),
Input: input,
Store: responses.Bool(false), // 不存 OpenAI 服务端
// ...
}
if req.SessionID != "" {
params.PromptCacheKey = responses.String(req.SessionID) // 用 sessionID 做 cache key
}
SessionID 是 loop.go(file:///workspace/internal/llmloop/loop.go) 第 153 行 uuid.NewString() 生成的------每文件一个 UUID,让同文件的多轮工具调用共享 cache,不同文件不互相污染。
Token 计数的差异
internal/llm/usage_resolver.go(file:///workspace/internal/llm/usage_resolver.go) 区分两种 cache:
go
cacheReadTokensPaths = [
"usage.cache_read_input_tokens", // Anthropic [0..2]
"cache_read_input_tokens",
"data.usage.cache_read_input_tokens",
"usage.prompt_tokens_details.cached_tokens", // OpenAI-compatible [3..]
"data.usage.prompt_tokens_details.cached_tokens",
"usage.input_tokens_details.cached_tokens", // OpenAI Responses
"data.usage.input_tokens_details.cached_tokens",
]
const anthropicCacheReadPathCount = 3
关键规则 :计算 TotalTokens = prompt + completion 时------
- Anthropic:cache token 加 进去(因为 Anthropic 的
input_tokens不含 cache); - OpenAI:cache token 不加 (因为 OpenAI 的
prompt_tokens已经包含了 cached 部分)。
判断方法是看命中的 path 是不是在前 3 个(Anthropic)还是后面(OpenAI)。
例子:同一份 prompt 的两次调用
Round 1 (cache miss):
prompt_tokens: 1500
cache_write_tokens: 1000 ← system prompt + tools 进 cache
cache_read_tokens: 0
completion_tokens: 500
total: 3000 ← (1500 + 1000 + 0) + 500 (Anthropic 加 cache)
Round 2 (cache hit):
prompt_tokens: 500 ← 只有 user message 是新的
cache_write_tokens: 0
cache_read_tokens: 1000 ← 命中 cache
completion_tokens: 500
total: 2000 ← (500 + 0 + 1000) + 500
#mermaid-svg-90ARmEoRfYtRAy7q{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-90ARmEoRfYtRAy7q .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-90ARmEoRfYtRAy7q .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-90ARmEoRfYtRAy7q .error-icon{fill:#552222;}#mermaid-svg-90ARmEoRfYtRAy7q .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-90ARmEoRfYtRAy7q .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-90ARmEoRfYtRAy7q .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-90ARmEoRfYtRAy7q .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-90ARmEoRfYtRAy7q .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-90ARmEoRfYtRAy7q .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-90ARmEoRfYtRAy7q .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-90ARmEoRfYtRAy7q .marker{fill:#333333;stroke:#333333;}#mermaid-svg-90ARmEoRfYtRAy7q .marker.cross{stroke:#333333;}#mermaid-svg-90ARmEoRfYtRAy7q svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-90ARmEoRfYtRAy7q p{margin:0;}#mermaid-svg-90ARmEoRfYtRAy7q .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-90ARmEoRfYtRAy7q .cluster-label text{fill:#333;}#mermaid-svg-90ARmEoRfYtRAy7q .cluster-label span{color:#333;}#mermaid-svg-90ARmEoRfYtRAy7q .cluster-label span p{background-color:transparent;}#mermaid-svg-90ARmEoRfYtRAy7q .label text,#mermaid-svg-90ARmEoRfYtRAy7q span{fill:#333;color:#333;}#mermaid-svg-90ARmEoRfYtRAy7q .node rect,#mermaid-svg-90ARmEoRfYtRAy7q .node circle,#mermaid-svg-90ARmEoRfYtRAy7q .node ellipse,#mermaid-svg-90ARmEoRfYtRAy7q .node polygon,#mermaid-svg-90ARmEoRfYtRAy7q .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-90ARmEoRfYtRAy7q .rough-node .label text,#mermaid-svg-90ARmEoRfYtRAy7q .node .label text,#mermaid-svg-90ARmEoRfYtRAy7q .image-shape .label,#mermaid-svg-90ARmEoRfYtRAy7q .icon-shape .label{text-anchor:middle;}#mermaid-svg-90ARmEoRfYtRAy7q .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-90ARmEoRfYtRAy7q .rough-node .label,#mermaid-svg-90ARmEoRfYtRAy7q .node .label,#mermaid-svg-90ARmEoRfYtRAy7q .image-shape .label,#mermaid-svg-90ARmEoRfYtRAy7q .icon-shape .label{text-align:center;}#mermaid-svg-90ARmEoRfYtRAy7q .node.clickable{cursor:pointer;}#mermaid-svg-90ARmEoRfYtRAy7q .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-90ARmEoRfYtRAy7q .arrowheadPath{fill:#333333;}#mermaid-svg-90ARmEoRfYtRAy7q .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-90ARmEoRfYtRAy7q .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-90ARmEoRfYtRAy7q .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-90ARmEoRfYtRAy7q .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-90ARmEoRfYtRAy7q .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-90ARmEoRfYtRAy7q .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-90ARmEoRfYtRAy7q .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-90ARmEoRfYtRAy7q .cluster text{fill:#333;}#mermaid-svg-90ARmEoRfYtRAy7q .cluster span{color:#333;}#mermaid-svg-90ARmEoRfYtRAy7q div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-90ARmEoRfYtRAy7q .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-90ARmEoRfYtRAy7q rect.text{fill:none;stroke-width:0;}#mermaid-svg-90ARmEoRfYtRAy7q .icon-shape,#mermaid-svg-90ARmEoRfYtRAy7q .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-90ARmEoRfYtRAy7q .icon-shape p,#mermaid-svg-90ARmEoRfYtRAy7q .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-90ARmEoRfYtRAy7q .icon-shape .label rect,#mermaid-svg-90ARmEoRfYtRAy7q .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-90ARmEoRfYtRAy7q .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-90ARmEoRfYtRAy7q .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-90ARmEoRfYtRAy7q :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Round 2 (cache hit)
- new user msg
(500 token)
cache_read
(命中 1000 token)
送 LLM
Round 1 (cache miss)
system prompt
(1500 token)
cache_write
(写入 1000 token)
- tools
(1000 token)
- user msg
(500 token)
送 LLM
配图说明:同一份 system prompt + tools 第一次被 cache(写),第二次直接命中(读)。这是 OCR "1/9 token" 数据的关键来源------OCR 每文件跑 7+ 轮工具调用,每轮的 system + tools 是同一份,cache 命中率非常高。
为什么 Store: false
OpenAI Responses API 默认会把请求存到服务端(用于后续 previous_response_id 引用)。OCR 显式关掉------因为 OCR 每次请求都带完整 history(无状态),存了也用不上,反而泄露隐私。
分-8 机制⑧:离线 BPE + 安全防御组合拳
原理
OCR 要做 token 计数(用于压缩阈值判断、budget look-ahead 等)。token 计数需要 BPE 词表。原版 tiktoken-go 第一次用时会去 openaipublic.blob.core.windows.net 拉词表------这在 air-gapped 环境、CI 网络 whitelist、企业内网都会失败。
OCR 的解法:编译期 //go:embed 把 cl100k_base.tiktoken 词表打包进二进制,启动时调 tiktoken.SetBpeLoader 替换默认 loader,永不联网。
对应论文
- BPE for NMT (Sennrich et al., ACL 2016)------把 BPE(Byte Pair Encoding,原是数据压缩算法)用于神经机器翻译的子词单元。这是 GPT 系列分词器的算法基础。OCR 嵌入的
cl100k_base.tiktoken就是 GPT-4 用的 BPE 词表。
OCR 落点
internal/llm/embedded_loader.go(file:///workspace/internal/llm/embedded_loader.go):
go
//go:embed bpe_data/*.tiktoken
var embedFS embed.FS
var urlToFileMap = map[string]string{
"https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken": "cl100k_base.tiktoken",
"https://openaipublic.blob.core.windows.net/encodings/o200k_base.tiktoken": "o200k_base.tiktoken",
// ...
}
func InitEmbeddedLoader() {
loader := &embeddedBpeLoader{}
tiktoken.SetBpeLoader(loader) // 替换默认 loader
}
parseBpeData 解析每行 <base64-token> <rank> → map[string]int,这就是 tiktoken 用的 BPE rank 表。
模型选择:
go
func encodingForModel(modelName string) string {
lower := strings.ToLower(modelName)
switch {
case strings.Contains(lower, "o1") || strings.Contains(lower, "o3") || strings.Contains(lower, "o4"):
return "o200k_base" // o1/o3/o4 系列用新词表
default:
return "cl100k_base" // 其他用 cl100k_base
}
}
降级 :encoding 加载失败时,回退到 len([]byte(text)) / 4------粗估但保证 token-based gating 不挂。
安全防御组合拳
OCR 还内建了一组安全防御,对应 Saltzer & Schroeder (1975) 的安全原则:
| 防御 | 对应原则 | 落点 |
|---|---|---|
--end-of-options 防 git flag 注入 |
Economy of Mechanism | review_cmd.go::validateReviewRefs(file:///workspace/cmd/opencodereview/review_cmd.go) |
pathutil.WithinBase() 防 path traversal |
Complete Mediation | internal/pathutil/path.go(file:///workspace/internal/pathutil/path.go) |
| viewer host-header allowlist 防 DNS rebinding | Complete Mediation | internal/viewer/hostguard.go(file:///workspace/internal/viewer/hostguard.go) |
| API key 只从 env 读、不打印 | Least Privilege | LLM client 配置 |
TLS 1.2+ 默认验证,从不设 InsecureSkipVerify |
Fail-safe Defaults | HTTP client |
CGO_ENABLED=0 缩小攻击面 |
Economy of Mechanism | 构建配置 |
例子:flag 注入攻击的拦截
恶意用户构造:
bash
ocr review --commit 'abc123 --exec=rm -rf /'
如果没有防御,git 命令会变成 git rev-parse --verify abc123 --exec=rm -rf /^{commit},可能执行任意命令。
OCR 的防御(review_cmd.go(file:///workspace/cmd/opencodereview/review_cmd.go)):
go
// 1. 拒绝以 - 开头的 ref
if strings.HasPrefix(ref, "-") {
return fmt.Errorf("ref must not start with '-'")
}
// 2. 用 --end-of-options 明确终止选项
cmd := exec.Command("git", "rev-parse", "--verify", "--end-of-options", ref+"^{commit}")
--end-of-options 是 git 的官方"flag 终结符"------之后的参数都被当作 positional,不会被解释为选项。
#mermaid-svg-M5Hj3aJqfKIBy3ZW{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-M5Hj3aJqfKIBy3ZW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .error-icon{fill:#552222;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .marker.cross{stroke:#333333;}#mermaid-svg-M5Hj3aJqfKIBy3ZW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-M5Hj3aJqfKIBy3ZW p{margin:0;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .cluster-label text{fill:#333;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .cluster-label span{color:#333;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .cluster-label span p{background-color:transparent;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .label text,#mermaid-svg-M5Hj3aJqfKIBy3ZW span{fill:#333;color:#333;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .node rect,#mermaid-svg-M5Hj3aJqfKIBy3ZW .node circle,#mermaid-svg-M5Hj3aJqfKIBy3ZW .node ellipse,#mermaid-svg-M5Hj3aJqfKIBy3ZW .node polygon,#mermaid-svg-M5Hj3aJqfKIBy3ZW .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .rough-node .label text,#mermaid-svg-M5Hj3aJqfKIBy3ZW .node .label text,#mermaid-svg-M5Hj3aJqfKIBy3ZW .image-shape .label,#mermaid-svg-M5Hj3aJqfKIBy3ZW .icon-shape .label{text-anchor:middle;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .rough-node .label,#mermaid-svg-M5Hj3aJqfKIBy3ZW .node .label,#mermaid-svg-M5Hj3aJqfKIBy3ZW .image-shape .label,#mermaid-svg-M5Hj3aJqfKIBy3ZW .icon-shape .label{text-align:center;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .node.clickable{cursor:pointer;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .arrowheadPath{fill:#333333;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-M5Hj3aJqfKIBy3ZW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-M5Hj3aJqfKIBy3ZW .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-M5Hj3aJqfKIBy3ZW .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .cluster text{fill:#333;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .cluster span{color:#333;}#mermaid-svg-M5Hj3aJqfKIBy3ZW div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-M5Hj3aJqfKIBy3ZW rect.text{fill:none;stroke-width:0;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .icon-shape,#mermaid-svg-M5Hj3aJqfKIBy3ZW .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .icon-shape p,#mermaid-svg-M5Hj3aJqfKIBy3ZW .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .icon-shape .label rect,#mermaid-svg-M5Hj3aJqfKIBy3ZW .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-M5Hj3aJqfKIBy3ZW .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-M5Hj3aJqfKIBy3ZW .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-M5Hj3aJqfKIBy3ZW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
否
是
用户输入
--commit 'abc --exec=rm -rf /'
ref.StartsWith('-')?
拒绝
构造 git 命令
git rev-parse --verify
--end-of-options ref^commit
git 执行
ref 有效?
拒绝: ref 不存在
继续 review
配图说明 :这是 OCR 防御深度的范例------一层 HasPrefix("-") 检查,一层 --end-of-options,再加一层 rev-parse --verify 验证 ref 真实存在。三层防御叠加,单层失效也不会被攻破。这就是 Saltzer & Schroeder 的 "Defense in Depth" 在 CLI 工程里的具体落地。
分-9 机制⑨(彩蛋):CommentWorkerPool 的 per-key WaitGroup
原理
CommentWorkerPool 异步跑评论后处理(行号解析、re-location、reflection),不阻塞主 LLM 循环。挑战在于------主循环结束后调 Await 等 drain 时,其他文件的 goroutine 可能还在 Submit 新任务。Go 的 sync.WaitGroup 文档明确警告:Add 不能与 Wait 并发调用,否则会 panic。
OCR 的解法:per-key WaitGroup ------每个文件路径一个独立的 WaitGroup,AwaitKey("a.go") 只等 a.go 的任务,不碰其他 key。
OCR 落点
internal/llmloop/pool.go(file:///workspace/internal/llmloop/pool.go):
go
type CommentWorkerPool struct {
semaphore chan struct{} // 全局并发上限 (default 8)
wg sync.WaitGroup // 全局 wait (永不与 Submit 并发 Await)
keysMu sync.Mutex
keys map[string]*sync.WaitGroup // per-key wait
}
func (p *CommentWorkerPool) SubmitFor(key string, f func()) {
p.wg.Go(func() { // Go 1.25 的 sync.WaitGroup.Go: Add(1) 同步后才起 goroutine
// ... 取 semaphore slot
// ... 找到/创建 keys[key] 的 kwg
kwg.Add(1)
defer kwg.Done()
defer func() { recover() /* panic 隔离 */ }()
f()
})
}
func (p *CommentWorkerPool) AwaitKey(key string) {
p.keysMu.Lock()
kwg := p.keys[key]
p.keysMu.Unlock()
if kwg != nil {
kwg.Wait() // 只等这个 key,不碰全局 wg
}
}
AwaitKey 不动 wg,所以可以与其他文件的 Submit 并发------它只碰 keys[key] 的 kwg,而那个 kwg 的 Add 已经在 SubmitFor 里同步完成了。
例子:8 文件并发场景
File A: main loop done → AwaitKey("a.go")
File B: main loop still running → Submit("b.go", comment_work)
File C: main loop still running → Submit("c.go", comment_work)
...
AwaitKey("a.go") 调用:
- 锁 keysMu,读 keys["a.go"] 的 kwg_A
- 解锁
- kwg_A.Wait() ← 这里并发安全,因为:
- kwg_A 的所有 Add 已经在 a.go 的 SubmitFor 调用里完成
- 文件 B/C 的 Submit 用的是 kwg_B / kwg_C,不影响 kwg_A
#mermaid-svg-ByCApFr5tIRnFHzs{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ByCApFr5tIRnFHzs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ByCApFr5tIRnFHzs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ByCApFr5tIRnFHzs .error-icon{fill:#552222;}#mermaid-svg-ByCApFr5tIRnFHzs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ByCApFr5tIRnFHzs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ByCApFr5tIRnFHzs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ByCApFr5tIRnFHzs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ByCApFr5tIRnFHzs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ByCApFr5tIRnFHzs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ByCApFr5tIRnFHzs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ByCApFr5tIRnFHzs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ByCApFr5tIRnFHzs .marker.cross{stroke:#333333;}#mermaid-svg-ByCApFr5tIRnFHzs svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ByCApFr5tIRnFHzs p{margin:0;}#mermaid-svg-ByCApFr5tIRnFHzs .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ByCApFr5tIRnFHzs .cluster-label text{fill:#333;}#mermaid-svg-ByCApFr5tIRnFHzs .cluster-label span{color:#333;}#mermaid-svg-ByCApFr5tIRnFHzs .cluster-label span p{background-color:transparent;}#mermaid-svg-ByCApFr5tIRnFHzs .label text,#mermaid-svg-ByCApFr5tIRnFHzs span{fill:#333;color:#333;}#mermaid-svg-ByCApFr5tIRnFHzs .node rect,#mermaid-svg-ByCApFr5tIRnFHzs .node circle,#mermaid-svg-ByCApFr5tIRnFHzs .node ellipse,#mermaid-svg-ByCApFr5tIRnFHzs .node polygon,#mermaid-svg-ByCApFr5tIRnFHzs .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ByCApFr5tIRnFHzs .rough-node .label text,#mermaid-svg-ByCApFr5tIRnFHzs .node .label text,#mermaid-svg-ByCApFr5tIRnFHzs .image-shape .label,#mermaid-svg-ByCApFr5tIRnFHzs .icon-shape .label{text-anchor:middle;}#mermaid-svg-ByCApFr5tIRnFHzs .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ByCApFr5tIRnFHzs .rough-node .label,#mermaid-svg-ByCApFr5tIRnFHzs .node .label,#mermaid-svg-ByCApFr5tIRnFHzs .image-shape .label,#mermaid-svg-ByCApFr5tIRnFHzs .icon-shape .label{text-align:center;}#mermaid-svg-ByCApFr5tIRnFHzs .node.clickable{cursor:pointer;}#mermaid-svg-ByCApFr5tIRnFHzs .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ByCApFr5tIRnFHzs .arrowheadPath{fill:#333333;}#mermaid-svg-ByCApFr5tIRnFHzs .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ByCApFr5tIRnFHzs .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ByCApFr5tIRnFHzs .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ByCApFr5tIRnFHzs .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ByCApFr5tIRnFHzs .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ByCApFr5tIRnFHzs .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ByCApFr5tIRnFHzs .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ByCApFr5tIRnFHzs .cluster text{fill:#333;}#mermaid-svg-ByCApFr5tIRnFHzs .cluster span{color:#333;}#mermaid-svg-ByCApFr5tIRnFHzs div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ByCApFr5tIRnFHzs .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ByCApFr5tIRnFHzs rect.text{fill:none;stroke-width:0;}#mermaid-svg-ByCApFr5tIRnFHzs .icon-shape,#mermaid-svg-ByCApFr5tIRnFHzs .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ByCApFr5tIRnFHzs .icon-shape p,#mermaid-svg-ByCApFr5tIRnFHzs .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ByCApFr5tIRnFHzs .icon-shape .label rect,#mermaid-svg-ByCApFr5tIRnFHzs .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ByCApFr5tIRnFHzs .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ByCApFr5tIRnFHzs .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ByCApFr5tIRnFHzs :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} CommentWorkerPool
AwaitKey('a.go')
SubmitFor('b.go', f)
SubmitFor('c.go', f)
只碰 kwg_A, 不影响
只碰 kwg_B
只碰 kwg_C
semaphore (cap=8)
wg (global, 永不与 Submit 并发 Await)
keysMu
keys mapstring*WaitGroup
{'a.go': kwg_A, 'b.go': kwg_B, ...}
File A main loop done
kwg_A.Wait()
File B main loop running
kwg_B.Add(1)
File C main loop running
kwg_C.Add(1)
a.go drain 完成, 文件 A 可以跑 review-filter
配图说明 :这张图揭示一个关键事实------AwaitKey 调用文件 A 的 kwg_A 时,文件 B/C 的 Submit 用的是各自的 kwg_B/kwg_C,三者完全隔离。如果用全局 wg.Wait() 等到所有文件 drain 完,会与正在 Submit 的 B/C 并发,触发 "Add called concurrently with Wait" panic。这就是 per-key 设计的必要性。
Panic 隔离
pool.go(file:///workspace/internal/llmloop/pool.go) 第 97-101 行:
go
defer func() {
if r := recover(); r != nil {
log.Printf("comment worker panic: %v\n%s", r, debug.Stack())
}
}()
一个评论处理的 panic 不会拖死整个 pool------它"贡献 0 条评论,与没产出评论无差别"(pool.go 第 117-119 行注释)。
总 八机制合奏 + 使用指南 + 调优案例
升华:八种机制像一支乐队
如果用一个比喻------八种机制像一支乐队:
- 确定性工程(①文件分治、⑤review-filter、⑥budget look-ahead、⑧安全防御)打节拍------稳定、可预测、不可错;
- Agent(②三区压缩、④re-location、⑤plan)即兴演奏------动态、灵活、有边界;
- 基础设施(③快照合并、⑦prompt cache、⑨per-key waitgroup)是声学与电路------让前两者能合奏。
节拍稳了,即兴才有底气;即兴有了,节拍才不枯燥。OCR 的工程精度让 LLM 可以放心即兴,LLM 的即兴让工程精度不变成僵硬。
使用指南(调优参数)
关键调优 flag
| Flag | 作用 | 默认 | 调优建议 |
|---|---|---|---|
--concurrency N |
并发文件数 | 8 | 大 PR 调高(注意 LLM rate limit);小 PR 调低(减少开销) |
--per-file-timeout duration |
单文件超时 | 0(不限) | CI 推荐设 5min,避免单文件卡死 |
--max-tools N |
每文件最大 LLM 轮次 | 30 | 想省 token 调到 15-20;想深度审调到 50 |
--max-tokens-budget N |
整次扫描 token 预算上限 | 0(不限) | scan 大仓库必设;review 一般不用 |
--rule file |
自定义规则 | - | 团队规范固化到规则文件 |
--background text |
给 LLM 的背景 | - | "本次改动是为了修复 #123" 类上下文 |
何时开 / 关 plan 阶段
- 不要关------除非你的 PR 都是几十行的小 diff(plan 阈值 50 行以下会自动跳过);
- 手动调阈值 ------
task_template.json里改PLAN_MODE_LINE_THRESHOLD,小 PR 多的场景调高到 100 行。
何时启用 dedup
ocr scan自动启用(默认DedupMinComments = 2);ocr review不启用 dedup(diff 模式评论本就不多);- 想关 dedup:scan_template.json 里设
DEDUP_TASK为 null。
端到端调优案例
Case A:调优 token 成本
场景:你的团队 review 一个 100 文件大 PR,发现 token 消耗太高。
调优步骤:
bash
# 1. 开启 prompt cache(默认就开,确认 provider 是 anthropic 或 openai-responses)
ocr config provider # 选 anthropic 或 openai-responses
# 2. 调小 MaxToolRequestTimes(默认 30,调到 20)
# 编辑 ~/.opencodereview/task_template.json:
# "MAX_TOOL_REQUEST_TIMES": 20
# 3. 设单文件超时,避免卡死烧 token
ocr review --from main --to feat --per-file-timeout 3m
# 4. 用 --audience 减少冗长
ocr review --from main --to feat --audience security # 只说安全问题
# 5. 如果还是超预算,限制 review 范围
ocr review --from main --to feat --excludes "**/*_test.go"
预期效果:token 从 ~300K 降到 ~150K,precision 几乎不变。
Case B:超大 PR 不爆预算
场景:你接手一个 200+ 文件的 monorepo PR,怕 token 爆。
bash
# 用 scan 模式(虽然你 diff 有,但 scan 的 budget 门控更可控)
ocr scan --path . --max-tokens-budget 500000 --concurrency 4
OCR 会:
- 枚举所有文件;
- 按语言分批;
- 批内 4 并发审;
- 每文件 acquire slot 前 look-ahead,预估超 500K 就跳过剩余;
- 跳过的文件在输出里标记
budget_hit。
预期:500K 预算内审完前 80-100 个文件,剩下文件标记跳过。
Case C:CI 中跑 ocr scan 全仓审计
场景:每周定时扫一次整个仓库,找历史遗留问题。
.github/workflows/ocr-scan.yml:
yaml
name: Weekly OCR Scan
on:
schedule:
- cron: '0 2 * * 1' # 每周一 2am
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-go@v5
with: { go-version: '1.25' }
- name: Install OCR
run: npm install -g @alibaba-group/open-code-review
- name: Run scan
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
ocr scan \
--path . \
--max-tokens-budget 2000000 \
--concurrency 8 \
--output-format json \
> ocr-scan-$(date +%Y%m%d).json
- name: Upload report
uses: actions/upload-artifact@v4
with:
name: ocr-scan-report
path: ocr-scan-*.json
预期:每周一份 2M token 内的全仓审计报告,作为代码质量基线追踪。
调优决策树
#mermaid-svg-Wt5kYk7STfbCjh4K{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Wt5kYk7STfbCjh4K .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Wt5kYk7STfbCjh4K .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Wt5kYk7STfbCjh4K .error-icon{fill:#552222;}#mermaid-svg-Wt5kYk7STfbCjh4K .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Wt5kYk7STfbCjh4K .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Wt5kYk7STfbCjh4K .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Wt5kYk7STfbCjh4K .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Wt5kYk7STfbCjh4K .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Wt5kYk7STfbCjh4K .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Wt5kYk7STfbCjh4K .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Wt5kYk7STfbCjh4K .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Wt5kYk7STfbCjh4K .marker.cross{stroke:#333333;}#mermaid-svg-Wt5kYk7STfbCjh4K svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Wt5kYk7STfbCjh4K p{margin:0;}#mermaid-svg-Wt5kYk7STfbCjh4K .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Wt5kYk7STfbCjh4K .cluster-label text{fill:#333;}#mermaid-svg-Wt5kYk7STfbCjh4K .cluster-label span{color:#333;}#mermaid-svg-Wt5kYk7STfbCjh4K .cluster-label span p{background-color:transparent;}#mermaid-svg-Wt5kYk7STfbCjh4K .label text,#mermaid-svg-Wt5kYk7STfbCjh4K span{fill:#333;color:#333;}#mermaid-svg-Wt5kYk7STfbCjh4K .node rect,#mermaid-svg-Wt5kYk7STfbCjh4K .node circle,#mermaid-svg-Wt5kYk7STfbCjh4K .node ellipse,#mermaid-svg-Wt5kYk7STfbCjh4K .node polygon,#mermaid-svg-Wt5kYk7STfbCjh4K .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Wt5kYk7STfbCjh4K .rough-node .label text,#mermaid-svg-Wt5kYk7STfbCjh4K .node .label text,#mermaid-svg-Wt5kYk7STfbCjh4K .image-shape .label,#mermaid-svg-Wt5kYk7STfbCjh4K .icon-shape .label{text-anchor:middle;}#mermaid-svg-Wt5kYk7STfbCjh4K .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Wt5kYk7STfbCjh4K .rough-node .label,#mermaid-svg-Wt5kYk7STfbCjh4K .node .label,#mermaid-svg-Wt5kYk7STfbCjh4K .image-shape .label,#mermaid-svg-Wt5kYk7STfbCjh4K .icon-shape .label{text-align:center;}#mermaid-svg-Wt5kYk7STfbCjh4K .node.clickable{cursor:pointer;}#mermaid-svg-Wt5kYk7STfbCjh4K .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Wt5kYk7STfbCjh4K .arrowheadPath{fill:#333333;}#mermaid-svg-Wt5kYk7STfbCjh4K .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Wt5kYk7STfbCjh4K .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Wt5kYk7STfbCjh4K .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Wt5kYk7STfbCjh4K .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Wt5kYk7STfbCjh4K .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Wt5kYk7STfbCjh4K .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Wt5kYk7STfbCjh4K .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Wt5kYk7STfbCjh4K .cluster text{fill:#333;}#mermaid-svg-Wt5kYk7STfbCjh4K .cluster span{color:#333;}#mermaid-svg-Wt5kYk7STfbCjh4K div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Wt5kYk7STfbCjh4K .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Wt5kYk7STfbCjh4K rect.text{fill:none;stroke-width:0;}#mermaid-svg-Wt5kYk7STfbCjh4K .icon-shape,#mermaid-svg-Wt5kYk7STfbCjh4K .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Wt5kYk7STfbCjh4K .icon-shape p,#mermaid-svg-Wt5kYk7STfbCjh4K .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Wt5kYk7STfbCjh4K .icon-shape .label rect,#mermaid-svg-Wt5kYk7STfbCjh4K .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Wt5kYk7STfbCjh4K .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Wt5kYk7STfbCjh4K .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Wt5kYk7STfbCjh4K :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} < 30 文件
30-100 文件
> 100 文件
是
否
是
否
是
只审 hot path
我要 review
PR 大小?
默认即可
ocr review --from main --to feat
调高并发
--concurrency 16
考虑 scan + budget
--max-tokens-budget 1M
想要省钱?
调 MaxToolRequestTimes=20
--audience security/developer
默认参数
怕爆预算?
--max-tokens-budget 500K
默认 + 高并发
整个仓库审?
ocr scan --path . --max-tokens-budget 2M
ocr scan --path internal/agent/
拿到评论
配图说明:这张决策树把"调优"变成可视化流程。从"PR 大小"出发分到三档,每档再根据子诉求(省钱 / 防爆 / 全审 / 部分)分到具体命令。这是 OCR 实战调优的速查图。
读到这里,你已经看到了 OCR 的"内部零件"。但要真正记住它,需要的不是更多细节,而是更高维的抽象 ------为什么是这种设计?这套设计在 AI 工程史里意味着什么?见 03_深刻不忘观_设计哲学.md。
本篇一句话总结:OCR 把 ReAct / Reflexion / Plan-and-Solve / MapReduce / BPE / Saltzer-Schroeder 这些经典思想,在"代码审查"这一个垂直场景里工程化收束成八种机制,每一种都有论文背书,每一种都经得起推敲。