PostHog ReviewHog 的 One-shot 分块与去重:把 PR 审查流水线的两个阶段从沙箱迁到直接 LLM 调用
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
导读
本文深入剖析 PostHog 仓库中 ReviewHog(AI 代码审查系统)的一次关键流水线改造实验:将「PR 分块(chunking)」与「问题去重(dedup)」这两个阶段从 agentic 沙箱(sandbox)执行,迁移为单次直接 LLM 网关调用(one-shot)。文章完整复现了实验的机制设计(门限常量、结构化输出、模型与推理力度固定)、两条端到端运行与离线采样批次的实测数据、发现的唯一回归信号及其 prompt 级修复,并结合仓库源码(常量定义、run_oneshot_review实现、Temporal 活动路由、测试用例)说明其底层原理。读完你可以掌握:这类"纯文本任务无沙箱化"改造的门限设计思路、如何用结构化输出消灭一类 schema 失败、以及如何用离线采样器低成本迭代 LLM prompt。
实验背景:为什么分块与去重值得"无沙箱化"
ReviewHog 的 PR 审查流水线中,chunking(把改动文件按关切点分组)和dedup(对审查出的问题做去重)是最后两个仍以 agentic 沙箱方式运行的阶段。但分析表明,这两个阶段本质上是纯文本任务:
- dedup 的 prompt 渲染时
CLAUDE_CODE_CONTEXT=""(issue_deduplicator.py),不需要任何代码上下文; - chunking 的 prompt 将 PR 元数据、评论和 patch 全部内联(split_pr_into_chunks.py 的
generate_chunking_prompt),输入完全自包含; - 存档数据显示,14/20 次 dedup 调用中工具使用为零。
而每次沙箱执行都要付出两类成本:
- 时间:沙箱供给(provisioning)约 55 秒的串行关键路径死时间,两个阶段合计占整轮运行 wall-clock 的 7%~11%;
- 可靠性:两类失败——Modal 供给抖动,以及 chunking 阶段约29% 的 schema 失败类(4/14 次任务返回了裸数组而非带顶层
chunks键的对象,在 2 次重试下是真实存在的"审查失败"路径)。
实验假设(见 POTENTIAL_EXPERIMENTS.md 的 Tier-2 第 7 项):如果改用一次直接网关调用、用structured outputs 从结构上保证 schema,那么在相同模型、相同 prompt 下,分块计划与去重决策质量应当保持,同时消掉阶段墙钟与失败类别。
实验设计:门限常量、模型固定与双路径路由
实验于 2026-07-03/04 执行,测试对象是冻结的 PR #62096(headba725a897db35053525e5bdfac2c64a8b007fcb4,674 处新增),该 PR 规模恰好位于单块门限(400)之上、两个 one-shot 门限之下,两个新路径都能被真实触发。评审与验证阶段保持不动(与生产一致),因此旧 10 项覆盖率的差异可归因于评审轮方差与分块结构抽签,而非 one-shot 改动本身。
门限与模型固定(constants.py)
constants.py 中定义了整套 one-shot 配置:
# ONE-SHOT (SANDBOX-FREE) CHUNKING + DEDUP CHUNKING_ONESHOT_MAX_ADDITIONS = 5000 # reviewable ADDED lines, like the other chunking gates DEDUP_ONESHOT_MAX_FINDINGS = 50 # issues entering dedup (before the positional pre-filter) ONESHOT_MODEL = "claude-sonnet-5" ONESHOT_REASONING_EFFORT = "xhigh"关键语义:
- 按新增行数(additions)计量:
CHUNKING_ONESHOT_MAX_ADDITIONS = 5000只统计可审查的新增行,与SINGLE_CHUNK_GATE_ADDITIONS = 400、CHUNK_TARGET_ADDITIONS = 300、CHUNK_SOFT_MAX_ADDITIONS = 600的既有约定一致; - 门限包含边界且可一键回退:
<= 5000走 one-shot;任一门限置 0 即完全禁用该路径,沙箱路径字节级还原; - 模型固定:one-shot 调用固定
claude-sonnet-5 @ xhigh(自适应思考 +output_config.effort=xhigh是 Messages API 原生的"xhigh"表达,与沙箱侧CHUNKING_MODEL/DEDUP_MODEL固定同一模型保持双路径平价)。
这些门限至今仍保留在仓库中默认开启,正是实验结论"采纳"落地后的状态。
一次调用原语:run_oneshot_review
direct_llm.py 的run_oneshot_review是整套机制的核心:
async def run_oneshot_review( *, team_id, user_id, prompt, system_prompt, model_to_validate: type[_ModelT], step_name: str, model=ONESHOT_MODEL, reasoning_effort=ONESHOT_REASONING_EFFORT, ) -> _ModelT:实现要点(均可从 test_direct_llm.py 的请求形状测试锁定):
- 单次 Messages 调用:经
get_async_anthropic_gateway_client(product="review_hog", team_id=...)发出,thinking={"type": "adaptive"},output_config={"effort": reasoning_effort}; - 结构化输出:
output_format=model_to_validate(阶段自身的 pydantic 模型),schema 由 SDK 保证——"裸数组缺顶层键"那一类失败在结构上不可能发生; - 阶段归因:
extra_headers={"x-posthog-property-ai_stage": step_name},让 dump 与成本查询能把调用归属到chunking/dedup等具体阶段; - Bedrock 回退故意关闭:其转发白名单会剥掉
output_config,静默丢失推理力度固定与 schema 约束; - 错误语义:Anthropic
APIError折叠为紧凑的ApplicationError(4xx 除 408/409/429 外标记不可重试,见 test_direct_llm.py);截断 JSON 的 pydanticValidationError折叠为可重试的紧凑错误;无解析输出时按stop_reason分支——max_tokens截断是确定性失败,直接标记不可重试避免烧预算(test_direct_llm.py)。
该原语后来也被 outcome 分类器(outcomes/judge.py)与 perspective 选择阶段复用,说明 one-shot 模式已推广为通用管道手段。
双路径路由:门限内的 one-shot,门限外的沙箱
- 分块(activities.py):≤400 新增行走确定性单块(跳过 LLM);否则渲染 prompt 后按
use_oneshot = bool(CHUNKING_ONESHOT_MAX_ADDITIONS) and additions <= CHUNKING_ONESHOT_MAX_ADDITIONS路由,one-shot 调run_oneshot_review(..., step_name="chunking"),超限走带CHUNKING_*pin 的run_sandbox_review。两条路径 prompt 逐字节相同;分块输出还会经reconcile_chunks强制"每个可审查文件恰好出现一次"(遗漏文件追加 catch-all 块、幻觉文件剔除、重复文件只保留首次出现)。门限路由由 test_review_activity.py 以CHUNKING_ONESHOT_MAX_ADDITIONS与+1两个参数化用例锁定。 - 去重(issue_deduplicator.py):先做位置预过滤(
_select_dedup_candidates),只在存在位置重叠的候选项时调用 LLM;门限按实际送入 LLM 的候选数计量(而非总数),len(candidates) <= DEDUP_ONESHOT_MAX_FINDINGS走run_oneshot_review(..., step_name="dedup"),超限走沙箱;位置不重叠的"unique"问题永远保留。路由同样由 test_issue_deduplicator.py 在门限边界上参数化锁定。
此外,review_hog作为网关产品注册进了 gateway_client.py 的Productliteral(第 38 行),实现"任何模型、可用 API key、不计费"的产品级平价管线。
实验方法与刻意混淆
实验含 2 次完整 e2e 运行(ONESHOT-1、ONESHOT-2)+ 1 批离线分块采样(5 次直接调用)+ 1 次线上 PR 确认(#67419)。运行协议沿袭此前轮次:预检 →run_review→ dump → no-verdict 检查 → 模型/机制验证(通过$ai_generation事件的ai_stage/ai_product属性确认 one-shot 生成且无分块/去重沙箱任务)→reset_review_hog。判定采用旧 ReviewHog 10 项基准(old_reviewhog_report.md)逐 dump 由 judge agent 判定,原始结果在 judge_results.json。
需要说明的是,实验采用了端状态式刻意混淆:one-shot 臂同时改变了模型(agent 默认 opus-4-8 @ high → sonnet-5 @ xhigh)与执行模式(沙箱 → 直接调用),测试的是目标终态而非单变量。另一个刻意选择是不固定分块结构(unpinned chunks)——分块计划本身就是被测量的输出,固定会短路 one-shot 分块路径。
实验结果:机制成立、质量持平、时间与可靠性收益明确
漏斗、成本与耗时(每轮)
| 运行 | 分块(抽签) | 单元数 | raw→dedup→valid | fetch→分块计划 | wave 结束→去重完成 | 总 token(in/out) | wall-clock |
|---|---|---|---|---|---|---|---|
| ONESHOT-1 | 2(unpinned) | 8 | 13→11→10 | 47s/attempt | 38s | 33.9M/230k sonnet-5 | 2374s(有效 ≈ 34 min) |
| ONESHOT-2 | 3(unpinned) | 12 | 18→14→9 | 36s | 54s | 53.1M/352k sonnet-5 | 1626s(27.1 min,无中断) |
对照基线(复用未重跑):C1(全 sonnet、固定分块、沙箱去重/分块)18→11→7、43 min、去重阶段约 10 min;B 对 17→11→6 / 18→14→6、约 21 min 有效。漏斗比较附带 unpinned-vs-pinned 分块结构差异的保留意见。
值得注意的两点:
- ONESHOT-1 的 valid=10 是历轮所有运行中的最高值,但 validator 存活率 10/11 = 91%(此前区间 43%–64%),报告明确要求 judge 先裁决 junk 才能当作胜利来读;
- ONESHOT-1 的分块首次尝试因用户发起的中途 worker 重启被丢弃,Temporal 在 5 分钟心跳超时后整次重试——one-shot 调用中途不可恢复,只能整体重试,这是基础设施事件而非路径问题。
旧 10 项覆盖率(judge 根因匹配)
V= 捕获且 validator 判定有效;i= 捕获但被 validator 驳回;.= 漏掉
old# | O1 O2 | 1 | . . | 从未在任何轮次浮现(此前 0/21) 2 | V V | O1 = 仅长度一半;O2 = 完整根因拆在两个 VALID finding 上 3 | V V | 每轮每次运行均捕获 4 | . . | 从未浮现(0/21) 5 | V . | 第三次浮现——本次为 VALID(update_action 整步替换未按危险操作门控) 6 | V . | 第三次 VALID(无界 compact 列表输出) 7 | . . | 仅浮现过一次(B2,被驳回) 8 | . . | 从未浮现(旧 must_fix) 9 | i . | validator 以合理的既有模式反驳驳回 10 | . . | 从未浮现(旧 must_fix)技能内容盲区(#1/#4/#8/#10)在所有轮次中持续不变——它们位于评审阶段,而本次实验未触碰该阶段。综合 4 valid(O1)/ 2 valid(O2),优于 sonnet 轮的 B 对(3/1)。
新发现(judge 对照 diff 与仓库验证)
| 运行 | new_plausible | 其中 validator-VALID | new_junk | 通过验证的 junk |
|---|---|---|---|---|
| O1 | 6 | 6 | 0 | 0 |
| O2 | 6 | 6 | 4(全部被驳回) | 0 |
两次运行独立浮现的高价值发现:list_actions的对象级访问控制绕过(must_fix、VALID,对照filter_queryset_by_access_level先例经 judge 验证)与$autocapture专属元素过滤的静默降级。O1 的 validator 获得明确好评:每个 VALID 事实准确、一次驳回合理,且抓出了某 finding 建议的修复方案行不通。
One-shot 机制记分板
| 检查项 | 结果 |
|---|---|
| 分块 one-shot ≤5k 新增行 | ✓ 两次运行 + 线上 PR(各 1 次生成,review_hog/chunking) |
| 去重 one-shot ≤50 findings | ✓ 两次运行(各 1 次生成,review_hog/dedup) |
| 去重沙箱回退 >50 | ✓ 线上 PR #67419:61 raw → 沙箱去重,零review_hog/dedup生成 |
| schema 失败 | 0(结构化输出) |
| 阶段归因 | ✓ai_product/ai_stage戳记将运行阶段与本地 cron 噪声分离 |
| 线上发布 | ✓ #67419 审查发布(30 条行内评论,固定到 head,published_head_sha已设) |
唯一回归信号:one-shot 分块器会"打碎"小 PR
离线批次(chunker-offline-sample.md)对 497 个可审查新增行的 PR #62096 做 5 次采样,得到4,4,4,3,3个分块,碎片最小到 16 个新增行;而沙箱存档在同一个 PR 上 17 次运行只出现过 2 或 3 块(约 50/50)。管内的两次抽签(2、3)正常;3217 新增行的线上 PR 则得到干净的 6 块计划(约 536 新增/块,远好于朴素 300 目标下的 11 块)。所有抽签覆盖均完整。
用户的裁定(2026-07-03):~500 新增行出 4 块明确过多——这是成本风险(审查单元数 = 分块数 × 4),不是正确性风险(覆盖在所有抽签中均完整),恰好命中 PLAN 中"分块计划退化"的 kill 标准。修复采用 prompt 调整而非代码:
- 在 prompt.jinja 的尺寸规则中加入显式 ~100 新增行下限("Undersized chunks are a failure mode... do NOT emit a chunk under ~100 added lines")与块数公式上限("more than (total added lines ÷ CHUNK_TARGET, rounded up, plus one) chunks means you split too finely"),两条路径共享同一 prompt 保持平价;
- 用 sample_oneshot_chunker_fixture.py(纯本地 fixture,无需 GitHub 与 DB)于2026-07-06 验证:5/5 次抽签均为 2 块、覆盖完整、零碎片——未调优时的 4,4,4,3,3 打碎消失。
离线采样器(sample_oneshot_chunker.py)的价值在于:每次采样只是一次数美分的直接网关调用(无沙箱),使未来任何 prompt 迭代的验证成本趋近于零且完全本地化。
结论与建议
报告的推荐分两层:
- 立即采纳 one-shot 去重——门限两侧路由均被证明,决策与存档行为一致,0 个通过验证的 junk,阶段时间从约 10 分钟降到 <1 分钟,且两个失败类别消失;
- 同样采纳 one-shot 分块——调优后的 prompt 已验证(#62096 上 5/5 抽签 2 块、无碎片);
CHUNKING_ONESHOT_MAX_ADDITIONS = 0保留为线上行为异常时的即时回退开关。
两条路径在实验分支中默认开启,采纳 = 提交该分支(门限如今仍在 constants.py 中默认开启即为落地证据)。
移交事项(后续轮次)
- Validator 轮:sonnet validator 的体积宽松(91%/64%/86% 存活率)在 #62096 上零通过验证的 junk,是校准数据点而非 junk 泄漏;但线上 PR 30 条行内评论是 UX 问题。轮次结束时
VALIDATION_MODEL已由用户回退为claude-opus-4-8 @ xhigh(当前仓库中的验证模型 pin 为 constants.py 的claude-opus-5,同样体现"宽松验证器不采纳"的方向); - Chunker prompt 调优:已验证(5/5 干净抽签),fixture 采样器使未来 prompt 迭代近乎免费且完全本地——未经批量验证不得再改 prompt;
- 技能内容轮:#1/#4/#8/#10 在又一配置下保持盲区,结论不变;
- 基础设施:两条非路径事件值得记住——activity 中途的 worker 重启会造成 5 分钟心跳超时停滞(one-shot 调用中途不可恢复,Temporal 整体重试);桌面 harness 的
LLM_GATEWAY_URL覆盖可能错误路由 agent shell 脚本(worker 不受影响)。
成本核算
2 次运行合计约 87M in / 580k out(naive 约 $350,真实成本远低于此——缓存读取占主导),外加约 10 次离线分块调用(约 $1)与 2 个 judge agent。one-shot 阶段本身约 $0.29/run naive(每次约 50k in / 4k out)。
仓库内继续深入
- 门限与全部相关常量:constants.py
- one-shot 原语实现:direct_llm.py
- 分块/去重的双路径路由与 Temporal 活动:activities.py、issue_deduplicator.py
- 调优后的分块 prompt(~100 行下限 + 块数公式):prompt.jinja
- 锁定机制与错误语义的测试:test_direct_llm.py、test_issue_deduplicator.py、test_review_activity.py
- 完整实验记录(计划、两次运行 dump、离线采样、判定结果):PLAN.md、ONESHOT-1.md、ONESHOT-2.md、judge_results.json
- 旧 10 项基准与冻结 fixture:old_reviewhog_report.md
一处必须强调的边界:本报告中的 judge 调用未经人工复核——在judge_results.json的 notes 字段中查看接近判定的详情后再作决策。所有耗时、成本与覆盖率数据均为该冻结 PR 与 2026-07 时点配置下的实测,分块结构为 unpinned 抽签,跨轮对比需附带结构差异保留意见。
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考