接 SERP API 跑 Agent,第一个版本能 demo,第二个版本能上线,但从 demo 到生产之间,最难的是稳定性。上游抖动、网络异常、限流、模型发起多次相同调用,每一个点都可能让你的服务出问题。
这篇文章把我踩过的坑整理一下:怎么设计超时和重试,怎么处理上游错误体,怎么在搜索失败时让 Agent 不至于崩。下面的例子都以 serpbase 的接口为例。
客户端要扛住的第一件事:超时
Google 这类上游偶发超时很正常,一次调用设个 8s 大概够用。但要分开两层来设:
- 客户端到 serpbase 的 HTTP 超时(
requests的timeout参数) - 整个 Agent tool call 的整体超时(防止一个搜索拖垮整个对话)
我一般这样写:
python
import requests
HTTP_TIMEOUT = 8 # 单次 HTTP 调用
TOTAL_TIMEOUT = 15 # 含重试的整体预算
def serp_search(query, hl="en", gl="us"):
return requests.post(
"https://api.serpbase.dev/google/search",
json={"q": query, "hl": hl, "gl": gl, "page": 1, "device": "default"},
headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
timeout=HTTP_TIMEOUT,
).json()
TOTAL_TIMEOUT 这层用 signal.alarm 或者 asyncio.wait_for 都可以,看你跑在什么 runtime 里。
重试不是越多越好
重试要回答三个问题:
- 哪些状态码要重试?
- 重试几次?
- 退避怎么算?
我的经验是:
- 重试对象:网络超时、429、5xx
- 不重试:4xx 业务错(
status=401这种,瞎重试只是浪费额度) - 次数:2 次够了
- 退避:指数
0.5s, 1.5s,再随机抖动一下避免雪崩
python
import random
import time
import requests
RETRY_STATUS = {429, 500, 502, 503, 504}
def with_retry(fn, max_retry=2):
for i in range(max_retry + 1):
try:
r = fn()
except (requests.Timeout, requests.ConnectionError):
if i == max_retry:
raise
time.sleep(0.5 * (2 ** i) + random.uniform(0, 0.2))
continue
if r.status_code in RETRY_STATUS and i < max_retry:
time.sleep(0.5 * (2 ** i) + random.uniform(0, 0.2))
continue
return r
return r
业务失败 vs 网络失败
serpbase 的失败响应也是 JSON,成功外壳和失败外壳字段一样,只是 status 非 0,并且多了 error_code、message。客户端要分开处理这两类错:
python
def call_serp(query):
r = with_retry(lambda: requests.post(
"https://api.serpbase.dev/google/search",
json={"q": query, "hl": "en", "gl": "us", "page": 1, "device": "default"},
headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
timeout=HTTP_TIMEOUT,
))
body = r.json()
if body.get("status") != 0:
raise SerpBaseBizError(body.get("error_code"), body.get("message"), body.get("request_id"))
return body
业务错要落日志,request_id 必须打进去------找客服或者自己排查都靠它。监控告警也要分开:
- 网络失败率:看 HTTP 状态码
- 业务失败率:看
status != 0的占比
这两类错的原因不同,处理方式也不同,别混在一起。
并发控制
SERP API 一般有 QPS 限制,多用户同时触发容易打爆。生产里加一个信号量:
python
import asyncio
sem = asyncio.Semaphore(5) # 最多 5 个并发
async def guarded_search(query):
async with sem:
return await call_serp_async(query)
Semaphore(5) 不是越大越好,要看你的套餐 QPS 限制。如果不确定,先从 3 开始,观察一周的 P95 延迟和成功率,再调大。
缓存
模型爱"确认性检索",同一个 session 里可能搜两三次同样的东西。给搜索加一层缓存能省不少钱:
python
import hashlib
import json
from cachetools import TTLCache
cache = TTLCache(maxsize=2000, ttl=600) # 10 分钟
def cached_serp_search(query, hl, gl, page):
key = hashlib.sha256(
json.dumps({"q": query, "hl": hl, "gl": gl, "p": page}, sort_keys=True).encode()
).hexdigest()[:16]
if key in cache:
return cache[key]
result = call_serp(query, hl, gl, page)
cache[key] = result
return result
新闻类查询 TTL 给 5--10 分钟,事实类查询可以给 30 分钟。top_stories 变化快,缓存要短。
失败降级
最关键的一点:搜索失败的时候,Agent 怎么办?
我现在的做法是三层降级:
- 重试 2 次后还是失败 → 返回结构化错误,模型收到错误信息
- 模型收到错误 → 改为基于已有知识回答,并明确告诉用户"以下信息未实时核实"
- 如果是降级回答 → 在 UI 上加个标识,让用户知道这是"猜测"
python
def agent_with_fallback(user_query):
try:
results = cached_serp_search(user_query, "en", "us", 1)
except (SerpBaseBizError, requests.HTTPError) as e:
return {
"answer": None,
"degraded": True,
"reason": str(e),
"request_id": getattr(e, "request_id", None),
}
return {"answer": synthesize_with_results(user_query, results), "degraded": False}
监控和告警
跑稳了之后要加监控。我一般盯这几个指标:
- 成功率:HTTP 2xx + 业务
status=0的占比。低于 95% 就要查。 - P95 延迟:超过 SLA 阈值要告警。serpbase 这类服务一般 2s 内能回来。
- 5xx 占比:超过 1% 就要排查,可能是 IP 池或上游问题。
- 业务错码分布:某个
error_code突然飙升一般是参数问题。
把这些指标用 Prometheus 或者你喜欢的监控系统打点,告警阈值按历史数据调,别一上来就设得很严。
一些实际跑出来的经验
request_id一定要落日志。我每次问客服第一个问题就是"能不能给我 request_id"。- 客户端和上游之间最好加一层代理(哪怕只是一个简单的函数),这样换上游服务的时候改动小。
- 监控别只看平均值。平均值会把问题平均掉,要看 P95、P99。
- 失败降级的提示要"诚实"。让模型说"这个我没查到"比"我编一个"对用户友好得多。
熔断和隔离
SERP API 是外部依赖,一旦上游出问题,你的 Agent 也会跟着出问题。生产里我加了一层简单的熔断器:
python
import time
class CircuitBreaker:
def __init__(self, failure_threshold=5, recovery_time=30):
self.failures = 0
self.threshold = failure_threshold
self.recovery_time = recovery_time
self.open_since = None
def allow(self):
if self.open_since is None:
return True
if time.time() - self.open_since > self.recovery_time:
self.open_since = None
self.failures = 0
return True
return False
def record_failure(self):
self.failures += 1
if self.failures >= self.threshold:
self.open_since = time.time()
def record_success(self):
self.failures = 0
breaker = CircuitBreaker(failure_threshold=5, recovery_time=30)
def serp_with_breaker(query):
if not breaker.allow():
raise SerpBaseBizError("circuit_open", "上游连续失败,临时熔断", None)
try:
r = with_retry(lambda: requests.post(
"https://api.serpbase.dev/google/search",
json={"q": query, "hl": "en", "gl": "us", "page": 1, "device": "default"},
headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
timeout=HTTP_TIMEOUT,
))
body = r.json()
if body.get("status") != 0:
breaker.record_failure()
raise SerpBaseBizError(body.get("error_code"), body.get("message"), body.get("request_id"))
breaker.record_success()
return body
except (requests.Timeout, requests.ConnectionError):
breaker.record_failure()
raise
熔断器打开时直接返回失败,不打上游。30 秒后尝试半开,恢复后继续。
熔断的核心不是"省几次调用",而是防止故障扩散------上游可能因为被打爆而恢复更慢,熔断可以让它喘口气。
测试
SERP API 的测试比较特殊,因为它依赖外部服务。几个常用的做法:
- 录制回放 :用真实 API 录一份响应,存到文件,测试时回放。注意
date这类时间字段要脱敏。 - Mock 客户端 :写一个假的
serp_search函数,返回固定 JSON。在单元测试里用。 - 契约测试:用 schemathesis 或者 pydantic 校验响应 JSON 符合预期 schema。这点对 SERP API 特别重要------上游偶尔会改字段,schema 校验能第一时间发现。
python
# 用 pydantic 校验响应
from pydantic import BaseModel
class OrganicResult(BaseModel):
rank: int
title: str
link: str
snippet: str | None = None
def test_response_shape():
body = call_serp("python asyncio")
organic = body["search"]["organic"]
for item in organic:
OrganicResult.model_validate(item) # 不符合 schema 会抛错
把这些都补上之后,SERP API 的稳定性基本能撑住生产。下面以 serpbase 的接口为例(文档),它的错误体结构和成功外壳一致,写客户端不用维护两套。
上线前 checklist
最后留个 checklist,上线前对着过一遍:
- HTTP 超时设置(建议 5--10s)
- 重试逻辑(2 次,指数退避,只重试 429/5xx)
- 业务错和 HTTP 错分开处理
-
request_id进日志 - QPS 信号量
- 缓存层(5--30 分钟 TTL)
- 熔断器
- 失败降级路径
- 监控指标(成功率、P95、5xx 占比)
- 告警阈值
- Pydantic schema 校验
- 录制回放的测试数据
按这个走完一遍,稳定性这块基本就稳了。