第 3 课
用 Contents 读页面,再给 Claude 应用接上 Exa 工具循环
用 Contents API 读取已知网址并处理失败,再用 exa-py 自带的 Claude 工具写出会自己搜索、读页面并附上引用的问答脚本。
课程目录第 3 / 3 课
学习位置仅保存在当前浏览器,有效期 180 天。
图文教材
本课按 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 的四个区别
- 没有
contents外层:highlights、text、summary直接放在请求体顶层,和ids并列;照搬第 2 课/search的写法拿不到想要的内容。 ids和urls可以互换:既能传网址,也能传搜索结果里的id。- 单个网址失败不会让整个请求失败:HTTP 仍然是 200,失败信息在
statuses里,例如CRAWL_NOT_FOUND、CRAWL_HTTP_403、CRAWL_TIMEOUT。 - 计费方式不同:
/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 。
