第 3 课

用 Contents 读页面,再给 Claude 应用接上 Exa 工具循环

用 Contents API 读取已知网址并处理失败,再用 exa-py 自带的 Claude 工具写出会自己搜索、读页面并附上引用的问答脚本。

图文20 分钟

课程目录第 3 / 3 课

学习位置仅保存在当前浏览器,有效期 180 天。

01 / 图文教材

图文教材

本课按 Exa 官方 Contents API、Anthropic Tool Calling、Error Codes 与 Anthropic 官方 Models overview、Web search tool 整理;在 exa-py 2.26.0、anthropic 1.15.0(Python SDK)上实测。 目标:用 Contents API 读取已知网址并正确处理单个网址失败;再用 exa-py 自带的 Claude 工具,写出一个会自己搜索、读页面、最后附上引用链接的问答脚本。

你将得到什么

  • read_pages.py:批量读网页,只取与问题相关的摘录,并逐个报告失败的网址
  • tools_preview.py、handler_check.py:不花模型费用,先把工具定义和处理器跑通
  • agent.py:一个完整的 Claude + Exa 工具循环,最多 5 轮
  • 上线前要注意的计费、命名冲突与用户标识

一、用 Contents 读取已知网址

已经知道网址(来自上一步搜索,或用户直接给的链接)时,用 /contents。官方推荐先要 highlights,并给一个 query 指明你关心什么:

curl -s -X POST "https://api.exa.ai/contents" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $EXA_API_KEY" \
  -d '{
    "ids": ["https://exa.ai/blog/dynamic-highlights"],
    "highlights": { "query": "token efficiency and quality results" }
  }'

对照结果(官方文档示例响应,摘录已截短):

{
  "requestId": "e492118ccdedcba5088bfc4357a8a125",
  "results": [
    {
      "id": "https://exa.ai/blog/dynamic-highlights",
      "title": "Dynamic Highlights",
      "url": "https://exa.ai/blog/dynamic-highlights",
      "highlights": ["With a 12k character budget, relative to existing highlights, ..."]
    }
  ],
  "statuses": [
    { "id": "https://exa.ai/blog/dynamic-highlights", "status": "success", "source": "cached" }
  ],
  "costDollars": { "total": 0.001 }
}

二、Contents 和 Search 的四个区别

  1. 没有 contents 外层:highlights、text、summary 直接放在请求体顶层,和 ids 并列;照搬第 2 课 /search 的写法拿不到想要的内容。
  2. ids 和 urls 可以互换:既能传网址,也能传搜索结果里的 id。
  3. 单个网址失败不会让整个请求失败:HTTP 仍然是 200,失败信息在 statuses 里,例如 CRAWL_NOT_FOUND、CRAWL_HTTP_403、CRAWL_TIMEOUT。
  4. 计费方式不同:/contents 按页、按内容类型计费,$1 / 千页;同一页同时要 text 和 highlights 算两份。官方提示:/search 本身就能附带这些内容,10 条以内不另收费,所以「先搜再读」的场景优先在搜索请求里直接要 highlights。

需要最新内容时,用 maxAgeHours:0 表示总是现抓,-1 表示只用缓存;旧参数 livecrawl 已弃用,两者不要同时传。

三、Python 版:逐个检查失败的网址

新建 read_pages.py:

import sys
from exa_py import Exa

exa = Exa()  # 读取 EXA_API_KEY
urls = sys.argv[1:] or [
    "https://exa.ai/blog/exa-3",
    "https://exa.ai/blog/this-page-does-not-exist-kun",
]

result = exa.get_contents(
    urls,
    highlights={"query": "What is new in Exa 3.0 and how much does Lite cost?"},
    text=False,  # 只要摘录;不写这行,exa-py 会默认再附带 1 万字符全文
)

for page in result.results:
    print("OK ", page.url)
    for h in page.highlights or []:
        print("    -", h[:120].replace("\n", " "))
for s in result.statuses or []:
    if s.status != "success":
        tag = s.error.tag if s.error else "unknown"
        print("ERR", s.id, tag)
