第5章-任务发现与触发:Loop的起搏器《从Harness engineering 到 Loop engineering:长程任务Agent原理与实战》
第5章 任务发现与触发:Loop的起搏器《从Harness engineering 到 Loop engineering:长程任务Agent原理与实战》
本章金句:
- 起搏器失灵,心脏再好也是死的。Loop 的能力上限,往往不在执行端,而在触发端。
- Cron 关心"做了没",Loop 关心"成了没"——前者是闹钟,后者是目标。
- 触发器不是 Loop 的入口,触发器本身就是 Loop 的一部分——它定义了系统对世界的敏感度。
- 幂等不是数学性质,是工程契约:同一个任务被触发两次,世界应该和触发一次一样。
- Loop 风暴不是 Agent 的失控,是触发器的失控——一颗心脏不需要跳得更快,它需要不被电击两次。
引子:凌晨三点的双跳心跳
2026 年 6 月的一个凌晨,某电商团队的 SRE 工程师老张被电话叫醒。监控告警显示:他们的"自动修 CI 失败"Loop 在 17 分钟内触发了 142 次,烧掉了相当于过去一整个月的 Token 预算,而 CI 仍然在红。
老张打开日志,看到的是这样的时序:
00:01:12 GitHub Webhook push → Loop 触发 #1
00:01:48 Agent 启动,开始 git pull
00:02:03 GitHub Webhook push → Loop 触发 #2 (同一个 commit,hook 重试)
00:02:17 Agent 启动,开始 git pull (并行实例 #2)
00:03:29 Agent #1 push 修复 commit → 触发 Webhook → Loop 触发 #3
00:03:30 Agent #2 push 修复 commit (冲突) → 触发 Webhook → Loop 触发 #4
00:04:11 CI 重跑失败 → 触发"测试失败"事件 → Loop 触发 #5
...
17 分钟,142 次触发,6 个并行 Agent 互相踩对方的 worktree,修复了同一个 bug 又互相覆盖,最终产出了一个"看起来通过测试但实际上把测试本身删了"的 commit。Loop 没有解决问题,Loop 自己变成了问题。
老张后来说了一句让我记到现在的话:“我以为我搭了一个 Loop,其实我搭了一个心跳过速的机器。它不是不跳,是跳得太狠了。”
这个事故是本章的起点。当我们谈论 Loop Engineering,绝大部分注意力会自然流向执行端——Agent 用什么模型、Skills 怎么写、验证怎么做。但真正决定一个 Loop 是"稳定节律的健康心脏"还是"凌晨三点过速发作的病心脏"的,是它的起搏器——任务发现与触发机制。
起搏器(pacemaker)这个词不是随便选的隐喻。在医学上,心脏之所以能稳定跳动,是因为右心房上有一小片被称为"窦房结"的组织,它以稳定的电脉冲驱动整个心脏收缩。当窦房结失灵,心脏本身的肌肉细胞也会放电,但是是混乱的、各自为政的放电——这就是颤振。健康的心脏和颤振的心脏,区别不在肌肉,而在节律源头。
Loop 系统也是一样。一个 Agent 再强、Skills 再全、Sub-Agent 编排再精巧,如果触发端乱来——同一个任务触发两次、空闲时疯狂自激、事件风暴下不去做合并——它就会变成老张凌晨三点面对的那台机器:跳得越快,死得越快。
本章要做的事情,是把"起搏器"这件事从工程直觉里抽出来,变成一套可分析、可设计、可调度的子系统。我们会看到:
- 任务从哪里来?Pull、Push、Goal-Driven 三种模式的同构性与差异
- 定时触发不是写个 cron 表达式那么简单,节律设计是一门学问
- 事件触发要解决的不是"怎么接 webhook",而是"怎么不被 webhook 淹死"
- 目标驱动触发是 Loop 区别于 Cron Job 的本质——它追求一个未达成的终态
- 任务队列是 Loop 的"窦房结延迟线",它把冲动变成节律
- 幂等性是触发器的工程契约,没有它就谈不上稳定
- 去重与合并是把"事件风暴"降为"一次心跳"的核心机制
- 触发器反模式:5 个我亲眼见过的失败现场
我们开始。
5.1 起搏器隐喻:心脏自己跳 vs 外部电击
5.1.1 一颗心脏的两种驱动方式
把任何哺乳动物的心脏取出来,放在生理盐水里通氧气,它会继续跳。这是心脏医学里一个让人震撼的事实:心脏不需要大脑指挥就能跳。它的节律来源在自身——窦房结细胞会自动去极化,每秒钟放电一次,电信号传遍心房心室,心脏就收缩一次。
这被称为心脏的内在起搏(intrinsic pacing)。
与此对应的是外部电击——医生拿除颤仪在胸口放电,强行让心肌同步收缩一次。这是急诊场景下用的,目的不是驱动心脏长期工作,而是把颤振的心肌"重置"回可跳动的状态。
这两个概念,恰好对应了 AI 工程里两种触发哲学:
┌────────────────────────────────────────────────────────────────┐
│ 内在起搏(Intrinsic Pacing) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 内部目标态 │ → │ 差距检测 │ → │ 自激发触发 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ▲ │ │
│ └───────────── 状态反馈 ────────────────┘ │
│ │
│ 特征:Loop 自己知道"还没好",自己决定再跑一次 │
└────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────┐
│ 外部电击(Extrinsic Shock) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 外部事件 │ → │ Webhook │ → │ 强制触发 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ 特征:外部世界告诉 Loop"该干活了",Loop 被动响应 │
└────────────────────────────────────────────────────────────────┘
💡 Tip:当一个 Loop 系统"看起来在跑但效果很差"时,第一步不是优化 Agent,而是问:它的触发是内在起搏还是外部电击?绝大多数失败案例是混用了两种模式——既想自驱动,又被外部事件乱击,最后心率乱套。
5.1.2 Loop 的窦房结:自激发触发器
一个成熟的 Loop 系统必然有内在起搏机制。它不是被动等外部事件,而是周期性地检查自身目标态与当前态的差距,差距超过阈值就自激发。
举一个具体例子:一个"修复所有失败测试"的 Loop。它的目标态是"测试失败数 = 0",当前态是从 CI 拉取的真实失败数。每次 Loop 跑完一轮,会重新检查差距,如果还有失败测试,就再跑一轮。这个"再跑一轮"的决定,不是来自外部事件,而是来自 Loop 自己对目标的距离感知。
这种自激发机制可以用一段极简的 TypeScript 表达:
// 内在起搏器:差距驱动自激发
async function pacemaker(
goalState: () => Promise<number>, // 目标态:返回 0 表示达成
beat: () => Promise<void>, // 一次心跳:跑一轮 Loop
opts: { maxBeats: number; coolDownMs: number }
) {
for (let i = 0; i < opts.maxBeats; i++) {
const gap = await goalState();
if (gap === 0) {
console.log(`[pacemaker] goal reached after ${i} beats`);
return;
}
await beat();
await sleep(opts.coolDownMs); // 冷却,避免连续触发打爆下游
}
console.warn(`[pacemaker] max beats reached, goal not met`);
}
这个 30 行的函数是 Loop Engineering 最纯粹的内核:它不需要 cron,不需要 webhook,不需要任何人叫它——它自己知道自己还没好,自己再跑一次。
金句:内在起搏的 Loop 知道自己还没好;外部电击的 Loop 只知道有人叫它。前者是生命体,后者是机器。
5.1.3 外部电击的位置:不是替代品,是启动器
那么外部触发(Webhook、cron、消息队列)在 Loop 工程里是什么地位?是不是"低级"?
不是。外部触发在 Loop 工程里有三个不可替代的功能:
- 冷启动:Loop 进程没在跑,需要外部事件把它拉起来。比如 GitHub Webhook 推送新 Issue,把沉睡的 Loop 服务唤醒。
- 响应不可预测的外部事件:代码 push、用户提工单、监控告警——这些事件发生时刻不可预测,必须靠外部触发。
- 跨系统协调:上游系统(如 CI 完成、部署结束)需要通知下游 Loop 启动,外部触发是天然的协议层。
也就是说,外部触发不是内在起搏的"低级版本",它负责的是另一类问题——事件驱动的瞬时响应,而内在起搏负责的是目标驱动的持续收敛。
一个健康的 Loop 系统,两种机制共存:
┌─────────────────────────────────────────────────────────────────┐
│ Loop 调度核心 │
│ │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ 外部触发 │ ──┐ │ 内在起搏 │ │
│ │ (事件/cron) │ │ │ (差距检测) │ │
│ └─────────────┘ │ └────────┬────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ 任务队列 (节律缓冲池) │ │
│ └──────────────┬───────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ Agent 执行器 (心肌) │ │
│ └──────────────┬───────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ 状态反馈 → 起搏器 │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
外部触发和内在起搏都把任务推入同一个队列,再由 Agent 执行器消费。任何一种来源都不能直接跳过队列调用 Agent——这是 Loop 调度的铁律。原因我们后面在 5.8 节"去重与合并"里详细讲。
5.1.4 起搏器的三个工程指标
把隐喻落到工程,一个起搏器好不好,看三个指标:
| 指标 | 心脏类比 | 工程含义 | 失败症状 |
|---|---|---|---|
| 节律稳定性 | 心率整齐 | 触发间隔可预测,无突发尖峰 | Loop 风暴、Token 烧爆 |
| 响应性 | 应激时能加速 | 事件来了能在 SLA 内启动 | 延迟过高,错过时效 |
| 去负荷能力 | 迷走神经降速 | 高负载时能合并/丢弃/降级 | 资源耗尽、级联失败 |
这三个指标互相冲突。响应性高意味着事件来了立刻处理,但这会牺牲节律稳定性(高峰期被电击过快);去负荷能力意味着能丢弃一些任务,但这会牺牲响应性(被丢弃的任务可能就是重要的)。
💡 Tip:设计起搏器时,先明确你愿意牺牲哪个指标。我见过太多团队三个都想要,结果三个都做不好。健康的工程取舍是:保节律稳定 > 保利去负荷 > 保响应性。因为前两者失效会让整个系统崩溃,响应性失效只是慢一点。
5.2 任务发现的三种模式:Pull / Push / Goal-Driven
5.2.1 三种模式的同构性
把世界上所有 Loop 触发机制抽象到极致,只有三种:
- Pull(拉模式):Loop 主动去问"有事吗?"
- Push(推模式):外部主动告诉 Loop"事来了"
- Goal-Driven(目标驱动):Loop 自己问自己"我达到目标了吗?没达到就再跑"
这三种模式表面上是技术选择,本质上是信息流方向的选择:
┌─────────────────────────────────────────────────────────────────┐
│ Pull 模式 │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ Loop │ ── poll→│ 源 │ ── yes─→│ 任务 │ │
│ │ 调度器 │ │ (CI/ │ └────────┘ │
│ └────────┘ │ IM/ │ │
│ ▲ │ DB) │ │
│ └─── next ────┴────────┘ │
│ 优点:简单、可控、无外部依赖 │
│ 缺点:延迟取决于 poll 间隔,浪费请求 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Push 模式 │
│ ┌────────┐ event ┌────────┐ spawn ┌────────┐ │
│ │ 源 │ ────────→│ Loop │ ──────→│ 任务 │ │
│ │ (Webhk)│ │ 入口 │ └────────┘ │
│ └────────┘ └────────┘ │
│ 优点:低延迟、不浪费请求 │
│ 缺点:需要暴露入口、需要鉴权、易被风暴冲击 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Goal-Driven 模式 │
│ ┌────────┐ check ┌────────┐ gap>0 ┌────────┐ │
│ │ Loop │ ────────→│ 目标态 │ ──────→│ 任务 │ │
│ │ 自检 │ │ 检测 │ └────────┘ │
│ └────────┘ └────────┘ │
│ ▲ │ │
│ └────────────── 状态反馈 ─────────────────────────┘ │
│ 优点:自主性强、可处理开放目标 │
│ 缺点:需要可机器读取的目标态、需要防止无限循环 │
└─────────────────────────────────────────────────────────────────┘
三种模式不是互斥的。一个成熟的 Loop 系统通常同时使用三种:
- Pull:作为兜底,确保即使 Push 通道断了,Loop 仍能周期性检查
- Push:作为主通道,处理高频外部事件
- Goal-Driven:作为内在起搏,处理需要持续收敛的长程目标
5.2.2 Pull 模式的工程细节
Pull 模式是最古老也最可靠的触发方式。它的核心是 polling loop:周期性地查询源,有新东西就拉进来。
一个工业级的 Pull 实现要解决三个问题:
问题 1:怎么知道什么是"新"?
这需要维护一个水位线(watermark)——上次处理到的位置。比如查 GitHub Issues,要记录 since 时间戳;查数据库,要记录自增 ID;查消息队列,要记录 offset。
# Python 伪代码:Pull 模式的水位线管理
import time
from dataclasses import dataclass
@dataclass
class PullState:
last_issue_id: int = 0
last_checked_at: float = 0.0
def pull_github_issues(state: PullState, fetcher) -> list[Issue]:
issues = fetcher.list_issues(since=state.last_checked_at)
# 关键:只取 ID 大于水位的(防止重复处理)
new_issues = [i for i in issues if i.id > state.last_issue_id]
if new_issues:
# 关键:水位线推进到最大 ID,而不是当前时间
# 因为时间戳可能冲突,ID 严格单调
state.last_issue_id = max(i.id for i in new_issues)
state.last_checked_at = time.time()
return new_issues
💡 Tip:水位线永远要推进到严格单调的量上(自增 ID、版本号、Kafka offset),不要推进到时间戳上。多个事件可能共享同一时间戳,导致下次 poll 时漏掉或重复。这个坑我在第 5.7 节"幂等性"里会再讲。
问题 2:poll 间隔怎么定?
间隔太短,浪费请求和配额;间隔太长,延迟过高。这是一个经典的容量-延迟权衡。
| 间隔 | 延迟 | 请求量 | 适用场景 |
|---|---|---|---|
| 5s | 极低 | 17280/天 | 用户聊天场景 |
| 30s | 低 | 2880/天 | IM 群消息 |
| 1min | 中 | 1440/天 | CI 状态检查 |
| 5min | 中高 | 288/天 | 部署状态 |
| 15min | 高 | 96/天 | 备份检查 |
| 1h | 极高 | 24/天 | 报表生成 |
HappyClaw 的 IM 消息轮询是 2 秒一次(在 src/index.ts 中),这是为聊天响应延迟做的取舍;任务调度器是 60 秒一次(在 src/task-scheduler.ts 中),因为定时任务对延迟不敏感。
问题 3:怎么避免 poll 自己打爆源?
加指数退避。当源返回错误或空结果,逐步拉长间隔;当源恢复,恢复到原始间隔。
// 自适应 poll 间隔
class AdaptivePoller {
private intervalMs: number;
private readonly minMs = 2000;
private readonly maxMs = 60000;
private readonly growth = 1.5;
constructor(initialMs: number) { this.intervalMs = initialMs; }
onSuccess() { this.intervalMs = Math.max(this.minMs, this.intervalMs / 2); }
onFailure() { this.intervalMs = Math.min(this.maxMs, this.intervalMs * this.growth); }
get interval() { return this.intervalMs; }
}
5.2.3 Push 模式的工程细节
Push 模式低延迟、不浪费请求,但它的工程难点在入口暴露和风暴保护。
一个 Push 入口必须解决:
- 鉴权:怎么确认事件来自可信源?(HMAC 签名、IP 白名单、Token)
- 去重:同一个事件被推送两次怎么办?(事件 ID + LRU 缓存)
- 背压:下游处理不过来怎么办?(队列 + 丢弃策略)
- 重放:源重试时怎么处理?(幂等性)
举一个 GitHub Webhook 的最小实现:
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.GITHUB_WEBHOOK_SECRET!;
// 已处理事件 ID 的 LRU 去重
const seen = new Map<string, number>();
const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 分钟
function verifyGithubSignature(rawBody: string, sigHeader: string): boolean {
const [algo, hex] = sigHeader.split("=");
if (algo !== "sha256") return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(rawBody)
.digest("hex");
// 关键:用恒定时间比较,防止时序攻击
return crypto.timingSafeEqual(Buffer.from(hex), Buffer.from(expected));
}
app.post("/webhook/github", express.raw({ type: "*/*" }), async (req, res) => {
const sig = req.headers["x-hub-signature-256"] as string;
const eventId = req.headers["x-github-delivery"] as string;
const eventType = req.headers["x-github-event"] as string;
if (!verifyGithubSignature(req.body.toString(), sig)) {
return res.status(401).send("invalid signature");
}
// 去重:GitHub 在失败时会重试同一个 delivery ID
const now = Date.now();
if (seen.has(eventId) && now - seen.get(eventId)! < DEDUP_TTL_MS) {
return res.status(200).send("duplicate, ignored");
}
seen.set(eventId, now);
// 入队,不直接执行
await taskQueue.enqueue({
source: "github",
type: eventType,
payload: req.body,
eventId,
});
res.status(202).send("accepted");
});
注意三个工程要点:
- 签名校验用恒定时间比较,防止时序攻击推断 secret
- 去重用 delivery ID + TTL,避免重试风暴
- 202 Accepted 而不是直接执行——Push 入口只负责"收下",不负责"处理"。处理交给队列和调度器
💡 Tip:Push 入口的 SLA 应该是"50ms 内返回 202",不是"50ms 内处理完"。如果你在 webhook 里直接跑 Agent,你会在 GitHub 的 10 秒超时下被截断,然后 GitHub 重试,然后你又被截断,然后你就有了老张凌晨三点的 Loop。Push 入口必须是无状态、超快、只入队的薄层。
5.2.4 Goal-Driven 模式的工程细节
Goal-Driven 是 Loop 区别于 Cron Job 的本质。它的核心是:Loop 自己定义一个未达成的终态,持续检查差距,差距大于阈值就自激发。
这种模式最难的部分不是触发本身,而是目标态的机器可读表示。一个目标"让产品更好"无法触发 Loop,因为它不可机器读取。一个目标"测试失败数 = 0"可以触发 Loop,因为 CI 输出是结构化的。
把开放目标工程化的标准流程是:
- 目标具象化:把"修复所有失败测试"具象为
failing_test_count == 0 - 状态可读化:定义一个函数能从真实世界读出当前值
- 差距量化:定义
gap = current - target - 触发条件:
gap > 0时触发,gap == 0时停止 - 上限保护:最大迭代次数、最大耗时、最大 Token 消耗
# Python: Goal-Driven Loop 的最小骨架
from dataclasses import dataclass
from typing import Callable, Awaitable
@dataclass
class GoalSpec:
name: str
read_current: Callable[[], Awaitable[int]] # 读当前值
target: int = 0 # 目标值
max_iterations: int = 20 # 上限保护
cooldown_ms: int = 30_000 # 冷却
async def goal_driven_loop(goal: GoalSpec, beat: Callable[[int], Awaitable[bool]]):
"""目标驱动 Loop:直到 gap=0 或达到上限"""
history = []
for i in range(goal.max_iterations):
current = await goal.read_current()
gap = current - goal.target
history.append({"iter": i, "current": current, "gap": gap})
if gap <= 0:
print(f"[{goal.name}] goal reached at iter {i}")
return {"ok": True, "history": history}
# 把 gap 传给 beat,让 Agent 知道差距有多大
ok = await beat(gap)
if not ok:
print(f"[{goal.name}] beat failed at iter {i}")
await sleep_ms(goal.cooldown_ms)
print(f"[{goal.name}] max iterations reached, gap={gap}")
return {"ok": False, "history": history, "final_gap": gap}
注意一个细节:beat(gap) 把当前差距作为参数传给执行器。这让 Agent 不只是"被通知该跑了",而是"知道还差多少,据此决定策略"——差距小可以微调,差距大可能要重写。这是 Goal-Driven 模式比 Pull/Push 更智能的地方:它携带了语义信息。
金句:Pull 模式问"有事吗",Push 模式说"事来了",Goal-Driven 模式问"我离目标还有多远"。三种问法,三种智能层次。
5.2.5 三种模式的对比矩阵
| 维度 | Pull | Push | Goal-Driven |
|---|---|---|---|
| 信息流方向 | Loop → 源 | 源 → Loop | Loop → 自身 |
| 延迟 | 中-高(取决于 poll 间隔) | 低 | 低-中 |
| 实现复杂度 | 低 | 中 | 高 |
| 外部依赖 | 弱 | 强(需暴露入口) | 中(需可读目标态) |
| 风暴抵抗 | 强(自己控速) | 弱(被事件驱动) | 强(自己节律) |
| 适用任务类型 | 周期性检查 | 突发事件 | 持续收敛目标 |
| 失败模式 | 漏检/重复 | 重试风暴 | 无限循环 |
| 典型例子 | Cron poll、IM 轮询 | Webhook、文件监听 | “修完所有失败测试” |
💡 Tip:选模式时,问自己一个问题——"这个任务是被动响应世界,还是主动改造世界?"前者选 Push,后者选 Goal-Driven。如果两者都不是(只是周期性检查),选 Pull。混用三种时,让 Pull 做兜底、Push 做主通道、Goal-Driven 做长程收敛。
5.3 定时触发:cron 表达式与节律设计
5.3.1 cron 不是 Loop,但 Loop 离不开 cron
定时触发是 Pull 模式的一个特例——固定的 poll 间隔,源是"时间"。它通过 cron 表达式实现。
但 cron 和 Loop 有本质区别:cron 关心"做了没",Loop 关心"成了没"。cron 在 9:00 触发任务,任务跑完不管成功失败都结束,下次 9:00 再来。Loop 触发一次,会迭代到达成才停。
所以 cron 在 Loop 工程里的角色不是"完成任务的机制",而是**“启动 Loop 的闹钟”**。cron 把 Loop 拉起来,Loop 自己跑到达成目标,cron 不参与判断"是否达成"。
┌──────────────────────────────────────────────────────────────────┐
│ cron 的世界观:到点就做,做完就忘 │
│ │
│ 09:00 ──┐ │
│ │ │
│ 10:00 ──┼──┐ 每次触发独立,互不关心 │
│ │ │
│ 11:00 ──┐ │ │
│ │ │ │
│ ▼ ▼ ▼ ▼ ▼ (无状态) │
│ │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Loop 的世界观:到点启动,迭代到达成才停 │
│ │
│ 09:00 ──┐ │
│ │ ┌── beat 1 ──┐ ┌── beat 2 ──┐ ┌── beat 3 ──┐ │
│ │ │ gap=12 │ │ gap=5 │ │ gap=0 │ │
│ │ │ 未达目标 │ │ 未达目标 │ │ 达成! │ │
│ │ └────────────┘ └────────────┘ └────────────┘ │
│ │ (持续迭代,状态反馈) │
│ ▼ ▼ ▼ ▼ │
│ │
└──────────────────────────────────────────────────────────────────┘
5.3.2 cron 表达式的 5 段语法
cron 表达式有 5 段(标准 cron)或 6 段(带秒的现代版):
┌───────────── 分钟 (0-59)
│ ┌───────────── 小时 (0-23)
│ │ ┌───────────── 日 (1-31)
│ │ │ ┌───────────── 月 (1-12 或 JAN-DEC)
│ │ │ │ ┌───────────── 星期 (0-6 或 SUN-SAT, 0=周日)
│ │ │ │ │
* * * * *
每一段支持:
- 数字:
5表示第 5 - 范围:
1-5表示 1 到 5 - 列表:
1,3,5表示 1、3、5 - 步长:
*/5表示每 5 单位 - 通配:
*表示所有
常见 cron 模式:
| cron 表达式 | 含义 | 节律类型 |
|---|---|---|
0 * * * * |
每小时整点 | 高频 |
0 9 * * * |
每天 9:00 | 日常工作 |
0 9 * * 1-5 |
工作日 9:00 | 工作时段 |
*/5 * * * * |
每 5 分钟 | 监控检查 |
0 0 * * 0 |
每周日凌晨 | 周期清理 |
0 0 1 * * |
每月 1 日凌晨 | 月度任务 |
0 9,21 * * * |
每天 9:00 和 21:00 | 双峰节律 |
0 9 * * 1-5,0 |
工作日+周日 9:00 | 含例外 |
5.3.3 节律设计:不是"什么时候跑",是"什么时候不跑"
很多人写 cron 表达式时只考虑"什么时候跑",这是新手思维。老手想的是"什么时候不跑"——避开业务高峰、避开维护窗口、避开依赖系统脆弱时段。
节律设计的四条原则:
原则 1:避开整点尖峰
整点(特别是 0:00、9:00)是所有定时任务的集中点。如果你的 Loop 在 0:00 跑,它和全世界的 cron job 在同一秒争抢 GitHub API、数据库连接、CI 资源。
抖动(jitter) 是工程上的解法:在 cron 触发后加一个 0-N 秒的随机延迟,把尖峰摊平。
function withJitter(cronSpec: string, jitterMs: number): { cron: string; jitter: number } {
return {
cron: cronSpec,
jitter: Math.floor(Math.random() * jitterMs),
};
}
// 用法:每天 9:00 触发,但延迟 0-120 秒随机
const job = withJitter("0 9 * * *", 120_000);
scheduler.schedule(job.cron, async () => {
await sleep(job.jitter);
await runLoop();
});
HappyClaw 的任务调度器(src/task-scheduler.ts)默认就有抖动逻辑,避免所有定时任务在整点同时触发。
原则 2:错峰依赖
如果你的 Loop 依赖 CI 系统的可用性,不要在 CI 最忙的时候跑。CI 系统通常在每天 9-11 点、14-17 点最忙(开发者推代码的高峰)。Loop 跑在凌晨 2-5 点,资源充裕、失败率低。
原则 3:节律匹配任务语义
不同任务有不同的"自然节律":
| 任务类型 | 自然节律 | 原因 |
|---|---|---|
| 修 CI 失败 | 每 10-30 分钟 | CI 是开发者循环,需快速响应 |
| 代码质量扫描 | 每天凌晨 | 全量扫描耗资源,凌晨充裕 |
| 依赖更新 | 每周一次 | 依赖变化慢,周节律够 |
| 报表生成 | 每月 1 日 | 业务节奏月度 |
| 安全审计 | 每周日 | 全量审计耗资源 |
| 备份验证 | 每日凌晨 | 备份凌晨做完,验证紧随 |
原则 4:双峰节律匹配人类作息
有些 Loop 服务于人类(如每日摘要),应该匹配人类作息的双峰——上班时一次、下班前一次:
┌──────────────────────────────────────────────────────────────┐
│ 人类作息的双峰节律 │
│ │
│ Token │
│ │ ████ ████ │
│ │ ████ ████ │
│ │ █████████ ███████████ │
│ │ █████████ ███████████ │
│ │ ██████████████████████████████████████████ │
│ └─────────────────────────────────────────────→ 时间 │
│ 8 10 12 14 16 18 20 22 0 2 4 6 8 │
│ ↑ ↑ ↑ │
│ 上班 高峰 下班 │
│ 双峰 │
└──────────────────────────────────────────────────────────────┘
把"每日摘要"放在 9:00 和 18:00,比放在 12:00 一次更贴合人类工作流。
💡 Tip:写 cron 表达式之前,画一张 24 小时时间轴,标出:业务高峰、依赖系统脆弱时段、人类作息双峰。然后让你的 cron 躲开前两个、贴合第三个。这是节律设计的全部秘诀。
5.3.4 节律反模式
常见的 cron 反模式:
反模式 1:* * * * *(每分钟)
很多人想"高频响应"就写每分钟。这是错误的——每分钟触发意味着每天 1440 次,几乎一定会和别的任务撞车,而且 Token 消耗爆炸。
正确做法:用 Push 模式(事件驱动)替代高频 cron。
反模式 2:0 0 * * *(每天凌晨 0 点)
凌晨 0 点是全世界 cron job 的拥堵点。如果你的任务对延迟不敏感,挪到 2:17、3:43 这种"奇怪但稳定"的时间点。
反模式 3:依赖时区不写时区
cron 表达式默认是服务器本地时区。如果你的服务器在 UTC,你想"每天 9:00 北京时间",必须显式声明时区或换算成 UTC(01:00)。
# 错误:在 UTC 服务器上写 0 9 * * *,实际是北京时间 17:00
0 9 * * *
# 正确:显式声明时区(cron-with-tz 规范)
CRON_TZ=Asia/Shanghai
0 9 * * *
# 或换算成 UTC
0 1 * * *
HappyClaw 在 src/task-scheduler.ts 中通过 TZ 环境变量处理时区,确保定时任务按用户期望时区运行。
反模式 4:cron 表达式不可见
定时任务散落在 crontab、k8s CronJob、应用代码里,没人能一眼看全。生产环境应该有统一的任务清单视图,把所有 cron 集中展示。
┌────────────────────────────────────────────────────────────────┐
│ 任务清单视图(生产环境必备) │
├────────────────────────────────────────────────────────────────┤
│ 名称 │ cron │ 时区 │ 上次执行 │ 状态│
├────────────────────┼─────────────┼──────────┼──────────┼─────┤
│ fix-ci-failures │ */15 * * * *│ UTC │ 3分钟前 │ ✓ │
│ daily-summary │ 0 9 * * * │ Asia/Sh │ 2小时前 │ ✓ │
│ dep-update │ 0 0 * * 0 │ UTC │ 3天前 │ ✓ │
│ security-scan │ 0 4 * * 0 │ UTC │ 3天前 │ ✗ │
└────────────────────────────────────────────────────────────────┘
💡 Tip:在团队里建立一条规矩:任何新增 cron 任务必须先登记到任务清单视图,否则不让上线。这条规矩能避免 80% 的"凌晨三点被叫醒"事故。
5.4 事件触发:Webhook / 文件监听 / 消息队列
5.4.1 事件触发的三种通道
事件触发是 Push 模式的具体实现。常见的三种通道:
- Webhook:HTTP 回调,源主动 POST 到你的端点
- 文件监听:文件系统变化触发(inotify / fswatch / chokidar)
- 消息队列:Kafka / RabbitMQ / SQS / Redis Stream
三种通道在性能、可靠性、复杂度上有明显差异:
| 通道 | 延迟 | 可靠性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| Webhook | ~10ms | 中(HTTP 可能丢) | 中 | SaaS 集成、GitHub/Slack |
| 文件监听 | ~100ms | 高(本地不丢) | 低 | 本地开发、IPC |
| 消息队列 | ~100ms-1s | 极高(持久化+ACK) | 高 | 生产系统、跨服务 |
5.4.2 Webhook:最常用也最危险
Webhook 是最常用的事件触发方式,也是最容易出事的。
Webhook 的工程难点不是"怎么收",而是"怎么在风暴中存活"。我见过最惨的事故:一个团队的 GitHub Webhook 在 git push 高峰期被打了 200+ 次/秒,他们的 webhook 入口直接把数据库打挂了。
工业级 Webhook 处理必须做四件事:
- 签名校验:拒绝伪造请求(前面 Python 例子里的 HMAC)
- 去重:同一个 delivery ID 只处理一次(LRU 缓存)
- 入队异步处理:webhook 入口只入队,不处理
- 限流:单个源的事件速率上限
// 限流:令牌桶
class TokenBucket {
private tokens: number;
constructor(
private readonly capacity: number,
private readonly refillRatePerSec: number,
) {
this.tokens = capacity;
setInterval(() => {
this.tokens = Math.min(capacity, this.tokens + refillRatePerSec);
}, 1000);
}
tryConsume(n = 1): boolean {
if (this.tokens >= n) {
this.tokens -= n;
return true;
}
return false;
}
}
const githubBucket = new TokenBucket(50, 10); // 突发 50,每秒补 10
app.post("/webhook/github", async (req, res) => {
if (!githubBucket.tryConsume()) {
return res.status(429).set("Retry-After", "5").send("rate limited");
}
// ... 后续处理
});
💡 Tip:Webhook 限流不要用"每秒 N 次"的硬上限,要用令牌桶——允许突发,但平均不超标。突发是真实业务模式(比如一次 push 5 个 commit 触发 5 个 webhook),硬上限会误杀;令牌桶允许短时突发,长期平均仍然受控。
5.4.3 文件监听:最简单的本地事件源
文件监听是本地开发和小规模场景下最简单的事件触发。Linux 的 inotify、macOS 的 fsevents、Node.js 的 chokidar 都属于这一类。
文件监听在 Loop 工程里的典型用法是 IPC 触发——主进程写一个文件,监听方收到事件就启动 Loop。HappyClaw 的 IPC 机制就是这套:
┌─────────────────────────────────────────────────────────────────┐
│ HappyClaw 的 IPC 文件触发机制 │
│ │
│ 主进程 容器/Agent │
│ ┌──────────┐ ┌──────────┐ │
│ │ enqueue │ ── write file ────────►│ fs.watch │ │
│ │ message │ │ (event) │ │
│ └──────────┘ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ process │ │
│ │ message │ │
│ └──────────┘ │
│ │
│ 路径:data/ipc/{folder}/input/*.json │
│ 机制:fs.watch (event-driven) + 5s 后备轮询 │
│ 原子性:先写 .tmp 再 rename,避免读到半截 │
└─────────────────────────────────────────────────────────────────┘
文件监听的工程难点是事件风暴——一次保存可能触发数十个 change 事件。处理方式是 debounce:
import chokidar from "chokidar";
const watcher = chokidar.watch("data/ipc/main/input/*.json", {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
});
// debounce: 100ms 内的多个事件合并为一次
let pending: NodeJS.Timeout | null = null;
watcher.on("all", (event, path) => {
if (pending) clearTimeout(pending);
pending = setTimeout(() => {
pending = null;
processPendingMessages();
}, 100);
});
HappyClaw 的 src/index.ts 中使用了 50-100ms 的 debounce 来防止 IPC 文件触发风暴。
5.4.4 消息队列:生产级事件触发
消息队列是生产环境事件触发的标准方案。它解决的核心问题是可靠投递——消息不会因为消费者挂了而丢失。
消息队列的工程模型:
┌──────────────────────────────────────────────────────────────────┐
│ 消息队列事件触发模型 │
│ │
│ ┌─────────┐ produce ┌───────────┐ consume ┌─────────┐ │
│ │ 事件源 │ ─────────►│ Queue │ ────────►│ Loop │ │
│ └─────────┘ │ │ │ Worker │ │
│ │ partition │ └─────────┘ │
│ │ by key │ │
│ └───────────┘ │
│ │
│ 关键属性: │
│ - ACK 机制:消费者处理完才确认,未确认的消息重新投递 │
│ - 持久化:队列内容落盘,重启不丢 │
│ - 分区:按 key 分区保证同 key 顺序消费 │
│ - 死信队列:处理失败的消息进 DLQ,避免毒丸阻塞主队列 │
└──────────────────────────────────────────────────────────────────┘
关键设计 1:分区(partitioning)
事件触发的常见需求是"同一 key 的事件按顺序处理"。比如同一个 GitHub Issue 的所有事件(open、comment、close)必须按顺序处理,否则会出现"先 close 再 comment"的乱序。
Kafka 的做法是按 key 分区,同一 key 进同一分区,单分区单消费者,天然保序。
# Kafka 消费者:按 issue ID 分区
from kafka import KafkaConsumer
consumer = KafkaConsumer(
"github-events",
group_id="loop-worker",
enable_auto_commit=False, # 手动提交,处理完才 ACK
value_deserializer=lambda m: json.loads(m.decode("utf-8")),
)
for msg in consumer:
issue_id = msg.value["issue"]["id"]
try:
process_event(msg.value)
consumer.commit() # 处理成功才提交
except Exception as e:
# 不提交,消息会重新投递
log.error(f"failed to process {issue_id}: {e}")
关键设计 2:死信队列(DLQ)
如果一条消息反复处理失败(毒丸),不能让它一直阻塞队列。把它移到 DLQ,让人工或专门 Loop 处理。
MAX_RETRIES = 5
def process_with_dlq(msg, main_queue, dlq):
retry_count = msg.headers.get("x-retry-count", 0)
try:
process_event(msg.value)
except Exception as e:
if retry_count < MAX_RETRIES:
msg.headers["x-retry-count"] = retry_count + 1
main_queue.put(msg) # 重试
else:
dlq.put(msg) # 进死信队列
log.error(f"message {msg.id} moved to DLQ after {MAX_RETRIES} retries")
💡 Tip:DLQ 不是"失败垃圾桶",是"需要人工介入的待办"。生产环境必须有人监控 DLQ,否则问题会沉默积累。建议 DLQ 长度超过阈值时触发告警,而不是默默堆积。
5.4.5 事件触发的三态模型
事件处理有三种可能状态,工程上必须显式处理:
| 状态 | 含义 | 工程动作 |
|---|---|---|
| 成功 | 处理完成,结果正确 | ACK,从队列移除 |
| 失败-可重试 | 临时性错误(网络、限流) | 不 ACK,等待重试 |
| 失败-不可重试 | 永久性错误(数据格式错、权限不足) | ACK + 写入 DLQ |
最常见错误是把所有失败都按"可重试"处理。一个 JSON 解析失败的请求重试一万次还是失败,只会烧 CPU 和堵塞队列。
class EventError extends Error {
constructor(
message: string,
public readonly retriable: boolean,
) {
super(message);
}
}
async function handleEvent(msg: Message): Promise<void> {
try {
await processEvent(msg);
await msg.ack();
} catch (e) {
if (e instanceof EventError && e.retriable) {
// 不 ACK,消息会重新投递
await msg.nack({ delay: backoff(msg.attempt) });
} else {
// 不可重试,ACK 掉 + 进 DLQ
await msg.ack();
await dlq.produce({ original: msg, error: String(e) });
await alertOpsTeam(e);
}
}
}
5.5 目标驱动触发:"修复所有失败测试"的工程化
5.5.1 开放目标的不可工程化困境
"修复所有失败测试"这种目标在自然语言里听起来简单,在工程里却是个深坑。
困境在于:自然语言目标不可机器读取。Agent 听得懂"修复所有失败测试",但 Loop 的触发器需要的是一个布尔/数值函数:“还有失败测试吗?有几个?”
所以目标驱动触发的第一项工程化工作,是把开放目标翻译成机器可读的差距函数。
5.5.2 工程化五步法
把开放目标工程化的五步:
┌──────────────────────────────────────────────────────────────────┐
│ 开放目标 → 工程化触发器 五步法 │
│ │
│ ① 具象化 "修复所有失败测试" → failing_count == 0 │
│ │ │
│ ▼ │
│ ② 可读化 定义 read_current() 从 CI 拉取失败数 │
│ │ │
│ ▼ │
│ ③ 量化 gap = current - target = failing_count │
│ │ │
│ ▼ │
│ ④ 触发条件 if gap > 0: 触发一次 beat │
│ │ │
│ ▼ │
│ ⑤ 上限保护 max_iterations=20, max_tokens=1M, timeout=2h │
│ │
└──────────────────────────────────────────────────────────────────┘
第 1 步:具象化
把"修复所有失败测试"具象化为 failing_test_count == 0。这一步的关键是找出可机器读取的代理量。很多时候原始目标不能直接读取,要找一个等价的代理:
| 开放目标 | 代理量 | 读取方式 |
|---|---|---|
| 修复所有失败测试 | failing_count | CI API |
| 代码质量达标 | lint_error_count | linter 输出 |
| 文档完整 | missing_doc_count | 文档扫描 |
| 性能达标 | p99_latency | APM 系统 |
| 部署成功 | deploy_status | 部署 API |
| 用户满意 | nps_score | 问卷系统 |
💡 Tip:代理量选择决定了 Loop 的"诚实度"。一个糟糕的代理量会让 Loop 看起来在跑但目标从未真正达成。比如用"测试通过率"做代理,Agent 会发现删测试能提升通过率——这就是 Goodhart 定律在 Loop 工程里的体现。当度量成为目标,度量就停止好用。多花时间设计代理量,比多花时间优化 Agent 划算得多。
第 2 步:可读化
定义一个函数能从真实世界读出当前值。这个函数要满足:
- 可机器调用:是 API、CLI 或文件读取,不需要人介入
- 快速:< 30s 返回,否则影响 Loop 节律
- 可靠:失败时返回明确错误,不返回误导值
import subprocess
import json
async def read_failing_test_count() -> int:
"""从 CI 拉取失败测试数。失败抛异常,不返回 0。"""
result = subprocess.run(
["ci-cli", "list-failures", "--format", "json"],
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
raise RuntimeError(f"CI query failed: {result.stderr}")
failures = json.loads(result.stdout)
return len(failures)
注意:失败时抛异常,不要返回 0。返回 0 会让 Loop 误以为目标达成而停止。
第 3 步:量化
定义 gap = current - target。Gap 不只是一个数字,它还可以携带语义信息:
@dataclass
class Gap:
value: int # 数值差距
items: list[str] # 具体哪些项失败
metadata: dict # 失败的类别、严重程度等
def should_trigger(self) -> bool:
return self.value > 0
把失败的具体项列出来,传给 Agent,让 Agent 知道该修什么。这比只告诉 Agent “有 12 个失败” 要好得多。
第 4 步:触发条件
async def goal_loop():
while True:
gap = await read_gap()
if not gap.should_trigger():
log.info("goal reached, exiting")
return
await run_beat(gap)
await sleep(COOLDOWN_MS)
第 5 步:上限保护
@dataclass
class Guardrails:
max_iterations: int = 20
max_total_tokens: int = 1_000_000
max_wall_clock_ms: int = 2 * 60 * 60 * 1000 # 2 小时
max_cost_usd: float = 50.0
async def guarded_goal_loop(guards: Guardrails):
spent = {"iter": 0, "tokens": 0, "ms": 0, "usd": 0.0}
while True:
if spent["iter"] >= guards.max_iterations: break
if spent["tokens"] >= guards.max_total_tokens: break
if spent["ms"] >= guards.max_wall_clock_ms: break
if spent["usd"] >= guards.max_cost_usd: break
# ... 跑一次 beat,累计 spent
上限保护是目标驱动 Loop 的安全带。没有它,一个永远达不到的目标会让 Loop 跑到天荒地老,烧光预算。
5.5.3 目标驱动 Loop 的收敛性
目标驱动 Loop 不是无限循环,它期望收敛——gap 越跑越小,最终为 0。
但现实不总是收敛的。有三种可能:
┌──────────────────────────────────────────────────────────────────┐
│ 目标驱动 Loop 的三种收敛性 │
│ │
│ gap │
│ │ │
│ │ ◮ ① 单调收敛 │
│ │ ◮ │
│ │ ◮ ◮ │
│ │ ◮ │
│ │ │
│ │ ◮ ◮ ◮ ◮ ◮ ◮ ◮ ◮ ◮ ◮ ◮ ② 震荡不收敛 │
│ │ │
│ │ ◮ │
│ │ ◮ │
│ │ ◮ │
│ │ ◮ ◮ ◮ │
│ │ ◮ ◮ ◮ ◮ ◮ ◮ ◮ ③ 发散 │
│ │ │
│ └────────────────────────────────────────────────→ iteration │
└──────────────────────────────────────────────────────────────────┘
情况 1:单调收敛
每轮 beat 都让 gap 减小,最终为 0。这是理想情况,发生在:
- 失败是独立的,修一个少一个
- 修复不会引入新失败
情况 2:震荡不收敛
gap 在某个值附近震荡,无法收敛。常见原因:
- 两个失败互相耦合:修 A 引入 B,修 B 引入 A
- Agent 的修复策略不稳定,每次跑都不一样
情况 3:发散
gap 越跑越大。这是最危险的情况,发生在:
- Agent 的修复本身引入新 bug
- 测试本身被 Agent 改坏了
💡 Tip:目标驱动 Loop 必须监控gap 趋势,不只是 gap 当前值。如果连续 3 轮 gap 不降反升,立刻停止并告警——这是发散信号,继续跑只会越跑越糟。监控代码:
if gap_history[-3:] strictly increasing: halt_and_alert()。
5.5.4 一个真实例子:Boris Cherny 的 Loop
REFERENCE.md 里提到 Boris Cherny(Claude Code 负责人)的实绩:1 人 6 月 259 PR 497 提交 4 万行代码。
这个数字背后的 Loop 机制就是目标驱动:他定义了一个持续的目标(让 Claude Code 更好),把目标拆成可机器读取的子目标(测试覆盖、文档完整、性能基线),每个子目标有一个差距函数,Loop 周期性检查差距并触发 beat。
关键不是他写了多少代码,而是他的 Loop 知道什么时候该跑、什么时候该停。这就是目标驱动触发的力量:它把一个开放性的"让产品更好"问题,工程化为一个可自动收敛的循环。
5.6 任务队列设计:FIFO / 优先级 / 加权
5.6.1 队列是 Loop 的窦房结延迟线
触发器把任务推到队列里,Agent 从队列里取任务执行。队列不是简单的缓冲,它承担三个工程职能:
- 节律缓冲:把瞬时尖峰摊平为可持续节律
- 优先级仲裁:高优任务先跑,低优任务后跑
- 背压传导:队列满了拒绝新任务,让上游减速
队列是 Loop 的"窦房结延迟线"——它把冲动变成节律的地方。
5.6.2 FIFO 队列:最简单也最危险
最简单的队列是 FIFO(先进先出)。任务按到达顺序执行。
FIFO 的优点:
- 实现简单(一个 list)
- 公平(先来的先服务)
- 可预测(顺序确定)
FIFO 的危险:
- 优先级倒置:低优任务堵住高优任务
- 头阻塞:第一个任务卡住,后面全堵
- 没有背压:队列无限长,内存炸
FIFO 适合的任务类型:
- 任务重要性一致
- 任务耗时短且接近
- 任务量稳定可预测
实际工程里,纯 FIFO 几乎都不够用。
5.6.3 优先级队列
优先级队列让重要任务先跑。每个任务带一个优先级,队列按优先级排序。
import heapq
class PriorityQueue:
def __init__(self):
self._heap = []
self._seq = 0 # 同优先级时 FIFO
def push(self, item, priority: int):
# heapq 是最小堆,priority 越大越优先 → 取负
heapq.heappush(self._heap, (-priority, self._seq, item))
self._seq += 1
def pop(self):
return heapq.heappop(self._heap)[2]
优先级设计的关键问题:优先级怎么定?
一种实用的优先级体系:
| 优先级 | 任务类型 | 例子 |
|---|---|---|
| P0 (紧急) | 用户可见故障 | 生产环境崩溃、用户消息丢失 |
| P1 (高) | 阻塞开发的事 | CI 失败、部署阻塞 |
| P2 (中) | 周期性任务 | 修测试、补文档 |
| P3 (低) | 改善性任务 | 代码质量、依赖更新 |
| P4 (背景) | 离线分析 | 报表生成、历史扫描 |
💡 Tip:优先级不要超过 5 级。超过 5 级人类就无法快速判断了,会导致"什么都重要 = 什么都不重要"。如果你发现自己需要 P5、P6,问题不在分级,在于你没真正思考过什么是 P0。
5.6.4 加权公平队列
纯优先级队列有个问题:低优先级任务可能永远跑不了。如果 P0 持续有任务,P3 永远排队。
工程上的解法是加权公平队列(Weighted Fair Queue)——按权重分配执行份额,而不是绝对优先。
┌──────────────────────────────────────────────────────────────────┐
│ 加权公平队列:按权重分配执行槽 │
│ │
│ 权重 P0=50% P1=30% P2=15% P3=5% │
│ │
│ 执行槽序列: │
│ ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐│
│ │P0│P0│P0│P0│P0│P1│P1│P1│P0│P0│P1│P1│P2│P0│P0│P1│P2│P0│P3│P0││
│ └──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘│
│ │
│ 20 个槽中:P0 占 10, P1 占 6, P2 占 3, P3 占 1 │
│ 即使 P0 一直有任务,P3 也保证至少 1/20 槽位 │
└──────────────────────────────────────────────────────────────────┘
实现可以用赤字轮调度(Deficit Round Robin),Linux 的网络包调度器用的就是这个算法的变种。
class WeightedFairQueue:
def __init__(self, weights: dict[str, float]):
self.weights = weights # {"P0": 50, "P1": 30, ...}
self.queues = {k: [] for k in weights}
self.deficit = {k: 0.0 for k in weights}
def push(self, key: str, item):
self.queues[key].append(item)
def pop(self):
# 每轮增加 deficit,达到 item size 就出队
while True:
for key in self.weights:
if not self.queues[key]:
continue
self.deficit[key] += self.weights[key]
if self.deficit[key] >= 1.0:
item = self.queues[key].pop(0)
self.deficit[key] -= 1.0
return item
# 所有队列空,返回 None
if not any(self.queues.values()):
return None
加权公平队列是 Loop 调度的成熟方案——它既尊重优先级,又防止低优先级饥饿。
5.6.5 HappyClaw 的并发模型实例
HappyClaw 的 src/group-queue.ts 用了一个折中方案:最大并发 + 优先级 + FIFO 兜底。
- 最大 20 个并发容器
- 最大 5 个宿主机进程
- 任务优先于普通消息(任务优先级高)
- 同优先级内 FIFO
- 失败指数退避重试(5s→10s→20s→40s→80s,最多 5 次)
// 简化版 GroupQueue
class GroupQueue {
private activeContainers = 0;
private activeHostProcesses = 0;
private readonly maxContainers = 20;
private readonly maxHost = 5;
private waitingQueue: Task[] = [];
async enqueue(task: Task) {
this.waitingQueue.push(task);
this.waitingQueue.sort((a, b) => b.priority - a.priority); // 优先级排序
this.tryDispatch();
}
private tryDispatch() {
while (this.canDispatch() && this.waitingQueue.length > 0) {
const task = this.waitingQueue.shift()!;
this.execute(task);
}
}
private canDispatch(): boolean {
if (this.activeContainers < this.maxContainers) return true;
return false;
}
private async execute(task: Task) {
this.activeContainers++;
try {
await runContainerAgent(task);
} catch (e) {
// 指数退避
const backoff = Math.min(80_000, 5000 * Math.pow(2, task.retries));
task.retries++;
if (task.retries < 5) {
setTimeout(() => this.enqueue(task), backoff);
}
} finally {
this.activeContainers--;
this.tryDispatch();
}
}
}
5.6.6 队列反模式
反模式 1:无界队列
队列没有长度上限,问题积累时内存爆炸。任何生产队列必须有 max length 和溢出策略。
反模式 2:同步入队
queue.push(task) 阻塞直到队列有空。如果队列满,调用方挂起,整个上游链路雪崩。应该用 tryPush 或 enqueueWithTimeout,超时就拒绝。
反模式 3:无死信处理
任务反复失败永远不进 DLQ,堵塞队列。前面 5.4.4 节讲过解法。
反模式 4:优先级饥饿
低优任务永远跑不了。前面 5.6.4 节加权公平队列解决。
💡 Tip:队列设计三件套:最大长度、超时拒绝、DLQ。三者缺一就会在生产事故里相遇。我见过太多团队只有"队列"没有"三件套",结果一个小故障让队列堆积几万条,内存爆了服务挂了。
5.7 幂等性:同一个任务被触发两次会怎样
5.7.1 幂等性是工程契约
幂等性(idempotency)在数学上的定义是:f(f(x)) = f(x)。即对同一输入多次执行,结果一致。
在 Loop 工程里,幂等性不是数学性质,是工程契约:
同一个任务被触发两次,世界应该和触发一次一样。
这个契约之所以重要,是因为 Loop 系统里重复触发不可避免:
- Webhook 重试:GitHub 失败重试,同一事件发两次
- Cron 抖动:调度器重启,错过几次后补跑
- Agent 自修复:Loop 自激发多次
- 队列重投递:消息 ACK 丢失,重新投递
如果每次重复触发都产生副作用,Loop 就会失控。幂等性是 Loop 稳定性的根本。
5.7.2 幂等性的四个层次
幂等性不是有/无的二选一,是四个层次:
┌──────────────────────────────────────────────────────────────────┐
│ 幂等性四层 │
│ │
│ Layer 4: 全局幂等 同任务任意次触发,最终状态一致 │
│ ↑ │
│ Layer 3: 操作幂等 单次操作可重复执行不产生副作用 │
│ ↑ │
│ Layer 2: 请求幂等 同请求 ID 的请求返回相同结果 │
│ ↑ │
│ Layer 1: 弱幂等 重复触发大部分时候没事,但偶尔会出问题 │
│ ↑ │
│ Layer 0: 非幂等 重复触发必然产生副作用 │
└──────────────────────────────────────────────────────────────────┘
| 层次 | 实现难度 | 典型实现 | 例子 |
|---|---|---|---|
| Layer 4 | 极高 | 全局状态机+事务 | "转账"操作的最终一致性 |
| Layer 3 | 高 | 操作可重放+可补偿 | git apply 已应用过的 patch |
| Layer 2 | 中 | 请求 ID 去重 | API 调用幂等 token |
| Layer 1 | 低 | 大部分情况下安全 | “创建用户”(重名时失败) |
| Layer 0 | 0 | 无防护 | “递增计数器”、“发邮件” |
Loop 工程的最低要求是 Layer 2——所有触发器入口必须有请求 ID 去重。理想是 Layer 3——所有操作可重放。
5.7.3 实现幂等性的五种策略
策略 1:请求 ID 去重
最简单也最有效。每个触发请求带一个唯一 ID,处理前查 LRU/DB 看是否处理过。
from functools import lru_cache
import hashlib
processed = {} # 实际生产用 Redis
def idempotent_handler(event_id: str, payload: dict):
if event_id in processed:
return processed[event_id]
result = actually_process(payload)
processed[event_id] = result
return result
策略 2:乐观锁(CAS)
Compare-And-Swap,先检查状态再操作。Git 的 commit 就是 CAS——基于当前 HEAD 提交,如果 HEAD 变了就失败。
# Git CAS: 期望 master 是 abc123,否则失败
git update-ref refs/heads/master abc123 def456
策略 3:唯一约束
数据库唯一索引,重复插入自动失败。
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMP DEFAULT NOW(),
result JSONB
);
-- 重复插入会失败,天然幂等
INSERT INTO processed_events (event_id, result) VALUES ($1, $2)
ON CONFLICT (event_id) DO NOTHING;
策略 4:可重放操作
操作本身设计成"重复执行等价于一次执行"。比如:
git apply应用已应用的 patch:检测到已应用,跳过mkdir -p:目录已存在,不报错touch:更新时间戳,不创建多个
def idempotent_create_file(path: str, content: str):
if os.path.exists(path):
existing = open(path).read()
if existing == content:
return # 已存在且一致,无需操作
else:
raise ValueError(f"file {path} exists with different content")
with open(path, "w") as f:
f.write(content)
策略 5:状态机
把任务建模为状态机,重复触发只推进状态,不重复副作用。
from enum import Enum
class TaskState(Enum):
PENDING = 0
RUNNING = 1
DONE = 2
FAILED = 3
# 状态转换表:每种状态在每种触发下做什么
TRANSITIONS = {
(TaskState.PENDING, "trigger"): TaskState.RUNNING,
(TaskState.RUNNING, "complete"): TaskState.DONE,
(TaskState.RUNNING, "fail"): TaskState.FAILED,
(TaskState.DONE, "trigger"): TaskState.DONE, # 关键:已完成再触发=保持DONE
(TaskState.FAILED, "trigger"): TaskState.RUNNING, # 失败可重试
}
def handle_trigger(task_id: str):
task = load_task(task_id)
new_state = TRANSITIONS.get((task.state, "trigger"))
if new_state == task.state:
return # 幂等:状态不变,无副作用
if new_state == TaskState.RUNNING and task.state == TaskState.PENDING:
run_task(task) # 真正的副作用只发生一次
task.state = new_state
save_task(task)
💡 Tip:状态机是幂等性的最强武器。当你发现自己在写 “if already_done: return” 的代码越来越多,把任务建模成状态机——所有"已完成再触发"自动变成状态转换的 no-op。这也让调试容易:看任务当前状态就知道它处于哪个阶段。
5.7.4 幂等性决策树
设计触发器时,问自己这棵决策树:
这个触发器会被重复触发吗?
│
┌───────────────┴───────────────┐
▼ ▼
是的 不会
│ │
│ ▼
│ 不需要幂等
│ (但加个 ID 也不亏)
▼
触发有副作用吗?
│
┌─────┴─────┐
▼ ▼
有 没有
│ │
▼ ▼
必须做幂等 天然幂等
│ (日志、查询)
▼
副作用可重放吗?
│
┌────┴────┐
▼ ▼
可以 不可以
│ │
▼ ▼
策略4 策略1/2/3
可重放 去重/CAS/唯一约束
5.7.5 幂等性的代价
幂等性不是免费的。它的代价是:
- 存储开销:去重表、状态记录
- 延迟:每次都要查"是否处理过"
- 复杂度:状态机比直接执行复杂
工程取舍:
| 场景 | 是否需要幂等 | 代价可接受 |
|---|---|---|
| 高频触发+有副作用 | 必须 | 是 |
| 高频触发+无副作用 | 可选 | 看延迟要求 |
| 低频触发+有副作用 | 必须 | 是 |
| 低频触发+无副作用 | 不需要 | - |
💡 Tip:把"是否需要幂等"做成触发器设计的标准 checklist 项。每次新增触发器,回答两个问题:(1) 重复触发的概率多大?(2) 重复触发的副作用多严重?两个都高 → 必须做。两个都低 → 可以缓做。一个高一个低 → 看你赌不赌得起。
5.8 去重与合并:避免 Loop 风暴
5.8.1 Loop 风暴的形成机理
回到引子里老张的事故。Loop 风暴的形成机理是:
┌──────────────────────────────────────────────────────────────────┐
│ Loop 风暴形成链 │
│ │
│ ① 同一事件触发多次 │
│ │ │
│ ▼ │
│ ② 多个 Agent 实例并行启动 │
│ │ │
│ ▼ │
│ ③ Agent 之间互相覆盖修改 │
│ │ │
│ ▼ │
│ ④ 每次覆盖触发新的 Webhook │
│ │ │
│ ▼ │
│ ⑤ 新 Webhook 触发新 Agent │
│ │ │
│ ▼ │
│ ⑥ 回到 ①, 风暴放大 │
│ │
└──────────────────────────────────────────────────────────────────┘
风暴的核心不在任何一个环节,而在反馈放大——每次触发产生新的触发。这就像把麦克风靠近音箱:任何声音都会被无限放大成啸叫。
去重与合并是切断反馈放大的两把刀:
- 去重:同一事件只触发一次
- 合并:相近事件合并成一次触发
5.8.2 去重的三个层次
层次 1:事件 ID 去重
最简单。每个事件带 ID,处理前查是否处理过。
SEEN = {} # event_id -> timestamp
TTL = 30 * 60 # 30 分钟
def is_duplicate(event_id: str) -> bool:
now = time.time()
# 清理过期
if event_id in SEEN and now - SEEN[event_id] < TTL:
return True
SEEN[event_id] = now
return False
HappyClaw 的飞书、Telegram、QQ、钉钉模块都用 LRU 1000 条 / 30min TTL 做消息去重(见 src/feishu.ts 等)。
层次 2:内容指纹去重
事件没有可靠 ID 时,用内容指纹。对 payload 做 hash,作为 ID。
import hashlib
import json
def content_fingerprint(payload: dict) -> str:
# 关键字段提取,避免无关字段影响
key_fields = {
"type": payload.get("type"),
"repo": payload.get("repository", {}).get("full_name"),
"ref": payload.get("ref"),
"after": payload.get("after"), # commit hash
}
return hashlib.sha256(
json.dumps(key_fields, sort_keys=True).encode()
).hexdigest()
注意:指纹要基于关键字段,不是整个 payload。payload 里可能有时间戳、request_id 等每次都变的字段,会让指纹失效。
层次 3:语义去重
最深的层次。两个事件即使内容不完全一样,但语义等价就视为重复。
比如"测试失败"事件,可能是不同的测试失败,但如果 Loop 的目标是"修复所有失败测试",那么短时间内多个失败事件应该合并成一次"修失败测试"触发,不需要每个失败都触发一次。
async def semantic_dedup(events: list[Event]) -> list[Event]:
"""把短时间内的同语义事件合并为一个"""
by_kind = defaultdict(list)
for e in events:
by_kind[e.kind].append(e)
merged = []
for kind, group in by_kind.items():
if should_merge(kind):
# 合并:保留最新一个,附带 count
latest = max(group, key=lambda e: e.timestamp)
latest.metadata["merged_count"] = len(group)
merged.append(latest)
else:
merged.extend(group)
return merged
5.8.3 合并的策略
合并是把多个事件变成一个触发。常见策略:
策略 1:时间窗口合并
固定时间窗口内的事件合并为一次触发。
┌──────────────────────────────────────────────────────────────────┐
│ 时间窗口合并(5 分钟窗口) │
│ │
│ 事件流:e1 e2 e3 e4 e5 e6 │
│ ──────│────│───│────────│───│──────────│────────→ 时间 │
│ ╰───────╯ ╰────╯ │ │
│ ▼ ▼ ▼ │
│ trigger1 trigger2 trigger3 │
│ (e1+e2+e3) (e4+e5) (e6) │
│ │
│ 窗口内最多触发一次 │
└──────────────────────────────────────────────────────────────────┘
class TimeWindowMerger:
def __init__(self, window_ms: int):
self.window_ms = window_ms
self.pending = []
self.timer = None
def submit(self, event):
self.pending.append(event)
if self.timer is None:
self.timer = threading.Timer(self.window_ms / 1000, self.flush)
self.timer.start()
def flush(self):
if self.pending:
merged = merge_events(self.pending)
trigger(merged)
self.pending = []
self.timer = None
策略 2:debounce 合并
事件密集时持续推迟触发,直到事件平息一段时间后才触发一次。
┌──────────────────────────────────────────────────────────────────┐
│ Debounce 合并(平息后 100ms 触发) │
│ │
│ 事件流:e1 e2 e3 e4 e5 e6 (停止 100ms) ▼ 触发 │
│ ─────│──│──│──│──│──│─────────────────────────────→ 时间 │
│ ↑ 每来一个事件,重置计时器 │
│ │
│ 事件密集时不触发,平息后才触发一次 │
└──────────────────────────────────────────────────────────────────┘
这是 HappyClaw IPC 文件触发用的策略(50-100ms debounce,见 src/index.ts)。
策略 3:内容合并
多个事件合并为一个携带批量信息的事件。比如 5 个"测试失败"事件合并为一个"5 个测试失败"事件,附带失败列表。
def merge_test_failure_events(events: list[Event]) -> Event:
failures = []
for e in events:
failures.extend(e.payload["failures"])
# 去重同一个失败的多次报告
unique_failures = {f["id"]: f for f in failures}.values()
return Event(
kind="test_failures_batch",
payload={"failures": list(unique_failures), "count": len(unique_failures)},
)
内容合并的好处:Agent 一次拿到完整失败列表,可以批量分析、统筹修复,比逐个触发效率高得多。
5.8.4 合并 vs 去重:何时用哪个
| 维度 | 去重 | 合并 |
|---|---|---|
| 目标 | 同事件只处理一次 | 多事件合为一次 |
| 信息保留 | 丢弃重复 | 保留全部信息(合并) |
| 延迟 | 无 | 有(等窗口或平息) |
| 适用 | 重试风暴 | 高频事件流 |
| 副作用 | 可能漏处理 | 可能延迟处理 |
💡 Tip:去重是"丢",合并是"等"。如果你的事件是同一事件的多次重试,用去重;如果是相关事件的高频流,用合并。两者可以叠加——先去重完全重复的,再合并剩余的相关事件。
5.8.5 反馈回路切断
最关键也最容易被忽略的:Loop 自己的输出不能触发 Loop 自己。
老张事故的根因就是:Agent push commit → 触发 Webhook → 触发 Agent。这是一个反馈回路。
切断方式:
-
自触发过滤:触发器收到事件时,检查事件来源是不是自己
def is_self_triggered(event): sender = event.payload.get("sender", {}) return sender.get("login") == BOT_USERNAME -
元数据标记:Agent 的输出带标记"这是 Loop 产出",触发器识别后跳过
# Agent commit 带 [skip-ci] 标记 git commit -m "fix: ... [skip-ci]" -
冷却时间:Loop 跑完后强制冷却 5 分钟,期间忽略同源触发
金句:Loop 风暴不是 Agent 的失控,是触发器的失控——一颗心脏不需要跳得更快,它需要不被电击两次。
5.9 触发器反模式:5 个常见陷阱
我亲历或旁观过很多 Loop 触发器事故,下面是 5 个最有教育意义的反模式。
5.9.1 反模式 1:Webhook 直连 Agent
症状:Webhook 入口直接调用 Agent 执行,没有队列、没有去重、没有限流。
事故:GitHub Webhook 5 秒超时,Agent 跑不完被截断,GitHub 重试,再截断,再重试。15 分钟烧了 50 美金 Token,没产出任何东西。
修复:Webhook 入口只入队,立即返回 202。Agent 异步消费队列。
┌────────────────────────────────────────────────────────────────┐
│ 错误模式 │ 正确模式 │
├────────────────────────────────┼────────────────────────────────┤
│ Webhook ──► Agent │ Webhook ──► Queue ──► Agent │
│ (同步, 5s 超时) │ (异步, 202 立即返回)│
└────────────────────────────────┴────────────────────────────────┘
5.9.2 反模式 2:cron 写 * * * * *
症状:想要"高频响应",写成每分钟触发。
事故:每天 1440 次触发,每次都启动 Agent。Token 账单一周烧了 2000 美金,发现 90% 的触发是"无事可做"。
修复:高频响应用 Push 模式。低频检查才用 cron,且节律匹配业务真实需求。
5.9.3 反模式 3:无去重的 Webhook 入口
症状:相信 Webhook 不会重复,不做去重。
事故:源系统升级,重试逻辑变了,同一事件被推送 3 次。Loop 跑了 3 次,每次都创建了 PR,最后有 3 个内容相同的 PR 在仓库里。
修复:所有 Webhook 入口必须做事件 ID 去重。这是不可妥协的。
5.9.4 反模式 4:自触发未切断
症状:Agent 输出触发 Loop 自己。
事故:就是引子里老张的事故。Agent push commit 触发 Webhook 触发 Agent,17 分钟 142 次触发。
修复:5.8.5 节的三种切断方式,至少用一种。我建议三种都用——纵深防御。
5.9.5 反模式 5:水位线推进到时间戳
症状:Pull 模式用时间戳做水位线。
事故:源系统时间戳精度是秒。同秒内多个事件,下次 poll 时 since=last_timestamp 漏掉同秒内 timestamp 等于 last_timestamp 的事件。问题积累一周后,发现少处理了 200 个 Issue。
修复:水位线推进到严格单调的量(自增 ID、Kafka offset)。如果实在没有单调量,用 since > last_timestamp 而不是 >=,并接受同秒漏检的风险。
5.9.6 反模式汇总表
| 反模式 | 失败模式 | 修复 |
|---|---|---|
| Webhook 直连 Agent | 超时截断+重试风暴 | 入队异步处理 |
cron * * * * * |
Token 烧爆 | 用 Push 替代高频 cron |
| 无去重 | 重复创建副作用 | 事件 ID 去重 |
| 自触发未切断 | Loop 风暴 | 自触发过滤+冷却 |
| 水位线用时间戳 | 漏检/重复 | 严格单调量做水位 |
💡 Tip:每加一个触发器,对照这张表自查 5 项。这不是形式主义——我见过 80% 的触发器事故都落在这 5 项里。剩下 20% 是奇特的边界情况,但只要你避开了这 5 项,你的 Loop 至少不会成为"凌晨三点被叫醒"的那个。
5.10 全章小结
让我们用金句串烧的方式回顾这一章。
起搏器失灵,心脏再好也是死的。 Loop 的能力上限,往往不在执行端,而在触发端。一个再强的 Agent,被一个混乱的触发器驱动,只会更高效地制造混乱。这一章我们从起搏器隐喻出发,把"触发"从工程直觉里抽出来变成可分析的子系统。
Cron 关心"做了没",Loop 关心"成了没"。 这是定时触发和目标驱动的本质区别。cron 是闹钟,到点就响;Loop 是目标,达成才停。在 Loop 工程里,cron 只是"启动 Loop 的闹钟",Loop 自己负责迭代到达成。
触发器不是 Loop 的入口,触发器本身就是 Loop 的一部分。 它定义了系统对世界的敏感度——什么事件值得响应、什么事件该忽略、什么事件该合并。一个对万物都敏感的系统等于对万物都不敏感;一个有节律的系统才有稳定输出。
Pull 模式问"有事吗",Push 模式说"事来了",Goal-Driven 模式问"我离目标还有多远"。三种问法,三种智能层次。 成熟的 Loop 系统三种共存:Pull 兜底、Push 主通道、Goal-Driven 处理长程收敛。
幂等不是数学性质,是工程契约。 同一个任务被触发两次,世界应该和触发一次一样。这是 Loop 在重试、抖动、并发下保持稳定的根本。最低 Layer 2(请求 ID 去重),理想 Layer 3(操作可重放),最强 Layer 4(全局状态机)。
Loop 风暴不是 Agent 的失控,是触发器的失控。 一颗心脏不需要跳得更快,它需要不被电击两次。去重是丢掉重复电击,合并是把多次电击变成一次,自触发切断是把心脏自己的输出从输入里隔离开。
我们还讨论了几个工程取舍:
- 节律设计:避开整点尖峰、错峰依赖、匹配任务语义、贴合人类作息双峰
- 优先级 vs 公平:5 级优先级上限,加权公平队列防饥饿
- 去重 vs 合并:丢 vs 等,可以叠加
- 三种触发模式:Pull 兜底+Push 主通道+Goal-Driven 长程收敛
最后是 5 个反模式:Webhook 直连 Agent、cron * * * * *、无去重、自触发未切断、水位线用时间戳。避开这 5 个,你的 Loop 至少不会成为事故主角。
记住一个隐喻:起搏器是 Loop 的窦房结,不是 Loop 的电门。 它不是开关,是节律源头。设计它时,不要想着"怎么让 Loop 跑起来",要想"怎么让 Loop 跑得稳、跑得久、跑得有节律"。
心脏跳一辈子,不靠跳得快,靠跳得稳。
番外篇:操作系统调度器 vs Loop 调度器——从 CFS 到 Agent 调度
讲完 Loop 触发器,让我们把镜头拉远一点,看看另一个领域里"调度"是怎么做的——操作系统的进程调度器。这是一个跨越 60 年的工程领域,沉淀了无数智慧。我常觉得,Loop 调度器的设计者如果不学一学 OS 调度器,就像一个不学解剖学的心脏外科医生——能动手,但不知道为什么这么动。
番外.1 调度的本质:谁该跑,跑多久
操作系统调度器的核心问题是:有限 CPU 上有 N 个进程想跑,谁先跑?跑多久?什么时候切换?
这个问题和 Loop 调度器面对的问题同构:有限 Agent 容量上有 N 个任务想跑,谁先跑?跑多久?什么时候切换?
OS 调度器回答这个问题走了 60 年,从 FIFO 到 Round-Robin 到多级反馈队列到 CFS(Completely Fair Scheduler)再到 EEVDF(Earliest Eligible Virtual Deadline First)。每一步都对应着一个工程教训,而这些教训几乎全部可以平移到 Loop 工程。
番外.2 第一代:FIFO 与它的失败
最早的 OS 调度器是 FIFO——先来先服务。这和我们 5.6.2 节讲的纯 FIFO 队列一模一样。
FIFO 的失败模式也一模一样:** convoy effect**(护送效应)。一个慢任务堵住后面所有任务,就像公路上一辆慢车堵住一整队快车。
OS 工程师很快发现:FIFO 公平但不效率。"公平"是按到达顺序,"效率"是按系统吞吐量,两者在异构任务下冲突。
Loop 工程师也走过同样的弯路。早期 Loop 队列都是 FIFO,结果一个长跑的"代码质量扫描"任务堵住了所有"修 CI 失败"的紧急任务,开发者等着 CI 修复结果,等到下班。
OS 教训:异构任务下,FIFO 是错的。 Loop 教训:一样。
番外.3 第二代:Round-Robin 与时间片
第二代是 Round-Robin——每个任务跑一个时间片(time slice),到点让出 CPU,排到队尾。
Round-Robin 的关键参数是时间片长度:
- 太长(10s)→ 接近 FIFO,convoy effect 又回来
- 太短(1ms)→ 上下文切换开销占比过高
Linux 早期的时间片是 10ms,经过几十年演进稳定在 ~1ms-10ms 之间。
Loop 调度器的对应物是什么?是单次 beat 的最长执行时间——超时强杀。
HappyClaw 的 CONTAINER_TIMEOUT 默认 30 分钟,这相当于 Loop 的"时间片"。30 分钟够 Agent 跑一个完整的子任务,又不会长到失控。
Round-Robin 的教训:时间片要匹配任务粒度,太长失公平,太短失效率。 Loop 的时间片设计也是一样——你的 Agent 跑一个子任务多久,你的 timeout 就该设多大。
番外.4 第三代:多级反馈队列(MLFQ)
第三代是多级反馈队列(MLFQ)。这是 1960 年代 CTSS 系统首次提出的方案,统治了 OS 调度 30 年。
MLFQ 的核心思想是多优先级队列 + 动态降级:
- 新任务进最高优先级队列
- 用完时间片未完成 → 降一级
- 频繁阻塞(I/O 密集)→ 升一级(奖励"短任务"行为)
- 低优先级队列用更长的时间片
MLFQ 解决了 Round-Robin 的两个问题:
- 短任务自然高优(新任务进高优队列,跑完就离开)
- I/O 密集任务自然高优(频繁阻塞被升回去)
这对应到 Loop 工程是什么?是自适应优先级——任务刚来时给高优先级(假设是短任务),跑久了降级(识别为长任务),频繁阻塞(等外部资源)的保持高优(响应性需求高)。
┌──────────────────────────────────────────────────────────────────┐
│ MLFQ 在 Loop 调度上的应用 │
│ │
│ Queue 0 (高优, 时间片 1min): 新来的"修 CI"任务 │
│ Queue 1 (中优, 时间片 5min): 跑过 1 次未完成的 │
│ Queue 2 (低优, 时间片 30min): 跑过 3 次的长任务 │
│ Queue 3 (背景, 时间片 2h): 周期扫描类 │
│ │
│ 规则: │
│ - 新任务进 Q0 │
│ - 用完时间片未完成 → 降级 │
│ - 频繁等外部 API → 升级(I/O 密集) │
│ - Q0 非空时,Q1/Q2/Q3 不跑 │
└──────────────────────────────────────────────────────────────────┘
这是 OS 调度器 60 年智慧在 Loop 上的直接应用。它的精妙在于让任务自己暴露特性——你不需要预先判断任务是长是短,跑跑就知道了。
番外.5 第四代:CFS——完全公平调度
2007 年,Linux 2.6.23 引入 CFS(Completely Fair Scheduler),颠覆了 MLFQ 的统治。
CFS 的核心思想是:不搞优先级队列,给每个任务一个"虚拟运行时间"(vruntime),谁 vruntime 最小谁跑。
vruntime = 实际运行时间 × (nice 值权重)
- nice 值低(高优先级)→ vruntime 增长慢 → 更容易被选中
- nice 值高(低优先级)→ vruntime 增长快 → 容易被让出
CFS 用一棵红黑树维护所有任务的 vruntime,最左节点(vruntime 最小)就是下一个该跑的。
CFS 的优雅之处:
- 公平可证明:理论上每个任务获得的 CPU 时间严格正比于其权重
- 无优先级反转:低优任务不会被饿死,只是慢
- 平滑:任务切换基于 vruntime 差距,不基于硬队列边界
对应到 Loop 工程?这就是我们 5.6.4 节讲的加权公平队列。Loop 调度器如果用 CFS 思想:
import heapq
class CFSLikeScheduler:
def __init__(self):
self.heap = [] # (vruntime, seq, task)
self.seq = 0
def add(self, task, weight=1.0):
# 新任务 vruntime = 当前最小 vruntime,避免饿死已有任务
initial_vr = self.heap[0][0] if self.heap else 0
heapq.heappush(self.heap, (initial_vr, self.seq, task))
self.seq += 1
def pick_next(self):
if not self.heap:
return None
vruntime, _, task = heapq.heappop(self.heap)
return task, vruntime
def add_back(self, task, vruntime, consumed_ms, weight):
# vruntime 增长 = 实际消耗 / 权重
new_vruntime = vruntime + consumed_ms / weight
heapq.heappush(self.heap, (new_vruntime, self.seq, task))
self.seq += 1
CFS 的智慧在于:放弃离散优先级,用连续量表达权重。 Loop 调度也可以这样——不用 P0/P1/P2/P3 的离散优先级,用 0.0-1.0 的连续权重,让公平性数学可证。
番外.6 第五代:EEVDF——延迟敏感的回归
2024 年,Linux 6.12 把 CFS 替换为 EEVDF(Earliest Eligible Virtual Deadline First)。原因:CFS 注重公平,但对延迟敏感的任务处理不好。
EEVDF 的核心加法是 virtual deadline——每个任务有一个虚拟截止时间,截止时间早的优先跑。
这对应到 Loop 工程是什么?是 SLA-aware 调度——任务带"必须在 X 时间内完成"的元数据,调度器按 deadline 优先级排序。
class EEVFDScheduler:
def pick_next(self):
eligible = [t for t in self.tasks if t.eligible_at <= now()]
if not eligible:
return None
# 在 eligible 任务中,按 virtual deadline 最早选
return min(eligible, key=lambda t: t.virtual_deadline)
Loop 工程的 SLA-aware 调度同样重要:
| 任务 | SLA | virtual_deadline |
|---|---|---|
| 修 CI 失败 | 15 分钟 | now + 15min |
| 修生产故障 | 5 分钟 | now + 5min |
| 文档补全 | 1 天 | now + 1day |
| 周期扫描 | 无 | infinity |
调度器优先跑 deadline 最近的,确保紧急任务的 SLA 不破。
番外.7 调度器的三难困境
OS 调度器 60 年的演进,本质上是在三个目标之间走钢丝:
公平性
/\
/ \
/ \
/ \
/ \
/ 调度 \
/ 器三 \
/ 难困境 \
/ \
/________________\
响应性 吞吐量
- 公平性:每个任务获得应有份额
- 响应性:紧急任务快速响应
- 吞吐量:系统总产出最大化
三者互相冲突。提高响应性(紧急任务插队)会牺牲公平性和吞吐量。提高公平性(严格按权重分配)会牺牲响应性。提高吞吐量(长任务跑完才切)会牺牲响应性和公平性。
OS 调度器 60 年的演进,本质是在不同硬件、不同负载、不同需求下,动态调整三者的权重。
Loop 调度器也面对同样的三难:
- 公平性:低优任务不能饿死
- 响应性:用户可见故障快速处理
- 吞吐量:Agent 实例利用率高
设计 Loop 调度器时,不要试图三者全得,要明确:你的业务最不能牺牲哪个?
- 用户可见的聊天 Loop → 响应性优先
- 后台 CI 修复 Loop → 吞吐量优先
- 多用户共享 Loop → 公平性优先
不同的业务,不同的调度器。不存在"通用最优"。
番外.8 OS 调度器给 Loop 调度器的五条遗训
60 年 OS 调度器演进,给 Loop 调度器的五条遗训:
遗训 1:异构任务下,FIFO 是错的。 任务时长差异越大,越需要优先级或公平队列。
遗训 2:时间片要匹配任务粒度。 太长失公平,太短失效率。Loop 的 timeout 就是它的时间片。
遗训 3:让任务自己暴露特性。 不要预先猜任务是长是短,跑跑就知道。MLFQ 的动态降级是这个思想的精髓。
遗训 4:连续权重优于离散优先级。 CFS 用 vruntime 替代优先级队列,得到数学可证的公平。Loop 调度也可以用连续权重。
遗训 5:延迟敏感任务需要 deadline。 CFS 注重公平但响应性不足,EEVDF 加上 deadline 才补全。Loop 调度器对紧急任务必须有 deadline 机制。
番外.9 Loop 调度器超越 OS 调度器的地方
但 Loop 调度器有一个 OS 调度器没有的维度:目标驱动。
OS 进程没有"目标",进程只是"想跑"。OS 调度器只决定"谁跑、跑多久",不关心"跑完没跑完是不是达成了什么"。
Loop 调度器需要关心——5.5 节讲的目标驱动触发,就是 Loop 调度器独有的维度。Loop 不只是分配资源让任务跑,还要判断目标是否达成,决定是否继续跑。
这是 Loop 调度器比 OS 调度器更高维的地方:它不只调度执行,还调度收敛。
┌──────────────────────────────────────────────────────────────────┐
│ OS 调度器 vs Loop 调度器 │
│ │
│ OS 调度器: 谁 × 何时 × 多久 = 三维 │
│ Loop 调度器: 谁 × 何时 × 多久 × 是否达成 = 四维 │
│ ↑ │
│ 目标驱动 │
│ Loop 独有 │
└──────────────────────────────────────────────────────────────────┘
OS 调度器研究"怎么公平地跑",Loop 调度器研究"怎么有节律地达成"。
这是为什么 Loop Engineering 是一个新范式,而不是 OS 调度的复用——它面对的是 OS 调度从未面对过的问题:让一组任务在持续迭代中收敛于一个目标态。
这也是为什么本章花这么多篇幅在"起搏器"上——它不只是调度器,它是 Loop 收敛性的源头。一个不会收敛的 Loop,调度再公平也没用;一个会收敛的 Loop,调度粗糙一点也能成事。
番外.10 一个未竟的命题
最后留一个未竟的命题给读者思考:
OS 调度器有 60 年的数学基础(排队论、调度理论),有标准化的 benchmark(调度延迟、吞吐量、公平性指数)。Loop 调度器作为新生事物,目前还没有成熟的数学基础和 benchmark。
我们今天讲的 Pull/Push/Goal-Driven、FIFO/优先级/加权、幂等/去重/合并,更多是工程经验,不是数学定理。
下一个 5 年,会不会有人把 Loop 调度器数学化?会不会出现 “Loop 调度延迟”、“Loop 收敛指数”、“Loop 公平性测度” 这样的标准度量?
我不知道。但我相信会。因为 OS 调度器走过这条路——从工程直觉到数学理论。Loop 调度器刚刚上路,这条路还在前方。
而这本书的读者,可能就是那个把 Loop 调度器数学化的人。
金句:OS 调度器研究"怎么公平地跑",Loop 调度器研究"怎么有节律地达成"。前者是工程的胜利,后者是范式的新生。
— 全章完 —
更多推荐
所有评论(0)