轻松一刻:来点注释该写的话
def fix_bug(): try: do_something() except Exception as e: # 别问为什么捕获所有异常,问就是怕崩 print("我也不知道哪里错了,但程序还在跑,好耶!") # TODO: 等我睡醒再修 passFunction Calling 实战
介绍
起步文件中包含 3 个工具(天气查询、计算器、时间查询)、完整的 Agent 循环以及 Gradio 界面。
目标 1:
跑通流程,将天气查询功能优化为爬取 wttr.in 网站,再测试以下 4 个问题(重点观察终端输出):
"现在几点了"→ 单工具调用"帮我算 (15+27)*3"→ 单工具调用"北京天气怎么样"→ 单工具调用"北京和上海哪个更热"→多工具调用,观察 LLM 如何连续调用两次 get_weather
目标 2:
a、新增一个工具:search_wikipedia(keyword),用于搜索维基百科(实操中改为爬取豆瓣网,即 search_douban_movie)
b、新增一个工具:read_file(filepath),用于读取本地文件 → 这样 Agent 就能"看"你的文件了,离 RAG 更近一步
目标 3:
a、在 Gradio 界面增加一个"思考过程"展示区,实时显示 Agent 当前正在调用哪个工具
b、修改 system_prompt,让 Agent 更倾向于使用工具,或更倾向于直接回答 → 观察两种 prompt 下 Agent 行为的差异
流程图: 用户提问 ↓ ┌─→ LLM 思考 │ ↓ │ 需要调工具? ──否──→ 输出最终答案,结束 │ ↓ 是 │ 执行工具,拿到结果 │ ↓ └── 把结果喂回 LLM(回到循环开头)
目标 1 解决及测试
将【Day4-6】中已实操过的天气查询工具代码优化过来,测试结果如下:
单工具调用
多工具调用
目标 2 解决及测试
新增两个工具,完整代码见文末。
测试结果如下:
目标 3 解决及测试
a、给 Agent 返回值增加"思考过程"
b、用列表记录思考过程
c、改动三:界面分栏布局
with gr.Row(): with gr.Column(scale=7): # 左侧占 7/12 chatbot = gr.Chatbot(label="💬 对话区域") with gr.Column(scale=5): # 右侧占 5/12 thought_display = gr.Textbox( label="🧠 思考过程", lines=25, # 显示 25 行 interactive=False, # 只读模式 )测试结果如下:
项目源代码
整体项目代码如下:
import os import json import datetime import gradio as gr from openai import OpenAI from dotenv import load_dotenv import requests import re from bs4 import BeautifulSoup import pandas as pd from pathlib import Path load_dotenv() # 读取 .env 里的 DEEPSEEK_API_KEY # ============================================================ # 1. 工具函数 —— Agent 能调用的"手和眼" # ============================================================ # 每个工具就是一个普通的 Python 函数,Agent 会根据用户问题自动决定调用哪个 def get_weather(city: str) -> str: """查询指定城市的天气 """ # # TODO 练习: 你可以把这里改成调用真实天气 API # weather_data = { # "北京": "晴天, 28°C, 湿度45%, 北风3级", # "上海": "多云, 26°C, 湿度65%, 东南风2级", # "广州": "阵雨, 30°C, 湿度80%, 南风3级", # "深圳": "晴转多云, 29°C, 湿度70%, 东风2级", # "成都": "阴天, 24°C, 湿度75%, 微风", # } try: url = f"http://wttr.in/{city}?format=j1&lang=zh" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" "" } result = requests.get(url,headers=headers) result.raise_for_status() data = result.json() current = data["current_condition"][0] area = data["nearest_area"][0] return ( f"{area['areaName'][0]['value']}天气\n" f"天气:{current['weatherDesc'][0]['value']}\n" f"温度:{current['temp_C']}°C(体感{current['FeelsLikeC']}°C)\n" f"湿度:{current['humidity']}%\n" f"风速:{current['windspeedKmph']}km/h" ) except Exception as e: print(e) return f"暂无 {city} 的天气数据" def calculate(expression: str) -> str: """计算数学表达式""" try: # 注意: 实际生产中不要用 eval, 这里为了演示简化处理 # 只允许数字和基本运算符 allowed = set("0123456789+-*/().% ") if not all(c in allowed for c in expression): return "错误: 只支持基本数学运算 (+,-,*,/,%,括号)" result = eval(expression) return f"{expression} = {result}" except Exception as e: return f"计算错误: {str(e)}" def get_current_time() -> str: """获取当前日期和时间""" now = datetime.datetime.now() return now.strftime("%Y年%m月%d日 %H:%M:%S 星期") + \ ["一", "二", "三", "四", "五", "六", "日"][now.weekday()] #2026/08/19 加入新功能,查询豆瓣网 def search_douban_movie(keyword): """ 爬取豆瓣电影信息 :param keyword: 搜索关键词(字符串) :return: 格式化后的电影信息字符串 """ # 设置请求头,模拟浏览器访问 headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 'Referer': 'https://movie.douban.com/', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3', 'Accept-Encoding': 'gzip, deflate, br', 'Connection': 'keep-alive', 'Upgrade-Insecure-Requests': '1' } # 构建搜索URL search_url = f'https://movie.douban.com/subject_search?search_text={keyword}' try: # 发送GET请求 response = requests.get(search_url, headers=headers, timeout=10) response.encoding = 'utf-8' # 检查响应状态 if response.status_code != 200: return f"请求失败,状态码: {response.status_code}" # 解析HTML soup = BeautifulSoup(response.text, 'html.parser') # 查找电影列表 - 豆瓣搜索页面的结构 movie_items = soup.find_all('div', class_='item') if not movie_items: return f"未找到与 '{keyword}' 相关的电影信息" # 存储结果 results = [] results.append(f"搜索结果: '{keyword}'") results.append("-" * 60) # 遍历前5个电影(避免过多) for idx, item in enumerate(movie_items[:5], 1): try: # 提取电影标题 title_elem = item.find('a', class_='title-text') title = title_elem.text.strip() if title_elem else "未知标题" # 提取评分 rating_elem = item.find('span', class_='rating_nums') rating = rating_elem.text.strip() if rating_elem else "暂无评分" # 提取评分人数 rating_people = item.find('span', class_='pl') if rating_people: people_text = rating_people.text.strip() people_match = re.search(r'(\d+)', people_text) people = people_match.group(1) if people_match else "0" else: people = "0" # 提取电影详情链接 link_elem = item.find('a', class_='title-text') link = link_elem['href'] if link_elem else "#" # 提取简短简介 desc_elem = item.find('span', class_='pl') if desc_elem and '简介' in desc_elem.text: # 如果有简介信息,提取 desc = desc_elem.text.strip() else: # 尝试获取其他描述信息 desc = "暂无简介" # 格式化结果 result = f""" 【电影 {idx}】 标题: {title} 评分: {rating} ({people}人评价) 链接: {link} 简介: {desc[:50]}... """ results.append(result.strip()) except Exception as e: results.append(f"【电影 {idx}】 解析失败: {str(e)}") # 返回结果字符串 return "\n\n".join(results) except requests.exceptions.Timeout: return "请求超时,请稍后再试" except requests.exceptions.ConnectionError: return "网络连接失败,请检查网络" except Exception as e: return f"爬取过程中出现错误: {str(e)}" def read_file(filepath): """ 简化版:只读取第一个sheet,输出表格形式 :param filepath: 文件名 :return: 字符串 """ script_dir = Path(__file__).parent # 构建database文件夹路径 database_dir = script_dir / "数据仓库" try: if not os.path.exists(database_dir/filepath): # 获取所有存在的文件 existing_files = [f for f in database_dir.iterdir() if f.is_file()] # 分类文件 excel_files = [f for f in existing_files if f.suffix.lower() in ['.xlsx', '.xls']] other_files = [f for f in existing_files if f.suffix.lower() not in ['.xlsx', '.xls']] # 构建返回信息 result_parts = [] result_parts.append(f"❌ 文件 '{filepath}' 在database文件夹中不存在") result_parts.append("=" * 70) if not existing_files: result_parts.append("📁 database文件夹为空,请放入Excel文件") return "\n".join(result_parts) if excel_files: result_parts.append(f"📊 可用的Excel文件(共{len(excel_files)}个):") result_parts.append("-" * 70) for i, file in enumerate(sorted(excel_files), 1): file_size = file.stat().st_size # 判断是否为xlsx或xls file_type = "📗" if file.suffix.lower() == '.xlsx' else "📘" result_parts.append(f" {i:2d}. {file_type} {file.name} ") return f"文件 '{filepath}' 不存在,数据仓库中有以下数据:{result_parts}" # 读取第一个sheet df = pd.read_excel(database_dir/filepath) result_parts = [] result_parts.append(f"文件: {os.path.basename(database_dir/filepath)}") result_parts.append("=" * 60) # 获取列宽 col_widths = [] for col in df.columns: max_len = max(len(str(col)), df[col].astype(str).str.len().max()) col_widths.append(min(max_len, 20)) # 限制最大宽度 # 生成表格 # 表头 header = "" for col, width in zip(df.columns, col_widths): header += f"{str(col)[:width]:<{width}} | " result_parts.append(header) result_parts.append("-" * len(header)) # 数据行 for idx, row in df.iterrows(): row_str = "" for col, width in zip(df.columns, col_widths): value = str(row[col])[:width] if not pd.isna(row[col]) else "空" row_str += f"{value:<{width}} | " result_parts.append(row_str) if idx >= 100: # 最多显示100行 result_parts.append(f"... 还有 {len(df) - 100} 行数据") break result_parts.append("=" * 60) result_parts.append(f"共 {len(df)} 行, {len(df.columns)} 列") return "\n".join(result_parts) except Exception as e: return f"错误: {str(e)}" # ============================================================ # 2. 工具 Schema 定义 —— 告诉 LLM "你有哪些工具、怎么用" # ============================================================ # 这段 JSON 是 OpenAI Function Calling 的标准格式 # LLM 会根据 name + description + parameters 来决定是否调用某个工具 TOOLS = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的当前天气情况,包括温度、湿度、风力等信息", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "要查询天气的城市名称,如:北京、上海、广州" } }, "required": ["city"] } } }, { "type": "function", "function": { "name": "calculate", "description": "计算数学表达式,支持加减乘除、括号、取余等运算", "parameters": { "type": "object", "properties": { "expression": { "type": "string", "description": "数学表达式,如: 3+5*2, (10+20)/3, 100%7" } }, "required": ["expression"] } } }, { "type": "function", "function": { "name": "get_current_time", "description": "获取当前的日期和时间,不需要任何参数", "parameters": { "type": "object", "properties": {}, "required": [] } } }, { "type": "function", "function": { "name": "search_douban_movie", "description": "对电影相关信息查询豆瓣网", "parameters": { "type": "object", "properties": { "keyword": { "type": "string", "description": "要查询的keyword,例如 我不是药神" } }, "required": ["keyword"] } } }, { "type": "function", "function": { "name": "read_file", "description": "读取本地数据仓库文件获取对应文件信息", "parameters": { "type": "object", "properties": { "filepath": { "type": "string", "description": "filepath,例如 销售数据.xlsx" } }, "required": ["filepath"] } } }, ] # 工具名 → 函数的映射表, 执行时用名字查找函数 TOOL_MAP = { "get_weather": get_weather, "calculate": calculate, "get_current_time": get_current_time, "search_douban_movie": search_douban_movie, "read_file":read_file, } # ============================================================ # 3. Agent 核心循环 —— 这是 Agent 的"大脑循环" # ============================================================ # # 流程图: # 用户提问 # ↓ # ┌─→ LLM 思考 # │ ↓ # │ 需要调工具? ──否──→ 输出最终答案, 结束 # │ ↓ 是 # │ 执行工具, 拿到结果 # │ ↓ # └── 把结果喂回 LLM (回到循环开头) # # 关键理解: LLM 不是一次就给出答案, 而是可能经过多轮 "思考→调工具→看结果→再思考" class FunctionAgent: def __init__(self, api_key: str = None, base_url: str = "https://api.deepseek.com"): self.client = OpenAI( api_key=api_key or os.getenv("DEEPSEEK_API_KEY"), base_url=base_url ) self.model = "deepseek-chat" self.max_iterations = 10 self.system_prompt = ( "你是一个有用的AI助手。你可以使用以下工具来帮助用户:\n" "1. get_weather - 查询城市天气\n" "2. calculate - 计算数学表达式\n" "3. get_current_time - 获取当前时间\n" "4. search_douban_movie - 获取关键词相关电影豆瓣网信息\n" "5. read_file - 读取本地数据仓库数据,没有对应文件时返回所有已存在文件名\n" "规则:\n" "- 如果用户的问题需要使用工具才能回答,请调用对应的工具\n" "- 如果不需要工具就能回答,直接回答即可\n" "- 可以在一次对话中调用多个工具\n" "- 调用工具后,根据工具返回的结果给出最终回答\n" "- 用中文回答" ) def run(self, user_message: str, history: list = None) -> tuple: """ Agent 主循环: 处理用户消息, 返回最终回答和思考过程 返回: (最终回答, 思考过程文本) """ messages = [{"role": "system", "content": self.system_prompt}] if history: messages.extend(history) messages.append({"role": "user", "content": user_message}) # 用于记录思考过程 thought_process = [] thought_process.append(f"📝 用户提问: {user_message}") thought_process.append("=" * 50) for i in range(self.max_iterations): step_info = f"\n🔄 第 {i + 1} 轮思考" thought_process.append(step_info) thought_process.append("-" * 40) # 调用 LLM response = self.client.chat.completions.create( model=self.model, messages=messages, tools=TOOLS, tool_choice="auto", ) msg = response.choices[0].message # 检查是否要调用工具 if msg.tool_calls: thought_process.append("🤔 LLM 决定调用工具:") messages.append(msg) for tool_call in msg.tool_calls: func_name = tool_call.function.name func_args = json.loads(tool_call.function.arguments) thought_process.append(f" 📌 调用工具: {func_name}") thought_process.append(f" 参数: {json.dumps(func_args, ensure_ascii=False)}") # 执行工具 func = TOOL_MAP.get(func_name) if func: result = func(**func_args) else: result = f"错误: 未知工具 {func_name}" thought_process.append(f" ✅ 工具返回: {result[:200]}{'...' if len(result) > 200 else ''}") thought_process.append("") messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": str(result) }) continue else: # LLM 给出最终答案 thought_process.append("💡 LLM 给出最终答案 (无需再调用工具)") thought_process.append("=" * 50) thought_process.append(f"✅ 最终回答: {msg.content}") return msg.content, "\n".join(thought_process) error_msg = "Agent 达到最大循环次数,可能陷入了死循环" thought_process.append(f"❌ {error_msg}") return error_msg, "\n".join(thought_process) def chat_with_thought(self, user_message: str, history: list) -> tuple: """ Gradio 聊天接口: 返回回复、更新后的历史和思考过程 """ # 转换历史格式 openai_history = [] for h in history: if h["role"] == "user": openai_history.append({"role": "user", "content": h["content"]}) elif h["role"] == "assistant": openai_history.append({"role": "assistant", "content": h["content"]}) # 运行 Agent reply, thought = self.run(user_message, openai_history) # 更新历史 history.append({"role": "user", "content": user_message}) history.append({"role": "assistant", "content": reply}) return history, history, thought # ============================================================ # 4. Gradio 界面 —— 带思考过程展示 # ============================================================ def create_interface(): agent = FunctionAgent() with gr.Blocks(title="Function Calling Agent", theme=gr.themes.Soft()) as demo: gr.Markdown(""" # 🤖 Function Calling Agent ### 你的第一个能自主调用工具的 AI Agent 试试问它: - "北京今天天气怎么样?" - "帮我算一下 (15 + 27) * 3" - "现在几点了?" - "北京和上海哪个温度更高?" (多工具调用) - "查询豆瓣电影:我不是药神" """) with gr.Row(): with gr.Column(scale=7): chatbot = gr.Chatbot( label="💬 对话区域", height=500, show_label=True, ) with gr.Column(scale=5): thought_display = gr.Textbox( label="🧠 思考过程", lines=25, show_label=True, interactive=False, placeholder="Agent 的思考过程会在这里显示...", ) with gr.Row(): msg = gr.Textbox( label="输入消息", placeholder="问点什么...", lines=2, scale=4 ) send_btn = gr.Button("🚀 发送", variant="primary", scale=1) with gr.Row(): clear_btn = gr.Button("🗑️ 清空对话", variant="secondary", scale=1) clear_thought_btn = gr.Button("🧹 清空思考过程", variant="secondary", scale=1) # 状态 chat_state = gr.State([]) thought_state = gr.State("") # 事件绑定 def clear_all(): return [], [], "" def send_message(msg_text, history, thought_text): if not msg_text.strip(): return history, history, thought_text # 调用 agent new_history, new_state, thought = agent.chat_with_thought(msg_text, history) return new_history, new_state, thought send_btn.click( send_message, inputs=[msg, chat_state, thought_state], outputs=[chatbot, chat_state, thought_display] ).then( lambda: "", outputs=[msg] ) msg.submit( send_message, inputs=[msg, chat_state, thought_state], outputs=[chatbot, chat_state, thought_display] ).then( lambda: "", outputs=[msg] ) clear_btn.click( clear_all, outputs=[chatbot, chat_state, thought_display] ) clear_thought_btn.click( lambda: "", outputs=[thought_display] ) # 添加一个折叠面板,显示工具列表 with gr.Accordion("🔧 可用工具列表", open=False): gr.Markdown(""" | 工具名称 | 功能描述 | 参数 | |---------|---------|------| | `get_weather` | 查询城市天气 | `city`: 城市名称 | | `calculate` | 计算数学表达式 | `expression`: 数学公式 | | `get_current_time` | 获取当前时间 | 无参数 | | `search_douban_movie` | 查询豆瓣电影 | `keyword`: 电影关键词 | | `read_file` | 读取本地数据文件 | `filepath`: 文件名 | """) return demo # ============================================================ # 5. 启动 # ============================================================ if __name__ == "__main__": if not os.getenv("DEEPSEEK_API_KEY"): print("错误: 请在 .env 文件中设置 DEEPSEEK_API_KEY") exit(1) demo = create_interface() demo.launch(server_name="127.0.0.1", server_port=7860, theme=gr.themes.Soft())总结与展望
本项目完整实现了一个基于 Function Calling 的 AI Agent 实战案例,核心成果包括:将天气查询优化为爬取 wttr.in 网站、新增豆瓣电影搜索与本地文件读取两个工具、在 Gradio 界面中加入思考过程展示区,并验证了单工具调用与多工具调用的完整链路。通过本项目的实践,可以清晰理解 Agent 循环的核心机制:LLM 思考、决定调用工具、执行工具、将结果喂回 LLM 的闭环流程。
不过,当前实现仍存在一些局限性,值得在后续迭代中重点关注:
- 安全风险:
calculate工具使用了eval执行表达式,虽然做了字符白名单过滤,但仍存在被绕过注入的风险,生产环境应改用安全的表达式解析库。 - 爬虫稳定性:豆瓣与 wttr.in 的页面结构可能随时变化,且对请求频率有限制,当前实现缺少重试机制、请求限速和异常降级策略,长期运行容易失效。
- 上下文管理:Agent 每次对话都会把完整历史拼进 messages,长对话下容易超出模型上下文窗口,也缺少对历史消息的裁剪与摘要压缩。
- 工具扩展性:工具注册、参数校验和错误处理都写死在代码里,新增工具需要改动多处,维护成本较高。
基于以上分析,后续可以从以下几个方向继续完善:
- 工具管理框架:将工具定义、注册、执行统一封装为装饰器或配置化机制,降低新增工具的成本,并统一处理参数校验与异常。
- 记忆机制:引入短期记忆(会话内摘要)与长期记忆(向量数据库存储历史关键信息),让 Agent 在多轮对话中保持上下文连贯。
- RAG 集成:将
read_file工具升级为基于向量检索的问答能力,让 Agent 能针对本地文档进行更精准的检索与回答。 - 安全加固:替换
eval为安全计算库,为爬虫增加重试与限速策略,并对工具调用结果做长度截断与敏感信息过滤。
总的来说,本项目已经搭建起一个功能完整的 Agent 雏形,后续只要在安全、稳定与智能化三个方向持续打磨,就能逐步演进为一个可投入实际业务使用的 AI 助手。