动手创建第一个智能体

本文记录了基于Python搭建旅行助手智能体的完整实操过程,依托大模型、天气及搜索API实现工具调用,完成天气查询与景点推荐功能,并对代码进行结构化优化,梳理了智能体思考、行动、观察的核心运行逻辑。

最近跟着DataWhale的Hello-Agent项目学习Agent开发,现做实操记录。 相关的代码在DataWhale的教学文档里均可查得,但我还是喜欢动手敲敲内容,可以加点自己的小料。

如果直接复制本文的代码,需要事先完成:

  • convert intendation to spaces (如果发现tab和空格有冲突。)

  • 创建main方法,把循环体放进去。

一、python环境准备

  • 创建项目目录,并创建python虚拟环境
# 创建项目目录
mkdir first_agent && cd first_agent

# 创建python虚拟环境
python3 -m venv .venv

# macOS / Linux
source .venv/bin/activate

# Windows (cmd)
.venv\Scripts\activate

# Windows (PowerShell)
.venv\Scripts\Activate.ps1

# 退出虚拟环境
deactivate

# 安装软件
pip install requests tavily-python openai

二、创建指令模板,定义角色、工具、思考、行动

AGENT_SYSTEM_PROMPT = """
你是一个智能旅行助手。你的任务是分析用户的请求,并使用可用工具一步步地解决问题。

# 可用工具:
 - `get_weather(city: str)`:查询制定城市的实时天气
 - `get_attraction(city: str, weather: str)`:根据城市和天气搜索推荐的旅游景点

# 输出格式要求:
你的每次回复必须严格遵循以下格式,包含一对Thought和Action:

Thought: [你的思考过程和下一步计划]
Action: [你要执行的具体行动]

Action的格式必须是以下之一:
1. 调用工具:function_name(arg_name="arg_value")
2. 结束任务:Finish[最终答案]

# 重要提示:
 - 每次只输出一对Thought-Action
 - Action必须在同一行,不要换行
 - 当收集到足够信息可以回答用户问题时,必须使用Action:Finish[最终答案]格式结束

请开始吧!
"""

三、定义工具类

这里需要两个工具,一个是查询真实天气的工具,另一个是查询并推荐旅游景点的工具。

查询真实天气的工具使用wttr.in

查询并推荐旅游景点的工具使用Tavily Search API,它是一个面向开发者的Web搜索API,专为 AI代理和大语言模型(LLM)设计的实时搜索与信息提取接口,用于增强生成式AI的知识获取能力。

使用Tavily Search API之前,需要先用邮箱注册并获取API Keys。

程序中使用读取配置key的形式调用API,因此需要把自己的key配置到系统的环境变量中。或者,硬编码(生产环境中不推荐)。

# 工具1:查询真实天气
# 了解更多接口内容,可访问https://github.com/chubin/wttr.in
import requests

def get_weather(city: str) -> str:
	"""
	调用wttr.in API查询真实的天气信息
	"""
	url = f"https://wttr.in/{city}?format=j1"
	try:
		resp = requests.get(url)
		resp.raise_for_status()
		data = resp.json()
		current_condition = data['current_condition'][0]
		temp_C = current_condition["temp_C"]
		weather_desc = current_condition["weatherDesc"][0]["value"]
		return f"{city}当前天气:{weather_desc},气温{temp_C}摄氏度"
	except requests.exceptions.RequestException as e:
		return f"错误:网络异常 - {e}"
	except (KeyError, IndexError) as e:
		return f"错误:参数异常 - {e}"


# 工具2:查询并推荐旅游景点
# 
import os
from tavily import TavilyClient

def get_attraction(city: str, weather: str) -> str:
	"""
	根据城市和天气,使用Tavily Search api搜索并返回优化后的景点推荐
	"""

	# 方式一: 通过系统的环境变量获取api_key,需提前配置好
	api_key = os.environ.get("TAVILY_API_KEY")
	# 方式二: 硬编码(生产环境中不推荐)
	api_key = "tvly-dev-xxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
	if not api_key:
	 	return "错误:未配置TAVILY_API_KEY环境变量"

	# 初始化客户端
	client = TavilyClient(api_key = api_key)
	# 构造查询prompt
	query = f"'{city}'在'{weather}'天气下最值得去的旅游景点推荐及理由"

	try:
		# 查询返回结果
		response = client.search(
		    query = query,
		    search_depth = "basic",
		    include_answer = True
		)
		# 如果结果干净,直接使用
		if response.get("answer"):
			return response["answer"]

		# 如果没有综合性回答,则格式化原始结果
		formatted_results = []
		for result in response.get("result",[]):
			formatted_results.append(f"- {result['title']}: {result['content']}")

		if not formatted_results:
			return "抱歉,没有找到相关的旅游景点推荐。"

		return "根据搜索,为您找到以下信息:\n" + "\n".join(formatted_results)
	except Exception as e:
		return f"错误:执行Tavily搜索时出现问题 - {e}"

