用 Tkinter 打造多合一 AI 助手:聊天 + 天气 + 旅游攻略实战
1. 引言
今天要分享的是一个用 Python Tkinter 写成的桌面小工具:ChatRobot。它把多个 AI 能力集合到一个界面里,主要包含三大功能:
- 接入 DeepSeek 大模型,实现智能对话;
- 调用 wttr.in 接口,实现城市天气查询;
- 调用 Tavily Search API,根据城市和天气推荐旅游景点。
其中,智能体(Agent)部分的设计思路参考了 Datawhale 出品的《初识智能体》教程(https://hello-agents.datawhale.cc/#/./chapter1/第一章%20初识智能体),该教程系统讲解了智能体的核心概念、工作流程与工具调用机制,帮助我更好地理解了如何把大模型、工具调用与业务逻辑组合成一个可用的智能体应用。
- 接入 DeepSeek 大模型,实现智能对话;
- 调用 wttr.in 接口,实现城市天气查询;
- 调用 Tavily Search API,根据城市和天气推荐旅游景点。
这个项目很适合作为 Python GUI + AI 接口调用的练习,因为它覆盖了事件驱动编程、HTTP 请求、第三方 API 接入等常见内容。
另外,原始代码里把 DeepSeek 和 Tavily 的密钥直接写在了源码中,这样非常不安全。本文在介绍项目的同时,已经把密钥改成从环境变量读取,避免把真实密钥提交到博客、代码仓库或分享给别人时泄露。
2. 项目功能总览
本项目的主窗口包含几个按钮:
- 聊天:输入问题,调用 DeepSeek 返回回答;
- 查询天气:输入城市,调用 wttr.in 返回天气;
- 旅游攻略:输入城市和天气,调用 Tavily 搜索旅游推荐;
- 退出:关闭程序。
整体界面如下:
ChatRobot
请输入您的问题:[____________]
[ 发送 ] [ 退出 ]
[ 查询天气 ] [ 旅游攻略 ]
聊天窗口是标准 ChatRobot 风格,天气和旅游功能则使用 Toplevel 弹出子窗口,方便独立操作。
3. 环境准备
在运行项目前,需要安装以下依赖:
pip install openai tavily-python requests
说明:
- tkinter 是 Python 标准库,一般情况下无需额外安装;
- openai 用于调用 DeepSeek 的 OpenAI 兼容接口;
- tavily-python 用于调用 Tavily 搜索能力;
- requests 用于调用 wttr.in 天气接口。
4. 密钥安全:不要硬编码
原代码中出现了类似下面这样的硬编码密钥,这是需要重点避免的问题:
client = OpenAI(
api_key="sk-你的真实密钥",
base_url="https://api.deepseek.com"
)
一旦把包含真实密钥的代码提交到 GitHub、粘贴到博客,或者分享给他人,密钥就可能被盗用,带来费用损失或安全风险。
更安全的做法是把密钥放进环境变量,然后在代码中通过 os.environ.get() 读取:
import os
# 方式一:从环境变量读取(推荐)
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY")
TAVILY_API_KEY = os.environ.get("TAVILY_API_KEY")
# 方式二:从配置文件读取(可选)
# 在项目根目录创建 config.ini 文件,内容如下:
# [api]
# deepseek_api_key = 你的DeepSeek密钥
# tavily_api_key = 你的Tavily密钥
#
# 然后使用 configparser 读取:
# import configparser
# config = configparser.ConfigParser()
# config.read("config.ini")
# DEEPSEEK_API_KEY = config.get("api", "deepseek_api_key")
# TAVILY_API_KEY = config.get("api", "tavily_api_key")
在运行程序之前,先设置环境变量。
Windows PowerShell:
$env:DEEPSEEK_API_KEY="你的DeepSeek密钥"
$env:TAVILY_API_KEY="你的Tavily密钥"
Windows CMD:
set DEEPSEEK_API_KEY=你的DeepSeek密钥
set TAVILY_API_KEY=你的Tavily密钥
macOS 或 Linux:
export DEEPSEEK_API_KEY="你的DeepSeek密钥"
export TAVILY_API_KEY="你的Tavily密钥"
这样,代码中就不会出现真实密钥,密钥只保存在本地环境中,分享代码时也更安全。
5. 界面搭建:主窗口与功能入口
Tkinter 代码通常从创建根窗口开始。我们设置窗口标题、大小、并禁止调整尺寸:
import tkinter as tk
root = tk.Tk()
root.title("ChatRobot")
root.geometry("800x800+100+200")
root.resizable(False, False)
聊天区域包含一个输入框和一个多行文本框,使用 grid 布局:
label = tk.Label(root, text="请输入您的问题:", font=("Arial", 12, "bold"))
label.grid(row=0, column=0, padx=10, pady=10, sticky=tk.W)
entry = tk.Entry(root, font=("Arial", 15, "bold"), width=51)
entry.grid(row=0, column=1, padx=10, pady=10, sticky=tk.W)
text = tk.Text(root, height=18, width=64, font=("Arial", 15, "bold"))
text.grid(row=1, column=0, columnspan=2, padx=10, pady=10, sticky=tk.W)
底部再放上发送、退出、查询天气、旅游攻略四个按钮,把聊天、天气和旅游三个功能串联起来。
6. 聊天功能:接入 DeepSeek
点击“发送”按钮后,程序读取输入框内容,然后把消息添加到聊天区域。这里需要注意:如果输入为空,应直接结束函数,避免后续使用未初始化的 client 变量。
def send_message():
query = entry.get().strip()
if not query:
text.insert(tk.END, "系统:请输入您的问题!\n")
return
text.insert(tk.END, "你:" + query + "\n")
entry.delete(0, tk.END)
btn1.config(state=tk.DISABLED, text="正在思考中...")
root.update()
if not DEEPSEEK_API_KEY:
text.insert(tk.END, "系统:未配置 DEEPSEEK_API_KEY 环境变量。\n")
btn1.config(state=tk.NORMAL, text="发送")
return
try:
client = OpenAI(
api_key=DEEPSEEK_API_KEY,
base_url="https://api.deepseek.com",
timeout=30.0, # 设置请求超时时间
)
response = client.chat.completions.create(
model="deepseek-v4-pro",
messages=[
{"role": "system", "content": "You are a helpful assistant"},
{"role": "user", "content": query},
],
stream=False,
reasoning_effort="high",
extra_body={"thinking": {"type": "enabled"}},
)
ans = response.choices[0].message.content
text.insert(tk.END, "机器人:" + ans + "\n")
except openai.APIConnectionError as e:
# 网络连接失败或超时
text.insert(tk.END, f"机器人:网络连接失败,请检查网络后重试。\n详细信息:{e}\n")
except openai.AuthenticationError as e:
# API 密钥无效或已过期
text.insert(tk.END, f"机器人:API 密钥无效或已过期,请检查 DEEPSEEK_API_KEY 配置。\n详细信息:{e}\n")
except openai.RateLimitError as e:
# 请求频率超限或余额不足
text.insert(tk.END, f"机器人:请求过于频繁或账户余额不足,请稍后再试。\n详细信息:{e}\n")
except openai.APIStatusError as e:
# 服务端返回 4xx/5xx 错误
text.insert(tk.END, f"机器人:DeepSeek 服务返回错误(状态码 {e.status_code})。\n详细信息:{e}\n")
except Exception as e:
# 兜底异常处理
text.insert(tk.END, f"机器人:发生未知错误 - {e}\n")
finally:
btn1.config(state=tk.NORMAL, text="发送")
这里使用了 DeepSeek 的 OpenAI 兼容接口,base_url 指向 https://api.deepseek.com。密钥通过环境变量读取,不再出现在源码中。
针对 send_message 函数,我们做了更细致的异常分类:
- 网络连接失败:
openai.APIConnectionError,通常是断网、DNS 解析失败或请求超时; - 密钥无效:
openai.AuthenticationError,说明DEEPSEEK_API_KEY配置错误或已过期; - 请求限流:
openai.RateLimitError,可能是请求过于频繁或账户余额不足; - 服务端错误:
openai.APIStatusError,DeepSeek 服务返回了 4xx/5xx 状态码; - 未知错误:兜底的
Exception,防止程序因未预料的异常而崩溃。
同时,在创建 OpenAI 客户端时增加了 timeout=30.0,避免请求长时间挂起导致界面卡死。
7. 天气查询:调用 wttr.in
天气功能放在一个新的 Toplevel 窗口中。用户输入城市后,程序请求:
https://wttr.in/城市名?format=j1
返回 JSON 后,提取当前天气状况和温度,并插到文本框中显示:
def get_weather():
city = entry1.get().strip()
if not city:
text2.insert(tk.END, "机器人:请输入城市名称\n")
return
btn3.config(state=tk.DISABLED, text="正在查询...")
top1.update()
url = f"https://wttr.in/{city}?format=j1"
try:
response = requests.get(url, timeout=10) # 设置 10 秒超时
response.raise_for_status()
data = response.json()
current_condition = data["current_condition"][0]
weather_desc = current_condition["weatherDesc"][0]["value"]
temp_c = current_condition["temp_C"]
ans = f"{city} 当前天气:{weather_desc},气温 {temp_c} 摄氏度"
text2.insert(tk.END, ans + "\n")
except requests.exceptions.Timeout as e:
# 请求超时
text2.insert(tk.END, f"错误:查询天气超时,请稍后重试。\n详细信息:{e}\n")
except requests.exceptions.ConnectionError as e:
# 网络连接失败
text2.insert(tk.END, f"错误:无法连接到天气服务,请检查网络。\n详细信息:{e}\n")
except requests.exceptions.HTTPError as e:
# 服务端返回 4xx/5xx 错误
status_code = e.response.status_code if e.response is not None else "未知"
text2.insert(tk.END, f"错误:天气服务返回错误(状态码 {status_code})。\n详细信息:{e}\n")
except requests.exceptions.JSONDecodeError as e:
# JSON 解析失败
text2.insert(tk.END, f"错误:天气服务返回的数据格式异常,无法解析。\n详细信息:{e}\n")
except (KeyError, IndexError) as e:
# 数据结构不符合预期,通常是城市名称无效
text2.insert(tk.END, f"错误:解析天气数据失败,可能是城市名称无效。\n详细信息:{e}\n")
except Exception as e:
# 兜底异常处理
text2.insert(tk.END, f"错误:发生未知错误 - {e}\n")
finally:
btn3.config(state=tk.NORMAL, text="查询")
这里同时处理了网络异常和数据解析异常。针对 get_weather 函数,我们做了更细致的异常分类:
- 请求超时:
requests.exceptions.Timeout,网络响应超过 10 秒; - 连接失败:
requests.exceptions.ConnectionError,无法连接到 wttr.in 服务; - HTTP 错误:
requests.exceptions.HTTPError,服务端返回了 4xx/5xx 状态码; - JSON 解析失败:
requests.exceptions.JSONDecodeError,返回内容不是合法的 JSON; - 数据结构异常:
KeyError、IndexError,通常是城市名称无效导致返回的数据结构不符合预期; - 未知错误:兜底的
Exception。
同时,在 requests.get() 中增加了 timeout=10,避免请求长时间挂起导致界面卡死。
8. 旅游攻略:调用 Tavily 搜索
旅游查询窗口需要用户输入城市和天气。随后程序构造一个用于搜索的自然语言问题,交给 Tavily 搜索:
query = f"'{city}' 在'{weather}'天气下最值得去的旅游景点推荐及理由"
tavily = TavilyClient(api_key=TAVILY_API_KEY)
response = tavily.search(
query=query,
search_depth="basic",
include_answer=True
)
当 Tavily 返回综合回答时,直接显示 response["answer"];如果没有综合性回答,则把原始结果格式化成列表展示:
if response.get("answer"):
text3.insert(tk.END, response["answer"] + "\n")
return
formatted_results = []
for result in response.get("results", []):
formatted_results.append(f"- {result['title']}: {result['content']}")
if not formatted_results:
text3.insert(tk.END, "抱歉,没有找到相关的旅游景点推荐。\n")
return
search_results = "根据搜索,为您找到以下信息:\n" + "\n".join(formatted_results)
text3.insert(tk.END, search_results + "\n")
这个设计让旅游推荐既有一个总览性回答,也有多个可继续展开的信息来源,体验更好。
9. 完整代码
下面是整理后的完整代码。密钥已经全部改成通过环境变量读取,不会再泄露真实值:
import os
import tkinter as tk
import requests
from openai import OpenAI
from tavily import TavilyClient
# 从环境变量读取密钥,避免硬编码泄露
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY")
TAVILY_API_KEY = os.environ.get("TAVILY_API_KEY")
root = tk.Tk()
root.title("ChatRobot")
root.geometry("800x800+100+200")
root.resizable(False, False)
label = tk.Label(root, text="请输入您的问题:", font=("Arial", 12, "bold"))
label.grid(row=0, column=0, padx=10, pady=10, sticky=tk.W)
entry = tk.Entry(root, font=("Arial", 15, "bold"), width=51)
entry.grid(row=0, column=1, padx=10, pady=10, sticky=tk.W)
text = tk.Text(root, height=18, width=64, font=("Arial", 15, "bold"))
text.grid(row=1, column=0, columnspan=2, padx=10, pady=10, sticky=tk.W)
def send_message():
query = entry.get().strip()
if not query:
text.insert(tk.END, "系统:请输入您的问题!\n")
return
text.insert(tk.END, "你:" + query + "\n")
entry.delete(0, tk.END)
btn1.config(state=tk.DISABLED, text="正在思考中...")
root.update()
if not DEEPSEEK_API_KEY:
text.insert(tk.END, "系统:未配置 DEEPSEEK_API_KEY 环境变量。\n")
btn1.config(state=tk.NORMAL, text="发送")
return
try:
client = OpenAI(
api_key=DEEPSEEK_API_KEY,
base_url="https://api.deepseek.com"
)
response = client.chat.completions.create(
model="deepseek-v4-pro",
messages=[
{"role": "system", "content": "You are a helpful assistant"},
{"role": "user", "content": query},
],
stream=False,
reasoning_effort="high",
extra_body={"thinking": {"type": "enabled"}},
)
ans = response.choices[0].message.content
text.insert(tk.END, "机器人:" + ans + "\n")
except Exception as e:
text.insert(tk.END, "机器人:" + str(e) + "\n")
finally:
btn1.config(state=tk.NORMAL, text="发送")
def weather():
top1 = tk.Toplevel(root)
top1.title("天气查询")
top1.geometry("800x800+100+200")
top1.resizable(False, False)
tk.Label(top1, text="请输入城市名称:", font=("Arial", 12, "bold")).grid(
row=0, column=0, padx=10, pady=10, sticky=tk.W
)
entry1 = tk.Entry(top1, width=50, font=("Arial", 12, "bold"))
entry1.grid(row=0, column=1, padx=10, pady=10)
text2 = tk.Text(top1, width=64, height=18, font=("Arial", 15, "bold"))
text2.grid(row=1, column=0, columnspan=2, padx=10, pady=10)
def get_weather():
city = entry1.get().strip()
if not city:
text2.insert(tk.END, "机器人:请输入城市名称\n")
return
btn3.config(state=tk.DISABLED, text="正在查询...")
top1.update()
url = f"https://wttr.in/{city}?format=j1"
try:
response = requests.get(url)
response.raise_for_status()
data = response.json()
current_condition = data["current_condition"][0]
weather_desc = current_condition["weatherDesc"][0]["value"]
temp_c = current_condition["temp_C"]
ans = f"{city} 当前天气:{weather_desc},气温 {temp_c} 摄氏度"
text2.insert(tk.END, ans + "\n")
except requests.exceptions.RequestException as e:
text2.insert(tk.END, f"错误:查询天气时遇到网络问题 - {e}\n")
except (KeyError, IndexError) as e:
text2.insert(tk.END, f"错误:解析天气数据失败,可能是城市名称无效 - {e}\n")
finally:
btn3.config(state=tk.NORMAL, text="查询")
btn3 = tk.Button(top1, text="查询", command=get_weather, font=("Arial", 20, "bold"), width=10)
btn3.grid(row=2, column=0, padx=10, pady=10)
tk.Button(top1, text="退出", command=top1.destroy, font=("Arial", 20, "bold"), width=10).grid(
row=2, column=1, padx=10, pady=10
)
def travel():
top2 = tk.Toplevel(root)
top2.title("旅游查询")
top2.geometry("800x800+100+200")
tk.Label(top2, text="请输入城市名称:", font=("Arial", 12, "bold")).grid(
row=0, column=0, padx=10, pady=10, sticky=tk.W
)
entry_city = tk.Entry(top2, width=10, font=("Arial", 12, "bold"))
entry_city.grid(row=0, column=1, padx=10, pady=10)
tk.Label(top2, text="请输入天气:", font=("Arial", 12, "bold")).grid(
row=1, column=0, padx=10, pady=10, sticky=tk.W
)
entry_weather = tk.Entry(top2, width=10, font=("Arial", 12, "bold"))
entry_weather.grid(row=1, column=1, padx=10, pady=10)
text3 = tk.Text(top2, width=64, height=18, font=("Arial", 15, "bold"))
text3.grid(row=2, column=0, columnspan=2, padx=10, pady=10)
def get_attraction():
city = entry_city.get().strip()
weather_text = entry_weather.get().strip()
if not city:
text3.insert(tk.END, "错误:请输入城市。\n")
return
if not weather_text:
text3.insert(tk.END, "错误:请输入天气。\n")
return
if not TAVILY_API_KEY:
text3.insert(tk.END, "错误:未配置 TAVILY_API_KEY 环境变量。\n")
return
btn3.config(state=tk.DISABLED, text="正在查询...")
top2.update()
tavily = TavilyClient(api_key=TAVILY_API_KEY)
query = f"'{city}' 在'{weather_text}'天气下最值得去的旅游景点推荐及理由"
try:
response = tavily.search(
query=query,
search_depth="basic",
include_answer=True
)
if response.get("answer"):
text3.insert(tk.END, response["answer"] + "\n")
return
formatted_results = []
for result in response.get("results", []):
formatted_results.append(f"- {result['title']}: {result['content']}")
if not formatted_results:
text3.insert(tk.END, "抱歉,没有找到相关的旅游景点推荐。\n")
return
search_results = "根据搜索,为您找到以下信息:\n" + "\n".join(formatted_results)
text3.insert(tk.END, search_results + "\n")
except Exception as e:
text3.insert(tk.END, f"错误:执行 Tavily 搜索时出现问题 - {e}\n")
finally:
btn3.config(state=tk.NORMAL, text="查询")
btn3 = tk.Button(top2, text="查询", command=get_attraction, font=("Arial", 20, "bold"), width=10)
btn3.grid(row=3, column=0, padx=10, pady=10)
tk.Button(top2, text="退出", command=top2.destroy, font=("Arial", 20, "bold"), width=10).grid(
row=3, column=1, padx=10, pady=10
)
btn1 = tk.Button(root, text="发送", command=send_message, font=("Arial", 20, "bold"), width=10)
btn1.grid(row=2, column=0, padx=10, pady=10)
tk.Button(root, text="退出", command=root.destroy, font=("Arial", 20, "bold"), width=10).grid(
row=2, column=1, padx=10, pady=10
)
tk.Button(root, text="查询天气", command=weather, font=("Arial", 20, "bold"), bg="lightblue", fg="blue", width=10).grid(
row=3, column=0, padx=10, pady=10
)
tk.Button(root, text="旅游攻略", command=travel, font=("Arial", 20, "bold"), bg="lightblue", fg="blue", width=10).grid(
row=3, column=1, padx=10, pady=10
)
root.mainloop()
10. 运行方法
按照下面的步骤运行项目:
- 安装依赖:
pip install openai tavily-python requests
- 设置环境变量,需要替换成你自己的真实密钥:
export DEEPSEEK_API_KEY="你的DeepSeek密钥"
export TAVILY_API_KEY="你的Tavily密钥"
Windows 用户可以使用 $env:KEY="value" 或 set KEY=value 的方式设置。
- 运行 Python 脚本:
python chatbot.py
启动后即可在主窗口聊天,或分别打开天气查询、旅游攻略窗口体验。
11. 小结
这个项目虽然不大,但已经把桌面 GUI、AI 对话、实时网络请求和第三方搜索服务整合到了一起。对于学习 Python 综合开发来说,是一个很实用的练手案例。
更重要的是,我们借此完成了一次安全改造:不再在源码中硬编码密钥,而是改用环境变量管理。以后如果要把代码分享到博客、GitHub 或教学文章里,就不会意外泄露自己的 API 密钥了。
你可以继续扩展这个项目,例如:
- 增加历史记录保存;
- 使用多线程避免界面卡顿;
- 增加更多服务接口,比如新闻、翻译、日程提醒;
- 把界面换成更现代的 ttkbootstrap 或 PyQt。
多线程改造:避免界面卡顿
当前代码中,send_message、get_weather、get_attraction 三个函数都是同步调用 API,在等待网络响应期间,Tkinter 主线程会被阻塞,导致窗口无响应(表现为“卡死”或“白屏”)。解决办法是把耗时的 API 调用放到子线程中执行,主线程只负责更新界面。
核心思路是:子线程负责网络请求,主线程通过 root.after() 调度界面更新。因为 Tkinter 不是线程安全的,子线程不能直接操作控件,必须把结果交回主线程处理。
以 send_message 为例,改造后的代码如下:
import threading
def send_message():
query = entry.get().strip()
if not query:
text.insert(tk.END, "系统:请输入您的问题!\n")
return
text.insert(tk.END, "你:" + query + "\n")
entry.delete(0, tk.END)
btn1.config(state=tk.DISABLED, text="正在思考中...")
if not DEEPSEEK_API_KEY:
text.insert(tk.END, "系统:未配置 DEEPSEEK_API_KEY 环境变量。\n")
btn1.config(state=tk.NORMAL, text="发送")
return
def worker():
"""子线程:执行耗时的 API 调用"""
try:
client = OpenAI(
api_key=DEEPSEEK_API_KEY,
base_url="https://api.deepseek.com",
timeout=30.0,
)
response = client.chat.completions.create(
model="deepseek-v4-pro",
messages=[
{"role": "system", "content": "You are a helpful assistant"},
{"role": "user", "content": query},
],
stream=False,
reasoning_effort="high",
extra_body={"thinking": {"type": "enabled"}},
)
ans = response.choices[0].message.content
# 通过 after 把结果交回主线程更新界面
root.after(0, lambda: text.insert(tk.END, "机器人:" + ans + "\n"))
except openai.APIConnectionError as e:
root.after(0, lambda: text.insert(tk.END, f"机器人:网络连接失败,请检查网络后重试。\n详细信息:{e}\n"))
except openai.AuthenticationError as e:
root.after(0, lambda: text.insert(tk.END, f"机器人:API 密钥无效或已过期,请检查 DEEPSEEK_API_KEY 配置。\n详细信息:{e}\n"))
except openai.RateLimitError as e:
root.after(0, lambda: text.insert(tk.END, f"机器人:请求过于频繁或账户余额不足,请稍后再试。\n详细信息:{e}\n"))
except openai.APIStatusError as e:
root.after(0, lambda: text.insert(tk.END, f"机器人:DeepSeek 服务返回错误(状态码 {e.status_code})。\n详细信息:{e}\n"))
except Exception as e:
root.after(0, lambda: text.insert(tk.END, f"机器人:发生未知错误 - {e}\n"))
finally:
# 恢复按钮状态也要回到主线程
root.after(0, lambda: btn1.config(state=tk.NORMAL, text="发送"))
# 启动子线程,主线程立即返回,界面保持响应
threading.Thread(target=worker, daemon=True).start()
关键点说明:
threading.Thread(target=worker, daemon=True).start():启动一个守护线程执行 API 调用,主线程立即返回,界面不会卡住;root.after(0, lambda: ...):把界面更新操作调度回主线程执行,避免跨线程操作 Tkinter 控件;daemon=True:设为守护线程,程序退出时子线程会自动结束,不会阻塞关闭;- 按钮状态恢复:
finally块中的按钮恢复操作同样通过root.after()回到主线程执行。
get_weather 和 get_attraction 的改造方式完全一致,只需把 requests.get() 和 tavily.search() 的调用放进 worker 子线程,并把所有 text2.insert / text3.insert 以及按钮状态恢复都包进 root.after(0, lambda: ...) 即可。改造后,即使网络请求耗时较长,窗口也能保持流畅响应,用户可以继续操作或随时关闭窗口。
希望这篇文章对你有所帮助。
更多推荐



所有评论(0)