สร้าง 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 เป็น CEO ของ API Pick เธอเขียนเกี่ยวกับการสร้าง API พร้อมใช้งานจริงสำหรับ AI agent และเวิร์กโฟลว์ LLM