# 用工具字典统一管理所有工具
available_tools = {
	"get_weather": get_weather,
	"get_attraction": get_attraction
}

四、封装OpenAI大模型的客户端

使用该封装的客户端需要准备好三个信息:model_id,api_key,base_url 以deepseek为例:

base_url (OpenAI):https://api.deepseek.com

api_key:自行创建

model:deepseek-v4-flash 或者 deepseek-v4-pro

from openai import OpenAI

class OpenAICompatibleClient:
	"""
	一个用于调用任何兼容OpenAI接口的LLM服务的客户端
	"""
	def __init__(self, model: str, api_key: str, base_url: str):
		self.model = model
		self.client = OpenAI(api_key = api_key, base_url = base_url)

	def generate(self, prompt: str, system_prompt: str) -> str:
		"""调用LLM API来生成回答"""
		try:
			messages = [
			    {'role': 'system', 'content': system_prompt},
			    {'role': 'user', 'content': prompt}
			]
			response = self.client.chat.completions.create(
				    model = self.model,
				    messages = messages,
				    stream = False
				)
			return response.choices[0].message.content
		except Exception as e:
			print(f"调用LLM API出错:{e}")
			return "错误:调用大模型服务出错"

五、创建循环体

基本框架搭建好后,需要把实例的信息提供完整,并通过循环体执行计划。

实例所需信息整理如下:

  • 工具1:城市(city)
  • 工具2:城市(city),天气(weather),api_key(TAVILY_API_KEY)
  • 大模型客户端:model,api_key,base_url,prompt,system_prompt

智能体的循环体调用工具和传统开发调用后端API稍微有些不同。传统开发调用API时需要明确给出具体参数名和对应的实例值,而这个智能体是通过prompt和正则表达式截取到结果再提示大模型使用工具时应该传递什么参数。

import re

# 配置实例信息
MODEL_ID = "deepseek-v4-flash"
API_KEY = "补充你的deepseek api_key"
BASE_URL = "https://api.deepseek.com"

TAVILY_API_KEY = "补充你的 tavily api_key"

os.environ["TAVILY_API_KEY"] = ("补充你的 tavily api_key")

llm = OpenAICompatibleClient(
        model = MODEL_ID,
        api_key = API_KEY,
        base_url = BASE_URL
	)

# 初始化prompt
user_prompt = "帮我查询今天厦门的天气,根据天气推荐一个合适的旅游景点"
prompt_history = [f"用户请求:{user_prompt}"]

print(f"用户输入:{user_prompt}\n" + "=" * 40)

