面向多平台 AI 搜索引擎的适配层架构设计
豆包、千问、Kimi、文心一言......每家 AI 搜索引擎的 API、返回格式、限流策略各不相同。本文深入讲解如何设计一个统一的多平台适配层架构,让上层业务无需关心平台差异。
一、问题背景
AI GEO 系统需要同时监测多个 AI 搜索平台。如果每个平台直接对接,会面临以下问题:
┌─────────────────────────────────────────────┐
│ 业务逻辑层 │
│ if platform == "doubao": │
│ call_doubao_api() │
│ elif platform == "qianwen": │
│ call_qianwen_api() # 完全不同 │
│ elif platform == "kimi": │
│ call_kimi_api() # 又不同 │
│ elif platform == "wenxin": │
│ call_wenxin_api() # 还不同 │
└─────────────────────────────────────────────┘
问题:
- 平台增加时,业务代码必须修改(违反开闭原则)
- 每个平台的限流、重试、错误处理逻辑重复编写
- 测试覆盖困难,代码膨胀
- 平台 API 变更影响面大
二、适配层架构总览
采用 适配器模式 + 策略模式 + 工厂模式 的组合设计:
┌──────────────────────────────────────────────────────┐
│ 业务层 │
│ CollectionService / MonitorService │
├──────────────────────────────────────────────────────┤
│ 适配层管理器 │
│ AdapterManager · AdapterFactory · AdapterRegistry │
├──────────┬──────────┬──────────┬──────────┬─────────┤
│ Doubao │ QianWen │ Kimi │ WenXin │ ... │
│ Adapter │ Adapter │ Adapter │ Adapter │ │
├──────────┴──────────┴──────────┴──────────┴─────────┤
│ 公共能力层 │
│ RateLimiter · RetryPolicy · CircuitBreaker · Cache │
├──────────────────────────────────────────────────────┤
│ 平台 API 层 │
│ 豆包 SDK · 千问 SDK · Kimi HTTP · 文心 SDK · ... │
└──────────────────────────────────────────────────────┘
三、核心接口设计
3.1 统一适配器接口
python
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional, List, Dict, Any
from enum import Enum
class PlatformType(Enum):
DOUBAO = "doubao"
QIANWEN = "qianwen"
KIMI = "kimi"
WENXIN = "wenxin"
DEEPSEEK = "deepseek"
ZHIPU = "zhipu"
@dataclass
class SearchRequest:
"""统一的搜索请求"""
keyword: str
platform: PlatformType
system_prompt: Optional[str] = None
temperature: float = 0.7
max_tokens: int = 2048
extra_params: Dict[str, Any] = field(default_factory=dict)
@dataclass
class SearchResponse:
"""统一的搜索响应"""
platform: PlatformType
keyword: str
answer_text: str
citations: List[str] = field(default_factory=list)
sources: List[Dict] = field(default_factory=list)
raw_response: Dict[str, Any] = field(default_factory=dict)
latency_ms: int = 0
tokens_used: int = 0
success: bool = True
error: Optional[str] = None
class AISearchAdapter(ABC):
"""AI 搜索引擎适配器抽象接口"""
@property
@abstractmethod
def platform(self) -> PlatformType:
"""平台标识"""
pass
@property
@abstractmethod
def rate_limit(self) -> dict:
"""限流配置: {max_requests, window_seconds}"""
pass
@abstractmethod
async def search(self, request: SearchRequest) -> SearchResponse:
"""执行搜索"""
pass
@abstractmethod
def parse_response(self, raw: Dict[str, Any], keyword: str) -> SearchResponse:
"""解析平台原始响应为统一格式"""
pass
@abstractmethod
def is_brand_mentioned(self, answer: str, brand_names: List[str]) -> bool:
"""检测品牌是否被提及"""
pass
3.2 豆包适配器实现
python
import httpx
import time
from typing import List
class DoubaoAdapter(AISearchAdapter):
"""豆包 AI 搜索适配器"""
def __init__(self, api_key: str, app_id: str):
self.api_key = api_key
self.app_id = app_id
self.base_url = "https://ark.cn-beijing.volces.com/api/v3"
self.client = httpx.AsyncClient(
timeout=30.0,
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20)
)
@property
def platform(self) -> PlatformType:
return PlatformType.DOUBAO
@property
def rate_limit(self) -> dict:
# 豆包 API: 60 次/分钟
return {"max_requests": 60, "window_seconds": 60}
async def search(self, request: SearchRequest) -> SearchResponse:
start = time.time()
try:
payload = {
"model": "doubao-pro-32k",
"messages": [
{"role": "system", "content": request.system_prompt or "你是一个搜索助手"},
{"role": "user", "content": request.keyword}
],
"temperature": request.temperature,
"max_tokens": request.max_tokens,
}
response = await self.client.post(
f"{self.base_url}/chat/completions",
json=payload,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
)
response.raise_for_status()
raw = response.json()
latency = int((time.time() - start) * 1000)
return self.parse_response(raw, request.keyword, latency)
except httpx.HTTPStatusError as e:
return SearchResponse(
platform=self.platform,
keyword=request.keyword,
answer_text="",
success=False,
error=f"HTTP {e.response.status_code}: {e.response.text}"
)
except Exception as e:
return SearchResponse(
platform=self.platform,
keyword=request.keyword,
answer_text="",
success=False,
error=str(e)
)
def parse_response(self, raw: Dict, keyword: str, latency: int = 0) -> SearchResponse:
answer = raw.get("choices", [{}])[0].get("message", {}).get("content", "")
citations = self._extract_citations(raw)
return SearchResponse(
platform=self.platform,
keyword=keyword,
answer_text=answer,
citations=citations,
raw_response=raw,
latency_ms=latency,
tokens_used=raw.get("usage", {}).get("total_tokens", 0),
success=True
)
def _extract_citations(self, raw: Dict) -> List[str]:
"""豆包特有的引用提取逻辑"""
citations = []
for choice in raw.get("choices", []):
msg = choice.get("message", {})
# 豆包在 annotations 字段返回引用
for annotation in msg.get("annotations", []):
if annotation.get("type") == "url_citation":
citations.append(annotation["url_citation"]["url"])
return citations
def is_brand_mentioned(self, answer: str, brand_names: List[str]) -> bool:
answer_lower = answer.lower()
return any(name.lower() in answer_lower for name in brand_names)
3.3 千问适配器实现
python
class QianwenAdapter(AISearchAdapter):
"""通义千问适配器"""
def __init__(self, api_key: str):
self.api_key = api_key
self.base_url = "https://dashscope.aliyuncs.com/api/v1"
self.client = httpx.AsyncClient(timeout=30.0)
@property
def platform(self) -> PlatformType:
return PlatformType.QIANWEN
@property
def rate_limit(self) -> dict:
# 千问: 120 次/分钟
return {"max_requests": 120, "window_seconds": 60}
async def search(self, request: SearchRequest) -> SearchResponse:
start = time.time()
try:
# 千问使用 enable_search 参数启用联网搜索
payload = {
"model": "qwen-plus",
"input": {
"messages": [
{"role": "system", "content": request.system_prompt or "你是一个搜索助手"},
{"role": "user", "content": request.keyword}
]
},
"parameters": {
"temperature": request.temperature,
"max_tokens": request.max_tokens,
"enable_search": True, # 启用联网搜索
"search_options": {"enable_citation": True}
}
}
response = await self.client.post(
f"{self.base_url}/services/aigc/text-generation/generation",
json=payload,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
)
response.raise_for_status()
raw = response.json()
latency = int((time.time() - start) * 1000)
return self.parse_response(raw, request.keyword, latency)
except Exception as e:
return SearchResponse(
platform=self.platform,
keyword=request.keyword,
answer_text="",
success=False,
error=str(e)
)
def parse_response(self, raw: Dict, keyword: str, latency: int = 0) -> SearchResponse:
# 千问的响应结构不同
output = raw.get("output", {})
answer = output.get("text", "")
# 千问在 search_results 中返回引用
citations = []
for sr in output.get("search_results", []):
citations.append(sr.get("url", ""))
return SearchResponse(
platform=self.platform,
keyword=keyword,
answer_text=answer,
citations=citations,
raw_response=raw,
latency_ms=latency,
tokens_used=raw.get("usage", {}).get("total_tokens", 0),
success=True
)
def is_brand_mentioned(self, answer: str, brand_names: List[str]) -> bool:
answer_lower = answer.lower()
return any(name.lower() in answer_lower for name in brand_names)
3.4 Kimi 适配器实现
python
class KimiAdapter(AISearchAdapter):
"""Kimi (Moonshot) 适配器"""
def __init__(self, api_key: str):
self.api_key = api_key
self.base_url = "https://api.moonshot.cn/v1"
self.client = httpx.AsyncClient(timeout=60.0) # Kimi 联网搜索较慢
@property
def platform(self) -> PlatformType:
return PlatformType.KIMI
@property
def rate_limit(self) -> dict:
# Kimi: 30 次/分钟(免费额度)
return {"max_requests": 30, "window_seconds": 60}
async def search(self, request: SearchRequest) -> SearchResponse:
start = time.time()
try:
payload = {
"model": "moonshot-v1-32k",
"messages": [
{"role": "system", "content": request.system_prompt or "你是一个搜索助手"},
{"role": "user", "content": request.keyword}
],
"temperature": request.temperature,
"max_tokens": request.max_tokens,
}
response = await self.client.post(
f"{self.base_url}/chat/completions",
json=payload,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
)
response.raise_for_status()
raw = response.json()
latency = int((time.time() - start) * 1000)
return self.parse_response(raw, request.keyword, latency)
except Exception as e:
return SearchResponse(
platform=self.platform,
keyword=request.keyword,
answer_text="",
success=False,
error=str(e)
)
def parse_response(self, raw: Dict, keyword: str, latency: int = 0) -> SearchResponse:
answer = raw.get("choices", [{}])[0].get("message", {}).get("content", "")
return SearchResponse(
platform=self.platform,
keyword=keyword,
answer_text=answer,
citations=[], # Kimi 免费版不返回引用
raw_response=raw,
latency_ms=latency,
tokens_used=raw.get("usage", {}).get("total_tokens", 0),
success=True
)
def is_brand_mentioned(self, answer: str, brand_names: List[str]) -> bool:
answer_lower = answer.lower()
return any(name.lower() in answer_lower for name in brand_names)
四、适配器工厂与注册表
python
from typing import Type, Dict
class AdapterRegistry:
"""适配器注册表 - 支持动态注册"""
_adapters: Dict[PlatformType, Type[AISearchAdapter]] = {}
_instances: Dict[PlatformType, AISearchAdapter] = {}
@classmethod
def register(cls, platform: PlatformType, adapter_class: Type[AISearchAdapter]):
"""注册适配器类"""
cls._adapters[platform] = adapter_class
@classmethod
def get_adapter(cls, platform: PlatformType, **config) -> AISearchAdapter:
"""获取适配器实例(单例)"""
if platform not in cls._instances:
if platform not in cls._adapters:
raise ValueError(f"No adapter registered for {platform}")
adapter_class = cls._adapters[platform]
cls._instances[platform] = adapter_class(**config)
return cls._instances[platform]
@classmethod
def get_all_adapters(cls) -> Dict[PlatformType, AISearchAdapter]:
"""获取所有已注册的适配器"""
return cls._instances.copy()
class AdapterFactory:
"""适配器工厂 - 根据配置创建适配器"""
@staticmethod
def create_from_config(config: dict) -> Dict[PlatformType, AISearchAdapter]:
adapters = {}
if doubao_cfg := config.get("doubao"):
adapters[PlatformType.DOUBAO] = DoubaoAdapter(
api_key=doubao_cfg["api_key"],
app_id=doubao_cfg["app_id"]
)
if qianwen_cfg := config.get("qianwen"):
adapters[PlatformType.QIANWEN] = QianwenAdapter(
api_key=qianwen_cfg["api_key"]
)
if kimi_cfg := config.get("kimi"):
adapters[PlatformType.KIMI] = KimiAdapter(
api_key=kimi_cfg["api_key"]
)
return adapters
五、公共能力层
5.1 统一限流器
python
import asyncio
import time
from collections import defaultdict
class RateLimiter:
"""基于令牌桶的分布式限流器"""
def __init__(self, redis_client):
self.redis = redis_client
async def acquire(self, key: str, max_requests: int, window: int) -> bool:
"""尝试获取一个令牌"""
now = time.time()
pipe = self.redis.pipeline()
# 清除窗口外的记录
pipe.zremrangebyscore(key, 0, now - window)
# 添加当前请求
pipe.zadd(key, {str(now): now})
# 计算窗口内请求数
pipe.zcard(key)
# 设置 key 过期时间
pipe.expire(key, window)
_, _, count, _ = await pipe.execute()
return count <= max_requests
class RateLimitedAdapter:
"""限流装饰器 - 包装任何适配器"""
def __init__(self, adapter: AISearchAdapter, rate_limiter: RateLimiter):
self.adapter = adapter
self.limiter = rate_limiter
async def search(self, request: SearchRequest) -> SearchResponse:
limit = self.adapter.rate_limit
key = f"rate_limit:{self.adapter.platform.value}"
acquired = await self.limiter.acquire(
key, limit["max_requests"], limit["window_seconds"]
)
if not acquired:
return SearchResponse(
platform=self.adapter.platform,
keyword=request.keyword,
answer_text="",
success=False,
error="Rate limit exceeded"
)
return await self.adapter.search(request)
5.2 熔断器
python
from enum import Enum
class CircuitState(Enum):
CLOSED = "closed" # 正常
OPEN = "open" # 熔断
HALF_OPEN = "half_open" # 半开
class CircuitBreaker:
"""熔断器 - 保护下游服务"""
def __init__(self, failure_threshold: int = 5, recovery_timeout: int = 60):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.state = CircuitState.CLOSED
self.failure_count = 0
self.last_failure_time = 0
async def call(self, func, *args, **kwargs):
if self.state == CircuitState.OPEN:
if time.time() - self.last_failure_time > self.recovery_timeout:
self.state = CircuitState.HALF_OPEN
else:
raise Exception("Circuit breaker is OPEN")
try:
result = await func(*args, **kwargs)
if self.state == CircuitState.HALF_OPEN:
self.state = CircuitState.CLOSED
self.failure_count = 0
return result
except Exception as e:
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = CircuitState.OPEN
raise
六、多平台并行采集
python
import asyncio
from typing import List, Dict
class MultiPlatformCollector:
"""多平台并行采集器"""
def __init__(self, adapters: Dict[PlatformType, AISearchAdapter]):
self.adapters = adapters
async def collect_all(self, keyword: str, brand_names: List[str]) -> Dict[str, SearchResponse]:
"""同时在所有平台搜索同一关键词"""
tasks = []
platforms = []
for platform, adapter in self.adapters.items():
request = SearchRequest(
keyword=keyword,
platform=platform,
system_prompt=f"请详细介绍关于'{keyword}'的信息"
)
tasks.append(adapter.search(request))
platforms.append(platform)
# 并行执行,设置超时
results = await asyncio.gather(*tasks, return_exceptions=True)
output = {}
for platform, result in zip(platforms, results):
if isinstance(result, Exception):
output[platform.value] = SearchResponse(
platform=platform,
keyword=keyword,
answer_text="",
success=False,
error=str(result)
)
else:
output[platform.value] = result
return output
async def collect_batch(
self,
keywords: List[str],
platforms: List[PlatformType],
concurrency: int = 10
) -> List[Dict[str, SearchResponse]]:
"""批量采集:多个关键词 × 多个平台"""
semaphore = asyncio.Semaphore(concurrency)
async def collect_one(keyword: str):
async with semaphore:
adapters = {p: self.adapters[p] for p in platforms if p in self.adapters}
collector = MultiPlatformCollector(adapters)
return await collector.collect_all(keyword, [])
tasks = [collect_one(kw) for kw in keywords]
return await asyncio.gather(*tasks)
七、架构权衡
7.1 适配器粒度
| 方案 | 优势 | 劣势 |
|---|---|---|
| 细粒度(每平台独立类) | 平台逻辑隔离清晰 | 类数量多 |
| 粗粒度(配置驱动) | 代码量少 | if-else 膨胀 |
选择:细粒度方案。每个平台差异足够大(响应格式、引用机制、限流),独立类更可维护。
7.2 同步 vs 异步
| 方案 | 适用场景 |
|---|---|
| 同步 | 少量关键词、实时查询 |
| 异步(asyncio) | 大批量、高并发采集 |
选择:异步方案。AI API 调用是 I/O 密集型,asyncio 可高效并发。
7.3 适配器实例管理
python
# 方案 A: 单例(推荐)
# 所有请求共享一个 adapter 实例,复用 HTTP 连接池
AdapterRegistry.get_adapter(PlatformType.DOUBAO, api_key="xxx")
# 方案 B: 每请求创建
# 无连接复用,适合极端隔离场景
DoubaoAdapter(api_key="xxx")
选择:单例方案。HTTP 连接池复用可大幅减少 TCP 握手开销。
八、扩展性设计
新增一个平台只需三步:
python
# 1. 实现适配器
class DeepSeekAdapter(AISearchAdapter):
def __init__(self, api_key: str):
# ...
@property
def platform(self) -> PlatformType:
return PlatformType.DEEPSEEK
async def search(self, request: SearchRequest) -> SearchResponse:
# 实现 DeepSeek API 调用
pass
def parse_response(self, raw, keyword, latency=0) -> SearchResponse:
# 实现 DeepSeek 响应解析
pass
def is_brand_mentioned(self, answer, brand_names) -> bool:
pass
# 2. 注册
AdapterRegistry.register(PlatformType.DEEPSEEK, DeepSeekAdapter)
# 3. 配置
# config.yaml 中添加 deepseek 配置即可
九、总结
多平台适配层是 AI GEO 系统的基础设施,其设计要点:
- 统一接口:SearchRequest/SearchResponse 屏蔽平台差异
- 适配器模式:每平台一个适配器,独立演进
- 工厂+注册表:动态管理适配器实例
- 公共能力层:限流、熔断、重试统一处理
- 并行采集:asyncio 并发调用多平台
- 开闭原则:新增平台不改现有代码