[ blog · tutorial ]9 min read

สร้าง research agent ด้วย Web Search + URL Extract (tool use ของ Claude ตั้งแต่ต้นจนจบ)

Sarah Choyเผยแพร่ 3 พฤษภาคม 2569อ่าน 9 นาที
สร้าง research agent ด้วย Web Search + URL Extract (tool use ของ Claude ตั้งแต่ต้นจนจบ)

บทเรียน 'AI research agent' ส่วนใหญ่จบแค่ที่ 'นี่คือ tool definition' แต่บทเรียนนี้ส่งมอบ agent ที่ใช้งานได้จริง: ใส่คำถามเข้าไป ได้คำตอบพร้อมการอ้างอิงออกมา ค้นหา สกัด ให้เหตุผล อ้างอิง — ทั้งหมดในไม่ถึง 120 บรรทัดของ Python

สรุปสั้น

  • ลูปของ research agent ที่เล็กที่สุดและยังมีประโยชน์คือ: ค้นหา → เลือกลิงก์ → สกัดเนื้อหา → ขอให้โมเดลตอบพร้อมการอ้างอิงแบบ inline
  • สองเครื่องมือ — Web Search (15 เครดิต) และ URL Extract (2 เครดิตต่อ URL) — ครอบคลุม 95% ของกรณีใช้งานที่ต้องตอบโดยมีหลักฐานรองรับ
  • tool use ของ Claude จัดการการประสานงานให้เอง คุณแค่ส่งต่อบล็อก tool_use → การเรียก API → บล็อก tool_result จนกว่าโมเดลจะหยุด
  • ต้นทุนตั้งแต่ต้นจนจบ: ราว 25 เครดิต + ราว $0.02 ค่า token ของ LLM ต่อหนึ่งคำถามที่ความลึกระดับทั่วไป

สิ่งที่เรากำลังจะสร้าง

research 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ดึง tool schema มา (ไม่ต้องเขียน JSON ด้วยมือ)

ทั้งสอง endpoint เผยแพร่เส้นทาง tool-schema ที่คืน tool definition ของ 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 รู้ว่าคุณรับ query, country_code, start_date ฯลฯ) และคำอธิบายที่ชัดเจนและเป็นมิตรกับโมเดลว่าเครื่องมือนี้ทำอะไร

2เขียน handler ของเครื่องมือ

เมื่อ Claude คืนบล็อก tool_use งานของคุณคือเรียก API จริงและคืนบล็อก tool_result หนึ่งฟังก์ชันต่อหนึ่งเครื่องมือ และหนึ่ง dispatcher ที่กำหนดเส้นทางตามชื่อที่ 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 ให้กู้คืนสถานการณ์อย่างนุ่มนวล (มักจะลองใช้ query อื่น) และเราส่งข้อความตอบกลับแบบดิบ — Claude คุ้นเคยกับรูปแบบ JSON ที่ API Pick คืนมาดีอยู่แล้ว จึงไม่ต้องแปลงอะไร

3ลูปของ agent

ลูปนั้นสั้น: ส่งบทสนทนาไป ดูทุกบล็อกในผลลัพธ์ เรียกเครื่องมือใดๆ ก็ตาม เพิ่มทั้ง tool_use ของ Claude และ 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 ห้าตัวเพื่อประมวลผลเป็นชุด เราจัดการเรื่องนี้ด้วยการเก็บบล็อก tool_use ทั้งหมด จากเทิร์นของผู้ช่วย และคืนบล็อก 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/...].

นั่นแหละคือ research agent ราว 120 บรรทัด สองเครื่องมือ เอาต์พุตที่มีแหล่งที่มา

มันมีต้นทุนเท่าไร

การเรียก research โดยทั่วไป:

  • การเรียกค้นหา 1 ครั้ง — 15 เครดิต ($0.015)
  • การเรียกสกัด 1 ครั้งครอบคลุม 3–5 URL — 6–10 เครดิต ($0.006–$0.010)
  • token ของ Claude ราว 3,000 ตัวขาเข้า + 800 ตัวขาออก — ราว $0.02

ตัวเลขกลมๆ: ราว 3 เซนต์ต่อหนึ่งคำตอบที่ค้นคว้ามา ที่ 1,000 การเรียก research ต่อวันก็ราว $30/วัน — ประมาณงบกาแฟตอนเช้าของนักวิเคราะห์คนหนึ่ง ตั้งงบให้กับ LLM ไม่ใช่ search API

การปรับแต่งสามอย่างสำหรับการใช้งานจริง

1. cache ตามคำถาม