print("费用(美元):", result.cost_dollars.total if result.cost_dollars else "未返回")

为什么要写 text=False:我们在本机截获了 exa-py 2.26.0 发出的请求体,只传 highlights 时它会自动补上 "text": {"maxCharacters": 10000},等于同时要了两种内容、按两份计费。加上 text=False 后请求体变成(实测):

{"urls": ["https://exa.ai/blog/exa-3", "https://exa.ai/blog/this-page-does-not-exist-kun"],
 "highlights": {"query": "What is new in Exa 3.0 and how much does Lite cost?"},
 "text": false}

text: false 是 OpenAPI 规范里明确支持的写法(关闭全文返回)。运行 python read_pages.py,对照结果(示例:摘录内容会不同;第二个网址故意写错,CRAWL_NOT_FOUND 这个结果我们已通过 Exa 免 key MCP 读取同一网址实测确认):

OK  https://exa.ai/blog/exa-3
    - We're also launching Exa Lite, a new search type at $4 per 1,000 requests, ...
ERR https://exa.ai/blog/this-page-does-not-exist-kun CRAWL_NOT_FOUND
费用(美元): 0.001

四、给 Claude 装上 Exa 工具

pip install anthropic exa-py
export ANTHROPIC_API_KEY="your-anthropic-key"

Anthropic 的 key 在 https://platform.claude.com/settings/keys 创建;EXA_API_KEY 沿用第 2 课的。

exa-py 自带两个现成的 Claude 工具:web_search(搜索没见过的页面)和 get_contents(读取已有网址)。不需要手写 JSON Schema。先离线看一眼它们长什么样,新建 tools_preview.py:

from exa_py import Exa

exa = Exa(api_key="offline-preview")  # 只构造工具定义,不发请求
for tool in (exa.anthropic.web_search(), exa.anthropic.get_contents()):
    print(tool["name"], "->", ", ".join(tool["input_schema"]["required"]))

对照结果(实测,不联网也能跑):

web_search -> query, objective
get_contents -> urls

模型只决定 query、objective(这次搜索要服务的目标)和要读的 urls;搜索类型、内容视图等都在创建工具时固定。默认搜索是 type="auto" 加 highlights,读页面默认返回最多 1 万字符的正文。

五、先不花模型费用,测处理器

exa.anthropic.handle_tool_use() 会执行一条模型消息里的全部 tool_use 块,并返回可以直接回传给模型的 tool_result。我们手工伪造一条「模型要求调用工具」的消息来测试它,新建 handler_check.py:

from anthropic.types import Message, ToolUseBlock, Usage
from exa_py import Exa

exa = Exa()  # 读取 EXA_API_KEY
tools = [exa.anthropic.web_search(), exa.anthropic.get_contents()]  # 先注册工具

# 手工伪造一条「模型要求调用工具」的消息,不花 Claude 的钱
fake = Message(
    id="msg_demo", type="message", role="assistant", model="claude-sonnet-5-5",
    stop_reason="tool_use", stop_sequence=None,
    usage=Usage(input_tokens=0, output_tokens=0),
    content=[
        ToolUseBlock(type="tool_use", id="toolu_1", name="web_search", input={
            "query": "Exa hosted MCP server setup guide",
            "objective": "Find the official Exa docs page for MCP setup",
        }),
        ToolUseBlock(type="tool_use", id="toolu_2", name="my_calc", input={"x": 1}),
    ],
)
for block in exa.anthropic.handle_tool_use(fake):
    print(block["tool_use_id"], "|", str(block["content"])[:160])

对照结果(实测,故意用错的 Exa key 运行):

