构建尽可能小的智能体(agent)。
一个模型、三个工具、一个 while 循环,无需任何框架。目标不是上线——而是亲手感受每一个基本原语,从而在之后形成自己的判断。
搭建项目骨架。
在写任何代码之前,我们需要三样东西:一个语料库(智能体将要阅读的文档)、一个存放智能体代码的地方,以及一个安装了 SDK 的 Python 环境。仅此而已。不需要向量数据库,不需要框架,不需要编排层。每增加一个基础设施组件,就多一层遮蔽——它会挡住你正努力学习的那些基本原语。
选择语料库
语料库(corpus)不过是一个装满文本文件的文件夹。选择你自己感兴趣的内容——当智能体出错时,你能立刻察觉。好的选择包括:PostgreSQL 文档、你自己的 Markdown 笔记,或某个开源项目的文档(React、Rust 手册、Kubernetes 概念)。目标是 50–500 个 Markdown 文件。
少于 50 个文件,智能体几乎不需要思考;超过 500 个,你会在检索(retrieval)迭代上花费太多时间。本教程以 PostgreSQL 文档为例。
目录结构
# Create the project mkdir research-agent && cd research-agent mkdir -p corpus agent evals scripts runs # Files we'll fill in over the next steps touch agent/__init__.py touch agent/loop.py # the agentic loop touch agent/tools.py # tool definitions + handlers touch agent/prompts.py # system prompts touch scripts/run.py # CLI entry point touch .env # for API keys
运行 tree(或 ls -R),你会看到:
research-agent/
├── agent/
│ ├── __init__.py
│ ├── loop.py
│ ├── prompts.py
│ └── tools.py
├── corpus/ # populate with your .md files
├── evals/
├── runs/
├── scripts/
│ └── run.py
└── .env
安装依赖
两个 API 的 Python 环境完全相同——只有 SDK 包不同。
# Create a virtual environment python -m venv .venv source .venv/bin/activate # Install Anthropic SDK + utilities pip install anthropic python-dotenv rich
# Create a virtual environment python -m venv .venv source .venv/bin/activate # Install OpenAI SDK + utilities pip install openai python-dotenv rich
三个包,只有一个与服务商相关。python-dotenv 从 .env 读取你的 API 密钥。rich 让追踪(trace)信息更易读。
.env 文件
# .env — get this from console.anthropic.com
ANTHROPIC_API_KEY=sk-ant-api03-...your-key...
# .env — get this from platform.openai.com/api-keys
OPENAI_API_KEY=sk-proj-...your-key...
立刻将 .env 添加到 .gitignore——提交到 git 的密钥会在数小时内被爬取。
可以。智能体的循环、工具和语料库完全相同——只有模型调用部分需要替换。到第一阶段结束时,你会拥有两个行为相同的 loop.py 版本,这是感受两个 API 设计选择所带来的得失的最简洁方式。
安装两个 SDK,并在 .env 中放入两个密钥。否则,现在先选一个。
因为框架会遮蔽你正在学习的内容。LangChain 的 AgentExecutor 是一个 400 行的类,负责管理循环、工具分发、重试、记忆和解析——而这些恰恰是本教程要讲的基本原语。从框架入手,你只会学到如何配置智能体,而不是它的工作原理。
从头构建之后,框架才会成为工具,而非黑盒。
现在运行 git init && git add . && git commit -m "scaffold"。等智能体跑起来之后,你会想要一个干净的检查点来做对比。
定义三个工具。恰好三个。
智能体就是一个在循环中调用工具的模型。没有工具,你只有一个聊天机器人;有了工具,你才有了智能体。三个工具是让研究型智能体展现其推理过程的最少配置。
三个工具及其原因
search_docs(query) 用于查找相关文档——返回文档 ID 加片段,而非全文。片段让上下文(context)保持精简,同时迫使智能体判断哪些文档值得获取。
fetch_doc(doc_id) 读取一篇完整文档。将"查找"与"读取"分开是有意为之:这让智能体的相关性判断在追踪中清晰可见。
submit_answer(answer, citations) 以结构化输出结束循环。没有它,智能体可能只是用纯文本说出答案,而我们将无从提取引用。
编写工具模式(schema)
两个 API 都使用 JSON Schema 定义参数,但包装方式不同。Anthropic 使用 {name, description, input_schema} 包装;OpenAI Responses API 使用 {type: "function", name, description, parameters}。Schema 本身完全相同。
# agent/tools.py TOOLS = [ { "name": "search_docs", "description": ( "Search the corpus by keyword. Returns up to " "5 matches, each with doc_id and 300-char snippet." ), "input_schema": { "type": "object", "properties": { "query": {"type": "string"} }, "required": ["query"], }, }, { "name": "fetch_doc", "description": "Fetch full text of a doc by doc_id.", "input_schema": { "type": "object", "properties": { "doc_id": {"type": "string"} }, "required": ["doc_id"], }, }, { "name": "submit_answer", "description": "Submit final answer. Ends conversation.", "input_schema": { "type": "object", "properties": { "answer": {"type": "string"}, "citations": { "type": "array", "items": {"type": "string"}, }, }, "required": ["answer", "citations"], }, }, ]
# agent/tools.py TOOLS = [ { "type": "function", "name": "search_docs", "description": ( "Search the corpus by keyword. Returns up to " "5 matches, each with doc_id and 300-char snippet." ), "parameters": { "type": "object", "properties": { "query": {"type": "string"} }, "required": ["query"], "additionalProperties": False, }, }, { "type": "function", "name": "fetch_doc", "description": "Fetch full text of a doc by doc_id.", "parameters": { "type": "object", "properties": { "doc_id": {"type": "string"} }, "required": ["doc_id"], "additionalProperties": False, }, }, { "type": "function", "name": "submit_answer", "description": "Submit final answer. Ends conversation.", "parameters": { "type": "object", "properties": { "answer": {"type": "string"}, "citations": { "type": "array", "items": {"type": "string"}, }, }, "required": ["answer", "citations"], "additionalProperties": False, }, }, ]
编写处理函数
处理函数是纯 Python——没有任何服务商相关代码。两个 API 共用同一套处理函数。
# agent/tools.py (continued — shared) from pathlib import Path CORPUS = Path("corpus") def search_docs(query: str) -> list[dict]: """Dumb substring scan. Intentionally bad.""" results = [] q = query.lower() for path in CORPUS.glob("*.md"): text = path.read_text(encoding="utf-8") if q in text.lower(): idx = text.lower().find(q) start = max(0, idx - 100) end = min(len(text), idx + 200) results.append({ "doc_id": path.stem, "snippet": text[start:end].strip(), }) if len(results) >= 5: break return results def fetch_doc(doc_id: str) -> dict: path = CORPUS / f"{doc_id}.md" if not path.exists(): return {"error": f"no doc: {doc_id}"} return { "doc_id": doc_id, "content": path.read_text(encoding="utf-8"), } HANDLERS = { "search_docs": search_docs, "fetch_doc": fetch_doc, # submit_answer is handled in the loop, not here }
单独测试工具
在接触循环之前,先验证工具能正常工作。打开 Python REPL:
>>> from agent.tools import search_docs >>> results = search_docs("connection pool") >>> for r in results: ... print(r["doc_id"], "→", r["snippet"][:60])
runtime-config-connection → ...connection pool can hold up to max_connections...
runtime-config-resource → ...each connection consumes shared memory, so pool size...
pgbouncer-modes → ...connection pooling in PgBouncer operates in three distinct...
libpq-connect → ...PQconnectdb opens a single connection; for connection pool...
ddl-system-columns → ...system catalogs in the connection pool are shared...
五个真实结果,每个都带有片段。简陋但好用。
additionalProperties 和顶层 "type": "function" 是怎么回事?顶层 type: Anthropic 只有一种工具(函数调用),不需要类型判别符。OpenAI Responses 除了函数之外还支持内置工具(web_search、file_search、computer_use),因此需要用 "type": "function" 将用户定义的函数与这些内置工具区分开。
additionalProperties: false: 这启用了 OpenAI 的"严格模式",确保模型的参数与你的 schema 完全匹配。不加此项,模型可能会自造字段。可选但推荐。Anthropic 的 API 以不同方式强制执行 schema 合规性,不需要这个标志。
上下文窗口(context window)是智能体必须管理的资源。如果 search_docs 返回完整文档,每次搜索都会向对话中倾倒约 2 万个令牌(token)。两次搜索后智能体就不堪重负;四次搜索后就会触及上下文限制。
"先片段后获取"迫使智能体判断哪些文档值得消耗这个成本(cost)。这个决策过程正是我们希望在追踪中看到的行为。
不要添加 list_all_docs 工具。你会有这个冲动,但这会培养错误的直觉——你希望智能体基于查询进行推理,而不是浏览加搜索。
编写循环。写得丑一点。
智能体的核心思想:一个 while 循环,每次迭代调用模型,执行它请求的工具,然后将结果回传。其他一切——框架、编排、智能体 SDK——都是围绕这个循环的装饰。
心智模型
历史列表在每次迭代中都会增长。模型在每一轮都能看到完整历史——它记得自己搜索了什么、结果是什么、做了哪些决定。这就是智能体在本次对话中的"记忆"。
这个心智模型对两个 API 完全相同。它们的差异在于命名:Anthropic 将历史称为 messages,OpenAI 称之为 input。Anthropic 返回 content 块;OpenAI 返回 output 项。Anthropic 使用 tool_use/tool_result;OpenAI 使用 function_call/function_call_output。形态相同,词汇不同。
系统提示词(system prompt)
两个 API 完全相同。
# agent/prompts.py SYSTEM_PROMPT = """You are a research assistant for a documentation corpus. You have three tools: - search_docs(query): find relevant documents - fetch_doc(doc_id): read a full document - submit_answer(answer, citations): finish Process: 1. Search for terms related to the user's question. 2. If a snippet looks promising, fetch the full doc. 3. Repeat until you have enough to answer confidently. 4. Submit your answer with citations to doc_ids you used. Rules: - Every claim must be supported by a cited doc. - If the corpus doesn't have the answer, say so honestly. - Don't search for the same thing twice. - Aim for 3-6 tool calls before submitting."""
循环本身
# agent/loop.py — Anthropic Messages API from anthropic import Anthropic from agent.tools import TOOLS, HANDLERS from agent.prompts import SYSTEM_PROMPT client = Anthropic() def run_agent(user_query: str, max_steps: int = 10): messages = [{"role": "user", "content": user_query}] trace = [] for step in range(max_steps): response = client.messages.create( model="claude-sonnet-4-5", max_tokens=4096, system=SYSTEM_PROMPT, tools=TOOLS, messages=messages, ) # Append assistant turn — required by API messages.append({ "role": "assistant", "content": response.content, }) trace.append({"step": step, "response": response}) if response.stop_reason == "end_turn": return {"status": "halted_no_answer", "trace": trace} tool_results = [] for block in response.content: if block.type != "tool_use": continue if block.name == "submit_answer": return { "status": "answered", "answer": block.input["answer"], "citations": block.input["citations"], "steps_used": step + 1, "trace": trace, } try: result = HANDLERS[block.name](**block.input) tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": str(result), }) except Exception as e: tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": f"error: {e}", "is_error": True, }) # All tool results in one user turn messages.append({"role": "user", "content": tool_results}) return {"status": "step_limit", "trace": trace}
# agent/loop.py — OpenAI Responses API import json from openai import OpenAI from agent.tools import TOOLS, HANDLERS from agent.prompts import SYSTEM_PROMPT client = OpenAI() def run_agent(user_query: str, max_steps: int = 10): # Responses API uses a flat list of "items" input_items = [ {"role": "user", "content": user_query} ] trace = [] for step in range(max_steps): response = client.responses.create( model="gpt-5.5", instructions=SYSTEM_PROMPT, tools=TOOLS, input=input_items, ) # Append every output item to history for item in response.output: input_items.append(item.model_dump()) trace.append({"step": step, "response": response}) # Find function_call items in this turn calls = [i for i in response.output if i.type == "function_call"] if not calls: # Model produced text without calling a tool return {"status": "halted_no_answer", "trace": trace} for call in calls: args = json.loads(call.arguments) if call.name == "submit_answer": return { "status": "answered", "answer": args["answer"], "citations": args["citations"], "steps_used": step + 1, "trace": trace, } try: result = HANDLERS[call.name](**args) output = str(result) except Exception as e: output = f"error: {e}" # Match output to call by call_id input_items.append({ "type": "function_call_output", "call_id": call.call_id, "output": output, }) return {"status": "step_limit", "trace": trace}
逻辑相同——形态各异
两个循环在结构上完全一致:发送历史与工具,追加响应,分发工具调用,追加结果,不断重复。但数据形态的差异值得深究。
Anthropic 使用带内容块的消息。 每一轮是一个 {role, content} 对象,其中 content 要么是字符串(用于用户输入),要么是块列表(用于工具调用、结果和文本)。API 强制要求严格的轮次交替:助手轮必须紧接在携带 tool_results 的用户轮之前出现。
OpenAI Responses 使用扁平项目列表。 input 参数是一个项目列表,每个项目都有一个 type:用户消息、function_call 项、function_call_output 项等。无需严格交替;项目之间通过 call_id 关联,而非位置顺序。
两个 API 中最常见的 Bug
anthropic.BadRequestError: Error code: 400 -
{'error': {'type': 'invalid_request_error',
'message': 'messages.1: tool_result block found without
corresponding tool_use block'}}
你的循环忘记在携带工具结果的用户轮之前追加助手轮。修正追加顺序即可。
openai.BadRequestError: Error code: 400 -
{'error': {'message': 'No tool call found for
function_call_output with call_id call_xyz...',
'type': 'invalid_request_error'}}
你在 input 中追加了 function_call_output,但没有匹配的 function_call。你忘记在添加工具结果之前先追加模型的输出项。
错误信息不同,根本错误相同:工具结果需要对应的调用也在历史中存在。
stop_reason 是什么,OpenAI 的等价物是什么?Anthropic 返回 response.stop_reason,取值如 "tool_use"(想调用工具,循环继续)或 "end_turn"(不调用工具直接结束,循环终止)。
OpenAI Responses 没有以同样方式暴露单一的 stop_reason。你需要检查 response.output——如果其中包含 function_call 项,则分发它们;如果只包含文本/消息项,则说明模型已完成。
call_id,而 Anthropic 用 tool_use_id?概念相同,名称不同。两者都是 API 生成的不透明字符串,用于将工具调用与其结果关联起来。模型在调用时返回它;你在输出时也返回它;API 据此完成匹配。
它们存在的原因:当模型在一轮中发出多个工具调用(并行工具,将在第三阶段讲解)时,API 需要知道哪个结果对应哪个调用。若没有 ID,你只能依赖位置顺序,这很脆弱。
max_steps=10?经验值。对于小语料库上的三工具研究智能体,10 步已经很宽裕——大多数查询在 4–6 步内完成。设置上限是出于安全考虑:如果模型陷入循环,我们不希望它无休止地消耗 API 额度。
如果大多数运行都触及步数上限,问题不在于上限——而是智能体卡住了。读取追踪并修复根本问题(通常是提示词问题)。
运行它。逐条观察追踪。做笔记。
现在我们在真实问题上运行智能体,逐行读取它的行为。这是第一阶段中最重要的步骤。第二和第三阶段的所有内容,都是对我们在这里观察到的行为的回应。
示例 1:一次干净的运行
$ python scripts/run.py "What's the default PostgreSQL port?"
以下是你会看到的追踪——无论使用哪个 API 都完全相同:
──────────────────── Step 0 ────────────────────
┌─ thinking ─────────────────────────────────────┐
│ I'll search for "default port" to find the │
│ PostgreSQL configuration. │
└────────────────────────────────────────────────┘
→ search_docs({'query': 'default port'})
──────────────────── Step 1 ────────────────────
┌─ thinking ─────────────────────────────────────┐
│ The snippet from runtime-config-connection │
│ mentions port 5432. Let me fetch the full doc │
│ to confirm. │
└────────────────────────────────────────────────┘
→ fetch_doc({'doc_id': 'runtime-config-connection'})
──────────────────── Step 2 ────────────────────
→ submit_answer({
'answer': 'PostgreSQL listens on TCP port
5432 by default. This can be changed via the
`port` parameter in postgresql.conf.',
'citations': ['runtime-config-connection']
})
─────────────── Final answer ───────────────
PostgreSQL listens on TCP port 5432 by default.
This can be changed via the `port` parameter in
postgresql.conf.
citations: ['runtime-config-connection']
status: answered
三步,路径清晰。 搜索 → 获取 → 提交。智能体没有对一个简单问题过度研究,没有获取多余的文档,引用了且仅引用了它实际使用的内容。
"thinking"块是真实的模型输出——Claude 和 GPT 都经常在工具调用旁边附上简短的文字来说明自己的计划。调试时很有用。
示例 2:一个多跳问题
$ python scripts/run.py "How does PgBouncer's transaction pooling
interact with prepared statements?"
──────────────────── Step 0 ────────────────────
→ search_docs({'query': 'PgBouncer transaction pooling'})
──────────────────── Step 1 ────────────────────
┌─ thinking ─────────────────────────────────────┐
│ Got pgbouncer-modes. I need to fetch the full │
│ doc AND search for prepared statements. │
└────────────────────────────────────────────────┘
→ fetch_doc({'doc_id': 'pgbouncer-modes'})
──────────────────── Step 2 ────────────────────
→ search_docs({'query': 'prepared statements'})
──────────────────── Step 3 ────────────────────
→ fetch_doc({'doc_id': 'sql-prepare'})
──────────────────── Step 4 ────────────────────
┌─ thinking ─────────────────────────────────────┐
│ Prepared statements are session-scoped. In │
│ transaction pooling, server connections rotate │
│ between transactions. They conflict. │
└────────────────────────────────────────────────┘
→ submit_answer({
'answer': 'Transaction pooling and prepared
statements conflict. Prepared statements are
scoped to a session, but transaction pooling
rotates server connections between
transactions...',
'citations': ['pgbouncer-modes', 'sql-prepare']
})
status: answered (5 steps)
双主题分解。 智能体意识到这是一个涉及两个事物的问题,并分别进行了搜索。正确的多跳行为。
顺序搜索-再获取。 每次搜索只引发一次获取决策,而非全部五个。"先片段后获取"的设计在发挥作用。
示例 3:你肯定会遇到的失败模式
现在来看一个智能体出错的问题。粗糙的子字符串搜索让第一阶段的失败模式变得格外清晰:
$ python scripts/run.py "When should I VACUUM versus VACUUM FULL?"
──────────────────── Step 0 ────────────────────
→ search_docs({'query': 'VACUUM'})
──────────────────── Step 1 ────────────────────
┌─ thinking ─────────────────────────────────────┐
│ Got 5 results but they all mention VACUUM │
│ briefly. Let me search more specifically. │
└────────────────────────────────────────────────┘
→ search_docs({'query': 'VACUUM FULL'})
──────────────────── Step 2 ────────────────────
→ search_docs({'query': 'when to use VACUUM'})
──────────────────── Step 3 ────────────────────
┌─ thinking ─────────────────────────────────────┐
│ The third search returned nothing — substring │
│ "when to use" doesn't appear literally. Let │
│ me try fetching routine-vacuuming directly. │
└────────────────────────────────────────────────┘
→ fetch_doc({'doc_id': 'routine-vacuuming'})
──────────────────── Step 4 ────────────────────
→ submit_answer({...})
status: answered (5 steps)
问题 1:冗余搜索。 由于子字符串匹配返回过宽泛的结果,搜索了三次。语义搜索本可在第一次就命中正确文档。
问题 2:短语搜索对自然语言失效。 "when to use VACUUM" 没有返回任何结果,因为没有任何文档包含这个字面短语。
问题 3:靠幸运猜测恢复。 它猜测了 routine-vacuuming,因为这是一个合理的文档名。换成另一个语料库,这招就不管用了。
这就是我们需要的数据。 三个具体理由,说明为什么要在第二阶段升级检索系统。
追踪日志
不要只是观察追踪——把它们记录下来。维护 runs/notes.md:
# Phase 1 trace log
## 2026-05-16 — first runs
### Q: "default postgres port"
- 3 steps, clean. ✓ search → fetch → submit
### Q: "PgBouncer pooling + prepared statements"
- 5 steps, correct answer
- ✓ decomposed into two sub-searches
### Q: "VACUUM vs VACUUM FULL"
- 5 steps, eventually correct
- ✗ 3 redundant searches before progress
- ✗ "when to use VACUUM" returned 0 results
- ✗ recovered by guessing doc_id, lucky
→ Phase 2 needs: semantic similarity, reranker
### Q: "How do I configure SSL?"
- 8 steps, hit token limit on fetch
- ✗ libpq-ssl doc is ~30k tokens
→ need chunking, not full-doc fetches
这份日志是第二阶段的设计压力来源。在编写检索栈之前,你会重新翻阅它。
在简单查询上,几乎相同——两个模型都能很好地处理这类工具调用(tool use)。你可能注意到的差异:
- 严格模式。 OpenAI 的
additionalProperties: false让复杂 schema 的参数解析更可靠。Anthropic 的模型往往无需此提示就能很好地遵循 schema。 - 思考风格。 Claude 倾向于简短的计划,GPT 倾向于更冗长的叙述。如果你想要某种风格,可以调整系统提示词。
- 从工具错误中恢复。 两者都能很好地恢复。我们将在第四阶段系统地测试这一点。
可能是你的语料库太小,或问题太简单,子字符串匹配就能覆盖。试试更难的问题:多跳问题、换了说法的术语、答案是隐含而非明说的概念性问题。粗糙搜索会在这些情况下失效。
如果还是能用,那你发现了一个有用的真相:检索复杂度应与问题复杂度相匹配。对于一个 50 篇文档的个人 Wiki,BM25 可能就够用了。对于有模糊问题的 5 万篇企业文档,你需要第二阶段的全套方案。
halted_no_answer 结束。怎么修?模型在没有调用 submit_answer 的情况下就产生了文本。常见原因:
- 模型认为已经回答了。 工具结果逐字包含了答案;模型用自己的话转述,没有调用工具。
- 模型放弃了。 多次搜索无结果;模型说"找不到"。修复方法:在提示词中加上"如果答案不在语料库中,请调用 submit_answer 并说明'不在语料库中'"。
通常是提示词调整问题,不需要改代码。
永久保存追踪日志。在调整第四阶段的评估(eval)时,你会想起第一阶段哪些问题比较难——它们是绝佳的测试用例。
交付物
一个可以返回带引用的答案、或在无法回答时优雅失败的 CLI 工具。一份包含约 10 次运行及观察的追踪日志。你应该能够清晰说明驱动第二阶段的三个主要失败模式。
- 不超过 100 行 Python 的智能体循环
- 三个带描述的工具,不使用任何框架
- 用 rich 打印的格式良好的追踪信息
- 10 次以上运行并附有笔记
- 已识别并命名的三个主要失败模式