用云端 Claude Code Agent 搭一个每天自动找 bug、修 bug、开 PR 的 Loop
给自己的项目挂了个每天凌晨跑的云端 Agent:审计代码 → 挑一个最严重的 bug → 写复现测试 → 修 → 开 PR,我早上起来审一下就行。
跑了两轮,这东西说白了就是一个 Loop。
前言
手上有一套自己维护了很久的系统,Kotlin 的服务一堆,数据处理和统计分析的代码是 Python,都在一个仓库里。
这种项目最怕的不是崩。崩了至少有告警。
最怕的是静默错。数据少写了一截,单位换算错了一个倍数,统计门槛形同虚设——程序照常跑,日志一片祥和,结论全是错的。
前阵子在 k3s 里搭了一套云端 Agent:控制面把任务派给一个 runner Pod,runner 里跑 claude -p(Claude Code 的无头模式),模型走 DeepSeek。平时我在网页上发任务,它在隔离的工作区里干活,能实时看到它在想什么、调了什么工具。
既然能派任务,那就让它每天自己去仓库里翻 bug。
Loop 是怎么跑的
Claude Code 里有个 /loop,把一个任务循环着跑。我这个是一样的意思,只是周期长一点,一轮一天,中间还夹着一个我。
一圈下来是这样的:
1 | ┌──────────── 定时触发(每天凌晨 3 点) |
每一轮的产出都会改变下一轮看到的代码。上一轮修掉的地方,下一轮不会再当候选。一圈一圈下来,仓库里能被翻出来的 bug 越来越少。
定的规矩
最开始想过让它把找到的问题全列出来、一次全修掉。
想了想还是算了。
一次修十个,PR 一大坨,我根本没法认真审,最后要么全合要么全不合。而且大模型很爱报「可能存在的隐患」,这种东西一多,真问题就被淹了。
所以规矩定得很死:
- 每天只交一个 PR,宁缺毋滥。
- 必须说清楚「什么输入/时序 → 什么错误结果」,先写一个会失败的测试把它复现出来。失败原因得和 bug 对得上,不能是 ImportError 那种。
- 风格问题、性能优化、重构都不算 bug。
- 拿不准就别开 PR,把看过的范围列出来就结束。
- PR 描述里必须附「其他候选」,按严重度排好。这个是给我排期用的,等于每天白送一份审计报告。
提示词
审计范围按我最在意的排了优先级:
1 | **P0 核心链路**(错了后果最重的,最高优先) |
后面还补了两块:安全与权限(接口漏了权限校验、信任可伪造的身份头、第三方凭证被打进日志)、可靠性(告警被吞、定时任务重入、RPC 超时后服务端还在执行)。
光给范围不够,还得告诉它怎么排「最严重」:
- 会造成数据损坏或错误执行 / 风控失效
- 密钥泄漏、鉴权绕过
- 数据被静默写错或永久丢失
- 研究结论被系统性带偏
- 服务崩溃、任务卡死、告警失灵
同级里挑触发概率高、影响面大、修复又小又确定的。
PR 是怎么开的
Agent 自己没有 push 权限。它只有一个 MCP 工具 open_pull_request:把本地 commit 交给控制面,控制面替它推到一个机器账号的 fork 上,再往主仓库开 PR。
这个机器账号在 Gitea 上是受限用户:
- 合并不了 PR
- 评论
/publish触发的 Actions 会卡在「待审批」,发布不了 - diff 里改
.gitea/下的 workflow 直接拒绝 - diff 里出现模板环境变量的值(6 位以上)也直接拒绝,防止它把密钥提交进去
所以 Loop 中间那个「人」是故意留的闸门。Agent 能做的最坏的事,就是开一个烂 PR。
踩坑
本机无 java
第一轮跑完,PR 描述里的「其他候选」第一条是一个 Kotlin 服务里的回补游标问题,排名比它最后修的那个还高。但它写了一句:
本机无
java、无 gradle 缓存,无法跑 Kotlin 测试来「先复现再修」,故放弃。
我明明在 setup 脚本里装了 JDK……
翻了一遍 runner 的代码,问题有三层:
- runner 镜像只有 JRE(runner 自己是个 Java 服务)。给 agent 的 PATH 是写死的
$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin,JRE 那个目录不在里面。setup 里我是临时JAVA_HOME=... ./gradlew跑的,所以 setup 看起来一切正常。 - 我往
$HOME/.gradle/gradle.properties写了org.gradle.java.home,以为能让 Gradle 找到 JDK,结果目录大小一直是 4K。后来才想明白:Gradle 的用户目录取的是 JVM 的user.home,也就是 passwd 里那个用户的家目录/home/node,不是环境变量$HOME。每个 Run 的$HOME是 runner 单独指过去的,Gradle 压根不看。 - setup 里 export 的环境变量,传不到后面 agent 的进程里。
最后的解法:JDK 软链进 $HOME/.local/bin(这个在 PATH 上),JAVA_HOME 走 Claude Code 的用户设置注入。runner 给每个 Run 设了 CLAUDE_CONFIG_DIR=$HOME/.claude,那 $HOME/.claude/settings.json 里的 env 就会作用到 agent 跑的每条命令上。
setup 脚本关键部分(这个 JDK 后来挪进了共享目录,第二轮起不用重下):
1 | set -euo pipefail |
私有 Maven 拉不到
项目的内部依赖平时从私有 Maven 仓库拉,runner 的网络策略访问不到。好在项目本来就支持「源码模式」,setup 里把所有模块切成源码依赖就行。
中文测试类名会炸
手动 kubectl exec 进去跑测试,没带 LANG=C.UTF-8,Kotlin 编译器写 class 文件直接报 Malformed input or input contains unmappable characters。agent 真实环境里是带了的,虚惊一场。
pkill 把自己杀了
杀 Kotlin 编译守护进程的时候顺手写了个 pkill -f KotlinCompileDaemon,把自己的 shell 也杀了(命令行里也有这个字符串)。这个坑我已经踩过不止一次了。
模型和时段
一开始我给它配的是 DeepSeek 的 pro,想着找 bug 是重活。结果被纠正:现在 deepseek-v4-flash 这个名字背后已经是更新的 V4.1-Flash,又强又便宜,单价只有 pro 的三分之一到四分之一。
DeepSeek 现在分峰谷计价,工作日北京时间 09:00–12:00、14:00–18:00 是高峰,价格翻倍。cron 定在北京时间凌晨 3 点(cron 按 UTC 写是 0 19 * * *),单轮最多跑 3 小时,怎么都碰不到高峰。
面板上的花费是假的
第一轮跑完,面板上显示花了 73 美元,吓一跳。
后来发现这个数是 Claude Code CLI 自己按 Claude 的价格估出来的,它不认识 DeepSeek。按 DeepSeek 的实际价格算,输入 46 万、缓存命中 1600 万、输出 9.7 万 token,大概 0.2 美元。
盯着它干活,发现了我自己的 bug
跑的过程中我一直开着网页看它实时输出,顺手发现了几个前端问题:对话区外面多一条滚动条、Tokens 一直是 0、长提示词没渲染 Markdown……
最离谱的一个是头部的运行 ID 显示成了 …356200,真实的是 …356225。前一天刚给对话区加了 SSE 实时推送,帧数据我直接用了 JSON.parse。后端主键是 19 位的雪花 ID,超出了 JS 的安全整数范围,解析出来就丢了精度,还把页面缓存里正确的那份覆盖掉了。项目里其实早就有一个专门处理大整数的解析函数,我新写的代码没用它。
后果不只是显示错:推送期间点「停止」,带的是错的 ID,停不掉。
Agent 还没给我找 bug,我先给自己找了一个。
第一轮找到了什么
第一轮跑了 28 分钟,交上来的是一个统计口径的问题。
评估流水线里有一道多重检验校正的显著性门槛,候选要从「初步评估」升级到「正式采用」,就靠这一道统计闸门。
门槛的公式要求输入逐观测(每个采样点)的统计量,代码里喂进去的是年化后的值。小时级序列的年化倍数是 √(365×24) ≈ 93.6,日级是 √365 ≈ 19.1。z 值被放大几十倍,p 值直接下溢到 0。
它在 PR 里给了一张表:
| 口径 | p 值 | 门槛判定 |
|---|---|---|
| 正确(逐观测,1h) | 0.1420 | 应该拒绝 |
| 现状(年化,1h) | 7.70e-17 | 放行 |
说人话:这道门槛从来没拦下过任何东西,而且候选塞得越多越容易过。没有报错,没有异常日志,库里那一列照样是一个看着很正常的小数。
它还点出了一个到位的问题:原来唯一测到这段接线的测试,期望值也是用同一个年化后的值算出来的,镜子照镜子,自洽但不正确。修复后的测试里,年化因子在测试里独立算一遍,不从被测模块 import。
这个结论不难核对:这个统计量的方差项本来就是按观测数推的,喂年化值进去量纲就不对。机器账号的 PR 发布不了,我把它的修改重开成自己的 PR,发布、合并,前后十来分钟。
第二轮,翻车了
当天下午又跑了一轮,交上来的还是同一个问题。
时间线一对就明白了:它 clone 代码的时候,我重开的那个 PR 还差一分钟才合进 main,它看到的代码里这个 bug 还在。
本来指望稳定的分支名能兜住,结果两轮起的名字不一样,一个偏 units,一个偏 annualized。让模型给同一个问题起同一个名字,本来就不靠谱。
正式跑之后间隔是一天,这种撞车会少很多。但只要我有一天没审 PR,第二天它大概率还会再交一份。
给 Loop 加上跨轮记忆
分支名去重靠不住,根子还是每轮都是新会话,没有记忆。
当天就给模板加了一个跨轮共享目录。跟 Run 的工作区不一样,它按模板持久,跑完不清。
现在的流程:
- 开局先读
findings.md台账和coverage.md覆盖表(没有就按固定格式建) - 台账里状态是
已开PR/已修复/不修/误报的条目直接跳过,候选的优先复核 - 结束用
flock "$AGENT_SHARED_DIR/.lock"把这一轮的结论追加回去
台账格式写死在提示词里:
1 | - [编号] 标题 | 严重度 | 模块 | 状态 | PR | 首次/最近发现 | 备注 |
顺带还解决了一件事:以前我关掉某个 PR,是它修错了还是不值得修,这个判断只在我脑子里。现在面板上把那条改成 不修 或者 误报,下一轮它就真的不报了。
改完手动跑了一轮验证,开局它就把两个文件建了出来,JDK 也直接从共享目录里吃,那 300 多 MB 不用重下了。
后面就是每天跑,接力往前推。