在 AI 代理还在改代码时做评审

agent-manager.dev 2026-10-21T09:45:32.236943

行号只是一个位置,不是身份标识。当写代码的一方一直在改动,要让评审意见仍然贴在原来的代码上,光刷新 git diff 是不够的。

通常的代码评审,是在作者停止编辑之后才开始的。一次提交、一个补丁或一个拉取请求,给评审者提供了一个稳定的对象。编程代理打破了这个前提。我经常在代理还在干活的时候就开始评审,在某一行留下评论,然后在看下一个文件的工夫,就眼看着这一行从第 84 行变成了第 91 行。

我最早那版终端评审界面会刷新 diff,并保留每条评论所属的文件和行号。看上去没问题,直到代理在带评论的那行上方插入了代码。评论还停在 84 行,它所指的代码却已经挪走了。这时把评审结果发回给代理,一条精确的指令就落到了错误的代码上——这比不写行号还要糟。

我想保住的正是这个循环:把代理的改动当作完整的文件来读,逐行评论,然后在代理继续工作的同时,把所有评论合成一条提示词发回去。

一个终端评审界面,行评论会被发回给编程代理

要让它真正实时跑起来,就得把评审当成并发状态来处理,而不是渲染出来的一份 git diff。

行号是位置,不是身份

每条保存下来的评论,最初只有这些显而易见的字段:

type annotation struct {
    file    string
    line    int
    deleted bool
    excerpt string
    text    string
}

line 是新文件中的行号;如果是纯粹的删除,则是旧文件中的行号。这些信息足够立刻把评论渲染出来,也能生成一条有用的 path:line 指令。但刷新之后要靠它找回同一段代码,就不够了。

额外的身份信息是 excerpt:创建评论时该行去掉首尾空白后的源码文本,最多截取 60 个字符(rune)。在同一次评审中刷新后,我会在新文件里寻找一段文本,要求它的 excerpt 相同,并且处于相同的「新增/删除」一侧。如果正好只匹配到一处,这条标注就移动到新的行号上。

matches, target := 0, 0
for _, line := range file.Lines {
    num, deleted := annotationLine(line)
    if deleted == note.deleted && excerptOf(line.Text) == note.excerpt {
        matches++
        target = num
    }
if matches == 1 && !annotationOccupies(target) {
    note.line = target
}

这段代码刻意采取了“宁可失败,也不误判”的策略:空行不能作为锚点;如果某个代码片段在文件中出现了两次,就保留原来的行号;如果目标行已经被其他评论占据,就不会把两条评论挤到同一行;如果 agent 重写了那一行本身,导致片段消失,就退而求其次,保留原始位置。

用抽象语法树(AST)或语法感知的指纹可以跟随更多改动,但目前的审查机制要能适用于 Git 能显示的所有语言。保守的文本锚点有一个重要特性:当它无法确定评论应该归属到哪里时,不会悄悄编造一个答案。

不是每次重新加载都是同一次审查

仅当周围的身份保持不变时,通过匹配文本重新锚定才是有效的。从“未提交的更改”切换到“最后一次提交”,可能显示同一个文件和同一行源码,但这是不同的补丁。切换工作树或会话就更明显不同了。

因此,注释的键由会话和仓库路径决定,而不是显示名称:

func (m *Model) reviewKey() string {
    return m.diff.sessID + "\x00" + m.diff.repoSel
}

分隔符避免了拼接歧义,而解析后的仓库路径则防止同一 agent 目录下两个同名仓库共享审查状态。

加载时还会携带会话、范围、仓库和一个单调递增的代数。只要其中任何一项与当前打开的审查不再匹配,界面就会丢弃该结果。上一個范围发出的慢速 Git 命令,不能仅仅因为完成得晚就重绘当前范围。

只有对同一会话、同一仓库、同一范围的静默刷新,才允许重新锚定。切换范围或切换会话会替换文件集合,但不会把已保存的评论挪到它们从未针对过的内容上。

这个区分比匹配算法本身更重要。「diff 变了」和「用户主动要求换一个 diff」产生的数据形状可能一模一样,但需要的状态转换完全不同。

用户打字时,冻结目标

代次校验能丢弃过期的工作,但解决不了一个更小的竞态:

  1. 一次刷新启动;
  2. 审查者打开第 84 行的评论编辑器;
  3. 刷新完成,文件位置发生偏移;
  4. 审查者按下回车。

到第四步,这段文字到底属于哪一行?

当评论编辑器或发送确认框打开时,UI 会暂停发起新的探测。更重要的是,在框打开之前启动的加载,如果在任一框处于活动状态时返回,就会被直接丢弃。in-flight 标志会被清除,但旧指纹会保留,因此下一次探测会在框关闭后加载当前 diff。

if m.diff.annotating || m.diff.sendConfirm {
    m.diff.loading = false
    return nil
}

这样做只在短暂交互期间冻结语义目标。代理没有被暂停,Git 没有被锁定,管理器的其余部分照常更新。评论保存后,下一次刷新会利用从审查者实际看到的稳定帧中捕获的摘录,把它移动到正确位置。

低成本刷新,按需加载

每次轮询都重建并对所有变更文件做语法高亮,让大型审查变得很难受。现在刷新路径拆成了两个阶段。

首先,用一个轻量的仓库指纹判断是否有相关内容发生变化。如果有变化,UI 会重新加载文件元数据,并立即加载选中文件的内容。带持久审查状态的文件——比如已保存的批注或已审查标记——也需要加载内容,以便检查状态并重新锚定。这些不太紧急的加载会在一个后台命令里串行执行,而不是为每个文件同时启动一个 Git 进程。

current := loadCurrentDiffFile()
for each file with comments or reviewed state {
    stateful = append(stateful, loadDiffFile(file))
}
return tea.Batch(current, loadSerially(stateful))

每个文件结果在改动模型之前,都要重复同一套身份检查:会话、作用域、代数、仓库、索引和路径。只看索引是不安全的——加载过程中,新的 diff 可能重新排列文件列表。

最终提示词带上坐标和上下文

审查者点击发送时,评论会变成一段带编号的单行提示词:

Code review of your uncommitted changes — address each numbered point,
then summarize what you changed per point:
(1) internal/store/store.go:91 (code: `return rows.Err()`) — wrap this error;
(2) internal/ui/list.go:44 (deleted line) — keep this behavior

评论正文里的换行会转成分隔符,这样文本不会在粘贴中途被提前提交。每条未删除的评论都带上最新行号和对应代码片段。坐标让 agent 快速定位指令,代码片段则保证:即使 agent 在上次刷新和读取提示词之间又改了代码,这条指令依然看得懂。

查看原文