用云端 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
先看一圈下来是什么样的:
1 | ┌──────────── 定时触发(每天凌晨 3 点) |
每一轮的产出会改变下一轮看到的代码。下一轮开始时它会先看 git log,最近修过的同类问题就不再报了。一圈一圈下来,代码库里能被它发现的 bug 越来越少。
跟 Claude Code 里的 /loop 是一个意思,只不过一轮的周期是一天,中间夹着一个人。
一次只修一个
最开始想过让它把找到的问题全列出来、全修掉。想了想还是算了。
一次修十个,PR 一大坨,我根本没法认真审,最后要么全合要么全不合。而且大模型很爱报「可能存在的隐患」,这种东西一多,真问题就被淹了。
所以规则定得很死:
- 每天只交一个 PR,宁缺毋滥。
- 必须说清楚「什么输入/时序 → 什么错误结果」,并且先写一个会失败的测试把它复现出来。失败原因得和 bug 对得上,不能是 ImportError 那种。
- 风格问题、性能优化、重构都不算 bug。
- 拿不准就别开 PR,把看过的范围和候选列出来就结束。
- 但 PR 描述里必须附上「其他候选」,按严重度排好。这个是给我排期用的,等于每天白送一份审计报告。
提示词怎么写
审计范围按我最在意的排了优先级:
1 | **P0 实盘交易**(真金白银,最高优先) |
后面又补了两块:安全与权限(接口漏了权限校验、信任可伪造的身份头、交易所 key 打进日志)和可靠性(告警被吞、定时任务重入、RPC 超时后服务端还在执行导致重复副作用)。
光给范围不够,还得告诉它怎么排「最严重」:
- 会造成资金损失或错误下单 / 风控失效
- 密钥泄漏、鉴权绕过
- 数据被静默写错或永久丢失
- 研究结论被系统性带偏
- 服务崩溃、任务卡死、告警失灵
同级里挑触发概率高、影响面大、修复又小又确定的。
去重
Agent 每轮都是全新的会话,看不到之前开过哪些 PR(runner 的网络策略只放行了 git clone,Gitea API 是禁的)。
我的办法比较土:分支名必须稳定可复现,格式是 bugscan/<模块>-<主要被改文件>-<问题主题>。同一个 bug 第二次被找到,推到同一个分支上,控制面就会更新原来那个 PR,而不是再开一个。
不想修的问题写进提示词里的「已知不修」列表,人工维护。
Agent 是怎么开 PR 的
Agent 本身没有 push 权限。它只有一个 MCP 工具 open_pull_request:把本地 commit 交给控制面,控制面替它推到一个机器账号的 fork 上,再往主仓库开 PR。
这个机器账号在 Gitea 上是受限用户:
- 合并不了 PR;
- 在 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 显示一切正常。 - 我在 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 脚本关键部分:
1 | set -euo pipefail |
还有一个:项目的内部依赖平时从私有 Maven 仓库拉,runner 的网络策略访问不到。好在项目本来就支持「源码模式」,setup 里把所有模块切成源码依赖就行。
验证的时候又踩了一下:测试类名里有中文,手动 kubectl exec 进去跑没带 LANG=C.UTF-8,Kotlin 编译器写 class 文件直接报 Malformed input or input contains unmappable characters。agent 真实环境里是带了的,虚惊一场。
还有,杀 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 分钟,交上来的是一个回测统计的问题。
回测流水线里有一道 Deflated Sharpe Ratio(DSR)门槛,用来防「挖了一堆候选、挑最好看的那个」这种多重检验偏差。策略要从「回测过」升级到「可上实盘」,这是唯一一道统计闸门。
DSR 的公式要求输入逐观测(每根 K 线)的 Sharpe,而代码里喂进去的是年化 Sharpe。小时线的年化倍数是 √(365×24) ≈ 93.6,日线是 √365 ≈ 19.1。z 值被放大几十倍,p 值直接下溢到 0。
它在 PR 里给了一张表:
| 口径 | p 值 | 门槛判定 |
|---|---|---|
| 正确(逐观测,1h bar) | 0.1420 | 应该拒绝 |
| 现状(年化,1h bar) | 7.70e-17 | 放行 |
也就是说这道门槛从来没拦下过任何东西,而且挖得越多越容易过。没有报错,没有异常日志,数据库里那一列照样是一个看着很正常的小数。
它还点出了一个很到位的问题:原来唯一测到这段接线的测试,期望值也是用同一个年化 Sharpe 算出来的,「镜像了接线本身:自洽不等于正确」。修复后的测试里,年化因子在测试中独立算一遍,不从被测模块 import。
这个结论不难核对:DSR 里 Sharpe 估计量的方差项本身就是按观测数推导的,喂年化值进去量纲就不对。因为机器账号的 PR 发布不了,我把它的修改重开成自己的 PR,发布、合并,前后十来分钟。
这个 Loop 还没闭合的地方
跑了一轮,能看到三个缺口:
- **轮与轮之间没有记忆。**每轮都是新会话,看不到之前开过、被关掉的 PR,也看不到上一轮列的「其他候选」。第一轮列了一串候选,第二轮不会接着修,而是从头再找一遍。现在只靠稳定的分支名和人工维护的「已知不修」列表撑着。
- **中间有个人。**这是故意的,不打算去掉。但是「把机器账号的 PR 重开成自己的」这一步纯属体力活,可以再简化。
- **没有结果反馈。**修完上线有没有引入新问题,我关掉某个 PR 是因为它修错了还是不值得修,这些都不会传回下一轮。
最想先补的是第 1 个:每轮结束时把候选清单和处理结果写进一份 ledger,下一轮开始先读它,优先从上一轮剩下的高优先级候选里挑。这样每天就是接力往前推,而不是每天从零开始。
做了再写。