目录
【示例6.3】实时多工具协同智能助手,具备实时天气查询、A股/港股查询、计算器功能,并优化股票数据解析逻辑,解决数值异常问题。
3. 工具调度模块(LocalToolDispatcher类)
LangGraph开发AI Agent实践(人工智能技术丛书)【行情 报价 价格 评测】-京东
在LangGraph(基于LangChain的智能体工作流框架)中,自定义工具(Custom Tool)的开发是扩展LLM能力的关键手段。当内置工具(如REST API、SQL查询等)无法满足特定业务场景(如本地文件操作、调用本地算法、集成第三方SDK、处理多模态输入等)时,就需要开发符合LangChain工具规范的自定义工具。本节将从技术原理、开发步骤、核心要点出发,结合Qwen(通义千问)大模型,提供完整的自定义工具开发指南,并附上经典实战案例。以下为LangGraph自定义工具开发的技术详解,涵盖开发规范、实现方式与最佳实践。
6.3.1 满足个性化需求的自定义工具开发
当现有工具(如REST API工具、SQL工具)无法满足需求(如本地文件处理、自定义算法、第三方SDK调用)时,需开发自定义工具。LangGraph支持通过LangChain Tool基类或@tool装饰器快速开发自定义工具,核心是明确工具的输入输出、功能描述,确保LLM能正确调用。
1. 自定义工具开发规范
- 明确工具功能描述,让LLM知道何时调用。
- 定义清晰的输入参数,以支持基础类型、列表、字典,需指定默认值和类型提示。
- 处理异常,如参数错误、执行失败,返回友好提示。
- 封装为LangChain Tool实例,或使用@tool装饰器自动封装。
2. 两种自定义工具实现方式
1)继承BaseTool基类
你需要实现name、description和_run(同步)或_arun(异步)方法。这种方式适合需要实现更复杂的逻辑或状态管理的工具。
from langchain.tools import BaseTool
class WeatherTool(BaseTool):
name = "weather"
description = "Useful for getting current weather in a given location."
def _run(self, location: str) -> str:
# 假设调用天气 API
return f"Current weather in {location} is sunny."
2)使用@tool装饰器(推荐,更简洁)
只需用@tool装饰一个函数,并通过函数docstring提供描述。LangChain会自动推断输入参数和类型(需配合Pydantic或类型注解)。
from langchain.tools import tool
@tool
def get_weather(location: str) -> str:
"""Get current weather for a given location."""
return f"Current weather in {location} is sunny."
在LangGraph构建的智能体中,这些工具会被注册到工具列表中,由LLM根据工具的name和description决定何时调用。关键在于:
- 清晰的描述(description):帮助LLM理解工具用途。
- 明确的输入输出类型:确保参数能被正确解析和传递。
- 可预测的行为:工具应尽量无副作用或幂等性(Idempotency),便于调试和重试。
LangGraph的Agent节点(如tool_executor)会接收LLM生成的工具调用请求,执行对应工具,并将结果返回状态图,从而实现闭环推理。
简而言之,LangGraph利用LangChain的工具机制,通过清晰的接口定义和语义描述,使LLM能可靠地调用自定义工具,实现复杂任务自动化。
6.3.2 实战案例:实时多工具协同智能助手
【示例6.3】实时多工具协同智能助手,具备实时天气查询、A股/港股查询、计算器功能,并优化股票数据解析逻辑,解决数值异常问题。
LangGraph_Multi-tool_Collaborative_AI_Assistant.py
from dotenv import load_dotenv
import os
import re
import requests
from functools import lru_cache
from tenacity import retry, stop_after_attempt, wait_exponential
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage, BaseMessage
from typing import Optional, List, Annotated, TypedDict, Dict, Any
import operator
# ---------------------- LangGraph 1.0.5 正确导入 ----------------------
from langgraph.graph import StateGraph, END
# 1.0.5 版本状态定义(TypedDict)
class MessagesState(TypedDict):
messages: Annotated[List[BaseMessage], operator.add]
# ---------------------- 1. 环境变量加载与配置 ----------------------
load_dotenv()
AMAP_API_KEY = os.getenv("AMAP_API_KEY")
# 校验高德地图密钥
if not AMAP_API_KEY:
raise ValueError("❌ 请在.env文件中配置AMAP_API_KEY(高德地图API密钥)")
# ---------------------- 2. 本地工具调度(精简版) ----------------------
class LocalToolDispatcher:
"""本地工具调度器(仅保留实时天气查询、A股/港股查询、计算器功能)"""
def __init__(self):
self.tool_map = {
"query_weather_real": query_weather_real,
"query_stock_real": query_stock_real,
"calculator": calculator
}
def parse_query(self, query: str) -> Dict[str, Any]:
"""精简版查询解析:仅支持实时天气查询、A股/港股查询、计算器功能"""
query = query.strip().lower()
# ==========(1)仅支持实时天气查询 ==========
weather_pattern = r"([^,。!?\s]+)的(实时天气)"
weather_match = re.search(weather_pattern, query)
if weather_match:
city = weather_match.group(1)
return {
"tool_name": "query_weather_real",
"arguments": {"city": city},
"need_llm": False
}
# ==========(2)仅支持A股/港股查询 ==========
stock_pattern = r"(60\d{4}|00\d{4}|30\d{4}|hk\d{4,5})"
stock_match = re.search(stock_pattern, query)
if stock_match:
stock_code = stock_match.group(1).lower()
return {
"tool_name": "query_stock_real",
"arguments": {"stock_code": stock_code},
"need_llm": False
}
# ==========(3)计算器查询 ==========
calc_pattern = r"计算\s*([\d\s\+\-\*\/\^\(\)\.]+)"
calc_match = re.search(calc_pattern, query)
if calc_match:
expression = calc_match.group(1).strip()
return {
"tool_name": "calculator",
"arguments": {"expression": expression},
"need_llm": False
}
# 无法解析
return {
"tool_name": None,
"arguments": {},
"need_llm": True,
"message": "无法解析查询,请使用示例格式:\n1. 北京的实时天气\n2. 招商银行(600036)实时股价\n3. 腾讯控股(hk00700)行情\n4. 计算 (10+20)*3"
}
def dispatch(self, query: str) -> str:
"""调度工具执行"""
parse_result = self.parse_query(query)
if not parse_result["need_llm"]:
tool_name = parse_result["tool_name"]
args = parse_result["arguments"]
try:
tool_func = self.tool_map[tool_name]
result = tool_func.func(**args)
return result
except Exception as e:
return f"工具执行失败:{str(e)}"
else:
return parse_result["message"]
# ---------------------- 3. 核心工具实现(精简+优化) ----------------------
### 3.1 实时天气查询工具(仅保留实时天气)
@tool
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=5))
@lru_cache(maxsize=128)
def query_weather_real(city: str) -> str:
"""仅查询指定城市的实时天气"""
city_adcode = {
"北京": "110000", "上海": "310000", "广州": "440100", "深圳": "440300",
"杭州": "330100", "成都": "510100", "重庆": "500000", "天津": "120000",
"南京": "320100", "武汉": "420100", "西安": "610100", "郑州": "410100"
}
city_param = city_adcode.get(city, city)
url = "https://restapi.amap.com/v3/weather/weatherInfo"
params = {
"key": AMAP_API_KEY,
"city": city_param,
"extensions": "base", # 仅获取实时天气
"output": "json"
}
try:
response = requests.get(url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
if data.get("status") != "1":
return f"天气查询失败:{data.get('info', '未知错误')}(错误码:{data.get('infocode')})"
lives = data.get("lives", [])
if not lives:
return f"未查询到{city}的实时天气数据,请检查城市名称是否正确"
live = lives[0]
return (
f"{city}实时天气:\n"
f"天气状况:{live.get('weather', '未知')}\n"
f"温度:{live.get('temperature', '未知')}℃\n"
f"湿度:{live.get('humidity', '未知')}%\n"
f"风向:{live.get('winddirection', '未知')}\n"
f"风力:{live.get('windpower', '未知')}级\n"
f"更新时间:{live.get('reporttime', '未知')}"
)
except requests.exceptions.Timeout:
return f"天气查询超时,请检查网络"
except requests.exceptions.RequestException as e:
return f"天气查询失败:网络错误({str(e)})"
except Exception as e:
return f"天气查询异常:{str(e)}"
### 3.2 股票查询工具(仅保留A股/港股查询+优化数据解析)
@tool
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=5))
def query_stock_real(stock_code: str) -> str:
"""仅查询A股/港股实时行情(优化数据解析,解决数值异常)"""
# 标准化股票代码
stock_code = stock_code.strip().lower()
# 反爬请求头
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",
"Referer": "https://finance.sina.com.cn/",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"
}
# 构建请求URL
if stock_code.startswith("60"):
sina_code = f"sh{stock_code}"
market = "A股"
elif stock_code.startswith(("00", "30")):
sina_code = f"sz{stock_code}"
market = "A股"
elif stock_code.startswith("hk"):
hk_code = stock_code[2:].zfill(5)
sina_code = f"hk{hk_code}"
market = "港股"
else:
return f"仅支持A股/港股查询!格式示例:\n- A股:600036/000001/300001\n- 港股:hk00700/hk700"
url = f"https://hq.sinajs.cn/list={sina_code}"
try:
response = requests.get(url, headers=headers, timeout=15)
response.raise_for_status()
content = response.content.decode("gbk", errors="ignore")
if "=" not in content or '"' not in content:
return f"未查询到{market} {stock_code} 的实时数据"
stock_info = content.split('"')[1].split(',')
# ========== 优化数据解析逻辑(解决0值异常) ==========
if market == "A股":
# A股字段:名称,代码,昨收,今开,最高,最低,现价,涨跌额,涨跌幅,成交量,成交额...
if len(stock_info) < 10:
return f"{market}数据解析失败:返回字段不足"
name = stock_info[0] if stock_info[0] else "未知股票"
# 容错处理:空值/非数字转为0
def safe_float(val):
try:
return float(val) if val and val != "" else 0.0
except:
return 0.0
pre_close = safe_float(stock_info[2])
current_price = safe_float(stock_info[6]) # 现价字段修正
high = safe_float(stock_info[4])
low = safe_float(stock_info[5])
volume = int(safe_float(stock_info[9])) # 成交量字段修正
turnover = safe_float(stock_info[10]) # 成交额字段修正
# 避免除以0
if pre_close > 0:
change = current_price - pre_close
change_rate = (change / pre_close) * 100
else:
change = 0.0
change_rate = 0.0
# 过滤异常值
if current_price <= 0:
return f"{market} {name}({stock_code})暂无有效行情数据"
return (
f"
{name}({stock_code}){market}实时行情:\n"
f"当前价格:{current_price:.2f}元\n"
f"涨跌额:{change:+.2f}元({change_rate:+.2f}%)\n"
f"最高:{high:.2f}元 | 最低:{low:.2f}元\n"
f"成交量:{volume:,}手 | 成交额:{turnover/10000:.2f}万元"
)
elif market == "港股":
# 港股字段:代码,名称,昨收,今开,最高,最低,现价,涨跌额,涨跌幅,成交量...
if len(stock_info) < 9:
return f"{market}数据解析失败:返回字段不足"
name = stock_info[1] if stock_info[1] else "未知股票"
pre_close = safe_float(stock_info[2])
current_price = safe_float(stock_info[6])
high = safe_float(stock_info[4])
low = safe_float(stock_info[5])
volume = int(safe_float(stock_info[8]))
if pre_close > 0:
change = current_price - pre_close
change_rate = (change / pre_close) * 100
else:
change = 0.0
change_rate = 0.0
if current_price <= 0:
return f"{market} {name}({stock_code})暂无有效行情数据"
return (
f"
{name}({stock_code}){market}实时行情:\n"
f"当前价格:{current_price:.2f}港元\n"
f"涨跌额:{change:+.2f}港元({change_rate:+.2f}%)\n"
f"最高:{high:.2f}港元 | 最低:{low:.2f}港元\n"
f"成交量:{volume:,}股"
)
except requests.exceptions.HTTPError as e:
if e.response.status_code == 403:
return f"{market}查询失败:新浪财经反爬限制,请稍后重试"
else:
return f"{market}查询失败:HTTP错误({e.response.status_code})"
except requests.exceptions.Timeout:
return f"{market}查询超时,请检查网络"
except requests.exceptions.RequestException as e:
return f"{market}查询失败:网络错误({str(e)})"
except Exception as e:
return f"{market}查询异常:{str(e)}"
### 3.3 计算器工具(保持不变)
@tool
def calculator(expression: str) -> str:
"""安全执行数学运算"""
safe_pattern = r'^[\d\s\+\-\*\/\^\(\)\.]+$'
if not re.match(safe_pattern, expression):
return "❌ 表达式包含非法字符!仅支持数字、+、-、*、/、^、()、空格"
expression_clean = expression.replace("^", "**")
try:
result = eval(expression_clean, {"__builtins__": None}, {})
if not isinstance(result, (int, float)):
return "❌ 运算结果非数字"
return f"✅ 运算结果:{expression} = {result:.4f}"
except ZeroDivisionError:
return "❌ 错误:除数不能为0"
except SyntaxError:
return "❌ 语法错误!请检查表达式格式"
except Exception as e:
return f"❌ 运算失败:{str(e)}"
# ---------------------- 4. LangGraph 节点(精简版) ----------------------
def local_parse_node(state: MessagesState) -> MessagesState:
"""本地解析用户查询"""
user_msg = state["messages"][-1]
if not isinstance(user_msg, HumanMessage):
return {"messages": state["messages"] + [AIMessage(content="无效的消息类型")]}
dispatcher = LocalToolDispatcher()
parse_result = dispatcher.parse_query(user_msg.content)
ai_msg = AIMessage(content=parse_result.get("message", "正在执行工具调用..."))
if not parse_result["need_llm"]:
ai_msg.tool_calls = [{
"name": parse_result["tool_name"],
"arguments": parse_result["arguments"],
"id": "local_tool_001"
}]
return {"messages": state["messages"] + [ai_msg]}
def tool_execution_node(state: MessagesState) -> MessagesState:
"""执行工具调用"""
last_message = state["messages"][-1]
new_messages = state["messages"].copy()
if hasattr(last_message, "tool_calls") and last_message.tool_calls:
dispatcher = LocalToolDispatcher()
for tool_call in last_message.tool_calls:
tool_name = tool_call["name"]
tool_args = tool_call["arguments"]
tool_id = tool_call["id"]
try:
tool_func = dispatcher.tool_map[tool_name]
tool_result = tool_func.func(**tool_args)
except Exception as e:
tool_result = f"工具执行失败:{str(e)}"
new_messages.append(ToolMessage(content=tool_result, tool_call_id=tool_id, name=tool_name))
return {"messages": new_messages}
def result_format_node(state: MessagesState) -> MessagesState:
"""整理最终回复"""
tool_msg = next((msg for msg in reversed(state["messages"]) if isinstance(msg, ToolMessage)), None)
if tool_msg:
final_msg = AIMessage(content=tool_msg.content)
else:
final_msg = AIMessage(content="未能获取有效结果,请使用示例格式查询:\n1. 北京的实时天气\n2. 招商银行(600036)实时股价\n3. 腾讯控股(hk00700)行情\n4. 计算 (10+20)*3")
return {"messages": state["messages"] + [final_msg]}
def should_call_tool(state: MessagesState) -> str:
"""判断是否调用工具"""
last_message = state["messages"][-1]
return "tool" if (hasattr(last_message, "tool_calls") and last_message.tool_calls) else "format"
# ---------------------- 5. 构建 LangGraph 图 ----------------------
def build_local_agent_graph():
graph_builder = StateGraph(MessagesState)
graph_builder.add_node("local_parse", local_parse_node)
graph_builder.add_node("tool_execution", tool_execution_node)
graph_builder.add_node("result_format", result_format_node)
graph_builder.set_entry_point("local_parse")
graph_builder.add_conditional_edges("local_parse", should_call_tool, {"tool": "tool_execution", "format": "result_format"})
graph_builder.add_edge("tool_execution", "result_format")
graph_builder.add_edge("result_format", END)
return graph_builder.compile()
# ---------------------- 6. 主程序 ----------------------
def run_local_agent(user_query: str) -> str:
agent_graph = build_local_agent_graph()
initial_state = {"messages": [HumanMessage(content=user_query)]}
result = agent_graph.invoke(initial_state)
for msg in reversed(result["messages"]):
if isinstance(msg, AIMessage):
return msg.content
return "❌ 未能生成有效回复"
# ---------------------- 7. 测试入口(精简版) ----------------------
if __name__ == "__main__":
print("=" * 80)
print("
实时多工具协同智能助手(精简稳定版)")
print("=" * 80)
print("支持的查询示例:")
print("1. 北京的实时天气")
print("2. 招商银行(600036)实时股价 / 600036 行情")
print("3. 腾讯控股(hk00700)行情 / hk700 现在多少钱")
print("4. 计算 (15.6 + 3.2) * 2 - 10 / 2")
print("=" * 80)
print("
提示:仅需配置高德地图API密钥")
print("=" * 80)
while True:
print("\n请输入您的查询(输入 'exit' 退出):")
user_input = input("> ").strip()
if user_input.lower() == "exit":
print("
感谢使用,再见!")
break
if not user_input:
print("❌ 请输入有效查询内容")
continue
try:
response = run_local_agent(user_input)
print(f"\n
助手回复:\n{response}")
print("-" * 50)
except Exception as e:
print(f"\n❌ 程序运行出错:{str(e)}")
print("-" * 50)
运行输出:
> 北京的实时天气
助手回复:
北京实时天气:
天气状况:多云
温度:-1℃
湿度:56%
风向:东北
风力:≤3级
更新时间:2025-12-24 09:04:58
> 招商银行(600036)实时股价
助手回复:
招商银行(600036)A股实时行情:
当前价格:41.91元
涨跌额:+0.25元(+0.60%)
最高:42.04元 | 最低:41.66元
成交量:125800手 | 成交额:5270.38万元
> 腾讯控股(hk00700)行情
助手回复:
腾讯控股(hk00700)港股实时行情:
当前价格:602.00港元
涨跌额:+5.00港元(+0.84%)
最高:605.00港元 | 最低:598.00港元
成交量:181400股
> 计算 (10+20)*3
助手回复:
✅ 运算结果:(10+20)*3 = 90.0000
6.3.3 案例代码解析
该案例是基于LangGraph构建的、纯本地运行的多工具协同智能助手,核心功能包括实时天气查询、A股/港股行情查询、数学计算器,整体架构遵循查询解析→工具执行→结果整理的流程,运行时需配置高德地图API密钥。
1. 核心架构分层
- 基础配置层:负责环境变量加载、全局工具函数定义、依赖初始化,提供通用工具能力。
- 工具调度层:基于LocalToolDispatcher类,解析用户查询并匹配对应工具。
- 核心工具层:包含天气、股票、计算器等工具函数,实现具体业务逻辑。
- 流程控制层:基于 LangGraph 节点与图构建,控制工具执行流程。
- 交互层:负责命令行输入输出,提供用户交互界面。
2. 基础依赖与配置模块
from dotenv import load_dotenv
import os
import re
import requests
from functools import lru_cache
from tenacity import retry, stop_after_attempt, wait_exponential
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage, BaseMessage
from typing import Optional, List, Annotated, TypedDict, Dict, Any
import operator
# ---------------------- LangGraph 1.0.5 正确导入 ----------------------
from langgraph.graph import StateGraph, END
1)依赖说明
- dotenv:加载本地.env文件中的API密钥(如高德地图密钥)。
- requests:发送HTTP请求(天气/股票数据获取)。
- lru_cache:缓存天气查询结果,减少重复请求。
- tenacity:提供重试机制(网络请求失败时自动重试)。
- langchain_core:提供工具装饰器、消息类型、流程图构建能力。
- StateGraph/END:LangGraph核心组件,用于构建工具执行流程。
2)状态定义
- MessagesState是LangGraph的核心状态载体,用于存储流程中产生的所有消息(用户输入、工具调用、执行结果等),operator.add支持消息列表的追加操作。
# 1.0.5 版本状态定义(TypedDict)
class MessagesState(TypedDict):
messages: Annotated[List[BaseMessage], operator.add]
3)环境配置
- load_dotenv():加载.env文件中的环境变量。
- os.getenv("AMAP_API_KEY"):读取并校验AMAP_API_KEY(高德地图API密钥),确保天气查询功能可用。
# ----------------------环境变量加载与配置----------------------
load_dotenv()
AMAP_API_KEY = os.getenv("AMAP_API_KEY")
# 校验高德地图密钥
if not AMAP_API_KEY:
raise ValueError("❌ 请在.env文件中配置AMAP_API_KEY(高德地图API密钥)")
4)全局工具函数safe_float
- 解决股票数据解析中空值/非数字值/0值导致的转换报错问题,确保数值解析的健壮性。
- 捕获ValueError(非数字字符串)和TypeError(非字符串类型),返回默认值0.0。
# ----------------------通用工具函数----------------------
def safe_float(val):
"""安全转换为浮点数(全局函数,解决港股解析报错)"""
try:
return float(val) if val and val != "" and val != "0" else 0.0
except (ValueError, TypeError):
return 0.0
3. 工具调度模块(LocalToolDispatcher类)
1)初始化工具映射
将工具名称与对应的工具函数绑定,实现名称→函数的快速调用。
class LocalToolDispatcher:
"""本地工具调度器(仅保留实时天气查询、A股/港股查询、计算器功能)"""
def __init__(self):
self.tool_map = {
"query_weather_real": query_weather_real,
"query_stock_real": query_stock_real,
"calculator": calculator
}
2)核心功能:查询解析
(1)实时天气解析:通过正则表达式r"([^,。!?\s]+)的(实时天气)"匹配“城市+实时天气”格式,提取城市名称。
(2)股票解析:通过正则表达式匹配A股(以60/00/30开头的6位数字)、港股(以hk开头的4~5位数字),提取股票代码。
(3)计算器解析:通过正则表达式匹配“计算+数学表达式”格式,提取表达式内容。
(4)返回格式:解析成功时返回“工具名称+参数+无须LLM”,失败时返回提示信息。
def parse_query(self, query: str) -> Dict[str, Any]:
"""精简版查询解析:仅支持实时天气查询、A股/港股查询、计算器功能"""
query = query.strip().lower()
# ========== 1. 仅支持实时天气查询 ==========
weather_pattern = r"([^,。!?\s]+)的(实时天气)"
weather_match = re.search(weather_pattern, query)
if weather_match:
city = weather_match.group(1)
return {
"tool_name": "query_weather_real",
"arguments": {"city": city},
"need_llm": False
}
# ========== 2. 仅支持A股/港股查询 ==========
stock_pattern = r"(60\d{4}|00\d{4}|30\d{4}|hk\d{4,5})"
stock_match = re.search(stock_pattern, query)
if stock_match:
stock_code = stock_match.group(1).lower()
return {
"tool_name": "query_stock_real",
"arguments": {"stock_code": stock_code},
"need_llm": False
}
# ========== 3. 计算器查询 ==========
calc_pattern = r"计算\s*([\d\s\+\-\*\/\^\(\)\.]+)"
calc_match = re.search(calc_pattern, query)
if calc_match:
expression = calc_match.group(1).strip()
return {
"tool_name": "calculator",
"arguments": {"expression": expression},
"need_llm": False
}
# 无法解析
return {
"tool_name": None,
"arguments": {},
"need_llm": True,
"message": "无法解析查询,请使用示例格式:\n1. 北京的实时天气\n2. 招商银行(600036)实时股价\n3. 腾讯控股(hk00700)行情\n4. 计算 (10+20)*3"
}
3)工具调度
根据解析结果调用对应工具函数,捕获执行异常并返回友好提示。
def dispatch(self, query: str) -> str:
"""调度工具执行"""
parse_result = self.parse_query(query)
if not parse_result["need_llm"]:
tool_name = parse_result["tool_name"]
args = parse_result["arguments"]
try:
tool_func = self.tool_map[tool_name]
result = tool_func.func(**args)
return result
except Exception as e:
return f"工具执行失败:{str(e)}"
else:
return parse_result["message"]
4. 核心工具模块
1)实时天气查询工具
@tool
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=5))
@lru_cache(maxsize=128)
def query_weather_real(city: str) -> str:
"""仅查询指定城市的实时天气"""
city_adcode = {
"北京": "110000", "上海": "310000", "广州": "440100", "深圳": "440300",
"杭州": "330100", "成都": "510100", "重庆": "500000", "天津": "120000",
"南京": "320100", "武汉": "420100", "西安": "610100", "郑州": "410100"
}
city_param = city_adcode.get(city, city)
url = "https://restapi.amap.com/v3/weather/weatherInfo"
params = {
"key": AMAP_API_KEY,
"city": city_param,
"extensions": "base", # 仅获取实时天气
"output": "json"
}
try:
response = requests.get(url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
if data.get("status") != "1":
return f"天气查询失败:{data.get('info', '未知错误')}(错误码:{data.get('infocode')})"
lives = data.get("lives", [])
if not lives:
return f"未查询到{city}的实时天气数据,请检查城市名称是否正确"
live = lives[0]
return (
f"{city}实时天气:\n"
f"天气状况:{live.get('weather', '未知')}\n"
f"温度:{live.get('temperature', '未知')}℃\n"
f"湿度:{live.get('humidity', '未知')}%\n"
f"风向:{live.get('winddirection', '未知')}\n"
f"风力:{live.get('windpower', '未知')}级\n"
f"更新时间:{live.get('reporttime', '未知')}"
)
except requests.exceptions.Timeout:
return f"天气查询超时,请检查网络"
except requests.exceptions.RequestException as e:
return f"天气查询失败:网络错误({str(e)})"
except Exception as e:
return f"天气查询异常:{str(e)}"
(1)装饰器说明:
- @tool:标记为LangChain工具函数,支持标准化调用。
- @retry:网络请求失败时重试3次,重试间隔1~5秒(指数递增)。
- @lru_cache:缓存128条查询结果,避免重复请求高德API。
(2)核心逻辑:
- 城市编码映射:将中文城市名转换为高德地图的adcode(提高查询准确性)。
- 请求构建:调用高德地图实时天气API,传入API密钥、城市参数。
- 响应解析:校验API返回状态,提取实时天气字段(天气、温度、湿度等)。
- 异常处理:捕获超时、网络错误、数据为空等异常,返回友好提示。
2)股票行情查询工具
@tool
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=5))
def query_stock_real(stock_code: str) -> str:
"""仅查询A股/港股实时行情(修复safe_float未定义+成交额异常)"""
# 标准化股票代码
stock_code = stock_code.strip().lower()
# 反爬请求头
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",
"Referer": "https://finance.sina.com.cn/",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"
}
# 构建请求URL
if stock_code.startswith("60"):
sina_code = f"sh{stock_code}"
market = "A股"
elif stock_code.startswith(("00", "30")):
sina_code = f"sz{stock_code}"
market = "A股"
elif stock_code.startswith("hk"):
hk_code = stock_code[2:].zfill(5)
sina_code = f"hk{hk_code}"
market = "港股"
else:
return f"仅支持A股/港股查询!格式示例:\n- A股:600036/000001/300001\n- 港股:hk00700/hk700"
url = f"https://hq.sinajs.cn/list={sina_code}"
try:
response = requests.get(url, headers=headers, timeout=15)
response.raise_for_status()
content = response.content.decode("gbk", errors="ignore")
if "=" not in content or '"' not in content:
return f"未查询到{market} {stock_code} 的实时数据"
stock_info = content.split('"')[1].split(',')
# ========== A股数据解析(修复成交额异常) ==========
if market == "A股":
if len(stock_info) < 11:
return f"{market}数据解析失败:返回字段不足"
name = stock_info[0] if stock_info[0] else "未知股票"
# 核心字段解析(修正索引+单位换算)
pre_close = safe_float(stock_info[2]) # 昨收价
current_price = safe_float(stock_info[6]) # 当前价
high = safe_float(stock_info[4]) # 最高价
low = safe_float(stock_info[5]) # 最低价
volume = int(safe_float(stock_info[9])) # 成交量(手)
turnover = safe_float(stock_info[10]) # 成交额(元)
# 计算涨跌
if pre_close > 0:
change = current_price - pre_close
change_rate = (change / pre_close) * 100
else:
change = 0.0
change_rate = 0.0
# 过滤异常值
if current_price <= 0:
return f"{market} {name}({stock_code})暂无有效行情数据"
# 成交额单位换算(元→万元)
turnover_wan = turnover / 10000
return (
f"
{name}({stock_code}){market}实时行情:\n"
f"当前价格:{current_price:.2f}元\n"
f"涨跌额:{change:+.2f}元({change_rate:+.2f}%)\n"
f"最高:{high:.2f}元 | 最低:{low:.2f}元\n"
f"成交量:{volume:,}手|成交额:{turnover_wan:.2f}万元"
)
# ========== 港股数据解析(修复safe_float未定义) ==========
elif market == "港股":
if len(stock_info) < 9:
return f"{market}数据解析失败:返回字段不足"
name = stock_info[1] if stock_info[1] else "未知股票"
# 核心字段解析
pre_close = safe_float(stock_info[2]) # 昨收价
current_price = safe_float(stock_info[6]) # 当前价
high = safe_float(stock_info[4]) # 最高价
low = safe_float(stock_info[5]) # 最低价
volume = int(safe_float(stock_info[8])) # 成交量(股)
# 计算涨跌
if pre_close > 0:
change = current_price - pre_close
change_rate = (change / pre_close) * 100
else:
change = 0.0
change_rate = 0.0
# 过滤异常值
if current_price <= 0:
return f"{market} {name}({stock_code})暂无有效行情数据"
return (
f"
{name}({stock_code}){market}实时行情:\n"
f"当前价格:{current_price:.2f}港元\n"
f"涨跌额:{change:+.2f}港元({change_rate:+.2f}%)\n"
f"最高:{high:.2f}港元 | 最低:{low:.2f}港元\n"
f"成交量:{volume:,}股"
)
except requests.exceptions.HTTPError as e:
if e.response.status_code == 403:
return f"{market}查询失败:新浪财经反爬限制,请稍后重试"
else:
return f"{market}查询失败:HTTP错误({e.response.status_code})"
except requests.exceptions.Timeout:
return f"{market}查询超时,请检查网络"
except requests.exceptions.RequestException as e:
return f"{market}查询失败:网络错误({str(e)})"
except Exception as e:
return f"{market}查询异常:{str(e)}"
上面代码的优化要点:
(1)反爬处理:设置浏览器请求头(User-Agent/Referer),避免新浪财经403错误。
(2)代码标准化:港股代码补全为5位(如hk700→hk00700),适配新浪API格式。
(3)数据解析修复:
- A 股:修正成交额字段索引(从9→10),增加单位换算(元→万元)。
- 港股:使用全局safe_float函数,解决未定义报错问题。
(4)异常值过滤:价格≤0时返回“暂无有效数据”,避免展示异常值。
(5)市场区分:A股/港股分别处理字段索引和单位(元/港元)。
3)计算器工具
@tool
def calculator(expression: str) -> str:
"""安全执行数学运算"""
safe_pattern = r'^[\d\s\+\-\*\/\^\(\)\.]+$'
if not re.match(safe_pattern, expression):
return "❌ 表达式包含非法字符!仅支持数字、+、-、*、/、^、()、空格"
expression_clean = expression.replace("^", "**")
try:
result = eval(expression_clean, {"__builtins__": None}, {})
if not isinstance(result, (int, float)):
return "❌ 运算结果非数字"
return f"✅ 运算结果:{expression} = {result:.4f}"
except ZeroDivisionError:
return "❌ 错误:除数不能为0"
except SyntaxError:
return "❌ 语法错误!请检查表达式格式"
except Exception as e:
return f"❌ 运算失败:{str(e)}"
(1)代码安全机制:
- 输入过滤:通过正则表达式仅允许数字、运算符、括号,防止注入攻击。
- 安全执行:eval调用时清空内置函数(__builtins__: None),限制执行范围。
- 异常捕获:处理除零错误、语法错误等,返回友好提示。
(2)功能支持:支持加减乘除、括号、幂运算(^转换为**),结果保留 4 位小数。
5. LangGraph流程控制模块
1)解析节点
- 从状态中提取最新用户消息。
- 调用调度器解析查询,生成工具调用指令(存储在ai_msg.tool_calls中)。
- 将解析结果追加到状态的消息列表中。
def local_parse_node(state: MessagesState) -> MessagesState:
"""本地解析用户查询"""
user_msg = state["messages"][-1]
if not isinstance(user_msg, HumanMessage):
return {"messages": state["messages"] + [AIMessage(content="无效的消息类型")]}
dispatcher = LocalToolDispatcher()
parse_result = dispatcher.parse_query(user_msg.content)
ai_msg = AIMessage(content=parse_result.get("message", "正在执行工具调用..."))
if not parse_result["need_llm"]:
ai_msg.tool_calls = [{
"name": parse_result["tool_name"],
"arguments": parse_result["arguments"],
"id": "local_tool_001"
}]
return {"messages": state["messages"] + [ai_msg]}
2)执行节点
- 检查解析节点生成的工具调用指令。
- 调用对应工具函数,执行并获取结果。
- 将工具执行结果封装为ToolMessage,追加到消息列表中。
def tool_execution_node(state: MessagesState) -> MessagesState:
"""执行工具调用"""
last_message = state["messages"][-1]
new_messages = state["messages"].copy()
if hasattr(last_message, "tool_calls") and last_message.tool_calls:
dispatcher = LocalToolDispatcher()
for tool_call in last_message.tool_calls:
tool_name = tool_call["name"]
tool_args = tool_call["arguments"]
tool_id = tool_call["id"]
try:
tool_func = dispatcher.tool_map[tool_name]
tool_result = tool_func.func(**tool_args)
except Exception as e:
tool_result = f"工具执行失败:{str(e)}"
new_messages.append(ToolMessage(content=tool_result, tool_call_id=tool_id, name=tool_name))
return {"messages": new_messages}
3)结果整理节点
- 从消息列表中提取最新的工具执行结果。
- 封装为最终回复(AIMessage),若无结果,则返回提示信息。
def result_format_node(state: MessagesState) -> MessagesState:
"""整理最终回复"""
tool_msg = next((msg for msg in reversed(state["messages"]) if isinstance(msg, ToolMessage)), None)
if tool_msg:
final_msg = AIMessage(content=tool_msg.content)
else:
final_msg = AIMessage(content="未能获取有效结果,请使用示例格式查询:\n1. 北京的实时天气\n2. 招商银行(600036)实时股价\n3. 腾讯控股(hk00700)行情\n4. 计算 (10+20)*3")
return {"messages": state["messages"] + [final_msg]}
4)条件判断函数
- 检查解析节点是否生成了工具调用指令。
- 返回"tool"(执行工具)或"format"(直接整理结果)。
def should_call_tool(state: MessagesState) -> str:
"""判断是否调用工具"""
last_message = state["messages"][-1]
return "tool" if (hasattr(last_message, "tool_calls") and last_message.tool_calls) else "format"
5)流程构建
(1)创建StateGraph实例,绑定状态类型。
(2)添加解析、执行、整理三个节点。
(3)设置入口为解析节点。
(4)解析节点后,根据条件判断:若有工具调用,则执行工具;若无工具调用,则直接整理结果。
(5)工具执行完成后,进入结果整理节点,最终结束流程(END)。
def build_local_agent_graph():
graph_builder = StateGraph(MessagesState)
graph_builder.add_node("local_parse", local_parse_node)
graph_builder.add_node("tool_execution", tool_execution_node)
graph_builder.add_node("result_format", result_format_node)
graph_builder.set_entry_point("local_parse")
graph_builder.add_conditional_edges("local_parse", should_call_tool, {"tool": "tool_execution", "format": "result_format"})
graph_builder.add_edge("tool_execution", "result_format")
graph_builder.add_edge("result_format", END)
return graph_builder.compile()
6. 主程序与交互模块
1)执行入口
- 构建流程图表。
- 初始化状态,包含用户查询的HumanMessage。
- 执行流程,提取最终的AIMessage作为回复。
def run_local_agent(user_query: str) -> str:
agent_graph = build_local_agent_graph()
initial_state = {"messages": [HumanMessage(content=user_query)]}
result = agent_graph.invoke(initial_state)
for msg in reversed(result["messages"]):
if isinstance(msg, AIMessage):
return msg.content
return "❌ 未能生成有效回复"
2)交互界面
- 循环接收用户输入,支持输入exit退出。
- 调用run_local_agent执行查询,输出结果。
- 捕获异常并提示,保证程序不崩溃。
if __name__ == "__main__":
print("=" * 80)
print("
实时多工具协同智能助手(最终稳定版)")
print("=" * 80)
print("支持的查询示例:")
print("1. 北京的实时天气")
print("2. 招商银行(600036)实时股价 / 600036 行情")
print("3. 腾讯控股(hk00700)行情 / hk700 现在多少钱")
print("4. 计算 (15.6 + 3.2) * 2 - 10 / 2")
print("=" * 80)
print("
提示:仅需配置高德地图API密钥")
print("=" * 80)
while True:
print("\n请输入您的查询(输入 'exit' 退出):")
user_input = input("> ").strip()
if user_input.lower() == "exit":
print("
感谢使用,再见!")
break
if not user_input:
print("❌ 请输入有效查询内容")
continue
try:
response = run_local_agent(user_input)
print(f"\n
助手回复:\n{response}")
print("-" * 50)
except Exception as e:
print(f"\n❌ 程序运行出错:{str(e)}")
print("-" * 50)
7. 功能扩展建议
(1)新增工具:可在LocalToolDispatcher的tool_map中添加新工具(如快递查询、汇率转换)。
(2)数据源扩展:股票查询可增加备用数据源(如东方财富API),解决新浪反爬问题。
(3)UI优化:可基于Streamlit封装Web界面,替代命令行交互。
(4)缓存优化:股票数据也可添加缓存,减少频繁请求。

769

被折叠的 条评论
为什么被折叠?



