Phase1 · 教学

🧠 单步Agent逐行讲解

精读 single_step_agent.py —— 每行代码标注干什么为什么

📁 文件结构总览

两个文件各司其职:一个定义工具(双手),一个驱动LLM调用工具(大脑)。

🛠 tools.py — 工具定义(双手)

定义两个函数:web_search() 联网搜索、calculate() 数学计算。
Phase1用假数据模拟,Phase3接入真实API。

🧠 single_step_agent.py — Agent主程序(大脑+双手协调者)

向LLM注册工具 → LLM决定是否调用 → 执行工具 → 结果回传LLM → 生成最终回答。
这就是 Think → Act → Observe 循环的单步版本。

🛠 tools.py 逐行精读

点击任意一行代码,展开该行的详细注释。

tools.py
1"""工具定义:Agent的双手。Phase1用模拟数据,Phase3接真实API。"""
干什么:模块级文档字符串(docstring),说明这个文件的用途。
为什么:约定俗成,读代码的人第一行就知道整个文件是干嘛的。
4def web_search(query: str) -> str:
干什么:定义搜索工具函数,接收查询字符串,返回字符串结果。
为什么:类型注解让LLM的JSON Schema和人类读者都清楚参数格式;返回值统一为str,方便直接拼回对话。
5 # Phase1:模拟搜索;Phase3将替换为真实API(如Tavily/SerpAPI)
干什么:注释说明当前是模拟实现。
为什么:教学阶段先聚焦Agent的调用机制,不被网络请求、API Key等细节干扰;机制学会后,换真实API只需改这个函数体。
6 mock_data = {
干什么:定义一个字典,存放预设的"假搜索结果"。
为什么:字典结构 = 关键词 → 答案,模拟搜索引擎"查词得结果"的行为。
7-16 "北京天气": "北京今天晴,气温15-25°C。", "上海天气": "上海今天多云,气温18-26°C。", "Python": "Python是一种高级编程语言,由Guido van Rossum于1991年发布。", # ... 更多预设条目
干什么:填入若干预设问答对。
为什么:覆盖Demo要演示的用例(天气、常识),保证教学演示可复现、不花钱、不联网
20 for key, value in mock_data.items():
干什么:遍历假数据的每个键值对。
为什么:需要逐条检查用户查询里是否包含某个预设关键词。
21 if key in query:
干什么:关键词匹配——如果查询中包含该关键词……
为什么:用户问"北京今天天气如何",包含"北京天气"(或拆词后的关键词)即可命中,模拟搜索引擎的模糊匹配。
22 return value
干什么:命中即返回对应的假答案。
为什么:模拟搜索"返回第一条结果"的行为。
23 return f"未找到关于'{query}'的相关信息。"
干什么:兜底返回——所有关键词都没命中时给出提示。
为什么:必须永远返回字符串,否则返回None回传给LLM会导致格式错误;同时这也是真实搜索"无结果"情况的模拟。
26def calculate(expression: str) -> str:
干什么:定义计算工具,接收数学表达式字符串(如"3+5*2"),返回结果字符串。
为什么:LLM数学算不准,把计算外包给Python解释器——这是"工具弥补LLM短板"的经典例子。
27 # 安全白名单:只允许数字、运算符、括号、小数点、空格
干什么:注释说明下面的过滤逻辑是安全白名单。
为什么:下一步要用eval执行用户输入,eval能执行任意Python代码(包括删文件!),必须先过滤。
28-37 allowed = set("0123456789+-*/().% ") if not all(c in allowed for c in expression): return "错误:表达式包含非法字符"
干什么:逐字符检查表达式,发现白名单外的字符就拒绝执行。
为什么:白名单策略("只放行已知安全的")比黑名单("拦截已知危险的")更安全——危险字符你是列不完的。字母、下划线等全被挡下,杜绝 import os 之类的注入。
40 return str(eval(expression))
干什么:用eval把字符串当数学表达式求值,结果转字符串返回。
为什么:eval一行搞定四则运算优先级,省去手写解析器;因为上面白名单已过滤,此时eval是安全的。生产环境建议用 ast.literal_eval 或专用数学库。

🧠 single_step_agent.py 逐行精读 (最核心)

记住一句话:LLM是大脑,工具是双手,Agent = 大脑 + 双手。

single_step_agent.py
1-7""" 单步Agent:LLM + 工具调用 核心思想: - LLM是大脑(负责思考和决策) - 工具是双手(负责执行动作) - Agent = 大脑 + 双手的协调者 """
干什么:文件头docstring,一句话点透Agent本质。
为什么:这是全文的纲领——LLM只会"说"不会"做",Agent框架负责把LLM的"意图"翻译成真实的函数调用,再把结果喂回去。整个文件都在实现这个协调过程。
9import json
干什么:导入json模块。
为什么:LLM返回的工具参数是JSON字符串(如 '{"expression":"3+5*2"}'),需要 json.loads 解析成Python字典才能调用函数。
10import os
干什么:导入os模块。
为什么:从环境变量读取API Key(OPENAI_API_KEY),密钥绝不能硬编码进代码。
11from openai import OpenAI
干什么:导入OpenAI官方SDK。
为什么:封装了HTTP请求细节,一行 client.chat.completions.create() 就能调用LLM。
13from tools import web_search, calculate
干什么:从tools.py导入两个工具函数。
为什么:双手(工具)与大脑(本文件)分离——职责清晰,加新工具只需改tools.py和注册表。
15-19client = OpenAI( api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL"), # 可选,兼容国产模型 )
干什么:创建LLM客户端对象,读取环境变量中的密钥和接口地址。
为什么:①密钥走环境变量,安全且便于部署;②base_url可选配,让同一份代码能切换到DeepSeek/Kimi等兼容OpenAI协议的模型。这一步相当于"接通大脑的电话线"。
21-52TOOLS = [ { "type": "function", "function": { "name": "web_search", "description": "搜索互联网获取实时信息,如新闻、天气、百科知识", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "搜索关键词"} }, "required": ["query"], }, }, }, { # calculate 的Schema结构同上... }, ]
干什么:定义工具注册表——用JSON Schema格式向LLM描述"你有哪些工具可用、每个工具叫什么名字、干什么用、需要什么参数"。
为什么:LLM看不见你的Python代码!它只能看到这份"说明书"。⚠️ description写得越好,LLM选工具越准——比如写明"实时信息、天气",LLM遇到天气问题才会想到调web_search。required声明必填参数,防止LLM漏传。
54-55TOOL_MAP = { "web_search": web_search, "calculate": calculate }
干什么:建立 工具名字符串 → 真实Python函数 的映射字典。
为什么:LLM只会返回名字(字符串"web_search"),不会返回函数本身。Python里字符串不能直接调用,必须通过这张表"按名取函数"。TOOLS是给LLM看的说明书,TOOL_MAP是给程序用的调度表——两张表各司其职
58def run(user_query: str) -> str:
干什么:定义Agent的主入口函数:传入用户问题,返回最终答案。
为什么:把整个Think-Act-Observe流程封装成一个函数,对外只暴露"问一句,答一句"的简单接口。
59-64 """ 单步Agent主流程: 1. Think —— LLM思考:直接回答 or 调用工具? 2. Act —— 如需工具,执行对应函数 3. Observe—— 把工具结果喂回LLM,生成最终回答 """
干什么:docstring画出流程图。
为什么:这三步是所有Agent框架(ReAct、LangChain、AutoGPT)的最小骨架。看懂这一个函数,就懂了Agent的核心机制。"单步"指最多只调一轮工具(对比Phase2的多步循环)。
66-72 messages = [ {"role": "system", "content": "你是一个智能助手,可以使用工具来帮助用户。"}, {"role": "user", "content": user_query}, ]
干什么:构造对话消息列表:system定人设 + user传问题。
为什么:messages是LLM的唯一记忆——LLM本身无状态,每次调用都要把完整历史发给它。这个列表后面会不断追加(工具调用请求、工具结果),像滚雪球一样越滚越大。system提示词里暗示"可以用工具",能提高LLM主动调工具的意愿。
74-80 # ===== 🤔 Think:第一轮调用,让LLM决策 ===== response = client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=TOOLS, # ← 关键!把工具说明书递给LLM )
干什么:第一轮API调用——把问题 + 工具说明书一起发给LLM,让它决策:这个问题是直接回答,还是需要调工具?
为什么:tools=TOOLS 是Agent与普通聊天机器人的分水岭!传了它,LLM才可能返回 tool_calls(工具调用请求)而不是普通文本。不传,LLM永远只能"说"。
82 choice = response.choices[0]
干什么:取响应中的第一个候选答案。
为什么:API设计上choices是个列表(可请求多个候选),但默认只有1个,取[0]即可。choice里有两个关键字段:finish_reason(为什么停止)和 message(内容/工具调用)。
84-86 if choice.finish_reason == "stop": return choice.message.content # 直接回答,结束
干什么:如果LLM认为不需要工具(finish_reason="stop"),直接返回它的文本回答。
为什么:闲聊、常识题(如"你好""什么是Python")LLM自己就能答,调工具纯属浪费。这个分支让Agent"能不动手就不动手"——好Agent懂得判断何时需要工具。finish_reason="tool_calls"时才进入下面的工具流程。
88-91 tool_calls = choice.message.tool_calls
干什么:提取LLM发出的工具调用请求列表。
为什么:LLM"想调工具"时不会真的执行(它没有手!),只是在回复里写明:"请帮我调用 web_search,参数是 {...}"。每个tool_call包含 id(调用编号)、function.name(工具名)、function.arguments(JSON参数字符串)。
93-95 messages.append(choice.message) # 把LLM的"调用请求"记入对话
干什么:把LLM发出的工具调用请求(assistant消息)追加进messages。
为什么:⚠️ 最容易漏也最关键的一步!第二轮调用时,API会校验:每个tool结果消息之前,必须有对应的assistant tool_call消息,且id匹配。漏了这行,API直接报错。这也符合"对话记忆"逻辑——LLM说过的话也要记入历史。
97-105 # ===== 🔧 Act:逐个执行工具 ===== for tc in tool_calls: func = TOOL_MAP[tc.function.name] # 按名取函数 args = json.loads(tc.function.arguments) # JSON字符串→字典 result = func(**args) # 真正执行!
干什么:循环执行每个工具调用:①TOOL_MAP按名字找到真实函数;②json.loads把参数字符串 '{"query":"北京天气"}' 解析成字典;③**args解包成关键字参数,调用函数得到结果。
为什么:这是Agent真正"动手"的时刻。三行对应三个翻译步骤:名字→函数、JSON→字典、字典→参数。用**解包后,加新工具完全不用改这段代码(开闭原则)。
107-114 # ===== 👁 Observe:把结果回传给LLM ===== messages.append({ "role": "tool", "tool_call_id": tc.id, # 与上面的请求对号入座 "content": result, # 工具返回的内容 })
干什么:以 role="tool" 的特殊消息类型,把工具执行结果追加进messages,并用tool_call_id标明"这是哪个调用的结果"。
为什么:LLM需要"看到"工具的执行结果才能组织最终回答——这就是Observe(观察)。tool_call_id是对账凭证,一个调用对一个结果。⚠️ ensure_ascii=False 出现在工具内部json.dumps时:保证中文不被转义成 \u4f60\u597d,LLM才读得懂。
116-123 # ===== 第二轮调用:LLM根据观察结果生成最终回答 ===== final = client.chat.completions.create( model="gpt-4o-mini", messages=messages, # 已含:问题 + 调用请求 + 工具结果 # 注意:这次不传 tools! ) return final.choices[0].message.content
干什么:第二轮调用LLM:它现在能看到用户问题、自己之前要求调的工具、以及工具返回的真实数据,于是组织语言给出最终回答并返回。
为什么:不传tools是"单步"的关键——LLM无法再发起新调用,只能老老实实总结,流程必然在此结束(若传了就可能连环调用,那是Phase2的多步Agent)。至此一轮 Think→Act→Observe 完成闭环。
127-139if __name__ == "__main__": for q in ["北京今天天气怎么样?", "帮我算一下 (12+8)*3/2 等于多少", "你好,介绍一下你自己"]: print(run(q))
干什么:脚本直接运行时,用三个用例测试Agent。
为什么:三个用例精心覆盖了三条路径——①搜索类(调web_search)②计算类(调calculate)③闲聊类(不调工具,走stop分支)。__name__判断保证被import时不会自动跑测试。

▶️ Demo 运行结果

三个用例分别走通 Agent 的三条路径。

用例1 · 搜索类 run("北京今天天气怎么样?")

🤔 Think : LLM判断需实时信息 → 请求调用 web_search({"query":"北京天气"}) 🔧 Act : TOOL_MAP["web_search"] → 命中关键词"北京天气" 👁 Observe: 返回"北京今天晴,气温15-25°C。" ✅ 最终回答: 北京今天天气晴朗,气温在15-25°C之间,适合外出活动。

用例2 · 计算类 run("帮我算一下 (12+8)*3/2 等于多少")

🤔 Think : LLM判断是数学计算 → 请求调用 calculate({"expression":"(12+8)*3/2"}) 🔧 Act : 白名单校验通过 → eval("(12+8)*3/2") 👁 Observe: 返回"30.0" ✅ 最终回答: (12+8)×3÷2 = 20×3÷2 = 30。

用例3 · 闲聊类 run("你好,介绍一下你自己")

🤔 Think : LLM判断无需工具 → finish_reason = "stop" ⏭️ Act : 跳过工具调用(L84-86分支直接返回) ✅ 最终回答: 你好!我是一个AI助手,可以回答问题、搜索信息和进行计算……

📇 概念速查卡片

六个核心概念,背下来就掌握了单步Agent。

🤔 Think

第一轮LLM调用(带tools=TOOLS)。LLM阅读工具说明书后决策:直接回答(stop)还是请求调工具(tool_calls)。

🔧 Act

执行工具:TOOL_MAP按名取函数 → json.loads解析参数 → func(**args)真正执行。Agent"动手"的时刻。

👁 Observe

把工具结果以 role="tool" + tool_call_id 追加进messages,让LLM"看到"执行结果,供第二轮总结。

📋 TOOLS

工具注册表(JSON Schema),给LLM看的说明书。description质量直接决定LLM选工具的准确率。

🗺 TOOL_MAP

名字→函数的映射字典,给程序用的调度表。LLM只返回字符串名字,必须靠它找到真实函数。

💬 messages

LLM的唯一记忆:system → user → assistant(tool_call) → tool(结果),滚雪球式累积,第二轮调用全靠它。