# 循环体
for i in range(5):
	print(f"--- 循环{i + 1} ---\n")

	# 构建完整的prompt
	full_prompt = "\n".join(prompt_history)

	# 调用大模型思考,其中system_prompt提示了大模型可以调用哪些工具,工具对应有什么参数需要传递
	llm_output = llm.generate(prompt = full_prompt,system_prompt = AGENT_SYSTEM_PROMPT)

	# 模型可能输出多余的Thought-Action对,需要截断
	# re.DOTALL 让 . 可以匹配任意字符(包括换行),所以能跨行匹配内容。
	# 第一个括号:匹配从 Thought: 开始,到 Action: 及其后面内容的连续片段。非贪婪匹配任意字符
	# 第二个括号:用来划定匹配内容的结束位置。下一行出现Thought: / Action: / Observation: 时,当前匹配终止。
	# (?=...) 是正向先行断言,判断后面是否符合规则,但不把这部分字符纳入匹配结果(只做 “边界判断”)
	# (?:...):非捕获组,只分组、不单独提取内容
	match = re.search(r'(Thought:.*?Action:.*?)(?=\n\s*(?:Thought:|Action:|Observation:)|\Z)' , llm_output , re.DOTALL)

	if match:
		truncated = match.group(1).strip()
		if truncated != llm_output.strip():
			llm_output = truncated

	print(f"模型输出:\n{llm_output}")
	prompt_history.append(llm_output)
	
	# 从llm_output字符串里,查找第一个以 Action: 开头的行 / 段落,并提取Action:后面的全部内容
	action_match = re.search(r"Action:(.*)", llm_output, re.DOTALL)

	if not action_match:
		observation = "错误:未解析到Action字段。请确保你的回复严格遵循'Thought:... Action:...'的格式"
		observation_str = f"Observation: {observation}"
		print(f"{observation_str}\n " + "="*40)
		prompt_history.append(observation_str)
		continue

	# 拿到 Action: 后面的完整内容。
	action_str = action_match.group(1).strip()

	if action_str.startswith("Finish"):
		final_answer = re.match(r"Finish\[(.*)\]",action_str).group(1)
		print(f"任务完成,最终答案:{final_answer}")
		break

	# 提取函数名
	tool_name = re.search(r"(\w+)\(", action_str).group(1)
	# 提取括号内参数
	args_str = re.search(r"\((.*)\)", action_str).group(1)
	# 解析 key="value" 成字典
	kwargs = dict(re.findall(r'(\w+)="([^"]*)"', args_str))

	if tool_name in available_tools:
		observation = available_tools[tool_name](**kwargs)
	else:
		observation = f"错误:未定义工具 '{tool_name}'"

	# 记录观察结果
	observation_str = f"Observation: {observation}"
	print(f"{observation_str}\n" + "=" * 40)
	prompt_history.append(observation_str)

六、第一个智能体成功运行

运行结果如下:

用户输入:帮我查询今天厦门的天气,根据天气推荐一个合适的旅游景点
========================================
--- 循环1 ---

模型输出:
Thought: 用户需要查询厦门今天的天气,并根据天气推荐景点。我首先需要获取厦门的实时天气数据。
Action: get_weather(city="厦门")
Observation: 厦门当前天气:Partly Cloudy ,气温23摄氏度
========================================
--- 循环2 ---

模型输出:
Thought: 厦门当前天气为局部多云,适合外出游览。现在根据城市和天气推荐景点。
Action: get_attraction(city="厦门", weather="Partly Cloudy")
Observation: Under partly cloudy weather, visit Gulangyu Island for its artistic atmosphere and > historical buildings, or explore the quaint village of Zengcuo'an for its creative shops and food.
========================================
--- 循环3 ---

模型输出:
Thought: 根据查询结果,厦门天气为局部多云,适合户外活动,推荐景点包括鼓浪屿和曾厝垵。选择其中一个作为推荐。
Action: Finish[根据厦门今天局部多云的天气,建议前往鼓浪屿游览,欣赏艺术氛围和历史建筑。]
任务完成,最终答案:根据厦门今天局部多云的天气,建议前往鼓浪屿游览,欣赏艺术氛围和历史建筑。

七、其他

至此,本应该结束。但我总感觉有些东西堵着让我不痛快。

  1. AGENT_SYSTEM_PROMPT是不是可以抽成skills?

我咨询了一下AI,回答是肯定的。AGENT_SYSTEM_PROMPT中关于“可用工具”的部分完全可以抽成独立的skills结构,这会让代码更易维护、扩展(比如动态增减技能 / 工具),也能避免把工具定义硬编码在纯文本Prompt里。

于是,我开始改造源代码。

from dataclasses import dataclass

@dataclass
class ToolParameter:
  name: str
  type: str
  description: str
  required: bool = True

@dataclass
class Skill:
  name: str
  description: str
  parameters: list[ToolParameter]

# 用类定义Skills
SKILLS = [
  Skill(
  	name = "get_weather",
  	description = "查询指定城市的实时天气",
  	parameters = [ToolParameter("city", "str", "要查询天气的城市名称")]
  	),
  Skill(
  	name = "get_attraction",
  	description = "根据城市和天气搜索推荐的旅游景点",
  	parameters = [
  		ToolParameter("city", "str", "旅游城市名称"),
  		ToolParameter("weather", "str", "该城市的天气描述")
  	]
  	)
]

