第 6 章 · 02 exasearch 联网搜索工具 本节摘要:本节精读 ——AutoHedge 里情绪 Agent 唯一的工具,也是整个项目里「让 LLM 联网」的关键。它封装了 Exa.ai 这家专做「自然语言搜索」的服务:输入一句问句(如「最近美联储加息的新闻」),Exa 返回带摘要的网页结果。本节逐行拆解这个 85 行的小函数:从环境变量取 API key → 构造带 schema 的 payload(让 Exa 直接返回结构化摘要)→ POST 请求 → 用 把结果转成 JSON 字符串。重点讲三个工程约定:为什么工具一律返回字符串(给 LLM 读)、docstring 为什么写得这么长(它就是给 LLM 的工具说明书)、以及 API key 缺失时的降级处理。
本节摘要:本节精读
exa_search_tool.py——AutoHedge 里情绪 Agent 唯一的工具,也是整个项目里「让 LLM 联网」的关键。它封装了 Exa.ai 这家专做「自然语言搜索」的服务:输入一句问句(如「最近美联储加息的新闻」),Exa 返回带摘要的网页结果。本节逐行拆解这个 85 行的小函数:从环境变量取 API key → 构造带 schema 的 payload(让 Exa 直接返回结构化摘要)→ POST 请求 → 用any_to_str把结果转成 JSON 字符串。重点讲三个工程约定:为什么工具一律返回字符串(给 LLM 读)、docstring 为什么写得这么长(它就是给 LLM 的工具说明书)、以及 API key 缺失时的降级处理。
内容来源:原项目源码
autohedge/tools/exa_search_tool.py,精读并套用体系化模板。
⚠️ 现实澄清:Exa.ai 是真实存在的搜索服务,需要付费 API key。没 key 这个工具直接抛错,情绪 Agent 就退化成「只能凭记忆瞎编新闻」——这也是为什么本项目情绪分析质量高度依赖外部 key 是否配置。
阅读完本节,你应当能够:
exa_search 函数的全部 85 行代码。any_to_str 的作用。schema 字段如何让 Exa 返回结构化摘要。完整代码(exa_search_tool.py,85 行):
import os import httpx from loguru import logger from swarms.utils.any_to_str import any_to_str def exa_search( query: str, ) -> str: """ # ← docstring 极长,见第三节解释 Exa Web Search Tool ... """ api_key = os.getenv("EXA_API_KEY") # ① 取 API key if not api_key: raise ValueError( # ② 没 key 直接抛错 "EXA_API_KEY environment variable is not set" ) characters = 20 # ③ 摘要字符上限(注释里写 20) sources = 2 # ④ 返回结果数 headers = { "x-api-key": api_key, # ⑤ Exa 用 header 鉴权 "content-type": "application/json", } payload = { # ⑥ 构造请求体 "query": query, "type": "auto", "numResults": sources, "contents": { "text": True, "summary": { "schema": { # ⑦ 用 schema 指定返回结构 "type": "object", "required": ["answer"], "additionalProperties": False, "properties": { "answer": { "type": "string", "description": ( "Key insights and findings from the search result" ), } }, } }, "context": {"maxCharacters": characters}, }, } try: logger.info( f"[SEARCH] Executing Exa search for: {query[:50]}..." ) response = httpx.post( # ⑧ POST /search "https://api.exa.ai/search", json=payload, headers=headers, timeout=30, ) response.raise_for_status() json_data = response.json() return any_to_str(json_data) # ⑨ 转字符串返回 except Exception as e: logger.error(f"Exa search failed: {e}") return f"Search failed: {str(e)}. Please try again." # ⑩ 失败返回提示字符串
逻辑链很清晰:query 字符串 → 检查 key → 构造 payload → POST → 转字符串返回;失败时返回一段提示文字。下面把关键步骤拆开讲。
Exa.ai(原 Metaphor)是一家定位独特的搜索服务。对比:
| 维度 | Google/Bing 搜索 API | Exa.ai |
|---|---|---|
| 查询方式 | 关键词(布尔表达式) | 自然语言句子(语义检索) |
| 索引方式 | 关键词倒排 | 用 embedding 做语义索引 |
| 返回 | 网页标题 + 摘录 + URL | 可直接返回正文文本 + 结构化摘要 |
| 适合 | 知道要找什么关键词 | 用自然语言描述需求 |
所以 exa_search 的入参 query 是一句话,比如代码 docstring 里的例子:"Show me the latest Python 3.12 documentation on dataclasses"、"Recent research on transformer architectures for vision tasks"。这种自然语言查询特别适合 LLM——LLM 本来就擅长把分析需求翻译成自然语言。
💡 为什么情绪 Agent 用 Exa:情绪分析需要看「最近的新闻和评论」,这种需求用关键词很难穷尽(你不知道今天会出什么新闻)。Exa 的语义搜索让 Agent 只要用一句话描述「找这只股票最近的负面新闻」,就能拿到结果,远比硬编码一堆关键词灵活。
注意这个函数的 docstring 长得离谱(从第 11 行到第 43 行,占了全文件近一半),里面详细写了 Features、Args、Returns、Example、Notes。为什么这么长?
因为 LLM 工具的 docstring 有双重身份:
exa_search.__doc__ 抽出来,塞进给 LLM 的「可用工具清单」。LLM 读这段文字,判断「当前任务要不要调这个工具」「该怎么传参」。所以这段 docstring 本质是写给 LLM 看的招领广告——它写得越清楚,LLM 越知道什么时候该用、什么时候别用。比如 Notes 里特意强调「searching for documentation 时把库名和 API 名都带上」,就是在教 LLM 怎么构造更好的 query。
💡 核心心法:在 LLM 工具体系里,docstring 是 prompt 的一部分。写工具说明 = 写给模型的提示词。这点和普通函数完全不同——普通函数 docstring 写得糙点不影响功能,工具函数 docstring 写得糙,LLM 就不会正确调用它。
看 payload 的 contents 部分:
"contents": { "text": True, # 返回正文文本 "summary": { "schema": { # 用 JSON Schema 指定摘要结构 "type": "object", "required": ["answer"], "additionalProperties": False, "properties": { "answer": { "type": "string", "description": "Key insights and findings from the search result", } }, } }, "context": {"maxCharacters": characters}, },
这段是 exa_search 设计的精华。它不只是「搜网页」,而是:
"text": True:让 Exa 把命中网页的正文文本也抓回来。"summary.schema":给 Exa 一个 JSON Schema,让它按这个结构返回摘要。这里要求摘要是一个对象,必须有 answer 字段(字符串),内容是「该结果的关键洞察」。"additionalProperties": False:严格限定只返回 answer,不许多塞字段。效果是:每条搜索结果不是一堆杂乱文本,而是一个结构化的 {answer: "..."} 对象,直接喂给 LLM 就能用。Exa 在服务端用模型把网页内容压成你指定的结构——这是「搜索 + 摘要 + 结构化」三合一。
💡 省 token 的关键:
characters = 20这个值偏小(注释这么写),实际生产里会调大。限制字符数 + 用 summary,是为了控制返回给 LLM 的 token 量——搜索结果动辄上万字,全塞进上下文既贵又稀释注意力。先压成精炼摘要,是工程上常见的做法。
鉴权(第 ①⑤ 步):
api_key = os.getenv("EXA_API_KEY") if not api_key: raise ValueError("EXA_API_KEY environment variable is not set") ... headers = {"x-api-key": api_key, "content-type": "application/json"}
EXA_API_KEY 读(通过 .env 加载,见 env_loader)。x-api-key,不是 query 参数——这样 key 不会进 URL 日志,稍安全。错误处理(第 ⑩ 步):
except Exception as e: logger.error(f"Exa search failed: {e}") return f"Search failed: {str(e)}. Please try again."
注意这里没有 raise,而是返回一段提示字符串。这是个有意的设计:工具函数如果抛异常,可能让上层 Agent 直接崩;返回字符串则让 LLM 看到「搜索失败」,它可以决定是重试还是换思路。这也是 LLM 工具的常见约定——尽量返回字符串而不是抛异常,把决策权交回模型。
第 ⑨ 步:
return any_to_str(json_data)
any_to_str 来自 swarms.utils,作用是把任意 Python 对象(dict/list/对象)转成字符串表示。为什么工具要返回字符串?
因为 LLM 的上下文是文本流。Agent 框架拿到工具返回值后,会把它作为一条「tool result」消息塞回对话历史。这条消息必须是文本——LLM 不懂 Python 对象,只懂字符串。所以工具函数统一约定:
any_to_str 内部通常就是 json.dumps 一类,把 dict 转成 JSON 文本。这样 LLM 看到的是:
[{"title": "...", "url": "...", "answer": "美联储 7 月加息 25 基点..."}, ...]
它读得懂,也就能基于这些信息继续分析。
把 exa_search 放进情绪 Agent 的视角,完整流程:
注意:调用 exa_search 的决策不是写死在代码里,而是 LLM 在推理时自己判断「我需要查新闻」才触发。下一节会详讲这个「LLM 决定何时调」的机制。
query → 检查 key → 构造 payload → POST → any_to_str 返回;失败返回提示字符串而非抛异常。summary.schema 让 Exa 按指定 JSON Schema 返回精炼摘要,既省 token 又便于 LLM 消费。EXA_API_KEY 读,放 x-api-key header;没 key 直接抛错,不让请求发出。any_to_str 转),因为 LLM 上下文是文本流;失败也返回提示字符串,把决策权交回模型。下一节,我们把视角拉到 Agent 一侧,看 swarms 框架如何让 LLM「自己决定」何时调 exa_search,以及结果怎么回灌进上下文。