返回日报条目

资料节译

代理群体的出口策略:集中限流、缓存与去重

阅读原文

我审阅的大多数智能体代码,对沙箱内部的行为很谨慎,对离开沙箱时的行为却很随意。然后它遇到一个网站:凡是 User-Agent 中带有 python-httpx 的请求都返回 403,它便重试四十次,耗掉整个下午。

网络对智能体越来越不宽容。出版商直接屏蔽爬虫,CDN 会根据 TLS 和请求头进行指纹识别,越来越多的网站开始采取“要么付费使用 API,要么走开”的做法。这不是一时的风气——而是常态。如果你的智能体集群要从你不拥有的域名获取任何内容,出站流量就必须作为一个子系统来设计,而不是在工具里随手塞一个 client.get() 调用。

下面是从代理层看到的情况:哪些会出故障、该记录什么,以及该在智能体前面部署什么。

有两种智能体工作负载,在笔记本上看起来都没问题,但到了集群规模都会出故障:

**广度优先研究。**数千个智能体实例分别针对公共来源追查略有不同的查询。OpenAI 的生物数据工作是这种模式中广为人知的例子;规模化的难点在于去重,而不是计算。

**深度优先工具调用。**一个智能体围绕一项任务循环执行,每一步调用十个外部 API。规模化的难点在于,一个行为不当的循环就可能产生比整个测试套件还多的流量。

这两种情况下的故障都一样:每个智能体都有自己的 HTTP 客户端和重试策略,却没有共享的“我们已经向这个主机问过这个问题”的概念。每个智能体各自的速率限制器并不是真正的速率限制器,而是一个倍增器。

把出站流量集中到一个地方——边车、代理进程,或供智能体调用的单一服务——这样速率限制器、robots.txt 缓存和去重索引就能共享。用于智能体集群的沙箱运行环境(近期出现的 OpenShell 就是其中之一)通常会提供一个网络边界,你可以借此实现这一点。用好这个边界;不要让智能体直接连出网络。

拒绝请求的状态不全是 403;不是 403 的那些情况更棘手:

信号含义正确应对
403策略或指纹识别导致的拒绝停止。不要重试。升级交由人工处理。
429 +Retry-After明确的背压信号遵从请求头指示,然后恢复请求
200 并带有挑战页面软封锁,状态码逻辑无法发现检测到后按失败处理
连接重置 / TLS 错误指纹识别,或确实不稳定加入随机抖动重试两次,然后停止

软封锁最容易让人栽跟头。你的提取器返回零行,智能体得出“没有结果”的结论,然后对一个空页面写出信心十足的摘要。检查每个主机的解析产出量——如果某个主机过去每页会返回 40 个链接,现在却在状态码为 200 时返回 0 个,那就是封锁,而不是内容变了。

通过轮换 IP 或伪装浏览器 User-Agent 来绕过封锁,是一场你必输的军备竞赛,也会带来合同问题:你是在规避一种表达运营方意愿的技术措施。工程上的应对办法是购买 API、取得数据许可,或者弃用该来源。

Python 3.10+,httpx。这个实现刻意保持精简:一个类、一个共享缓存,遇到未知主机时默认拒绝。

import time
import urllib.robotparser
from collections import defaultdict
from urllib.parse import urlparse
import httpx
class EgressGate:
    """Shared politeness + robots.txt gate. One instance per fleet, not per agent."""
    def __init__(self, user_agent: str, min_interval_s: float = 1.0):
        self.ua = user_agent
        self.min_interval = min_interval_s
        self._last_hit: dict[str, float] = defaultdict(float)
        self._robots: dict[str, tuple[float, urllib.robotparser.RobotFileParser]] = {}
        self._client = httpx.Client(
            headers={"User-Agent": user_agent},   # identify yourself, always
            timeout=httpx.Timeout(10.0, connect=5.0),
            follow_redirects=True,
        )
    def _robots_for(self, url: str) -> urllib.robotparser.RobotFileParser:
        host = urlparse(url).netloc
        cached = self._robots.get(host)
        # Cache for an hour. Re-fetching robots.txt per request is itself abuse.
        if cached and time.monotonic() - cached[0] < 3600:
            return cached[1]
        rp = urllib.robotparser.RobotFileParser()
        rp.set_url(f"https://{host}/robots.txt")
        try:
            rp.read()
        except Exception:
            rp.disallow_all = True  # fail closed: unknown host, stay out
        self._robots[host] = (time.monotonic(), rp)
        return rp
    def fetch(self, url: str) -> httpx.Response | None:
        host = urlparse(url).netloc
        if not self._robots_for(url).can_fetch(self.ua, url):
            return None  # policy skip, not an error - log it separately
        wait = self.min_interval - (time.monotonic() - self._last_hit[host])
        if wait > 0:
            time.sleep(wait)
        self._last_hit[host] = time.monotonic()
        for attempt in range(3):
            resp = self._client.get(url)
            if resp.status_code == 429:
                # Retry-After is sometimes an HTTP date; parse defensively in prod.
                delay = float(resp.headers.get("Retry-After", 2 ** attempt))
                time.sleep(min(delay, 60.0))
                continue
            break
        if resp.status_code == 403:
            raise PermissionError(f"{host} refused agent traffic")
        return resp

有两点值得指出。min_interval 是每个主机的全局间隔下限,因此两个智能体同时请求同一域名时会排队等待。触发 403 时抛出异常是刻意为之:403 应该向上传递并终止子任务,而不是被三层调用栈下面的 try/except: pass 吞掉。

如果你要让一千个智能体针对固定语料库运行,最省钱的优化就是不要重复请求。对 (method, url, normalized_query) 元组进行哈希,在共享存储中通过原子 SETNX 抢占任务;未抢到的请求等待抢到者的结果,而不是重新推导。如果没有这一步,故障场面会很难看:2,000 个智能体各自发现相同的 40 个页面,接着 2,000 个礼貌的速率限制器全都以每秒一次请求的速度猛敲同一个主机。

按 URL 和内容哈希为抓取到的载荷建立索引,并根据来源内容的易变程度设置 TTL。不要存储你没有权利保留的正文——你的网关应能按主机强制执行这项策略,而不是把它留给提取时自行判断。

每次抓取都记录:主机、状态码、字节数、延迟、策略决策(allowed / robots_skip / blocked)、去重键,以及发起请求的智能体 ID。然后监控两个比率:每个主机的策略跳过率,以及每个主机的解析产出量。两者同时异常就是软封锁的典型信号。这两项都不会显示在状态码仪表板上。

保留智能体 ID。有人问你的集群为什么访问了他们的网站 8,000 次时,“哪个智能体、执行什么操作、属于哪项任务”是唯一能结束这场对话的答案。

**在请求路径中获取 robots.txt。**你把一次请求变成了两次,还给每次请求增加了对某个主机的依赖,而该主机可能会限制请求速率。

**每个智能体进程各自配置速率限制器。**十个智能体,十个限制器,达到预期速率的十倍。

**重试 403。**它不会变成 200,而且你现在把自己是个麻烦的信号加倍放大了。

**将 200 视为成功。**挑战页面、同意提示页和 JS 外壳都会返回 200,但没有内容。

**忽略 Retry-After,并使用固定的 1s 退避。**固定退避会让你的重试同步成惊群;应加入随机抖动。

**把轮换出站 IP 当作解决办法。**它能管用一周,却会把速率限制问题变成法律问题。

**所有请求都使用同一个 User-Agent 字符串。**你将无法解释自己的流量,也无法申请加入允许名单。