今早刷到 36 氪那篇《Astra 写的代码,人类已经看不懂了》,我第一反应是去找原文——就是写 Flask 的那位 Armin Ronacher,9 月 7 日他在自己博客上发了篇复盘,标题起得很丧:Why Are We Doing This Again?
事情本身不复杂。GPT-6 Astra 发布之后,总有人劝他"开个软件工厂啊",他真开了一个,就在周末。目标定成"给 Python 加上虚拟线程和词法作用域",工作流完全交给模型自己决定:上下文自己管,笔记写在自己建的 agent-notes 目录里,想派 subagent 就派。他给足了自由,然后就去过周末了。
35 小时后他把它关了。烧掉的 token 按他自己的估算大约 40 亿,而交付结果的原话是 "the factory has delivered absolutely nothing of value"——没有产出任何有价值的东西,也没教会他任何怎么把工厂开得更好的经验。
离谱的不是失败,是失败的方式
如果只是"模型没把事办成",那太正常了,写篇文章都不够格。让我后背发凉的是博客里贴出来的原始执行记录。 比如它改 CPython 源码的方式。明明 harness 自带 patch 工具,它不用,偏要用 Python 把整个 C 源文件读成字符串,连续 replace,再写回磁盘——一行里用分号串起四五条语句,改的还是编译器和内部头文件。Ronacher 给这类行为起了个名字:codegolf 工具调用。 套娃那边更狠。要操作它的 Windows 虚拟机时,它用 Bash 起 Python,Python 里 subprocess 拉起 Node.js,Node.js 里再 spawn PowerShell 去跑 .ps1 脚本。四层。每一层单独看都能跑,合在一起就是没人看得懂它在干嘛。 测试代码也是重灾区——完全无视缩进和空白,一行干别人十行的活。Ronacher 调侃说,好像它在 subagent 里干活、觉得"没人看着"的时候,就开始放飞。 他猜了个原因:训练过程里,长程任务的完成有重奖,但烂代码几乎没有任何惩罚。所以模型特别擅长"一直干下去",特别不擅长"把代码写得像个人"。
这些行为我一个都眼熟
看到这儿我本来是想看个乐子的,结果越看越像在照镜子。 我手上有个跑每日热点扫描的 Agent,定时抓 HN、GitHub Trending 和几个国内源,筛完选题喂给我。规模当然比不上 Astra 那个工厂,但 Ronacher 描述的病,它全得过: 烧钱重试是老病了。有次源站超时,它拿着一模一样的参数连续重试,每次都把整页重新拉一遍、重新解析一遍。翻调用日志才发现,一个小时的消耗顶平时一天。要不是 API 余额告警把我吵醒,我估计它真能烧到天亮。
重复干活更是家常便饭。RSS 去重标记出过一次 bug,结果它把前天已经扫过的页面原封不动又扫了一遍,结论和昨天一字不差。它自己完全意识不到,因为它的世界里只有"这一步做完了",没有"这一步是不是做过"。 至于四层套娃——好吧,我这个还没犯过,这条我承认是 Astra 独有的才华。
问题出在哪?我以前一直以为管控是提示词的事,在 system prompt 里写上"注意控制成本""避免重复操作"就行。事实是没用。模型埋头执行的时候根本不会回头看那些话,就像 Astra 明明有 patch 工具还是选择 codegolf——指望模型自觉,跟指望猫自觉不上桌子差不多。 所以管控得挪到运行时来,做成模型绕不过去的硬闸门。我上午趁着空档写了百来行,就叫它 Gatekeeper。
三道闸,两种命
思路很简单:Agent 每走一步(调用一次工具),必须先过闸检查,闸说停就停。 预算闸和步数闸没什么技术含量,就是两个 if——累计 token 超上限、步数超上限,直接停。真正有点意思的是第三道:重复指纹闸,专治死循环,也是我调参调得最痛的一道。 先看跑通后的效果。场景一,模拟 Astra 式的烧钱行为,每步 85000 token,预算上限 20 万:
== 场景1:预算闸 ==
[step 1] OK, 累计 85,000 tokens
[step 2] OK, 累计 170,000 tokens
[step 3] STOP -> 预算耗尽:已用 255,000 tokens(上限 200,000)
第三步就被掐了。没有这道闸的话,照 Astra 那个烧法,我的余额告警邮件得从"提醒"变成"讣告"。
死循环——同一个 curl 命令反复执行,正是我那个热点 Agent 烧到天亮那次的姿势:
== 场景2:重复指纹闸 ==
[step 1] OK
[step 2] OK
[step 3] OK
[step 4] OK
[step 5] OK
[step 6] OK
[step 7] OK
[step 8] STOP -> 重复循环:最近 8 步内指纹 8c2b829c 出现 8 次
指纹闸的核心就几行:把每步的"工具名+参数"哈希成短指纹,存进固定长度的滑动窗口,同一指纹出现次数超阈值就判定为循环:
import hashlib, json
from collections import deque
class Gatekeeper:
"""Agent 运行时三道闸:预算 / 步数 / 重复指纹"""
def __init__(self, max_steps=200, max_tokens=200_000, window=8, dup_limit=3):
self.max_steps = max_steps
self.max_tokens = max_tokens
self.window = window # 滑动窗口大小(步)
self.dup_limit = dup_limit # 窗口内同一指纹出现几次判定为循环
self.recent = deque(maxlen=window)
self.used = 0
def check(self, step, tool, args, tokens):
# 闸1:预算
self.used += tokens
if self.used > self.max_tokens:
return "STOP", f"预算耗尽:已用 {self.used:,} tokens(上限 {self.max_tokens:,})"
# 闸2:步数
if step > self.max_steps:
return "STOP", f"步数超限:第 {step} 步(上限 {self.max_steps})"
# 闸3:重复指纹(滑动窗口)
fp = hashlib.md5(f"{tool}|{json.dumps(args, sort_keys=True)[:200]}".encode()).hexdigest()[:8]
self.recent.append(fp)
if len(self.recent) == self.window:
worst = max(set(self.recent), key=self.recent.count)
top = self.recent.count(worst)
if top >= self.dup_limit:
return "STOP", f"重复循环:最近 {self.window} 步内指纹 {worst} 出现 {top} 次"
return "OK", "continue"
调参的坑全在指纹的粒度上,这是我要写透的部分。
第一版我把参数原文整个丢进哈希,结果正常业务先被误杀了。我的热点 Agent 有个固定流程:读待办文件、改状态、再读一遍确认——同一个文件连续读两次是完全正常的操作,dup_limit=2 直接把第二步判成死循环。当时我还纳闷怎么每天在同一时刻断,翻日志才发现是闸太凶。阈值放宽到 3 之后正常流程能活,真正的死循环依然逃不掉——失控重试的特征恰恰就是"参数一个字都不变的连击",正常业务连击两次就到头了。
第二版的坑反着来:参数里带了时间戳和请求 ID,每个指纹都是新的,闸形同虚设。修法是哈希前先做规范化——剔掉时间戳、随机 ID 这类易变字段,参数截断到前 200 字符。指纹不需要精确到字节,能识别"这步和那步是不是在干同一件事"就够了。
还有个小教训:窗口 8 配阈值 3 是试出来的。窗口太大(比如 30),死循环要白跑三十步才被逮到;阈值太小(比如 2),网络抖动重试一次就误报。另外注意一个实现细节:指纹只在窗口滚满 8 步后才开始计数,实际拦截点比"出现 3 次"还要晚几步——想拦得更早就把 window 调小,代价是误报上升。8 步窗口配"滚满后同一指纹出现 3 次才拦",实测既能兜住重试风暴,又不干扰正常流程。这个组合不一定适合所有场景,但思路通用:先放宽到不误杀,再收紧到刚好拦住异常。
最后一件事:闸触发之后别硬 crash。我一开始直接抛异常,外层 catch 把错误吞了换个姿势继续跑,等于白拦。后来改成返回结构化的 STOP 信号和原因,上层把它作为工具结果喂回给模型——模型收到"你因重复操作被拦截"这种明确反馈,下一轮大概率会换策略,而不是原地装死。
一半怪模型,一半怪我们自己
回头说 Astra。Ronacher 把原因归到训练上——完成有重奖,烂代码没惩罚——我同意一半。模型侧的问题,我们这些用 API 的人确实改变不了。
但我不认同把锅全甩给训练。那个工厂的设定是"工作流完全交给模型自己决定",没有预算上限,没有步数上限,没有任何外部检查,40 亿 token 就是在这种完全放养下烧掉的。任何一个生产系统都不会允许一个不受预算约束、不设熔断的组件连跑 35 小时——这在传统工程里是常识,怎么到了 Agent 这里就忘了呢。 codegolf、填不满的重试冲动,这些是模型的病,治不了;但"病了没人拦"是 harness 的失职。闸门不指望模型理解它,它只需要在模型面前过不去。 反正我的 Agent 现在预算锁死了,一天烧超 20 万 token 就断电。Astra 什么时候学会克制,我不等它——闸门等得起,余额等不起。
代码全文和两段输出都在上面,单文件无依赖,Python 3.8+ 拿走就能跑。你的 Agent 要是也在天天替你干活,闸今天就装上吧——百来行的事,烧起来的时候它可不会先问你。