Agent规模化运行,先设计出站访问策略
文章将Agent外部访问视为独立子系统,讨论站点封禁、请求指纹和无效重试带来的工程问题。研究型批量任务需要处理重复抓取,连续工具调用则需要统一的访问策略与日志。
AI 深度解读
Agent 在沙箱里守规矩,不代表它访问外部网站时也有分寸。原文讨论的是一个容易被低估的问题:当数千个 Agent 同时检索资料、调用 API,网络请求必须有统一出口,否则各自合理的限流和重试,叠加后就可能变成流量事故。
作者的结论是:把出口策略做成共享基础设施,让访问许可、限流、去重和日志先于请求执行。这个方向值得采用,但文中的 Python 示例只是起点,尤其不能直接相信它已经解决了并发问题。
为什么每个 Agent 都限流,网站还是会被打爆
作者区分了两类工作负载。
一类是广度优先研究:数千个 Agent 围绕相近问题访问公开资料。原文以 OpenAI 的生物数据工作为例,认为主要扩展难题在于去重。另一类是深度优先工具调用:单个 Agent 每一步调用 10 个外部 API,一个失控循环就能制造大量流量。
它们的问题相同:HTTP 客户端、重试规则和请求记录各自独立,谁也不知道其他 Agent 已经访问过什么。
每个 Agent 的限流器,限制不了整个集群的访问总量。
10 个 Agent 各配一个限流器,总速率可能就是预期的 10 倍。作者还举了一个放大场景:2,000 个 Agent 分别发现相同的 40 个页面,即便每个实例都保持每秒一次,也会集中冲向同一个站点。
因此,出口可以放在 sidecar、代理进程或共享服务中,但限流状态、robots.txt 缓存和去重索引必须共享。原文提到 OpenShell 等沙箱运行时提供了可承接策略的网络边界。部署了代理还不够,Agent 也不能绕过它直接联网。
返回 200,为什么还可能是访问失败
作者描述过一种典型故障:网站对带有 python-httpx User-Agent 的请求返回 403,调用方却重试 40 次,最后耗掉一个下午。原文没有提供完整实验记录,这更像作者观察到的问题案例。
更隐蔽的是软封锁:网站返回 200,内容却是验证页、授权提示或没有实际数据的 JavaScript 页面。提取器得到空结果,Agent 再据此生成一份语气肯定的摘要。
因此,成功条件不能停在状态码。作者建议按站点监控解析产出率(parse yield),也就是一页实际提取出多少有效内容。如果过去每页能提取 40 条链接,现在突然变成 0,即便状态码还是 200,也应怀疑访问被拦截。
这里需要留一个判断余地:页面改版、解析器失效也会造成相同现象。产出下降应触发排查,不能单凭这个指标就认定站点封锁。
作者主张遇到明确拒绝后,选择购买 API、取得数据授权或放弃来源,不把轮换 IP、伪装浏览器 User-Agent 当成长久方案。这包含工程判断和访问边界立场,并没有对照数据证明每种替代方案的成本。
统一出口要管许可,也要管重复请求
原文给出的最小实现基于 Python 3.10+ 和 httpx。初始化部分明确身份、设置请求超时,并保存按主机划分的状态:
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,
)
要看的是 min_interval_s=1.0 和 httpx.Timeout(10.0, connect=5.0):默认按主机保留一秒访问间隔,请求超时参数为 10 秒,连接超时为 5 秒。
完整示例用 _robots_for 缓存每个主机的 robots.txt,有效期为 3600 秒;读取抛出异常时设置 disallow_all=True,采用失败关闭(fail-closed)策略,也就是规则无法确认时先拒绝访问。每次请求都重新读取规则,会增加流量和故障依赖,缓存有实际价值。
下面的方法把规则检查、限流和状态处理串起来:
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
这里最明确的策略是最后的 raise PermissionError:403 应结束当前子任务,不能被下层的异常吞掉后继续重试。429 最多尝试 3 次,缺少 Retry-After 时使用 2 ** attempt,单次等待上限为 60 秒。生产实现还需支持日期形式的 Retry-After,并加入随机抖动,避免大量请求同时醒来。
减少流量还要靠请求前去重。作者建议对 (method, url, normalized_query) 计算哈希,通过共享存储的原子 SETNX 抢占任务,其他请求等待执行者的结果。响应按 URL 和内容哈希保存,TTL 随来源更新频率调整;能否留存正文,则由按站点配置的策略决定。
日志也应记录决策。每次访问保留 host、status、bytes、latency、allowed / robots_skip / blocked、去重键和 agent ID,并监控各站点的策略跳过率与解析产出率。当有人追问为何被访问了 8,000 次时,至少能定位到 Agent 和任务。
我的判断
我赞成把出口提升为独立子系统。它同时处理流量放大、重复成本和结果可信度,比给每个工具函数补几次退避更完整。只有单个脚本时,共享服务可能过重,但统一请求入口仍然有用。
示例表达了正确的策略意图,却没有实现完整的并发保证。
作者声称同域名的两个 Agent 会排队,但 _last_hit 的读取、等待和更新没有锁,也没有队列。并发调用可能同时通过检查;多进程、多个副本更不会自动共享这个字典。真正的全局限流还需要串行调度或原子协调。
另外,follow_redirects=True 会自动跟随跳转,示例没有展示跳转到新主机后的策略复查。robots.txt 读取也没有走这里配置的 httpx.Client,不能据此假定它使用相同身份和超时设置。
原文没有吞吐、成本或错误率的对照数据,因此应把它看作设计经验和最小草图。结尾清单也被截断,最后还混入一条举报提示,不能据此补出完整验收标准。
能拿来做什么
- 给 Node 工具服务或 AI 检索助手收拢请求入口,先按目标主机共享限流状态,再确认多个进程和服务副本是否真正共用额度。
- 将 403、429 和解析为空分别处理:403 终止相关子任务,429 尊重
Retry-After,200 但无有效内容时暂停摘要生成并检查响应。 - 对固定资料集的批量研究,在任务分发前建立原子去重和结果复用机制,同时明确缓存 TTL、正文留存权限以及执行者失败后的接管方式。
- 为抓取日志补上 agent ID、任务关联和策略决策,让一次异常访问既能追到请求来源,也能解释为什么被允许或拒绝。