第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 工程里有三个不可替代的功能:

  1. 冷启动:Loop 进程没在跑,需要外部事件把它拉起来。比如 GitHub Webhook 推送新 Issue,把沉睡的 Loop 服务唤醒。
  2. 响应不可预测的外部事件:代码 push、用户提工单、监控告警——这些事件发生时刻不可预测,必须靠外部触发。
  3. 跨系统协调:上游系统(如 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 触发机制抽象到极致,只有三种:

  1. Pull(拉模式):Loop 主动去问"有事吗?"
  2. Push(推模式):外部主动告诉 Loop"事来了"
  3. 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 入口必须解决:

  1. 鉴权:怎么确认事件来自可信源?(HMAC 签名、IP 白名单、Token)
  2. 去重:同一个事件被推送两次怎么办?(事件 ID + LRU 缓存)
  3. 背压:下游处理不过来怎么办?(队列 + 丢弃策略)
  4. 重放:源重试时怎么处理?(幂等性)

举一个 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 输出是结构化的。

把开放目标工程化的标准流程是:

  1. 目标具象化:把"修复所有失败测试"具象为 failing_test_count == 0
  2. 状态可读化:定义一个函数能从真实世界读出当前值
  3. 差距量化:定义 gap = current - target
  4. 触发条件gap > 0 时触发,gap == 0 时停止
  5. 上限保护:最大迭代次数、最大耗时、最大 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 模式的具体实现。常见的三种通道:

  1. Webhook:HTTP 回调,源主动 POST 到你的端点
  2. 文件监听:文件系统变化触发(inotify / fswatch / chokidar)
  3. 消息队列: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 处理必须做四件事:

  1. 签名校验:拒绝伪造请求(前面 Python 例子里的 HMAC)
  2. 去重:同一个 delivery ID 只处理一次(LRU 缓存)
  3. 入队异步处理:webhook 入口只入队,不处理
  4. 限流:单个源的事件速率上限
// 限流:令牌桶
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 从队列里取任务执行。队列不是简单的缓冲,它承担三个工程职能:

  1. 节律缓冲:把瞬时尖峰摊平为可持续节律
  2. 优先级仲裁:高优任务先跑,低优任务后跑
  3. 背压传导:队列满了拒绝新任务,让上游减速

队列是 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) 阻塞直到队列有空。如果队列满,调用方挂起,整个上游链路雪崩。应该用 tryPushenqueueWithTimeout,超时就拒绝。

反模式 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 幂等性的代价

幂等性不是免费的。它的代价是:

  1. 存储开销:去重表、状态记录
  2. 延迟:每次都要查"是否处理过"
  3. 复杂度:状态机比直接执行复杂

工程取舍:

场景 是否需要幂等 代价可接受
高频触发+有副作用 必须
高频触发+无副作用 可选 看延迟要求
低频触发+有副作用 必须
低频触发+无副作用 不需要 -

💡 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。这是一个反馈回路。

切断方式:

  1. 自触发过滤:触发器收到事件时,检查事件来源是不是自己

    def is_self_triggered(event):
        sender = event.payload.get("sender", {})
        return sender.get("login") == BOT_USERNAME
    
  2. 元数据标记:Agent 的输出带标记"这是 Loop 产出",触发器识别后跳过

    # Agent commit 带 [skip-ci] 标记
    git commit -m "fix: ... [skip-ci]"
    
  3. 冷却时间: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 的两个问题:

  1. 短任务自然高优(新任务进高优队列,跑完就离开)
  2. 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 的优雅之处:

  1. 公平可证明:理论上每个任务获得的 CPU 时间严格正比于其权重
  2. 无优先级反转:低优任务不会被饿死,只是慢
  3. 平滑:任务切换基于 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 调度器研究"怎么有节律地达成"。前者是工程的胜利,后者是范式的新生。

— 全章完 —

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