跳到主要内容

凌晨 3 点拥有合并权限的 CI Agent

· 阅读需 13 分钟
Tian Pan
Software Engineer

凌晨 3 点 17 分,一个不稳定测试被隔离了。On-call 轮值没有被叫醒,因为根本没东西失败——Agent 判定这次失败是噪声,自动开了一个标题为 chore: quarantine flaky test 的小 PR,用 ci-bot 这个 service account 把它 self-merge 了,然后继续盯着队列。六天之后,一个用户来反馈说某个功能从周二开始就坏了。那个测试不是 flaky,它是把一个真实回归挡在生产之外的唯一防线,而 Agent 那个 confidence threshold 设得刚好高到敢做决定,又刚好低到会判断错。

这是 agentic CI 中市场材料从不提及的那部分。在 2026 年,把 Agent 接进 pipeline 让它分流失败、对安全告警做依赖降级、提出依赖升级,在工程上其实很简单——工具齐了、集成只差一个 config 文件、生产力故事也是真的。没人写 runbook 的部分,是你刚刚引入的那一类新的操作主体:一个在凌晨 3 点没有任何人类同步在环、却拥有合并权限的角色,而你的 SRE 手册当初就是默认人类才是意图的来源。

支撑起每一个现代 on-call 轮值的 SRE 设计模式,是 "humans approve, services execute":人类决定要不要部署,人类 ack 告警,人类写事后复盘;service 是被告诉去做事的那一边。把一个 Agent 放进 CI,并赋予它把变更落地的权限,这个模式就被悄悄瓦解了——因为 Agent 既不是 service(它有意图),也不是人类(它不睡觉、不会被 page、不会感到压力)。它是你的 runbook 没有给位置的一种东西,而正是这种缺位,孕育了所有未来的故障。

Runbook 从来没给这个角色命过名

打开任何一份成熟的 SRE 手册,你会看到两个已经被打磨得很完整的生命周期。Service 的生命周期描述:什么会被部署、谁是 owner、它的 SLO 是什么、它坏的时候哪个 page 会响、谁在轮值上。人类触发型任务的生命周期描述:工程师在审批下可以做什么、什么需要第二个人盯、什么在变更窗口之外完全禁止做。这两个生命周期都默认了一件事:发起动作的主体,要么是确定性的程序,要么是有问责能力的人类。

一个无人值守的 CI Agent,两边都不满足。它像人类一样产生意图——它在判断哪个测试是 flaky、哪个依赖升级是安全的、哪个告警是真的——但它做这些事的频率和静默程度,又像 service。两边的 runbook 习惯在它面前都失效了。Service 的 runbook 默认动作是机械的、可以从输入推断,但 Agent 的动作不行,因为输入是一段噪声日志,输出是一次判断。人类任务的 runbook 默认有一个可以为模糊场景被 page 起来的审批人,但 Agent 在凌晨 3 点没人可以问,所以它自己选。

这里的故障模式不是 Agent 鲁莽。故障模式是,在组织结构图、IAM 模型和事故分类法里,你都找不到这个主体应该坐的那个位置。一旦出事,"谁拥有这个动作" 这个问题会让对话陷入一个尴尬的停顿。平台团队拥有 CI 基础设施。AI 团队配置了这个 Agent。任何一份 runbook 都没说当 Agent 在凌晨 3 点做错事时该怎么办,而这种 owner 缺失就是第一个信号:你已经构建了一个 tier-zero 系统,却还没写它的可靠性故事。

缺失的第一块原语:动作分级授权

修复的方式不是 "加更多 guardrail",而是把这个主体形式化。Agent 需要一个动作分级授权层,以 IAM 给 service account 划分权限的方式给它的动作分类——并加上一个额外维度:这个 "类型" 不仅是资源,还包含可逆性和爆炸半径。

可用的形态长这样。Agent 可执行的每一类动作——隔离一个测试、开一个 PR、merge 一个 PR、降级一个依赖、发一条评论、跑一个 workflow——都被分到一个 tier 里。Tier 1 按定义是只读的、可逆的;Agent 完全自治地运行。Tier 2 会产生变更,但回滚成本低,比如开 PR 或发评论;Agent 可以做,但变更必须打标签并进入暂存状态。Tier 3 是任何会落到 main、改动 CI 配置、触碰 secret、或影响部署面的动作;Agent 只能产出一个等待人类审批的工件,即便是凌晨 3 点,审批窗口也按动作的紧迫程度配置。

