# PR #29342 完整报告

- 仓库：`sgl-project/sglang`
- 标题：Add native Exa-backed web_search support
- 合并时间：2026-06-27 21:41
- 原文链接：http://prhub.com.cn/sgl-project/sglang/pull/29342

---

## 执行摘要
本 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 扩展路径的兼容性。

## 实现拆解

1. **Exa 客户端模块 **(`python/sglang/srt/entrypoints/search/exa_client.py`)：新建 `ExaClient` 类，依赖 `aiohttp` 和 `msgspec`，封装了搜索 (`/search`) 和内容获取 (`/contents`) 两个 API 端点；配置通过 `ExaSearchConfig` 控制，支持环境变量读取和边界验证。

2. **浏览器工具重写 **(`python/sglang/srt/entrypoints/tool.py`)：`HarmonyBrowserTool` 不再依赖 `gpt_oss`，改为直接拥有 `ExaClient` 实例；新增 `_dispatch_browser_call` 方法解析 `browser.search` / `browser.open` / `browser.find` 动作；通过 `_browser_state` 维护页面状态以支持后续操作。

3. **工具服务器扩展 **(`python/sglang/srt/entrypoints/openai/tool_server.py`)：新增 `NativeToolServer` 类继承 `DemoToolServer`，关闭 Python 工具，仅暴露浏览器工具；在 `DemoToolServer` 中添加 `aclose` 方法以确保 `ExaClient` 会话关闭。

4. **HTTP 服务器集成 **(`python/sglang/srt/entrypoints/http_server.py`)：在 `lifespan` 函数中根据 `EXA_API_KEY` 条件初始化 `NativeToolServer`，替代外部 MCP 工具服务器；在 `finally` 块中调用 `aclose` 进行清理。

5. **环境变量注册 **(`python/sglang/srt/environ.py`)：为 `EXA_API_KEY`、`SGLANG_EXA_NUM_RESULTS`、`SGLANG_EXA_SEARCH_TYPE`、`SGLANG_EXA_INCLUDE_HIGHLIGHTS` 添加 envs 描述符，支持类型解析和默认值。

6. **错误检查 **(`python/sglang/srt/entrypoints/openai/serving_responses.py`)：当请求包含 `web_search` 或 `web_search_preview` 工具但 `supports_browsing` 为 False 时，返回 400 错误和配置引导。

7. **测试覆盖 **(`test/registered/unit/entrypoints/openai/test_exa_search.py`)：单元测试验证头部、负载构建、环境配置读取；集成测试验证无网络时的错误路径。

8. **文档更新 **(`docs_new/cookbook/...`)：更新 GPT-OSS cookbook 中的 web_search 配置说明。

### ExaClient 核心实现 (`python/sglang/srt/entrypoints/search/exa_client.py`)
```python
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`)
```python
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）或添加更多原生工具。