def generate_system_prompt(skills):
  # 生成工具说明的模板片段
  tools_template = "\n - `{name}({params})`:{desc}"
  tools_str = ""
  for skill in skills:
  	# 拼接参数(如:"city: str, weather: str")
          params_str = ",".join([f"{p.name}:{p.type}" for p in skill.parameters])
          tools_str += tools_template.format(
              name=skill.name, params=params_str, desc=skill.description
          )
  # 完整的system_prompt模板
  system_prompt_template = """
  	你是一个智能旅行助手。你的任务是分析用户的请求,并使用可用工具一步步地解决问题。

  	# 可用工具:{tools}

  	# 输出格式要求:
  	你的每次回复必须严格遵循以下格式,包含一对Thought和Action:

  	Thought: [你的思考过程和下一步计划]
  	Action: [你要执行的具体行动]

  	Action的格式必须是以下之一:
  	1. 调用工具:function_name(arg_name="arg_value")
  	2. 结束任务:Finish[最终答案]

  	# 重要提示:
  	 - 每次只输出一对Thought-Action
  	 - Action必须在同一行,不要换行
  	 - 当收集到足够信息可以回答用户问题时,必须使用Action:Finish[最终答案]格式结束

  	请开始吧!
  	""".strip()
  return system_prompt_template.format(tools = tools_str)

# 生成最终的system_prompt
AGENT_SYSTEM_PROMPT = generate_system_prompt(SKILLS)

修改后智能体正常运行,结果如下:

用户输入:帮我查询今天厦门的天气,根据天气推荐一个合适的旅游景点
========================================
--- 循环1 ---

模型输出:
Thought: 用户需要查询厦门今天的天气,并根据天气推荐景点。首先需要调用 get_weather 获取厦门实时天气信息。
Action: get_weather(city="厦门")
Observation: 厦门当前天气:Overcast ,气温23摄氏度
========================================
--- 循环2 ---

模型输出:
Thought: 获取到厦门当前天气为阴天(Overcast),气温23摄氏度。现在需要根据这个天气推荐景点,调用 get_attraction 工具。
Action: get_attraction(city="厦门", weather="Overcast")
Observation: Under overcast weather, visit Gulangyu Island for its quiet, scenic paths and cultural architecture. It's less crowded and perfect for leisurely strolls.
========================================
--- 循环3 ---

模型输出:
Thought: 已经获取到天气和推荐景点,可以回答用户问题。
Action: Finish[厦门今天天气为阴天,气温23摄氏度。推荐景点:鼓浪屿。阴天时鼓浪屿人少,适合悠闲漫步,欣赏静谧的小路和建筑文化。]
任务完成,最终答案:厦门今天天气为阴天,气温23摄氏度。推荐景点:鼓浪屿。阴天时鼓浪屿人少,适合悠闲漫步,欣赏静谧的小路和建筑文化。

然后,也可以抽成skills.md,只不过代码中需要自行解析md文件。这个工作不是我研究智能体的目的,暂且放下了。

  1. 智能体核心流程梳理

智能体大循环是:思考→执行工具→观察思考→再思考。

核心目标是:(1)大模型根据用户问题判断调用哪个工具(或者结束);(2)代码提取“要执行的动作”;(3)执行动作,反馈结果给大模型;(4)循环。

关于正则表达式,这个案例中,不论大模型输出多少,都只截取了第一对的“Thought - Action”。我在程序中补充了log文件输出,从log内容也可以证实这一点。

从log文件内容可以看出:(1)工作流程:发送prompt → 获取llm回复 → 提取接口参数信息 → 传递参数,调用工具 → 获取工具执行结果,记录observation → 循环。符合“感知 - 思考 - 行动 - 观察”流程(2)发给LLM的完整上下文会不断“记忆”历史信息,包含上一轮发给LLM的完整上下文 + LLM第一对“Thought - Action”的输出 + Observation内容;(3)正则表达式每次只提取第一对的“Thought - Action”。

[2026-06-12 10:36:11] 
============================================================ 【新任务启动】 ============================================================

[2026-06-12 10:36:11] 智能体任务开始运行
[2026-06-12 10:36:11] 【初始用户请求】帮我查询今天厦门的天气,根据天气推荐一个合适的旅游景点
[2026-06-12 10:36:11] 
============================================================ 【第 1 轮循环】 ============================================================