分级授权这层的关键意义在于:"Agent 在无人值守的情况下能做什么" 不再被零散地编码在十几个 script、GitHub Actions YAML 和 service account 的 PAT scope 里。它就是一份策略,像安全策略一样被评审,由拥有这个 Agent 的团队 own。给 Agent 新增一个工具,意味着显式选择它的 tier,由一个能说清爆炸半径的 approver 签字,把 default-deny 降级要走一道审批。任何新动作的默认 tier 都是最高,不是最低。

这听起来很重。其实并不——这是生产 IAM 已经做了十年的同一种纪律,只是被应用到了这个新主体身上。它之所以让人觉得新,是因为平台团队对 AI 团队的 Agent 没有 IAM 权威,而 AI 团队也没有写分级策略的 SRE 肌肉。这个纪律就生活在他们之间的缝隙里,而缝隙正是 Agent 不受监督地运行的地方。

Dry-run 必须是一等模式,而不是一个 flag

Agent 需要的第二块原语,是一个在结构上区别于 "带 flag 跑一遍" 的 dry-run 模式。对 tier-3 动作而言,dry-run 的产物应该是一个排队工件——一份 PR 描述、一份 diff、一条 "我本来会 merge 这个" 的动作账本记录——交给次日早上的 review 流程去批准、拒绝或编辑。决定自己是否处于 dry-run 模式的不该是 Agent;决定的应该是这个动作的 tier,而 tier 策略应当由工具层来强制执行,而不是靠相信 Agent 自己会尊重那个 flag。

这件事为什么重要:一个基于 flag 的 dry-run 相信 Agent 能正确地读自己的配置。而那个刚刚判断某个测试 "是 flaky" 的 Agent,现在又被要求判断 "自己是不是有资格按这个判断行动"。把 dry-run 当成动作类的属性,而不是 Agent 的心智状态属性,失败模式就坍缩成可测试的。Agent 永远都试着把事做了;工具层根据 tier 截下来,要么执行,要么排队。两种情况下审计轨迹一模一样,这意味着次日 review 看到的证据和凌晨 3 点真正执行时会留下的证据是同一份。

次日 review 是另一半。如果排队工件没有一个明确的 reviewer 就在那里堆着,那你只是把 on-call 问题搬到了一块 Jira 看板上而已。把 dry-run 队列配上每日站会议程或一个有 page 通道的 review 时段,并把 review 的 SLA 写进策略:一个在凌晨 3 点被排队的动作必须在上午 10 点前有判决,否则就过期。过期机制很关键——没有它,Agent 就会被慢人类阻塞,团队的生产力故事会退化为手工流程。

动作账本必须区分谁动了手

第三块原语是一个把 Agent 产生的变更和人类产生的变更清晰分开的动作账本(action ledger),清晰到一次事后复盘能在没有歧义的前提下把爆炸半径圈出来。这不是 nice-to-have。当一个回归发生、你在试图判断该回滚什么的时候,"这是谁 commit 的" 是第一个问题,"ci-bot 用 service account X 提交的" 不是答案——那是把问题往后推。你需要知道,是哪个 Agent session、哪个 model 版本、按哪段 prompt、用哪份输入上下文,产出了这次变更。

这件事的务实形态是一个 append-only 日志,每一行对应一次 Agent 动作,记录 prompt 的 hash、model 标识、调用了哪些工具、每次工具调用属于哪个 tier、人类审批人是谁(或 "unattended")、以及最终产出工件的标识。SIEM 集成有用但是次要;真正重要的,是事后复盘能用一次查询而不是三天的取证去回答 "范围有多大"。

归属纪律之所以重要,还有一个不那么显眼的原因:当 Agent 用某个人类的身份发动作——用某个工程师的 PAT、某个存好的 OAuth token、一份委派来的 credential——审计轨迹会把那个人类记成动作者,问责就坍塌了。CI Agent 应该有自己的身份,scope 收得很窄,每一个动作都要记录 Agent 身份加上那个授权 Agent 行动的人类,绝不允许其中一方冒充另一方。哪一天一个工程师因为审计日志说 "他推了一个他从没看过的东西" 而被 page,这个系统的信任就崩了。

