02_OpenCodeReview 具体观:八种算法机制与背后的论文谱系

序言:从"工程黑魔法"到"论文谱系"

读 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 用分治解决了两个根本问题

  1. 大 PR 不能塞一个 context------50 文件 diff 塞进一个 prompt,LLM 会"挑食"漏审;
  2. 文件之间天然独立 ------审查 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

策略是两段式

  1. 第一段(确定性) :用文本滑动窗口把 existing_code 匹配到 hunk 或文件内容,得到行号。
  2. 第二段(动态) :如果文本匹配失败,让 LLM 重新生成一个更精准的 existing_code snippet,再试一次。失败则回滚到原始 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 的解析:

  1. normalizeLine("if err != nil {")"if err != nil {"
  2. normalizeLine(" return err")"return err"
  3. normalizeLine("}")"}"
  4. 在 hunk 的 new side(context + added)扫描:
    • 第 47-49 行:if err != nil {return err} ← 匹配!
  5. 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

}'

  1. 文本匹配

(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 调用阶段 + 一个事后反思阶段

  1. Plan 阶段(可选):让 LLM 先看 diff,输出一份 JSON checklist("重点审哪些地方")。
  2. Main 阶段 :把这份 plan 作为 {``{plan_guidance}} 注入主任务 prompt,进入 tool-use 循环。
  3. 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 失败的优雅降级

stripEmptyPlanBlockinternal/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 的策略:

  1. 分批:按语言或目录分组(同语言文件相邻处理,提高 prompt cache 命中率)。
  2. 批内并发:批内最多 8 文件并发。
  3. 批间串行:批与批之间串行(让前批的 dedup 跑完再开下一批)。
  4. 预算前瞻:每文件 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  // 跳过本批剩余文件
    }
}

estimateFileTokensinternal/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 的安全门

maybeRunDedupinternal/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
}

SessionIDloop.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:embedcl100k_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 会:

  1. 枚举所有文件;
  2. 按语言分批;
  3. 批内 4 并发审;
  4. 每文件 acquire slot 前 look-ahead,预估超 500K 就跳过剩余;
  5. 跳过的文件在输出里标记 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 这些经典思想,在"代码审查"这一个垂直场景里工程化收束成八种机制,每一种都有论文背书,每一种都经得起推敲。

相关推荐
变量未定义~1 小时前
图论+动态规划——魔法阵
算法
大爱编程♡1 小时前
C++基础-类和对象
c++·算法
ysa0510301 小时前
【板子】费用流
算法·深度优先·图论
玖玥拾2 小时前
LeetCode 80 删除有序数组中的重复项 II
算法·leetcode
青山木2 小时前
Hot 100 --- 电话号码的字母组合
java·数据结构·算法·leetcode·逻辑回归
闪电悠米2 小时前
力扣hot100-240.搜索二维矩阵2-单调性剪枝详解
数据结构·算法·leetcode·矩阵·哈希算法
imuliuliang2 小时前
关于基于图论的最短路径算法性能对比分析7
算法
hurrycry_小亦3 小时前
洛谷题目:P1233 [ICPC 2001 Taejon R] 木棍加工 题解(本题较难)
c++·算法·贪心算法·动态规划
imuliuliang3 小时前
关于从栈与队列看算法思维的演化路径7
算法