前面 6 篇文章,我们学了发动机原理(Loop)、车架设计(Harness)、接口标准(MCP)、零件品牌对比(框架)、建筑规范(设计模式)。你已经了解了 Agent 的方方面面——但你从来没有亲手造过一个。今天,我们开箱,把零件一个个组装起来,让一个 Agent 真正跑起来。不用任何框架,纯 Python 手写,你会亲眼看到前面讲的每一个概念在代码中是怎么工作的。
这是「小白讲 AI」系列的第 19 篇。第 12 篇我们学了怎么调用 AI API(单次对话)。这一篇我们要在那个基础上,把”单次对话”升级成”自主循环”——让 AI 不只是回答你一次,而是自己持续工作直到任务完成。
一、我们要造什么? 今天的目标是构建一个 “研究助手 Agent” ——你给它一个研究问题,它会:
自主搜索互联网上的相关信息
阅读搜索结果中的关键内容
整理信息并生成一份结构化的研究报告
全程自主决策,你不需要一步步指导它
比如你问:”2026 年最流行的 AI Agent 框架有哪些?”它会自己去搜索、阅读、比较、整理,最后给你一份有条有理的报告。
我们不使用任何框架 ——所有代码都是手写的。这样你能清楚地看到 Agent 的每一个组件在做什么。(下一篇我们再用框架重构,对比两种方式的差异。)
二、技术准备 你需要:
Python 3.10+
Anthropic SDK :pip install anthropic
一个 Claude API Key (参考第 12 篇的获取方法)
1 2 3 4 5 pip install anthropic httpx export ANTHROPIC_API_KEY="你的API Key"
三、Step 1:搭建最简单的 Loop 还记得第 15 篇讲的 Agent Loop 吗?Plan → Execute → Observe → Reflect → Decide。我们先实现一个最简版本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 import anthropicimport jsonclient = anthropic.Anthropic() def run_agent (user_task: str , max_steps: int = 10 ): """运行一个最简单的 Agent Loop""" system_prompt = """你是一个研究助手 Agent。用户会给你一个研究问题, 你需要通过搜索和阅读来收集信息,最终生成一份研究报告。 你可以使用以下工具: - search:搜索互联网 - fetch_url:获取网页内容 当你认为信息足够时,直接输出最终的研究报告。 在报告的开头写上 [报告完成]。""" messages = [{"role" : "user" , "content" : user_task}] for step in range (max_steps): print (f"\n--- 第 {step + 1 } 步 ---" ) response = client.messages.create( model="claude-sonnet-4-6-20250514" , max_tokens=4096 , system=system_prompt, messages=messages, tools=get_tools(), ) if response.stop_reason == "tool_use" : tool_results = handle_tool_calls(response) messages.append({"role" : "assistant" , "content" : response.content}) messages.append({"role" : "user" , "content" : tool_results}) elif response.stop_reason == "end_turn" : final_text = extract_text(response) print (f"\n{'=' *50 } " ) print ("Agent 完成任务!" ) print (f"{'=' *50 } " ) print (final_text) return final_text print (f"\n⚠️ Agent 在 {max_steps} 步内未完成任务" ) return "任务未在限制步数内完成"
看到了吗?这段代码里藏着前面几篇的核心概念:
Loop (第 15 篇):for step in range(max_steps) 就是 Agent 的心跳循环
Bounded Execution (第 14 篇):max_steps = 10 就是熔断机制
State Management (第 14 篇):messages 列表存储了完整的对话状态
Context Assembly (第 13 篇):每次调用模型时,把 system_prompt + messages 组装成上下文
第 14 篇讲过,Tool Registry 是 Harness 的第一个组件。现在我们来定义 Agent 能用的工具。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 def get_tools (): """工具注册表:定义 Agent 可以使用的工具""" return [ { "name" : "search" , "description" : "在互联网上搜索信息。输入搜索关键词,返回相关结果摘要。" , "input_schema" : { "type" : "object" , "properties" : { "query" : { "type" : "string" , "description" : "搜索关键词" } }, "required" : ["query" ] } }, { "name" : "fetch_url" , "description" : "获取指定网页的文本内容。用于深入阅读搜索结果中的链接。" , "input_schema" : { "type" : "object" , "properties" : { "url" : { "type" : "string" , "description" : "要获取内容的网页 URL" } }, "required" : ["url" ] } } ]
每个工具的定义包含三个要素:
name :工具名称
description :工具描述(模型根据这个描述来决定什么时候该用这个工具)
input_schema :参数格式(JSON Schema)
这就是 MCP 的 Tool 原语(第 16 篇)的简化版本——MCP 用的是同样的 JSON Schema 格式来描述工具。
五、Step 3:实现工具执行 定义了工具”菜单”之后,还需要实现工具的实际执行逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 import httpxdef execute_tool (tool_name: str , tool_input: dict ) -> str : """执行工具并返回结果""" if tool_name == "search" : return do_search(tool_input["query" ]) elif tool_name == "fetch_url" : return do_fetch_url(tool_input["url" ]) else : return f"未知工具:{tool_name} " def do_search (query: str ) -> str : """简易搜索实现(使用 DuckDuckGo)""" try : resp = httpx.get( "https://api.duckduckgo.com/" , params={"q" : query, "format" : "json" , "no_html" : 1 }, timeout=10 ) data = resp.json() results = [] for topic in data.get("RelatedTopics" , [])[:5 ]: if "Text" in topic: results.append(topic["Text" ]) elif "Topics" in topic: for sub in topic["Topics" ][:2 ]: if "Text" in sub: results.append(sub["Text" ]) if data.get("Abstract" ): results.insert(0 , f"摘要:{data['Abstract' ]} " ) if not results: return f"搜索 '{query} ' 未找到直接结果。请尝试更具体的关键词。" return f"搜索 '{query} ' 的结果:\n" + "\n" .join( f"- {r} " for r in results[:8 ] ) except Exception as e: return f"搜索失败:{str (e)} " def do_fetch_url (url: str ) -> str : """获取网页内容(截取前 3000 字符)""" try : resp = httpx.get(url, timeout=15 , follow_redirects=True ) text = resp.text import re text = re.sub(r'<script[^>]*>.*?</script>' , '' , text, flags=re.DOTALL) text = re.sub(r'<style[^>]*>.*?</style>' , '' , text, flags=re.DOTALL) text = re.sub(r'<[^>]+>' , ' ' , text) text = re.sub(r'\s+' , ' ' , text).strip() if len (text) > 3000 : text = text[:3000 ] + "\n...[内容已截断]" return f"网页内容:\n{text} " except Exception as e: return f"获取网页失败:{str (e)} "
注意 do_fetch_url 中的截断逻辑——这就是第 13 篇 Context Engineering 在实战中的体现。工具返回的内容可能很长,如果全部放进上下文,会挤占有限的”注意力预算”。截断到 3000 字符是一个实用的策略。
六、Step 4:处理工具调用 当模型决定调用工具时,我们需要解析它的请求、执行工具、然后把结果返回给模型。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 def handle_tool_calls (response ) -> list : """处理模型发出的工具调用请求""" tool_results = [] for block in response.content: if block.type == "tool_use" : tool_name = block.name tool_input = block.input tool_id = block.id print (f" 🔧 调用工具:{tool_name} " ) print (f" 参数:{json.dumps(tool_input, ensure_ascii=False )} " ) result = execute_tool(tool_name, tool_input) preview = result[:100 ] + "..." if len (result) > 100 else result print (f" 结果:{preview} " ) tool_results.append({ "type" : "tool_result" , "tool_use_id" : tool_id, "content" : result }) return tool_results def extract_text (response ) -> str : """从模型响应中提取文本内容""" texts = [] for block in response.content: if hasattr (block, "text" ): texts.append(block.text) return "\n" .join(texts)
七、Step 5:加上 Guardrails(护栏) 第 14 篇讲了四层护栏。在我们的简易 Agent 中,我们实现最关键的两层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 tool_call_counts = {} MAX_CALLS_PER_TOOL = 5 def guarded_execute_tool (tool_name: str , tool_input: dict ) -> str : """带护栏的工具执行""" tool_call_counts[tool_name] = tool_call_counts.get(tool_name, 0 ) + 1 if tool_call_counts[tool_name] > MAX_CALLS_PER_TOOL: return (f"⚠️ 工具 {tool_name} 已调用 {MAX_CALLS_PER_TOOL} 次," "达到上限。请使用已有信息完成任务。" ) if tool_name == "fetch_url" : url = tool_input.get("url" , "" ) blocked = ["localhost" , "127.0.0.1" , "internal" , ".local" ] if any (b in url for b in blocked): return "⚠️ 安全限制:不允许访问内部网络地址。" return execute_tool(tool_name, tool_input)
这段代码实现了两个护栏:
工具调用频率限制 (Bounded Execution):同一个工具最多调用 5 次
URL 安全过滤 (Guardrail Layering 的工具调用护栏):阻止访问内部网络
八、完整代码整合 把上面所有部分拼在一起,就是一个完整的 Agent:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 """ 研究助手 Agent - 小白讲 AI 第 19 篇配套代码 纯手写实现,不使用任何 Agent 框架 """ import anthropicimport httpximport jsonimport reclient = anthropic.Anthropic() def get_tools (): return [ { "name" : "search" , "description" : "在互联网上搜索信息。返回相关结果摘要。" , "input_schema" : { "type" : "object" , "properties" : { "query" : {"type" : "string" , "description" : "搜索关键词" } }, "required" : ["query" ] } }, { "name" : "fetch_url" , "description" : "获取网页的文本内容。用于深入阅读搜索结果。" , "input_schema" : { "type" : "object" , "properties" : { "url" : {"type" : "string" , "description" : "网页 URL" } }, "required" : ["url" ] } } ] tool_call_counts = {} MAX_CALLS_PER_TOOL = 5 def execute_tool (name: str , input : dict ) -> str : tool_call_counts[name] = tool_call_counts.get(name, 0 ) + 1 if tool_call_counts[name] > MAX_CALLS_PER_TOOL: return f"⚠️ {name} 已达调用上限 ({MAX_CALLS_PER_TOOL} 次)。" if name == "search" : return do_search(input ["query" ]) elif name == "fetch_url" : return do_fetch_url(input ["url" ]) return f"未知工具:{name} " def do_search (query: str ) -> str : try : resp = httpx.get( "https://api.duckduckgo.com/" , params={"q" : query, "format" : "json" , "no_html" : 1 }, timeout=10 ) data = resp.json() results = [] if data.get("Abstract" ): results.append(f"摘要:{data['Abstract' ]} " ) for topic in data.get("RelatedTopics" , [])[:5 ]: if "Text" in topic: results.append(topic["Text" ]) if not results: return f"搜索 '{query} ' 无直接结果,请换个关键词。" return "\n" .join(f"- {r} " for r in results[:8 ]) except Exception as e: return f"搜索失败:{e} " def do_fetch_url (url: str ) -> str : if any (b in url for b in ["localhost" , "127.0.0.1" , ".local" ]): return "⚠️ 安全限制:不允许访问内部地址。" try : resp = httpx.get(url, timeout=15 , follow_redirects=True ) text = resp.text text = re.sub(r'<script.*?</script>' , '' , text, flags=re.DOTALL) text = re.sub(r'<style.*?</style>' , '' , text, flags=re.DOTALL) text = re.sub(r'<[^>]+>' , ' ' , text) text = re.sub(r'\s+' , ' ' , text).strip() return text[:3000 ] + ("\n...[已截断]" if len (text) > 3000 else "" ) except Exception as e: return f"获取失败:{e} " def run_agent (task: str , max_steps: int = 10 ): system = """你是一个研究助手 Agent。用户给你一个研究问题, 你需要通过搜索和阅读来收集信息,然后生成一份结构化的研究报告。 工作流程: 1. 先思考需要搜索什么关键词 2. 搜索并阅读结果 3. 如果信息不够,继续搜索 4. 信息足够后,生成报告 报告格式要求: - 有清晰的标题和小节 - 引用信息来源 - 客观呈现事实,不编造内容""" messages = [{"role" : "user" , "content" : task}] tool_call_counts.clear() for step in range (max_steps): print (f"\n🔄 第 {step + 1 } /{max_steps} 步" ) response = client.messages.create( model="claude-sonnet-4-6-20250514" , max_tokens=4096 , system=system, messages=messages, tools=get_tools(), ) if response.stop_reason == "tool_use" : results = [] for block in response.content: if block.type == "tool_use" : print (f" 🔧 {block.name} ({json.dumps(block.input , ensure_ascii=False )[:80 ]} )" ) result = execute_tool(block.name, block.input ) results.append({ "type" : "tool_result" , "tool_use_id" : block.id , "content" : result }) messages.append({"role" : "assistant" , "content" : response.content}) messages.append({"role" : "user" , "content" : results}) elif response.stop_reason == "end_turn" : final = "\n" .join( b.text for b in response.content if hasattr (b, "text" ) ) print (f"\n{'=' *50 } " ) print ("✅ Agent 完成任务!" ) print (f"{'=' *50 } \n" ) print (final) return final print (f"\n⚠️ 达到最大步数 ({max_steps} ),任务未完成。" ) return None if __name__ == "__main__" : run_agent("2026 年最流行的 AI Agent 框架有哪些?请对比它们的优缺点。" )
九、运行效果 运行这个脚本,你会看到类似这样的输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 🔄 第 1/10 步 🔧 search({"query": "2026 AI Agent frameworks popular"}) 🔄 第 2/10 步 🔧 search({"query": "LangGraph vs CrewAI vs OpenAI Agents SDK 2026"}) 🔄 第 3/10 步 🔧 fetch_url({"url": "https://..."}) 🔄 第 4/10 步 🔧 search({"query": "Microsoft Agent Framework AutoGen 2026"}) 🔄 第 5/10 步 ================================================== ✅ Agent 完成任务! ================================================== # 2026 年主流 AI Agent 框架对比报告 ...
Agent 自主决定了搜索什么、阅读什么、什么时候信息足够了可以开始写报告。你没有一步步指导它——它自己完成了整个过程。
十、回顾:代码中的概念对应 最后,让我们把代码和前面 6 篇的概念对应起来:
概念
对应代码
篇目
Context Assembly
system + messages 组装
第 13 篇
上下文截断
text[:3000]
第 13 篇
Tool Registry
get_tools()
第 14 篇
Guardrails
MAX_CALLS_PER_TOOL、URL 过滤
第 14 篇
State Management
messages 列表、tool_call_counts
第 14 篇
Bounded Execution
max_steps = 10
第 14 篇
Agent Loop
for step in range(max_steps)
第 15 篇
Execute → Observe → Decide
调用模型 → 解析响应 → 判断是调工具还是结束
第 15 篇
Tool Schema (类似 MCP)
input_schema 用 JSON Schema 定义
第 16 篇
不用框架,手写 Harness
整个脚本
第 17 篇
Autonomous 模式
整体架构
第 18 篇
每一个我们讲过的概念,都在这 100 行代码里找到了对应。 这就是为什么我们要先讲 6 篇理论再动手——理解了概念之后,代码就不再是”照着抄”,而是”每一行都知道为什么这么写”。
十一、这个 Agent 还缺什么? 我们手写的这个 Agent 能跑,但离”生产级”还差很多。它缺少:
外循环反思 :Agent 跑到一半不会”回头看看做得对不对”
对话历史压缩 :如果步数很多,messages 列表会越来越长
错误重试 :工具调用失败就直接返回错误信息,没有自动重试
持久化 :Agent 的状态全在内存里,程序一退出就丢了
可观测性 :没有 Traces 和 Spans,难以分析性能瓶颈
MCP 集成 :工具是硬编码的,不能动态发现
这些正是框架 帮你解决的问题。下一篇,我们会用 MCP 让这个 Agent 的工具能力指数级扩展,并引入框架来对比差异。
总结 今天我们从零手写了一个能自主工作的 Agent,它虽然只有 100 多行代码,但包含了 Agent 的所有核心组件:
Agent Loop :持续循环直到任务完成或触发熔断
Tool Registry + Execution :定义工具、解析调用、执行并返回结果
Context Assembly :组装 System Prompt + 对话历史 + 工具结果
Guardrails :工具调用频率限制 + URL 安全过滤
Bounded Execution :最大步数限制(熔断器)
State Management :对话历史和工具调用计数
先理解,再使用框架。 当你亲手写过一遍 Agent 的每个组件之后,再去用 LangGraph 或 CrewAI,你就不是”被动使用”,而是”主动选择”——你知道框架在帮你做什么,也知道什么时候该绕过框架自己写。
延伸阅读
Anthropic Claude API 文档 - Tool Use —— Claude 工具调用的完整 API 参考。
Building effective agents - Anthropic —— 从简单 Agent 到生产级系统的进阶指南。
本文完整代码 —— 如果你想直接运行代码,可以在这里找到。
小白讲 AI 系列第 19 篇完。下一篇是系列的最终章——我们要让这个 Agent “联网”:通过 MCP 接入外部工具生态,加入多 Agent 协作,并引入 LangGraph 框架重构,看看从”手写”到”框架”的差异有多大。