toolu_1 | Error: Request failed with status code 401: {"requestId":"...","error":"Invalid API key. ...
toolu_2 | Error: unknown tool "my_calc"

两个要点:

  • 出错时处理器不会抛异常,而是把错误写进 tool_result 交给模型。所以要先用这个脚本确认 key 有效,否则模型会拿着报错文字「作答」。换成真实 key 后,toolu_1 那行会以 Title: ... URL: ... 开头。
  • 不认识的工具会得到 unknown tool 结果而不是被丢弃。实测还发现:如果没先调用 exa.anthropic.web_search() 创建工具,web_search 也会被判为 unknown,所以务必先建好 tools 列表。

六、完整的工具循环

新建 agent.py。模型 ID 用 Anthropic 模型页当前列出的 claude-sonnet-5-5(Exa 文档示例里的 claude-sonnet-4-6 已被 Anthropic 列为旧版模型):

import sys
import anthropic
from exa_py import Exa

MODEL = "claude-sonnet-5-5"
exa = Exa()                      # 读取 EXA_API_KEY
claude = anthropic.Anthropic()   # 读取 ANTHROPIC_API_KEY
tools = [exa.anthropic.web_search(), exa.anthropic.get_contents()]

DEFAULT_Q = "Exa 3.0 有哪些更新?请附上官方来源链接。"
question = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_Q
messages = [{"role": "user", "content": question}]

for turn in range(1, 6):  # 最多 5 轮,防止无限循环
    response = claude.messages.create(
        model=MODEL,
        max_tokens=2048,
        system="先用工具检索;只依据检索到的页面作答,文末列出引用的 URL。",
        messages=messages,
        tools=tools,
    )
    calls = [b for b in response.content if b.type == "tool_use"]
    for b in calls:
        print(f"[第 {turn} 轮] 调用 {b.name}: {b.input}")
    if not calls:
        print("".join(b.text for b in response.content if b.type == "text"))
        break
    messages.append({"role": "assistant", "content": response.content})
    messages.append({"role": "user", "content": exa.anthropic.handle_tool_use(response)})

循环逻辑和官方说明一致:每次请求都带上 tools,只要模型还在调用工具就执行并回传结果,直到它直接给出文字答案。运行 python agent.py,对照结果(示例格式:调用次数、查询词和答案措辞每次都会不同):

[第 1 轮] 调用 web_search: {'query': 'Exa 3.0 official release announcement', 'objective': '...'}
[第 2 轮] 调用 get_contents: {'urls': ['https://exa.ai/blog/exa-3']}
Exa 3.0 于 2026 年 10 月 19 日发布,主要变化有……
来源:https://exa.ai/blog/exa-3

七、上线前的三件事

  • 和 Claude 自带搜索并用:Anthropic 的服务端网页搜索工具(当前文档示例为 web_search_20260318)固定使用 web_search 这个名字,而工具名必须唯一。两者同时用时,把 Exa 的改名:exa.anthropic.web_search(name="exa_web_search")。
  • 标记终端用户:替你的用户发请求时,给 exa.search() 传 user_identifier,值用 UUID 或内部 ID 的哈希,1–64 位字母、数字、_、-;绝不能放邮箱、手机号或姓名。
  • 估算成本:一轮 auto 搜索 $0.007,读一页 $0.001;上面的循环最多 5 轮,Exa 侧单次提问的花费通常在几分钱以内,Claude 的 token 费用另算。

常见错误对照

现象原因处理
/contents 报错或拿不到摘录照搬了 /search 的 contents 外层把 highlights / text 移到顶层
请求成功但没有某页内容该网址抓取失败查看 statuses 里的 error.tag
账单里 Contents 用量翻倍exa-py 默认附带全文只要摘录时加 text=False
模型回答里出现「Invalid API key」工具出错被当成结果交给了模型先跑 handler_check.py 确认 key 有效
unknown tool "web_search"没先用工厂函数创建工具先建好 tools 列表再调用处理器
工具名冲突报错与 Claude 自带 web_search 重名给 Exa 工具传 name="exa_web_search"

学完这三课,你已经能用三种方式给 AI 接上实时网页:MCP 免代码接入、直接调用 Search / Contents API、在自己的 Claude 应用里做工具调用。更多场景(Deep Search、Exa Agent、Monitors)见官方文档 https://exa.ai/docs/get-started/quickstart 。