pi-review-agent 是怎么"记住"上一次 review 的

pi-review-agent 用 append-only JSONL + actions/cache 前缀命中 + transcript 重放,让 reviewer 跨 CI run 续接会话,同时踩中 DeepSeek prefix cache 的计费折扣。

语速

AI code review 的 GitHub Action 里,pi-review-agent 有一个不太显眼但很关键的设计——跨 CI run 续接每个 reviewer 的会话:同一个 PR 第二次推送时,reviewer 知道"上次我提了什么、作者改了什么、哪些 issue 还没解决",而不是每次从零开始。

这篇文章拆开它的实现:上一次的对话记录落在哪、下一次 run 怎么加载回来、新 diff 怎么接进去,以及最关键的——为什么非要这么折腾。

为什么需要"记住上一次"

两个动机,一个是功能上的,一个是钱。

功能上:增量 review 需要上下文。同一个 PR 第 N 次推送时,reviewer 应该知道"上次我提了什么、作者改了什么、哪些 issue 还没解决"。无状态的 review 每次从零开始,会反复报相同的问题,会漏掉跨 commit 才看得出的 regression。

:DeepSeek 做 content-addressed prefix caching——如果请求的前缀(system prompt + 历史对话)和上一次完全一样,这部分 token 按缓存价计费。每次 review 都能把上一轮的 system prompt + history 完整重放给模型,那部分前缀就命中缓存,按 DeepSeek 公开定价里 cached token 大约是普通 input 的 2%(input $0.14/1M,cacheRead $0.0028/1M)计费。PR review 这种高频、长 prompt、内容高度重叠的场景,省下来的钱是实打实的。

