执行摘要
本 PR 为 SGLang Responses API 添加了原生的 Exa 网络搜索支持。通过新增 ExaClient 模块和重构 HarmonyBrowserTool,用户只需设置 EXA_API_KEY 环境变量即可使用 web_search 工具,无需额外配置 MCP 工具服务器。PR 包含完整的客户端实现、测试覆盖和文档更新,影响力中等,是 Responses 功能的重要扩展。
功能与动机
PR 正文指出:'Adds native web_search support for GPT-OSS/SGLang Responses workflows, backed by Exa when EXA_API_KEY is configured on the SGLang server.' 主要动机是提供提供者无关的 web_search 接口,用 Exa 作为原生后端,降低用户配置成本,同时保持 MCP 扩展路径的兼容性。
实现拆解
-
Exa 客户端模块 (python/sglang/srt/entrypoints/search/exa_client.py):新建 ExaClient 类,依赖 aiohttp 和 msgspec,封装了搜索 (/search) 和内容获取 (/contents) 两个 API 端点;配置通过 ExaSearchConfig 控制,支持环境变量读取和边界验证。
-
浏览器工具重写 (python/sglang/srt/entrypoints/tool.py):HarmonyBrowserTool 不再依赖 gpt_oss,改为直接拥有 ExaClient 实例;新增 _dispatch_browser_call 方法解析 browser.search / browser.open / browser.find 动作;通过 _browser_state 维护页面状态以支持后续操作。
-
工具服务器扩展 (python/sglang/srt/entrypoints/openai/tool_server.py):新增 NativeToolServer 类继承 DemoToolServer,关闭 Python 工具,仅暴露浏览器工具;在 DemoToolServer 中添加 aclose 方法以确保 ExaClient 会话关闭。
-
HTTP 服务器集成 (python/sglang/srt/entrypoints/http_server.py):在 lifespan 函数中根据 EXA_API_KEY 条件初始化 NativeToolServer,替代外部 MCP 工具服务器;在 finally 块中调用 aclose 进行清理。
-
环境变量注册 (python/sglang/srt/environ.py):为 EXA_API_KEY、SGLANG_EXA_NUM_RESULTS、SGLANG_EXA_SEARCH_TYPE、SGLANG_EXA_INCLUDE_HIGHLIGHTS 添加 envs 描述符,支持类型解析和默认值。
-
错误检查 (python/sglang/srt/entrypoints/openai/serving_responses.py):当请求包含 web_search 或 web_search_preview 工具但 supports_browsing 为 False 时,返回 400 错误和配置引导。
-
测试覆盖 (test/registered/unit/entrypoints/openai/test_exa_search.py):单元测试验证头部、负载构建、环境配置读取;集成测试验证无网络时的错误路径。
-
文档更新 (docs_new/cookbook/...):更新 GPT-OSS cookbook 中的 web_search 配置说明。
ExaClient 核心实现 (python/sglang/srt/entrypoints/search/exa_client.py)
import asyncio
from typing import Any, Optional
import aiohttp
import msgspec
from sglang.srt.environ import envs
from sglang.srt.utils import print_warning_once
class ExaClient:
def __init__(self, api_key: str, *, config: Optional[ExaSearchConfig] = None, base_url: str = "https://api.exa.ai", timeout: float = 30.0):
self.api_key = api_key
self.config = config or ExaSearchConfig()
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self._session: Optional[aiohttp.ClientSession] = None
self._session_lock = asyncio.Lock()
def _headers(self) -> dict[str, str]:
return {
"x-api-key": self.api_key,
"Content-Type": "application/json",
"x-exa-integration": "sglang",
}
def _search_payload(self, query: str) -> dict[str, Any]:
payload = {"query": query, "numResults": self.config.num_results, "type": self.config.search_type}
if self.config.include_highlights:
payload["contents"] = {"highlights": True}
return payload
def _contents_payload(self, urls: list[str]) -> dict[str, Any]:
payload = {"urls": urls, "text": True}
if self.config.include_highlights:
payload["highlights"] = True
return payload
async def search(self, query: str) -> dict[str, Any]:
return await self._post("/search", self._search_payload(query))
async def contents(self, urls: list[str]) -> dict[str, Any]:
return await self._post("/contents", self._contents_payload(urls))
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None:
async with self._session_lock:
if self._session is None:
self._session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.timeout))
return self._session
async def close(self):
if self._session is not None:
await self._session.close()
self._session = None
async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
session = await self._get_session()
url = f"{self.base_url}{path}"
async with session.post(url, json=payload, headers=self._headers()) as response:
response_text = await response.text()
if response.status >= 400:
raise ExaClientError(f"Exa API 请求失败,状态码 {response.status}: {response_text}")
try:
return await response.json()
except Exception as exc:
raise ExaClientError(f"解析 Exa API 响应失败: {exc}")
HarmonyBrowserTool 重构 (python/sglang/srt/entrypoints/tool.py)
import orjson
from sglang.srt.entrypoints.search.exa_client import ExaClient, ExaSearchConfig
from sglang.srt.environ import envs
from sglang.srt.utils import print_info_once, print_warning_once
class HarmonyBrowserTool(Tool):
def __init__(self, client: ExaClient | None = None):
self.enabled = True
if client is not None:
self.exa_client = client
print_info_once("Browser tool initialized")
return
api_key = envs.EXA_API_KEY.get()
if not api_key:
self.enabled = False
print_warning_once("EXA_API_KEY is not set, browsing is disabled")
return
self.exa_client = ExaClient(api_key, config=ExaSearchConfig.from_env())
print_info_once("Browser tool initialized")
async def _dispatch_browser_call(self, context: ConversationContext, recipient: str, args: dict[str, Any]) -> str:
if recipient == "browser.search":
query = args.get("query")
if not query:
raise ValueError("browser.search requires a query")
data = await self.exa_client.search(query)
return self._format_search_results(context, query, data)
if recipient == "browser.open":
url = self._resolve_url(context, args)
data = await self.exa_client.contents([url])
return self._format_page_contents(context, url, data)
if recipient == "browser.find":
pattern = args.get("pattern")
if not pattern:
raise ValueError("browser.find requires a pattern")
return await self._find_pattern(context, args, pattern)
raise ValueError(f"Unknown browser action: {recipient}")
评论区精华
本 PR 无实质性技术讨论。合并者 JustinTong0323 在 CI 失败后多次触发 /rerun-failed-ci,最终批准合并。Issue 评论仅有 Gemini 配额提示和 CI 命令。
风险与影响
风险:
- 新引入
aiohttp 和 msgspec 依赖,可能与其他模块产生版本冲突。
- Exa 外部 API 的可用性直接影响 web_search 功能,但错误路径会返回清晰提示。
- 环境变量配置错误时静默回退默认值(有警告但可能被忽略)。
- NativeToolServer 关闭依赖正确的生命周期管理,若
aclose 未被调用可能导致资源泄漏。
影响:
- 用户需要设置
EXA_API_KEY 才能使用 web_search;现有 MCP 工具服务器用户不受影响。
- 影响范围限于 Responses API 的 web_search 路径,不涉及核心推理流程。
- 为 SGLang 增加了原生第三方 API 集成范例,便于后续添加其他后端。
关联脉络
本 PR 是 SGLang Responses API 工具系统的重要扩展,与此前新增的 ResponsesNativeWebSearchTestCase (PR #29342 自身) 形成完整实现。没有发现与近期其他历史 PR 的直接关联。未来可能进一步支持其他搜索后端(如 Bing、Google)或添加更多原生工具。
参与讨论