大多数抓取方案只在设计阶段选择一次代理配置,然后就一直沿用。对这个网站用带城市定位的住宅代理,对那个网站用ISP代理,行得通的地方就用数据中心代理。这种选择通常是花一个下午试几个选项就定下来的,而且很少被重新审视,直到出问题为止。
这其实是一种在不确定性下做出的决策,一天之内重复发生成千上万次,每次请求之后都会有反馈。这正是统计学家所说的多臂老虎机问题的典型形态,而针对这类问题已经有了成熟的算法。本指南将解释这一框架,给出一个经过测试的简短实现,并展示在一次模拟中(最佳选项在中途发生变化)该方法的表现。
关键要点
- 对于某个目标,你可以使用的每种代理配置都是一个”臂”;每次请求是一次”拉动”;一个良好的响应是一个”奖励”。目标是让每个良好响应的成本最低,而不是在任何代价下追求最高的成功率。
- 汤普森采样(Thompson sampling)能够处理”使用已知最佳选项”与”测试其他选项”之间的权衡,只需几行代码,不需要调参计划。
- 目标会发生变化,因此旧的证据应当逐渐淡化。折扣因子让选择器大致记住最近一千次结果。
- 在我们的模拟中,带折扣的汤普森采样与提前知道真实比率的策略相比,差距在2%以内,而固定的”安全”选择每个良好响应的成本高出25%,坚持早期赢家的做法成本高出57%。
- 只将真正良好的响应计为成功,并限制敏感目标所能看到的探索量。
框架
想象一排老虎机,每台的中奖概率未知。每次拉动都能让你了解到关于一台机器的信息,而花在一台表现不佳的机器上的每次拉动,就是没有花在表现好的机器上的一次拉动。这种在”利用已知信息”和”探索未知信息”之间的张力,就是多臂老虎机问题。
对于代理选择而言,这种映射是直接的:
| 老虎机术语 | 在抓取中的对应 |
|---|---|
| 臂 | 针对某一目标的一种代理配置:代理类型、定位方式、会话设置 |
| 拉动 | 一次请求 |
| 奖励 | 包含你想要的数据的响应 |
| 一次拉动的成本 | 带宽、重试次数以及该请求花费的时间 |
| 非平稳性 | 目标更改了防御措施,或某个代理池的信誉发生了变化 |
有两个特点使得抓取场景中的版本比教科书中的版本更复杂。各个臂的成本不同,因此最佳的臂是每次成功成本最低的那个,而不是成功率最高的那个。而且比率是会变动的:今天有效的配置,明天可能就被封锁,这正是子网连带效应背后的模式,也是构建目标健康度评分的原因。
选择器
汤普森采样为每个臂的成功率维护一个概率分布,这是一个由成功和失败次数构建的Beta分布。在每次请求之前,它为每个臂抽取一个可能的比率,并选择在这些抽样下每次成功成本看起来最低的那个臂。证据较少的臂分布较宽,因此偶尔会抽到一个较高的比率而被尝试。证据较多的臂抽样会接近其真实比率。随着证据的积累,探索会自然而然地减少。
下面的版本增加了两点:每次尝试的成本,以及一个让旧证据向先验衰减的折扣因子,从而让选择器能够察觉到变化。
import random
class ProxySelector:
"""Pick a proxy configuration per request with Thompson sampling, minimising cost per successful response.
Each configuration keeps a Beta(successes, failures) belief about its success rate. A discount below 1
lets old outcomes fade, so the selector notices when a configuration that used to work starts failing.
"""
def __init__(self, costs, discount=0.999, prior=(1.0, 1.0), rng=None):
self.costs = dict(costs) # configuration -> cost per attempt
self.discount = discount
self.prior = prior
self.wins = {arm: prior[0] for arm in self.costs}
self.losses = {arm: prior[1] for arm in self.costs}
self.rng = rng or random.Random()
def choose(self):
"""Sample a plausible success rate for each configuration and take the cheapest per expected success."""
def sampled_cost_per_success(arm):
rate = self.rng.betavariate(self.wins[arm], self.losses[arm])
return self.costs[arm] / max(rate, 1e-9)
return min(self.costs, key=sampled_cost_per_success)
def update(self, arm, success):
"""Record one outcome. Every belief decays towards the prior first, so recent results count most."""
for a in self.costs:
self.wins[a] = self.prior[0] + self.discount * (self.wins[a] - self.prior[0])
self.losses[a] = self.prior[1] + self.discount * (self.losses[a] - self.prior[1])
if success:
self.wins[arm] += 1
else:
self.losses[arm] += 1
在使用时,每次请求前调用choose(),通过所选配置发送请求,然后调用update()并传入该响应是否良好。折扣因子为0.999时,等效记忆大约为一千次结果;0.995时大约为两百次。
模拟
为了观察当答案发生变化时该选择器的表现,我们模拟了一个目标,配有四种配置。下面的成功率和成本是为了让权衡关系清晰可见而设定的假设值,并非对任何提供商或网络的实际测量:
| 配置 | 假设成功率 | 假设每次尝试成本 | 每次成功的成本 |
|---|---|---|---|
| 数据中心 | 25% | 0.5 | 2.00 |
| ISP | 80%,在第5,000次请求后降至30% | 0.9 | 1.13,之后为3.00 |
| 住宅,国家定位 | 92% | 1.3 | 1.41 |
| 住宅,城市定位 | 95% | 1.6 | 1.68 |
成本是涵盖带宽和尝试开销的相对单位。在前5,000次请求中,ISP是获得良好响应最便宜的方式;之后该目标开始封锁它,带国家定位的住宅代理成为最佳选择。每种策略各进行了20,000次请求,并且我们用100个不同的随机种子对每种策略重复了实验。
| 策略 | 每个良好响应的成本 | 相对于完美事后诸葛亮 | 良好响应数 | 变化后适应所需的请求数 |
|---|---|---|---|---|
| 完美事后诸葛亮(知道真实比率) | 1.348 | 基准 | 17,806 | 0 |
| 汤普森采样,折扣0.999 | 1.375 | +2.0% | 16,694 | 约800 |
| 汤普森采样,折扣0.995 | 1.389 | +3.0% | 15,704 | 约1,075 |
| 汤普森采样,无折扣 | 1.428 | +5.9% | 15,933 | 约3,500 |
| Epsilon-greedy,10%探索 | 1.441 | +6.9% | 15,848 | 约2,800 |
| 汤普森采样,折扣0.98 | 1.443 | +7.0% | 13,834 | 从未稳定 |
| 始终使用住宅,城市定位 | 1.684 | +24.9% | 19,000 | 不适用 |
| 在四种配置间轮询 | 1.689 | +25.3% | 12,731 | 不适用 |
| 始终使用ISP,早期的赢家 | 2.118 | +57.1% | 8,501 | 不适用 |
数据为100次运行的平均值;对于每种策略,90%的运行结果都落在其平均值的2.5%以内。“适应所需的请求数”是指变化发生后,直到一个200次请求的窗口中有90%流量转向新的最佳配置所需的请求数中位数。
结果说明了什么
**固定选择在两个方向上都代价高昂。**始终使用最可靠的配置带来了最多的良好响应,但每个响应的成本高出25%。一旦目标发生变化,始终使用早期获胜配置的策略是所有策略中最差的,比基准高出57%。
**仅靠学习而不遗忘是不够的。**未加折扣的汤普森采样在变化发生后花了大约3,500次请求才停止信任它为旧赢家积累的证据。折扣因子为0.999将这一数字降到了大约800。
**遗忘得太快本身也是个问题。**在折扣因子为0.98时,选择器只记住了大约50次结果,不断重新测试表现较差的选项,从未稳定下来。有用的范围取决于流量:记忆应当覆盖足够多的请求以区分各配置,但不应超出这个范围。
**目标决定了答案。**该老虎机算法的目标是最小化每个良好响应的成本。如果重要的是不计成本获取最多数据,或者有截止时间限制,奖励机制就必须体现这一点;否则选择器会非常高效地优化错误的目标。
在真实流量中使用
- **严格定义成功。**带有封锁页面或空结果的HTTP 200响应是失败。将你用于衡量静默失败率的相同检查方式提供给选择器,否则它会学会偏爱那些悄无声息失败的配置。
- **每个目标运行一个选择器。**不同网站的成功率各不相同,共享一个选择器会把你希望它学到的差异平均掉。
- **限制敏感目标上的探索量。**每一次通过表现不佳的配置发出的探索性请求,都可能被网站记在你头上。对于明显不适用的配置,应当直接移除,而不是让选择器重新去发现这一点。
- **保持会话的一致性。**对于依赖状态的任何事务,比如登录或购物车,应按会话而不是按请求来选择配置,正如粘性会话与轮换会话中所解释的那样。
- **使用真实成本。**在按带宽计费的代理上,每次尝试的成本取决于页面大小和重试次数;每条干净记录的成本背后的数字才是正确的输入。
- **保留负载均衡的底线。**老虎机算法决定使用哪种配置,而不是如何在该配置内分配请求;这仍然是轮换、并发和重试架构的职责。
底线
只选择一次代理配置并一直沿用,等于赌你的成本和目标都不会发生变化。将每次请求都当作一次小型实验,配合汤普森采样和合理的记忆机制,就能把这个赌注变成一个持续更新的测量过程。
在我们的模拟中,这种方法与提前知道答案相比差距在2%以内,在最佳选项发生变化后大约800次请求内就能完成适应,并且避免了固定策略25%到57%的溢价成本。文中的比率和成本都是假设值;而代码足够简短,你可以用自己的数据来运行它。
来源与参考文献
- Daniel Russo及其同事,A Tutorial on Thompson Sampling,2017年。
- 模拟由Shifter于2026年10月2日使用上述代码运行;成功率和成本均为假设值,并非对任何网络的实际测量。