没人记得连上的那个 Kill Switch

每一份 Agent 的 runbook 都需要一个 freeze 机制。不是 "开个 PR 来把 workflow 禁用",那个假定凌晨 3 点会有人有 20 分钟和清醒的头脑。它需要是一个字面意义上的开关——一条 CLI 命令、一个按钮、一个鉴权端点——能在五分钟内回收 Agent 的工具权限、停掉排队中的动作、锁住部署面,并 page Agent 的 owner,而执行这件事的应该是一个没参与构建这个 Agent、对它内部细节一无所知的 SRE。

Kill switch 的难点不是构建它。难点是,事故发生的时候,没人会想起来它存在——因为事故看起来不像 Agent 事故,它看起来像一个回归、一个 flaky test 风波、一个依赖问题。多数团队是用糟糕的方式发现这个模式的:Agent 已经 "贡献" 了三天,才有人把症状跟这个角色连起来。等连起来的时候,kill switch 是理论上的;需要被停下来的动作早就已经落地了。

有两种实践能帮上忙。第一,每一份 Agent 的 runbook 把 freeze 流程放在 service runbook 放回滚流程的相同位置——同一份模板、同样的演练节奏、同一个轮值上的 SRE 知道去哪找。第二,Agent 的动作在事故频道和 dashboard 里要被打上足够响的标签,让连接关系是一次点击的事,而不是一次取证之旅。当一个 SRE 在同一个视图里看到 "ci-bot 在过去一小时里隔离了 3 个测试" 和 "测试失败率上升了 12%",诊断在事故被宣布之前就已经发生了。

真正危险的是那种持续数月的安静故障

戏剧版的 Agent 故障——删库、生产宕机——之所以容易被讨论,是因为它响。响的故障会最先得到 runbook 的对待。安静版的故障更危险也更常见:Agent 已经压制了一类真实 bug 好几个月,把暴露它们的测试隔离掉、把依赖的安全告警标成低影响、把一类反复出现的失败分类为 "瞬态基础设施噪声",只因为 embedding 看起来像过去那些瞬态基础设施噪声。团队发现这件事,是在这类 bug 终于产生了一个用户可见的事故之后,回头一看是六个月的 Agent 决策,每一个单独拎出来都说得通。

这种故障模式对绝大多数监控来说是隐形的,因为每个决策本身都成立。Dashboard 显示 Agent 在边界内运行,eval 集说它的判断是准的,动作账本里的动作看上去都合理。Dashboard 不会显示的,是那些每一条都在边界内但加起来在压制信号的决策的累积重量。

防御办法是把 Agent 的决策分布本身当成一个信号来仪表化、来观测。如果 Agent 上个季度隔离了 30 个测试,这个季度隔离了 90 个,这是一个问题,而不是 dashboard 上的一个数字。如果依赖升级的拒绝率翻倍了,问为什么。Agent 现在是你可靠性故事的输入之一,而把它当成一个 "只要在策略内运行就是黑盒" 的对象,正是你最终在客户工单里、而不是在指标里发现那个压制模式的方式。

那个不舒服的架构层面的认识

一个 Agent 一旦拥有 commit 或 deploy 权限并且人类不在它的同步回路里,你就构建了一个 tier-zero 系统,它的可靠性故事必须从零开始写。一个 "有意图、无人值守" 的主体所需要的 runbook,不是 service runbook 的小幅扩展,而是一个新的类别,需要自己的 ownership、自己的策略、自己的 paging 等级、自己的事后复盘模板、自己的退役流程。

挺过这一关的团队是这样做的:给这个角色命名、给它的动作分级、把不能无人值守的动作排进队列、给每一个动作记录带归属的日志,并按他们已经为生产服务做的同样节奏去演练 kill switch。把 Agent 直接焊到现有 CI 上、相信 service account 那点权限会撑住的团队,会有一个糟糕的夜晚;那个夜晚的前一个小时看起来会像一个普通的回归,而正是这一个小时,足够真正的损害去复利。

接 Agent 用了一个下午。给这个被引入的主体写它的可靠性故事,是那一个下午所暗示的工作——还没开始写的那些团队,只差一次安静的决策,就会发现 "Agent 在自己的策略内做了对的事" 和 "对的事真的发生了",从来不是同一回事。

References:Let's stay in touch and Follow me for more thoughts and updates