> **繁體中文** | [简体中文](./build-first-agent-in-7-steps.zh-Hans.md) | [English](./build-first-agent-in-7-steps.en.md) # 7 步打造你的第一個 AI Agent > [← 回主路線 README](../README.md) > 📌 **這份是給 Track B(Agent Builder)的**——教你**從零寫**一個 agent。 > 走 [Track A(CLI Power User)](../tracks/cli/A1-cli-intro.md) 的人**不需要跑**這份;但讀過之後對「**agent 從 LLM API 到 production 怎麼一步步組起來**」會有更深的理解,可作為 optional 進階補充。 這是一份**跨 7 個 stage 的具體 walkthrough**——同一個 agent,從 Stage 1 寫到 Stage 7,每個 stage 都附可執行的程式碼骨架。 > **怎麼讀這份**:每一節都是上一節的延伸。後面 stage 的 snippet 預設你已經有前面 stage 的檔案在同一個資料夾。要實際跑: > 1. 照 Stage 0 設好環境 > 2. 每個 stage 開新檔案(`step1_*.py`、`step2_*.py`...) > 3. 後面 stage 用 `from step1_xxx import ...` 引用前面寫的東西 > > 所有依賴一次裝完:`pip install anthropic openai requests beautifulsoup4 langgraph langchain-anthropic langchain-core chromadb langfuse fastapi uvicorn pydantic` 要做的 agent:**Paper Summary Bot** — 給定一個 arXiv 論文 URL,輸出 3 段摘要 + 5 個關鍵詞 + 跟相關論文的比較。 每個 stage 都會把同一個 agent **加一層能力**。最後它會是一個跨多 LLM、有 memory、能 deploy 的 agent。 --- ## 📋 全程概覽 | Stage | 你會加的能力 | 程式碼複雜度 | |---|---|---| | 0 | 環境準備(Python、API key、git) | — | | 1 | 第一次呼叫 LLM API | ~10 行 | | 2 | 寫一個專業的 prompt | ~20 行 | | 3 | Tool use:自動抓取 arXiv 論文 | ~80 行 | | 4 | 用 framework 重寫,加上 reflection | ~40 行(framework 抽象掉細節)| | 5 | 包成 Claude Code Skill | SKILL.md + 30 行 | | 6 | 加 RAG memory:跟過去看過的論文比較 | ~60 行 | | 7 | 加 eval、observability、deploy | ~100 行 | **總計**:約 350 行 Python + 結構化設定 = 一個你看著它從零長到 production 的具體例子。 --- ## Stage 0 — 環境準備 ```bash # 安裝 Python 3.11+ python --version # 建虛擬環境 python -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate # 安裝所有 stage 會用到的套件(一次裝完,後面 stage 不會再 pip install) pip install anthropic openai requests beautifulsoup4 \ langgraph langchain-anthropic langchain-core \ chromadb langfuse fastapi uvicorn pydantic # Claude API key(去 console.anthropic.com 申請) export ANTHROPIC_API_KEY="sk-ant-..." # 建 repo mkdir paper-summary-bot && cd paper-summary-bot git init echo ".env\n.venv/\n__pycache__/" > .gitignore ``` **檢查點**:你應該能跑 `python -c "from anthropic import Anthropic; print('OK')"` 而不報錯。 --- ## Stage 1 — 第一次呼叫 LLM ```python # step1_hello_llm.py from anthropic import Anthropic client = Anthropic() response = client.messages.create( model="claude-sonnet-4-6", max_tokens=500, messages=[{ "role": "user", "content": "請用 3 句話介紹什麼是 ReAct agent。" }] ) print(response.content[0].text) print(f"\n--- Tokens: input={response.usage.input_tokens}, " f"output={response.usage.output_tokens} ---") ``` 跑:`python step1_hello_llm.py` **學到什麼**:API call 的長相、`messages` 結構、`usage` 怎麼算 token。 --- ## Stage 2 — 寫專業的 prompt ```python # step2_paper_summary.py from anthropic import Anthropic client = Anthropic() SYSTEM_PROMPT = """你是學術論文摘要助手。你的任務: 1. 用 3 段摘要描述論文:(a) 動機、(b) 方法、(c) 結果。 2. 列出 5 個關鍵詞。 3. 用條列點出 2-3 個跟主流方法的差別。 格式要求: - 每段摘要 ≤ 60 字 - 關鍵詞用英文(technical term) - 整體 300 字以內 - 不要瞎掰;不知道就說「論文沒提到」""" PAPER_TEXT = """[論文 abstract 貼這裡]""" response = client.messages.create( model="claude-sonnet-4-6", max_tokens=800, system=SYSTEM_PROMPT, messages=[{"role": "user", "content": PAPER_TEXT}] ) print(response.content[0].text) ``` **學到什麼**:system prompt 跟 user message 分工、明確格式要求、防 hallucinate 的「不知道就說沒提到」。 --- ## Stage 3 — Tool use:自動抓論文 ```python # step3_tool_use.py import requests from anthropic import Anthropic from step2_paper_summary import SYSTEM_PROMPT # 上一個 stage 寫的 client = Anthropic() # 定義 tool TOOLS = [{ "name": "fetch_arxiv", "description": "Fetch arXiv paper abstract by URL", "input_schema": { "type": "object", "properties": { "arxiv_url": {"type": "string"} }, "required": ["arxiv_url"] } }] def fetch_arxiv(arxiv_url: str) -> str: """Tool 實作。""" arxiv_id = arxiv_url.split("/")[-1].replace(".pdf", "") api_url = f"http://export.arxiv.org/api/query?id_list={arxiv_id}" r = requests.get(api_url) # 簡化:實際要 parse XML return r.text[:5000] # ReAct loop def run_agent(user_query: str): messages = [{"role": "user", "content": user_query}] while True: response = client.messages.create( model="claude-sonnet-4-6", max_tokens=2000, tools=TOOLS, messages=messages, system=SYSTEM_PROMPT, # 從 Stage 2 來 ) # 沒有更多 tool 要呼叫 → done if response.stop_reason == "end_turn": return response.content[-1].text # 處理 tool call tool_use = next(b for b in response.content if b.type == "tool_use") if tool_use.name == "fetch_arxiv": result = fetch_arxiv(**tool_use.input) messages.append({"role": "assistant", "content": response.content}) messages.append({ "role": "user", "content": [{ "type": "tool_result", "tool_use_id": tool_use.id, "content": result, }] }) # 跑 print(run_agent("摘要這篇論文:https://arxiv.org/abs/2210.03629")) ``` **學到什麼**:tool schema 怎麼寫、ReAct loop 怎麼運作、`stop_reason` 怎麼判定結束、tool_result 怎麼回傳給 LLM。 **這是 Stage 3 最大的躍進——你的程式從「呼叫 LLM」變成「LLM 呼叫你的程式」。** --- ## Stage 4 — 用 framework + 加 reflection > **裝套件**:`pip install langgraph langchain-anthropic langchain-core` 用 LangGraph 重寫,加一個「self-review」node: ```python # step4_langgraph.py from typing import TypedDict, Annotated from langgraph.graph import StateGraph, END from langgraph.prebuilt import create_react_agent from langgraph.graph.message import add_messages from langchain_anthropic import ChatAnthropic from langchain_core.tools import tool from langchain_core.messages import HumanMessage @tool def fetch_arxiv(arxiv_url: str) -> str: """Fetch arXiv paper abstract.""" # 同 Stage 3 的實作 import requests arxiv_id = arxiv_url.split("/")[-1].replace(".pdf", "") r = requests.get(f"http://export.arxiv.org/api/query?id_list={arxiv_id}") return r.text[:5000] class State(TypedDict): messages: Annotated[list, add_messages] revisions: int # 防止無限 loop llm = ChatAnthropic(model="claude-sonnet-4-6") react_agent = create_react_agent(llm, tools=[fetch_arxiv]) MAX_REVISIONS = 2 def reflect(state: State) -> State: """讓 LLM 評估前一輪的摘要,並決定是否要再改。""" last_summary = state["messages"][-1].content # 用一個明確的 yes/no 判定,不要靠關鍵字 match review_prompt = ( f"以下摘要是否符合:3 段、各 ≤60 字、5 個英文關鍵詞、不瞎掰?\n\n" f"{last_summary}\n\n" "請只回答 PASS 或 NEEDS_REVISION,不要解釋。" ) verdict = llm.invoke(review_prompt).content.strip().upper() return { "messages": [HumanMessage(content=f"[Reviewer 判定: {verdict}]")], "revisions": state.get("revisions", 0) + 1, } def should_continue(state: State) -> str: """判斷下一步去 agent 還是 END。""" last_msg = state["messages"][-1].content if state["revisions"] >= MAX_REVISIONS: return END # 達到上限,無條件退出 if "NEEDS_REVISION" in last_msg: return "agent" # 回去重做 return END # PASS 就退出 # 組 graph graph = StateGraph(State) graph.add_node("agent", react_agent) graph.add_node("reflect", reflect) graph.add_edge("agent", "reflect") graph.add_conditional_edges("reflect", should_continue, {"agent": "agent", END: END}) graph.set_entry_point("agent") app = graph.compile() # 跑 result = app.invoke({ "messages": [HumanMessage(content="摘要 https://arxiv.org/abs/2210.03629")], "revisions": 0, }) print(result["messages"][-1].content) ``` **學到什麼**:framework 抽掉的東西(while loop、message 結構、tool 註冊)、graph 怎麼定義條件分支跟正確的終止條件、reflection pattern 怎麼讓 agent 在限定回合內 self-correct(不會無限 loop)。 **注意**:Stage 4 之後不再示範 LangGraph 內部 state 細節——後面 stage 把 LangGraph agent 當黑盒用即可。 --- ## Stage 5 — 包成 Claude Code Project Skill > 這一步**不是** Python,是把前面 Stage 1-4 的邏輯,重新包成 Claude Code 自己會載入的 **project skill**。`description` 寫得清楚的話,Claude 會在使用者提到相關需求時自動觸發。 在你 repo 內建立: ``` your-repo/ └── .claude/ └── skills/ └── paper-summary/ └── SKILL.md ``` `SKILL.md` 內容: ```markdown --- name: paper-summary description: 摘要 arXiv 論文。當使用者貼 arXiv URL、提到論文 ID(如 2210.03629),或要求「summarize this paper / 摘要論文」時觸發。輸出 3 段摘要 + 5 個關鍵詞 + 與主流方法差別。 --- # Paper Summary Skill ## What this does 摘要 arXiv 論文成結構化的 3 段 + 關鍵詞 + 差異點。 ## When Claude should use this 使用者: - 貼 arXiv URL(`https://arxiv.org/abs/...` 或 `arxiv.org/pdf/...`) - 提到具體論文(標題或 ID)並要 summary / 摘要 / 重點 - 問「這篇論文跟其他方法差在哪」 ## How to do it 1. 從 URL 抓 paper 內容(用 Claude Code 內建的 WebFetch tool;或在使用者貼了 PDF 時用 Read tool) 2. 套用以下 prompt 結構: - 動機(≤60 字) - 方法(≤60 字) - 結果(≤60 字) - 5 個英文 keyword - 2-3 點跟主流方法的差別 3. 不確定的內容回「論文沒提到」,不要瞎掰 ## References - `references/example-summaries.md` — 3 個範例輸出,照這個風格寫 ``` 放好後,**在這個 repo 裡開 Claude Code**——project-level skill 會自動載入(不需要安裝指令)。Claude 看到 description 跟使用者輸入吻合就會用這個 skill。 驗證它是否生效:在 Claude Code 對話裡貼 `https://arxiv.org/abs/2210.03629`,看 Claude 是不是按你定義的格式回應。 **學到什麼**:project skill 跟 plugin marketplace skill 的差別(這個是 project-level、進到 repo 就生效;plugin 是另一個層級的安裝)、`description` 是觸發機制(不是 magic 的 trigger_phrases 欄位)、references/ 怎麼支援更長的 example。 **進階**:如果想把這個 skill 包成可分享的 plugin(讓別人也能裝在自己的 Claude Code),參考 [Stage 5.4 Plugins & Marketplaces](../stages/05-claude-code-ecosystem.md#54--plugins-與-marketplaces)。本 walkthrough 不展開 plugin 打包流程。 --- ## Stage 6 — 加 RAG memory 讓 agent **記得它看過的論文**,新論文進來時跟過去的比較。 ```python # step6_memory.py import chromadb from chromadb.utils import embedding_functions from langchain_anthropic import ChatAnthropic llm = ChatAnthropic(model="claude-sonnet-4-6") # 開一個本地 vector DB chroma = chromadb.PersistentClient(path="./paper_memory") embed_fn = embedding_functions.DefaultEmbeddingFunction() collection = chroma.get_or_create_collection( name="papers", embedding_function=embed_fn, ) def store_paper(arxiv_id: str, summary: str): """把摘要存進 vector DB.""" collection.add( documents=[summary], ids=[arxiv_id], metadatas=[{"arxiv_id": arxiv_id}], ) def find_similar(query_summary: str, top_k: int = 3) -> list[dict]: """找跟新論文最像的 3 篇。""" results = collection.query(query_texts=[query_summary], n_results=top_k) return [ {"id": id_, "summary": doc} for id_, doc in zip(results["ids"][0], results["documents"][0]) ] # 修改 Stage 4 的 agent,加上 compare_with_memory step: def compare_with_memory(state): new_summary = state["messages"][-1].content similar = find_similar(new_summary, top_k=3) if not similar: return {"comparison": "(資料庫裡沒有相關論文)"} compare_prompt = f"""新論文摘要:{new_summary} 資料庫中最像的 3 篇: {chr(10).join(f"- {p['id']}: {p['summary'][:200]}" for p in similar)} 請點出新論文的 2-3 個 unique contribution(跟以上不重疊的部分)。""" response = llm.invoke(compare_prompt) # 存新論文進 memory store_paper(arxiv_id="...", summary=new_summary) return {"comparison": response.content} ``` 把 `compare_with_memory` 接進 Stage 4 的 graph: ```python # step6_memory.py 接續上面 from step4_langgraph import State, react_agent, reflect, should_continue, MAX_REVISIONS from langgraph.graph import StateGraph, END graph = StateGraph(State) graph.add_node("agent", react_agent) graph.add_node("reflect", reflect) graph.add_node("compare", compare_with_memory) # 新加的 node graph.add_edge("agent", "reflect") graph.add_conditional_edges("reflect", should_continue, {"agent": "agent", END: "compare"}) graph.add_edge("compare", END) graph.set_entry_point("agent") app_with_memory = graph.compile() ``` **學到什麼**:vector DB 怎麼用、embedding 跟相似度查詢、把 agent 從「stateless」變成「有記憶」、persistent storage 的設計、graph 怎麼擴新 node 而不重寫前面的邏輯。 --- ## Stage 7 — Eval + Observability + Deploy ### 7.1 Eval (`promptfoo`) > **裝**:`npm install -g promptfoo` Promptfoo 的 Python provider 要的是「可呼叫的 function」,不是 module 變數。所以先包一個薄 wrapper: ```python # eval_provider.py """Promptfoo Python provider — 給 promptfoo 呼叫的 function。""" from step2_paper_summary import SYSTEM_PROMPT from step3_tool_use import run_agent # Stage 3 寫的 ReAct loop def call_api(prompt: str, options: dict, context: dict) -> dict: """Promptfoo 會傳 vars(context['vars'])+ prompt 進來。""" paper_url = context["vars"]["paper_url"] output = run_agent(f"請摘要這篇論文:{paper_url}") return {"output": output} ``` ```yaml # promptfooconfig.yaml prompts: - "請摘要:{{paper_url}}" providers: - id: file://eval_provider.py label: paper-summary-agent tests: - description: "ReAct paper" vars: paper_url: "https://arxiv.org/abs/2210.03629" assert: - type: contains value: "Reasoning" - type: llm-rubric value: "回應包含 5 個英文關鍵詞、每段不超過 60 字" - description: "RAG paper" vars: paper_url: "https://arxiv.org/abs/2104.08663" assert: - type: contains value: "retrieval" ``` 跑:`promptfoo eval && promptfoo view` ### 7.2 Observability (`langfuse`) > **裝**:`pip install langfuse` > **環境變數**(去 [cloud.langfuse.com](https://cloud.langfuse.com) 申請): > ```bash > export LANGFUSE_PUBLIC_KEY="pk-lf-..." > export LANGFUSE_SECRET_KEY="sk-lf-..." > export LANGFUSE_HOST="https://cloud.langfuse.com" # 或自架的 URL > ``` ```python # step7_observability.py from langfuse.decorators import observe from step3_tool_use import run_agent # 前面 stage 的 agent @observe(name="paper-summary-agent") def run_paper_agent(arxiv_url: str) -> str: return run_agent(f"摘要 {arxiv_url}") if __name__ == "__main__": out = run_paper_agent("https://arxiv.org/abs/2210.03629") print(out) ``` 跑完之後到 Langfuse dashboard 看每次呼叫的 trace、cost、latency、tool use。 ### 7.3 Deploy(Docker + FastAPI) > **裝**:`pip install fastapi uvicorn pydantic` ```python # main.py from fastapi import FastAPI from pydantic import BaseModel from step7_observability import run_paper_agent # 用 Langfuse 包過的版本 app = FastAPI() class PaperRequest(BaseModel): arxiv_url: str @app.post("/summarize") def summarize(req: PaperRequest): return {"summary": run_paper_agent(req.arxiv_url)} ``` ```text # requirements.txt anthropic requests langgraph langchain-anthropic langchain-core chromadb langfuse fastapi uvicorn pydantic ``` ```dockerfile # Dockerfile FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] ``` ```bash docker build -t paper-summary-bot . docker run -p 8000:8000 \ -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \ -e LANGFUSE_PUBLIC_KEY=$LANGFUSE_PUBLIC_KEY \ -e LANGFUSE_SECRET_KEY=$LANGFUSE_SECRET_KEY \ paper-summary-bot # 或 deploy 到 Cloud Run / Fly.io / Railway / 自家 K8s ``` **學到什麼**:eval 怎麼當回歸測試、observability 怎麼讓你 debug production agent、把 agent 從 script 變成 service。 --- ## ✅ 完整 walkthrough 之後你應該能: - [ ] 從零打造 ReAct agent(Stage 3) - [ ] 用 framework 重寫並加進階 pattern(Stage 4) - [ ] 把 agent 包成 Claude Code skill(Stage 5) - [ ] 加 RAG memory 讓 agent 變成有狀態(Stage 6) - [ ] 寫 eval + 接 observability + deploy(Stage 7) **這個範例的程式碼大約 350 行**——比一般的 framework example 多,但每一行都是真的會用到的。 --- ## 🚧 進階延伸 如果你想再玩更深,這個 paper-summary-bot 可以延伸成: - **Multi-agent paper review**:兩個 agent 分別當 supportive reviewer 跟 adversarial reviewer,第三個 agent 當 area chair → for-researcher branch - **Conference report generator**:給定一個 conference proceedings URL,產出每個 track 的高層摘要 → 知識工作者 branch - **同主題論文趨勢追蹤**:每週掃 arXiv,找新論文跟現有 memory 比較,產 weekly digest → 個人助理 branch 每條都對應一個 specialized branch。 --- ## 💡 維護這個 walkthrough 這個範例會隨時間更新——SDK 介面變化、framework 演進、最佳實踐改變。如果你發現某段程式碼跑不起來: 1. 先在 issue 裡回報具體錯誤訊息 + 你的環境(Python 版本、套件版本) 2. PR 修正請說明「為什麼這樣改」 3. 不要把這份檔案改成只 demo 你最熟悉的 framework——這份是給**多元 framework 學習**用的