หากผลิตภัณฑ์ของคุณถามคำถามที่คล้ายกัน ให้แฮช (question, today's date) แล้ว cache คำตอบสุดท้ายไว้หนึ่งชั่วโมง การค้นคว้าที่ขับเคลื่อนโดยผู้ใช้ส่วนใหญ่มีอัตรา hit แบบ long-tail สูงกว่า 30%

2. จำกัดขอบเขตเมื่อความสดใหม่สำคัญ

สำหรับคำถามแบบ 'สัปดาห์นี้' / 'วันนี้' ให้ตั้ง start_date ในการเรียกค้นหาเป็นวันที่ปัจจุบันหรือเมื่อวาน หากไม่มีมัน การค้นหาอาจคืนบทความของปีที่แล้ว เวอร์ชันที่ง่ายที่สุด: กฎใน system prompt อย่างเช่น 'ใส่ start_date เสมอเมื่อคำถามมีคำว่า วันนี้ สัปดาห์นี้ ล่าสุด หรือ ใหม่สุด'

3. การ์ดป้องกันการ hallucinate

กฎที่ให้ผลคุ้มค่าที่สุดเพียงข้อเดียว: 'หากคุณมีเนื้อหาที่สกัดมาไม่เพียงพอที่จะตอบ ให้คืนค่า: ฉันหาแหล่งที่มาที่น่าเชื่อถือสำหรับเรื่องนี้ไม่ได้ อย่าเดา' การเพิ่มข้อนี้เข้าไปใน system prompt ลดคำตอบที่กุขึ้นเองลงเป็นหลักสิบเท่าในการทดสอบของเรา

จะต่อยอดไปทางไหนต่อ

ส่วนขยายตามธรรมชาติสามอย่าง:

  • ทำให้เป็นแนวดิ่ง: เปลี่ยน Web Search เป็น Academic Search สำหรับ agent รีวิวงานวิจัย, SEC Filings Search สำหรับ due diligence, หรือ Clinical Search สำหรับการวิจัยทางการแพทย์ handler ไม่เปลี่ยน — เปลี่ยนแค่ system prompt เท่านั้น
  • เพิ่มเอาต์พุตแบบมีโครงสร้าง: ขอให้โมเดลคืน JSON ที่มีฟิลด์ answer และ citations[] เพื่อให้คุณเรนเดอร์เป็นการ์ด UI ได้
  • สตรีม token: เปลี่ยนไปใช้ client.messages.stream() สำหรับเอาต์พุตแบบเพิ่มทีละส่วน — มีประโยชน์เมื่อคำตอบยาว ลูปของ tool-call ยังคงเหมือนเดิม

คำถามที่พบบ่อย

ทำไมต้องใช้สองเครื่องมือ แทนที่จะใช้ endpoint 'answer' เพียงตัวเดียว?

การแยกการค้นหาและการสกัดออกจากกันทำให้คุณควบคุมได้ว่าจะอ่านกี่ URL จะหยุดเมื่อไร และจะอ้างอิงตัวไหน endpoint 'answer' แบบโฮสต์ตัวเดียวซ่อนสิ่งเหล่านี้ไว้ — คุณไม่สามารถเปลี่ยนกลยุทธ์ได้ง่ายๆ โดยไม่ต้องออกแบบสถาปัตยกรรมใหม่ ด้วยสองเครื่องมือ โค้ดชุดเดียวกันสามารถกลายเป็น agent สรุปข่าวตอนเช้า ตัวรีวิวงานวิจัย หรือตัวเก็บข้อมูลข่าวกรองคู่แข่งได้ เพียงปรับ prompt เท่านั้น

ใช้ได้กับ OpenAI Assistants / Responses API ด้วยไหม?

ได้ สถาปัตยกรรมเหมือนกันทุกประการ — สิ่งเดียวที่เปลี่ยนคือวิธี parse บล็อก tool-call และวิธีส่งผลลัพธ์กลับ รูปแบบของ handler จะกลายเป็น submit_tool_outputs(...) แทนที่จะเป็นบล็อกเนื้อหา tool_result แต่ลูปของ agent และ tool definition ยังคงเป็น JSON ชุดเดียวกัน

agent ตัดสินใจว่าจะหยุดเมื่อไรอย่างไร?

Claude จะคืนค่า stop_reason: end_turn เมื่อมีบริบทเพียงพอที่จะตอบ และคืน tool_use เมื่อต้องการเรียกเครื่องมือ ลูปจะวนต่อไปเรื่อยๆ จนกว่าจะถึง end_turn ในทางปฏิบัติ โมเดลมักทำวงจรค้นหา/สกัด 1–3 รอบก่อนจะตอบ

จะทำให้มันอ้างอิงแหล่งที่มาจริงๆ ได้อย่างไร?

มีสองคันโยก คันแรก: ใน system prompt ให้กำหนดอย่างชัดเจนว่าต้องมี '[source: URL]' แบบ inline หลังทุกข้อความที่กล่าวอ้าง คันที่สอง: ผลลัพธ์ของ URL Extract มี URL อยู่ด้วย ดังนั้นเมื่อโมเดลมีมันอยู่ในบริบทก็มักจะอ้างอิงโดยธรรมชาติ หากการอ้างอิงหลุดไป ให้เพิ่มขั้นตอนจัดรูปแบบขั้นสุดท้ายที่ปฏิเสธคำตอบและขอให้โมเดลเพิ่มการอ้างอิง

แล้วเรื่อง latency ล่ะ?

การค้นหาแต่ละรอบใช้เวลาราว 1 วินาที การสกัดแต่ละชุดใช้เวลา 1–4 วินาทีขึ้นอยู่กับจำนวน URL และการเรนเดอร์ JS การเรียก 'ค้นคว้าหนึ่งคำถาม' โดยทั่วไปจะอยู่ที่ 5–15 วินาทีตามเวลาจริง หากต้องการให้เร็วขึ้น ให้ทำการสกัดแบบขนาน (extract รับรายการ URL ในการเรียกครั้งเดียวอยู่แล้ว) และจำกัดให้โมเดลใช้เครื่องมือหนึ่งรอบต่อหนึ่งคำถามด้วยคำสั่งใน system prompt

API ที่ใช้ในบทความนี้

Sarah Choy
เขียนโดย
Sarah Choy
CEO, API Pick

Sarah Choy เป็น CEO ของ API Pick เธอเขียนเกี่ยวกับการสร้าง API พร้อมใช้งานจริงสำหรับ AI agent และเวิร์กโฟลว์ LLM