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.ts 里 sessionFile() 决定路径:
| |
每个 (pr, persona) 一个文件,JSONL,每行一个 AgentMessage。user / assistant / tool_use / tool_result 全部混在一起按时间顺序排。append-only——只追加不改写:
| |
(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 里这两步:
| |
这个 key 设计是整个机制的精妙之处,看一眼可能觉得理所当然,其实每个字段都有用意:
key带github.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)都走这套:
| |
两步,泾渭分明:
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 末尾然后发起流。所以续接的形状是:
| |
模型看到的是一段连续对话,它的"记忆"完全来自重放的 transcript,不需要任何特殊的 memory API 或向量检索。
diff 本身的来历:action.yml 里先 gh pr diff(失败回退到 GitHub pulls API 的 .diff 端点)落到 $RUNNER_TEMP/pi-review-diff.txt → index.ts 的 prepareDiff() 过滤(锁文件必砍、diff-exclude glob、diff-max-size-kb 字节预算 + 超了就截断并附 notice)→ 作为 opts.diff 传进 runReview。
几个容易写错的地方
这套机制能稳,靠的是几个容易写错的地方都写对了:
只持久化成功 attempt 的消息
| |
retry 循环里,appendTranscript 只在成功路径调用。一次 attempt 失败(流断了、超时了、429 了),它产生的 newMessages 直接丢弃。原因在注释里:半截 transcript 写进去,下一轮 resume 时它成了 prefix 的一部分,但这段 prefix 对应的是一个没有正常结束的对话——模型上下文是断的,而且 DeepSeek 的 prefix cache 是按内容寻址的,下次重放这段坏 prefix 还是会"命中",但模型看到的就是一个没 assistant 回复就突然冒出新的 user turn 的怪状态。宁可丢这次重试的产物,也要保证落盘的 transcript 是干净完整的。
retry 每次都新建 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.ts 里 buildCoordinatorInput() 把各 persona 本轮的 review 结果拼成一段文本作为它的输入。所以 coordinator 的"记忆"是"我上轮综合了哪些 persona 的什么结论",这让它能在新一轮判断"哪些 issue 是新出的、哪些是没解决的"。
fail-closed
如果一个 persona reviewer 整个挂了(产不出内容),runTeamReview 不是当它"通过",而是把 verdict 强制改成 CANNOT MERGE。证据缺失不等于没证据——reviewer 没说话比 reviewer 说"没问题"危险得多。
为什么这套设计能省钱:把数字摆出来
provider 配置(src/provider.ts)里 DeepSeek 的 cost 表:
| |
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 的甜点,顺便把账单打了折。
- pi-review-agent — 项目仓库