用云端 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
2
3
4
5
6
7
  ┌──────────── 定时触发(每天凌晨 3 点)
▼
审计代码 → 选出一个最严重的 bug → 写复现测试(必须先红)
→ 最小修复(测试转绿) → 开 PR
→ 我审核 → 发布 → 合并到 main
│
└──► 下一轮基于新的 main 继续找

每一轮的产出会改变下一轮看到的代码。下一轮开始时它会先看 git log,最近修过的同类问题就不再报了。一圈一圈下来,代码库里能被它发现的 bug 越来越少。

跟 Claude Code 里的 /loop 是一个意思,只不过一轮的周期是一天,中间夹着一个人。

一次只修一个

最开始想过让它把找到的问题全列出来、全修掉。想了想还是算了。

一次修十个,PR 一大坨,我根本没法认真审,最后要么全合要么全不合。而且大模型很爱报「可能存在的隐患」,这种东西一多,真问题就被淹了。

所以规则定得很死:

  • 每天只交一个 PR,宁缺毋滥。
  • 必须说清楚「什么输入/时序 → 什么错误结果」,并且先写一个会失败的测试把它复现出来。失败原因得和 bug 对得上,不能是 ImportError 那种。
  • 风格问题、性能优化、重构都不算 bug。
  • 拿不准就别开 PR,把看过的范围和候选列出来就结束。
  • 但 PR 描述里必须附上「其他候选」,按严重度排好。这个是给我排期用的,等于每天白送一份审计报告。

提示词怎么写

审计范围按我最在意的排了优先级:

1
2
3
4
5
6
7
8
9
10
11
12
**P0 实盘交易**(真金白银,最高优先)
- 看:下单方向/数量/价格精度与单位换算(张 vs 币、USDT vs 币本位、步长取整方向)、
重复下单/幂等键、部分成交与撤单竞态、风控闸门/killswitch 能否被绕过或失效时默认放行、
测试网与主网混淆、异常被吞后继续交易。

**P0 数据采集**
- 看:静默丢数(取数成功但零写入、异常被 catch 后返回成功)、时间戳与时区(UTC vs +08)、
去重键/幂等写入、缺口检测与回补游标前进条件……

**P0 因子挖掘 / 回测**
- 看:前视偏差、幸存者偏差(下架币被剔除)、收益与因子错位、缺失值填充引入泄漏、
IC/t 值/多重检验门槛计算错误、手续费/资金费成本漏算或符号反……

后面又补了两块:安全与权限(接口漏了权限校验、信任可伪造的身份头、交易所 key 打进日志)和可靠性(告警被吞、定时任务重入、RPC 超时后服务端还在执行导致重复副作用)。

光给范围不够,还得告诉它怎么排「最严重」:

  1. 会造成资金损失或错误下单 / 风控失效
  2. 密钥泄漏、鉴权绕过
  3. 数据被静默写错或永久丢失
  4. 研究结论被系统性带偏
  5. 服务崩溃、任务卡死、告警失灵

同级里挑触发概率高、影响面大、修复又小又确定的。

去重

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 的代码,问题有三层:

  1. runner 镜像本身只有 JRE(runner 自己是个 Java 服务),而给 agent 的 PATH 是写死的 $HOME/.local/bin:/usr/local/bin:/usr/bin:/bin,JRE 那个目录根本不在里面。setup 脚本里我是临时 JAVA_HOME=... ./gradlew 跑的,所以 setup 显示一切正常。
  2. 我在 setup 里往 $HOME/.gradle/gradle.properties 写了 org.gradle.java.home,以为能让 Gradle 找到 JDK。结果目录大小一直是 4K。原因是 Gradle 的用户目录取的是 JVM 的 user.home,也就是 passwd 里那个用户的家目录 /home/node,不是环境变量 $HOME。每个 Run 的 $HOME 是 runner 单独指过去的,Gradle 压根不看它。
  3. 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
set -euo pipefail
JDK="$HOME/jdk21"
if [ ! -x "$JDK/bin/javac" ]; then
mkdir -p "$JDK"
curl -fsSL --retry 3 --max-time 300 \
https://api.adoptium.net/v3/binary/latest/21/ga/linux/x64/jdk/hotspot/normal/eclipse \
| tar -xz -C "$JDK" --strip-components=1
fi
# 链进 PATH 上的目录
mkdir -p "$HOME/.local/bin"
for b in "$JDK"/bin/*; do ln -sf "$b" "$HOME/.local/bin/$(basename "$b")"; done

# setup 的环境变量传不到 agent,走 Claude Code 用户设置的 env
python3 - "$HOME/.claude/settings.json" "$JDK" <<'PY'
import json, os, sys
path, jdk = sys.argv[1], sys.argv[2]
os.makedirs(os.path.dirname(path), exist_ok=True)
cfg = json.load(open(path)) if os.path.exists(path) else {}
cfg.setdefault('env', {}).update({'JAVA_HOME': jdk, 'SKIP_INTEGRATION_TESTS': 'true'})
json.dump(cfg, open(path, 'w'), indent=2)
PY

还有一个:项目的内部依赖平时从私有 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 还没闭合的地方

跑了一轮,能看到三个缺口:

  1. **轮与轮之间没有记忆。**每轮都是新会话,看不到之前开过、被关掉的 PR,也看不到上一轮列的「其他候选」。第一轮列了一串候选,第二轮不会接着修,而是从头再找一遍。现在只靠稳定的分支名和人工维护的「已知不修」列表撑着。
  2. **中间有个人。**这是故意的,不打算去掉。但是「把机器账号的 PR 重开成自己的」这一步纯属体力活,可以再简化。
  3. **没有结果反馈。**修完上线有没有引入新问题,我关掉某个 PR 是因为它修错了还是不值得修,这些都不会传回下一轮。

最想先补的是第 1 个:每轮结束时把候选清单和处理结果写进一份 ledger,下一轮开始先读它,优先从上一轮剩下的高优先级候选里挑。这样每天就是接力往前推,而不是每天从零开始。

做了再写。