自定义工具开发(LangGraph框架)

目录

6.3.1  满足个性化需求的自定义工具开发

1. 自定义工具开发规范

2. 两种自定义工具实现方式

6.3.2  实战案例:实时多工具协同智能助手

【示例6.3】实时多工具协同智能助手,具备实时天气查询、A股/港股查询、计算器功能,并优化股票数据解析逻辑,解决数值异常问题。

6.3.3  案例代码解析

1. 核心架构分层

2. 基础依赖与配置模块

3. 工具调度模块(LocalToolDispatcher类)

4. 核心工具模块

5. LangGraph流程控制模块

6. 主程序与交互模块

7. 功能扩展建议


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)实时股价

 助手回复:

 招商银行(600036A股实时行情:

当前价格: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)缓存优化:股票数据也可添加缓存,减少频繁请求。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值