[2026-06-12 10:36:11] 【发给LLM的完整上下文】
用户请求:帮我查询今天厦门的天气,根据天气推荐一个合适的旅游景点
[2026-06-12 10:36:14] 【LLM 原始完整输出】
Thought: 用户需要查询今天厦门的天气,然后根据天气推荐景点。首先调用get_weather获取天气信息。
Action: get_weather(city="厦门")
[2026-06-12 10:36:14] 【提取到的 Action 内容】get_weather(city="厦门")
[2026-06-12 10:36:14] 【识别工具名】get_weather
[2026-06-12 10:36:14] 【原始参数字符串】city="厦门"
[2026-06-12 10:36:14] 【解析后参数字典】{'city': '厦门'}
[2026-06-12 10:36:14] 工具1:get_weather开始运行
[2026-06-12 10:36:19] 【工具执行结果(Observation)】
Observation: 厦门当前天气:Overcast ,气温23摄氏度
[2026-06-12 10:36:19] 
============================================================ 【第 2 轮循环】 ============================================================

[2026-06-12 10:36:19] 【发给LLM的完整上下文】
用户请求:帮我查询今天厦门的天气,根据天气推荐一个合适的旅游景点
Thought: 用户需要查询今天厦门的天气,然后根据天气推荐景点。首先调用get_weather获取天气信息。
Action: get_weather(city="厦门")
Observation: 厦门当前天气:Overcast ,气温23摄氏度
[2026-06-12 10:36:21] 【LLM 原始完整输出】
Thought: 天气已获取为Overcast(阴天),现在根据此天气推荐景点。
Action: get_attraction(city="厦门", weather="Overcast")
[2026-06-12 10:36:21] 【提取到的 Action 内容】get_attraction(city="厦门", weather="Overcast")
[2026-06-12 10:36:21] 【识别工具名】get_attraction
[2026-06-12 10:36:21] 【原始参数字符串】city="厦门", weather="Overcast"
[2026-06-12 10:36:21] 【解析后参数字典】{'city': '厦门', 'weather': 'Overcast'}
[2026-06-12 10:36:21] 工具2:get_attraction开始运行
[2026-06-12 10:36:26] 【工具执行结果(Observation)】
Observation: Under overcast weather, visit Gulangyu Island for cultural and historical sites, and Xiamen Sea World for marine life exhibits. For indoor activities, go to Snow World for a snow experience or Fantasy Dream Kingdom for interactive attractions.
[2026-06-12 10:36:26] 
============================================================ 【第 3 轮循环】 ============================================================

[2026-06-12 10:36:26] 【发给LLM的完整上下文】
用户请求:帮我查询今天厦门的天气,根据天气推荐一个合适的旅游景点
Thought: 用户需要查询今天厦门的天气,然后根据天气推荐景点。首先调用get_weather获取天气信息。
Action: get_weather(city="厦门")
Observation: 厦门当前天气:Overcast ,气温23摄氏度
Thought: 天气已获取为Overcast(阴天),现在根据此天气推荐景点。
Action: get_attraction(city="厦门", weather="Overcast")
Observation: Under overcast weather, visit Gulangyu Island for cultural and historical sites, and Xiamen Sea World for marine life exhibits. For indoor activities, go to Snow World for a snow experience or Fantasy Dream Kingdom for interactive attractions.
[2026-06-12 10:36:31] 【LLM 原始完整输出】
Thought: 已获取厦门今日天气为阴天(Overcast),根据推荐,适合游览文化历史景点。建议选择鼓浪屿作为今日推荐景点。
Action: Finish[推荐您今天去厦门鼓浪屿游览,体验丰富的文化历史景点,适合阴天出行。]
[2026-06-12 10:36:31] 【提取到的 Action 内容】Finish[推荐您今天去厦门鼓浪屿游览,体验丰富的文化历史景点,适合阴天出行。]
[2026-06-12 10:36:31] 【任务结束】最终答案:推荐您今天去厦门鼓浪屿游览,体验丰富的文化历史景点,适合阴天出行。
[2026-06-12 10:36:31] 智能体全部循环执行完毕

至此,我认为对第一个智能体的整体认知算是完整了。

转载须知

本文欢迎转载,但请务必保留原文链接,谢谢!

商业合作请联系邮箱:choibunbing@gmail.com