[ blog · tutorial ]9 min read

用 Search + URL Extract 搭一个研究型 Agent(Claude tool use,端到端)

Sarah Choy发布于 2026年5月3日约 9 分钟阅读
用 Search + URL Extract 搭一个研究型 Agent(Claude tool use,端到端)

大多数「AI 研究 Agent」教程停在「这是一个工具定义」就没了。这一篇交付的是能跑的 Agent:问题进,带引用的答案出。搜索、抽取、推理、引用 —— 全部不到 120 行 Python。

一句话总结

  • 最小可用的研究 Agent 循环是:搜索 → 挑链接 → 抽取正文 → 让模型给出带内联引用的答案。
  • 两个工具 —— Web Search(15 credits)和 URL Extract(每个 URL 2 credits)—— 覆盖 95% 的「带依据回答」场景。
  • 编排交给 Claude tool use;你只负责把 tool_use 块 → API 调用 → tool_result 块来回传递,直到模型停下。
  • 端到端成本:在典型深度下,每个问题约 25 credits + 约 $0.02 的 LLM token 费用。

我们要搭什么

一个研究型 Agent:接收一个输入 —— 一句自然语言的问题 —— 返回一个带来源的答案。架构上:

question → [Claude]
              ↓ tool_use(search)
            [API Pick Web Search] → ranked URLs
              ↓ tool_use(extract)
            [API Pick URL Extract] → cleaned bodies
              ↓ Claude reads, decides if more is needed
              ↓ end_turn
          answer with inline [source: URL] citations

大约 120 行 Python。两个工具 —— search 和 extract —— 加一个 Agent 循环,处理 Claude 决定做的任何事。我们分 4 步从零搭起来。

1拉取工具 schema(不用手写 JSON)

两个端点都发布了一个 tool-schema 路由,返回的 Claude 工具定义形状正好就是 messages.create 期望的格式。

import requests

API_KEY = "pk_yourkey"

def fetch_tool(tool_path: str) -> dict:
    """Fetch a Claude tool definition from API Pick's tool-schema endpoint."""
    schema = requests.get(f"https://www.apipick.com{tool_path}/tool-schema").json()
    return schema["claude"]

WEB_SEARCH_TOOL = fetch_tool("/api/search/web")
URL_EXTRACT_TOOL = fetch_tool("/api/extract")

# Cache these once at module load. They don't change between requests.

这一步白送你两样重要的东西:参数 schema(让 Claude 知道你接受 querycountry_codestart_date 等),以及一段清晰、对模型友好的工具功能描述。

2写工具 handler

当 Claude 返回一个 tool_use 块时,你要做的就是调用真正的 API,并返回一个 tool_result 块。每个工具一个函数,一个分发器根据 Claude 选的名字来路由。

def call_tool(tool_use_block) -> dict:
    """Execute the tool Claude asked for and return a tool_result block."""
    name = tool_use_block.name
    args = tool_use_block.input

    if name == "web_search":
        endpoint = "https://www.apipick.com/api/search/web"
    elif name == "extract_urls":
        endpoint = "https://www.apipick.com/api/extract"
    else:
        return {
            "type": "tool_result",
            "tool_use_id": tool_use_block.id,
            "content": f"Unknown tool: {name}",
            "is_error": True,
        }

    resp = requests.post(
        endpoint,
        headers={"x-api-key": API_KEY},
        json=args,
        timeout=30,
    )

    if resp.status_code != 200:
        return {
            "type": "tool_result",
            "tool_use_id": tool_use_block.id,
            "content": f"HTTP {resp.status_code}: {resp.text[:500]}",
            "is_error": True,
        }

    return {
        "type": "tool_result",
        "tool_use_id": tool_use_block.id,
        "content": resp.text,
    }

三点值得注意。tool_use_id 是 Claude 把结果和它之前那次调用对应起来的依据 —— 你必须原样回传。is_error: True 告诉 Claude 优雅地恢复(往往是换一个查询再试)。我们直接传回原始响应文本 —— Claude 对 API Pick 返回的 JSON 形状很适应,所以不需要做任何转换。

3Agent 循环

循环很短:发出对话,查看响应里的每一个块,运行所有工具,把 Claude 的 tool_use 和你的 tool_result 都追加进对话,重复,直到 stop_reason 变成 end_turn

import anthropic

client = anthropic.Anthropic()

SYSTEM_PROMPT = """You are a research assistant. When the user asks a question:

1. Use web_search to find relevant sources. Prefer recent (set start_date) for time-sensitive queries.
2. Use extract_urls to read the body of the top 3-5 most relevant URLs from the search results.
3. Answer the question concisely. After every factual claim, include an inline citation in the form [source: URL].
4. If you don't have enough information, say so — don't fabricate.

Be concise. Aim for 3-5 sentence answers unless the user asks for depth."""