但这里有个坑:不是所有 OpenAI-compatible 路径都把 cacheRead 透传出来。opencode 的 openai-compatible 适配层会把它丢成 0(anomalyco/opencode#34022),于是即便上游真的命中了缓存、真的便宜了,账单上也看不出来,更糟的是 agent 自己的 cost 计算会按全价走。pi-review-agent 用 pi-ai 这个库,cacheRead 字段一路上到 usage,cost 表也单独列了 cacheRead 折扣价——这是这个项目存在的直接理由。

所以"记住上一次"不只是为了 review 质量,更是为了让 prefix cache 真的能命中、命中的钱真的能省下来。

整体数据流

三个阶段:落盘 → 跨 run 搬运 → 加载注入

1. 落盘:一个 PR 一个目录,一个 persona 一个 jsonl

存储布局很简单,src/review.tssessionFile() 决定路径:

1
<sessionsRoot>/<pr>/<persona>.jsonl

每个 (pr, persona) 一个文件,JSONL,每行一个 AgentMessage。user / assistant / tool_use / tool_result 全部混在一起按时间顺序排。append-only——只追加不改写:

1
2
3
4
async function appendTranscript(file, messages) {
  const block = messages.map((m) => JSON.stringify(m)).join("\n") + "\n";
  await fs.appendFile(file, block);
}

(pr, persona) 这个二维 key 的选择是有意的:

  • pr 维度隔离不同 PR 的上下文(不能让 PR #42 的 review 记忆串到 PR #43)
  • persona 维度隔离同一 PR 内不同 reviewer 的上下文(quality reviewer 不该看到 security reviewer 的对话)
  • team 模式下的 coordinator 也是一个"persona",名字就叫 "coordinator",文件 coordinator.jsonl——它也走同一套 resume 路径

2. 跨 CI run 搬运:actions/cache 的 key 设计

CI run 之间是无状态的——每次跑在一个全新的 runner 上,文件系统是空的。要让上一次的 jsonl 跨 run 活下来,靠 action.yml 里这两步:

1
2
3
4
5
6
7
- name: Restore review session
  uses: actions/cache@v5
  with:
    path: ${{ ... }}/.pi-review-sessions
    key: pi-review-session-${{ github.repository }}-${{ steps.pr.outputs.pr }}-${{ github.run_id }}
    restore-keys: |
      pi-review-session-${{ github.repository }}-${{ steps.pr.outputs.pr }}-

这个 key 设计是整个机制的精妙之处,看一眼可能觉得理所当然,其实每个字段都有用意:

  • keygithub.run_id:run_id 每次 run 都唯一。这意味着 key 永远不会精确命中自己——save 总是新建一个 cache entry。如果去掉 run_id,第二次 run 会 restore 命中第一次的 entry,然后 save 时 actions/cache 发现 key 没变就跳过写回,新追加的 transcript 就丢了。带 run_id 强制每次都写。
  • restore-keys 只有前缀(不带 run_id):精确 key 永远 miss,但前缀匹配会把这个 PR 最近一次成功 run 的 sessions 目录还原回来。这就是"续接"的实际机制。
  • github.repository 在 key 里:fork 的 PR 不会串到上游 repo 的 session(cache 是 repo scope 的,但写上更明确)。
  • pr 在 key 里,persona/team 不在:一个 cache entry 装下这个 PR 的所有 reviewer + coordinator(persona 编码在文件名里)。这样 cache hit 率最高。

粒度是 (repo, pr),不是 (repo, pr, persona)。如果按 persona 拆 cache entry,N 个 persona 就要 N 次 cache round-trip;现在一次 restore 把整个目录搬回来。

3. 加载 + 注入:transcript 当种子,diff 当新 user turn

runReview() 是核心。每次调用(每个 persona、每次 run)都走这套:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export async function runReview(opts) {
  const file = await sessionFile(opts);
  const transcript = await loadTranscript(file);   // 不存在 → []
  const resumed = transcript.length > 0;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const newMessages = [];
    const agent = new Agent({
      initialState: {
        systemPrompt,
        model,
        tools,
        messages: transcript,    // ← 上轮 transcript 整段当种子
      },
      sessionId,
      streamFn: ...,
    });
    const done = collectFromAgent(agent, newMessages);
    await agent.prompt(`Review this diff:\n\n${opts.diff}`);  // ← 新 diff 作为下一条 user turn
    const collected = await done;
    // ...
    await appendTranscript(file, newMessages);   // 只追加本轮新增的消息
    return { content, usage, resumed, ... };
  }
}

两步,泾渭分明:

a. transcript 作为种子塞进 Agent 的 initialState

new Agent({ initialState: { messages: transcript } }) 把上一轮的完整对话历史(user + assistant + tool_use + tool_result)作为这个 agent 的初始 messages。从模型的角度看,它"以为"自己刚才已经聊过这些——因为 DeepSeek 做 content-addressed prefix caching,这段重放的前缀会命中缓存,usage.cacheRead > 0,命中部分按 2% 计费。

b. 新 diff 作为新的 user turn 接在后面

agent.prompt("Review this diff:\n\n" + diff) 在 pi-agent-core 里就是把传入文本当作下一条 user message 追加到 messages 末尾然后发起流。所以续接的形状是:

1
2
[seeded: system + 上轮 user + 上轮 assistant + 上轮 tool...]
+ "Review this diff:\n\n<本轮新 diff>"    新注入的 user turn

模型看到的是一段连续对话,它的"记忆"完全来自重放的 transcript,不需要任何特殊的 memory API 或向量检索。

diff 本身的来历:action.yml 里先 gh pr diff(失败回退到 GitHub pulls API 的 .diff 端点)落到 $RUNNER_TEMP/pi-review-diff.txtindex.tsprepareDiff() 过滤(锁文件必砍、diff-exclude glob、diff-max-size-kb 字节预算 + 超了就截断并附 notice)→ 作为 opts.diff 传进 runReview

几个容易写错的地方

这套机制能稳,靠的是几个容易写错的地方都写对了:

只持久化成功 attempt 的消息

1
2
3
4
5
// Only persist the transcript of a successful attempt — a partial
// transcript from a failed run would poison the next session's cache
// prefix and confuse the resume path.
await appendTranscript(file, newMessages);
return { ... };

retry 循环里,appendTranscript 只在成功路径调用。一次 attempt 失败(流断了、超时了、429 了),它产生的 newMessages 直接丢弃。原因在注释里:半截 transcript 写进去,下一轮 resume 时它成了 prefix 的一部分,但这段 prefix 对应的是一个没有正常结束的对话——模型上下文是断的,而且 DeepSeek 的 prefix cache 是按内容寻址的,下次重放这段坏 prefix 还是会"命中",但模型看到的就是一个没 assistant 回复就突然冒出新的 user turn 的怪状态。宁可丢这次重试的产物,也要保证落盘的 transcript 是干净完整的。

retry 每次都新建 Agent,但种子不变

1
2
3
4
// Fresh Agent per attempt: a half-run agent after a stream error is
// not safe to continue. The transcript seed is replayed each time;
// DeepSeek's prefix cache absorbs the replay at a discount.
const agent = new Agent({ ... });

一个流出错的 agent 内部状态是脏的(可能停在 tool_use 还没等到 tool_result 的中间态),继续用它会触发各种边界 bug。所以每次 retry 都新建一个干净的 Agent,把同一份 transcript 种子重放进去。种子不变意味着 prefix cache 仍然命中——retry 不是免费的,但也是打折的。

coordinator 也走 resume

前面提过,coordinator 是一个 persona 名为 "coordinator" 的 reviewer,文件 <pr>/coordinator.jsonl,跨 run 同样续接。但它的"diff"不是 git diff——orchestrate.tsbuildCoordinatorInput() 把各 persona 本轮的 review 结果拼成一段文本作为它的输入。所以 coordinator 的"记忆"是"我上轮综合了哪些 persona 的什么结论",这让它能在新一轮判断"哪些 issue 是新出的、哪些是没解决的"。

fail-closed

如果一个 persona reviewer 整个挂了(产不出内容),runTeamReview 不是当它"通过",而是把 verdict 强制改成 CANNOT MERGE。证据缺失不等于没证据——reviewer 没说话比 reviewer 说"没问题"危险得多。

为什么这套设计能省钱:把数字摆出来

provider 配置(src/provider.ts)里 DeepSeek 的 cost 表:

1
2
3
4
5
6
cost: {
  input: 0.14,       // 普通 input,$ / 1M tokens
  output: 0.28,
  cacheRead: 0.0028, // 命中缓存的 input,input 的 2%
  cacheWrite: 0,
}

pi-ai 的 calculateCost() 自动把 usage.cacheRead 乘上 cost.cacheRead。所以一个 PR 第二次推送时:

  • system prompt(可能几千 token 的 persona 指南 + 语言指令)+ 上一轮 transcript(reviewer 的 reasoning、读过的文件、grep 结果)这部分前缀全部命中 cache,按 $0.0028/1M 计费
  • 只有本轮新增的 diff(通常几百到几千 token)按 $0.14/1M

对一个 review 频繁、persona prompt 又长(quality/security/performance 各一套几千 token 指南)的项目,第二轮开始的 review 成本能降到第一轮的零头。

一句话总结

jsonl 当 seed 重放(吃 prefix cache)+ 新 diff 当下一条 user turn 追加——这就是 pi-review-agent"加载历史 + 注入新改动"的全部机制。没有向量数据库,没有 memory API,没有 embedding,就是一个 append-only 的 JSONL 文件 + actions/cache 的前缀模糊命中 + pi-agent-core 的 initialState.messages。简单到几乎不像一个"记忆系统",但它正好踩中了 prefix caching 的甜点,顺便把账单打了折。