def research(question: str) -> str:
    messages = [{"role": "user", "content": question}]

    while True:
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=2048,
            system=SYSTEM_PROMPT,
            tools=[WEB_SEARCH_TOOL, URL_EXTRACT_TOOL],
            messages=messages,
        )

        # Append the assistant's response (may contain tool_use blocks)
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "end_turn":
            # Pull out the text answer
            return "\n".join(b.text for b in response.content if b.type == "text")

        if response.stop_reason == "tool_use":
            # Run every tool the model asked for in this turn
            tool_results = [
                call_tool(b)
                for b in response.content if b.type == "tool_use"
            ]
            messages.append({"role": "user", "content": tool_results})
            continue

        raise RuntimeError(f"Unexpected stop_reason: {response.stop_reason}")

注意模型可以在一轮里发起多次工具调用 —— 比如它可能发一次 extract_urls 调用,带五个 URL 一起批处理。我们的处理方式是:收集 assistant 这一轮里的所有 tool_use 块,并在下一个 user 轮里返回所有 tool_result 块。

4跑起来

if __name__ == "__main__":
    answer = research("What were the major announcements at OpenAI DevDay this year?")
    print(answer)

你会看到类似这样的输出:

OpenAI's DevDay focused on three themes: cheaper inference (a new gpt-mini
tier at roughly half the prior cost) [source: https://openai.com/devday-2026],
agent infrastructure (a managed agent runtime with persistent memory and
tool sandboxing) [source: https://techcrunch.com/...], and developer
tooling (a new Responses API replacing the legacy Assistants API)
[source: https://platform.openai.com/docs/...].

这就是一个研究型 Agent。约 120 行,两个工具,带来源的输出。

这要花多少钱

一次典型的研究调用:

  • 1 次搜索调用 —— 15 credits($0.015)
  • 1 次抽取调用,覆盖 3–5 个 URL —— 6–10 credits($0.006–$0.010)
  • 约 3,000 输入 + 800 输出的 Claude token —— 约 $0.02

取整:每个研究答案约 3 美分。每天 1,000 次研究调用,约 $30/天 —— 差不多就是一个分析师一早咖啡的预算。该精打细算的是 LLM,不是搜索 API。

三个生产环境的优化

1. 按问题缓存

如果你的产品会问相似的问题,就对 (question, today's date) 做哈希,把最终答案缓存一个小时。大多数用户驱动的研究,长尾命中率都在 30% 以上。

2. 时效重要时加约束

对「本周」「今天」类问题,在搜索调用里设上 start_date,取当天或昨天。不设的话,搜索可能返回去年的文章。最简单的版本:在 system prompt 里加一条规则,比如 「当问题里含 today、this week、recent 或 latest 时,始终带上 start_date」。

3. 防幻觉护栏

杠杆最高的单条规则:「如果你没有足够的抽取内容来作答,就返回:我没能为此找到可靠来源。不要猜。」 在我们的测试里,把这条加进 system prompt,能把编造的答案数量降低一个数量级。

接下来往哪走

三个自然的扩展方向:

  • 做成垂直版:把 Web Search 换成 Academic Search 做文献综述 Agent、换成 SEC Filings Search 做尽职调查,或换成 Clinical Search 做医学研究。handler 不用改 —— 改的只是 system prompt。
  • 加结构化输出:让模型返回带 answercitations[] 字段的 JSON,这样你就能把它渲染成一张 UI 卡片。
  • 流式输出 token:切换到 client.messages.stream() 做增量输出 —— 答案较长时很有用。工具调用循环不变。

常见问题

为什么用两个工具,而不是一个「answer」端点?

把搜索和抽取拆开,你才能控制读多少个 URL、什么时候停、引用哪几个。单一托管的「answer」端点把这些都藏起来了 —— 想换策略就得重新架构。用两个工具,同一份代码只要改改 prompt,就能变成晨报简报 Agent、文献综述员,或者竞品情报抓取器。

这套方案在 OpenAI Assistants / Responses API 上也能用吗?

可以。架构完全一样 —— 唯一变的是你怎么解析工具调用块、怎么提交结果。handler 的形状会变成 submit_tool_outputs(...) 而不是一个 tool_result 内容块,但 Agent 循环和工具定义还是同一份 JSON。

Agent 怎么决定什么时候停?

当 Claude 拿到足够上下文可以作答时,会返回 stop_reason: end_turn;想调用工具时返回 tool_use。循环就一直跑到 end_turn 为止。实际中,模型通常做 1–3 轮搜索/抽取就开始作答。

怎么确保它真的会引用来源?

两个抓手。第一:在 system prompt 里明确要求每条结论后面内联写出 '[source: URL]'。第二:URL Extract 的响应里带着 URL,所以模型上下文里有它时往往会自然引用。如果引用偶尔漏了,加一道最终格式化检查 —— 没引用就打回,让模型补上。

延迟怎么样?

每次搜索往返约 1s;每批抽取耗时 1–4s,取决于 URL 数量和是否需要 JS 渲染。一次典型的「研究一个问题」调用墙钟时间在 5–15s。想更快,就把抽取步骤并行化(extract 本来就能在一次调用里接收一组 URL),并用 system prompt 约束模型每个问题只做一轮工具调用。

本文涉及的 API

Sarah Choy
作者
Sarah Choy
CEO, API Pick

Sarah Choy 是 API Pick 的 CEO,专注于为 AI Agent 与 LLM 工作流构建可用于生产的 API。