LangGraph实现可以查快递的聊天机器人
简介
本文主要基于LangGraph官网中的QuickStart示例代码,通过Streamlit来实现一个支持工具调用的聊天机器人。通过这个实现,主要可以看下LangGraph实现的ChatBot和用LangChain的区别在哪里?有哪些优势?后续我会基于streamlit可视化聊天界面继续探究LangGraph的其他特性,比如CRAG、数据库查询助手等。
准备
- 国内大语言模型,本文用的是通义千问,参考地址:https://help.aliyun.com/zh/dashscope/developer-reference/acquisition-and-configuration-of-api-key?spm=a2c4g.11186623.0.0.2f1e65c2CBUPJk
- 聚合数据的快递查询API,参考地址:https://www.juhe.cn/docs/api/id/43,首次开通1分钱可以调用100次
代码
创建streamlit
创建一个streamlit页面,设置标题,宽屏布局
import streamlit as st
st.set_page_config(page_title="Langgraph chatbot", layout="wide")
st.title("Langgraph chatbot")
实现加载历史聊天记录
使用streamlit的session_state实现首次问候语,并加载历史聊天记录。
# 初始化历史聊天记录
if st.sidebar.button("清空聊天记录") or "langgraph_messages" not in st.session_state:
st.session_state["langgraph_messages"] = [
{"role": "assistant", "content": "您好,我是langgraph实现的聊天机器人"}]
# 加载历史聊天记录
for msg in st.session_state.langgraph_messages:
st.chat_message(msg["role"]).write(msg["content"])
实现Langgraph Chatbot
按照官方教程,创建graph第一步首先是创建state,这里创建一个自定义State
class State(TypedDict):
# messages属性
messages: Annotated[list, add_messages]
graph_builder = StateGraph(State)
定义大模型,这里使用阿里通义千问
# 定义大模型
llm = ChatTongyi()
创建工具
from langchain_core.tools import tool
@tool
def get_logistics_info(no: str) -> str:
"""根据快递单号查询快递信息"""
# 基本参数配置
api_url = 'http://v.juhe.cn/exp/index' # 接口请求URL
api_key = '你的APIKEY' # 在个人中心->我的数据,接口名称上方查看
# 快递单号前两位和api里面的公司代码映射,这里只是简单判断,实际需要调用网站提供的api来实时查询
express_no_map = {
"jt": "jtexpress", # 极兔速递
"yt": "yt", # 圆通
"sf": "sf", # 顺丰
}
# 取快递单号前两位字母,不一定所有快递单号都是这个规则,需要考虑具体实现。
express_com = express_no_map[no[:2].lower()]
# 接口请求入参配置
request_params = {
'key': api_key,
'com': express_com,
'no': no, # 快递单号
# 'senderPhone': '',
# 'receiverPhone': '',
'dtype': 'json',
}
# 发起接口网络请求
response = requests.get(api_url, params=request_params)
# 解析响应结果
if response.status_code == 200:
response_json = response.json()
return response_json
else:
# 网络异常等因素,解析结果异常。可依据业务逻辑自行处理。
print('请求异常')
绑定工具
tools = [get_logistics_info]
llm_with_tools = llm.bind_tools(tools)
实现chatbot节点
def chatbot(state: State):
return {"messages": [llm_with_tools.invoke(state["messages"])]}
实现工具节点,考虑到工具会有多个,langgraph官网是实现了BaseToolNode类,支持传入具体的工具列表实例化工具节点,这里要注意下content = json.dumps(tool_result, ensure_ascii=False)
这一行,官网没加ensure_ascii=False
参数,这里记得加下。如果工具返回包含中文字符会导致编码问题,大模型不认识unicode字符,会解读错误工具的返回信息。
class BasicToolNode:
def __init__(self, tools: list) -> None:
# 创建一个以工具名称为KEY,工具对象为Value的MAP
self.tools_by_name = {tool.name: tool for tool in tools}
def __call__(self, inputs: dict):
if messages := inputs.get("messages", []):
message = messages[-1]
else:
raise ValueError("No message found in input")
outputs = []
for tool_call in message.tool_calls:
tool_result = self.tools_by_name[tool_call["name"]].invoke(
tool_call['args']
)
# json中包含中文一定要加上ensure_ascii=False参数,否则输出ascii编码,大模型不认识。
content = json.dumps(tool_result, ensure_ascii=False)
outputs.append(
ToolMessage(
content=content,
name=tool_call["name"],
tool_call_id=tool_call["id"],
)
)
return {"messages": outputs}
# 创建工具节点
tool_node = BasicToolNode(tools=tools)
有了chabot节点和工具节点,然后把节点加入图,然后需要实现条件边,也就是要实现当用户提问不涉及工具调用的时候,大模型就是一个普通聊天,当用户的提示词涉及工具调用,大模型会自己调用工具,并通过工具的返回结果进行回复。整个图的构造:
- __start__节点接收用户的初始输入
- state传递给chabot,chatbot基于用户提示词判断是否需要调用tools,如果不需要,则直接回复,回复后就进入__end__节点,结束本轮聊天
- 如果调用了tools,那么tools节点执行完成之后,需要传递给chabot进行回复。
- chabot到tools在一轮对话过程中可能会有多次交互,直到不存在tool_call之后,进入__end__节点
以下是chabot到tools的路由逻辑代码实现
# 创建工具路由
def route_tools(state: State) -> Literal["tools", "__end__"]:
if isinstance(state, list):
ai_message = state[-1]
elif messages := state.get("messages", []):
ai_message = messages[-1]
else:
raise ValueError(f"No messages found in input state to tool_edge: {state}")
if hasattr(ai_message, "tool_calls") and len(ai_message.tool_calls) > 0:
return "tools"
return "__end__"
开始构建图
这里通过SqliteSaver实现了聊天记忆
graph_builder.add_node("chatbot", chatbot)
graph_builder.add_node("tools", tool_node)
graph_builder.add_edge(START, "chatbot")
# chatbot->tools边
graph_builder.add_conditional_edges(
"chatbot",
route_tools,
{"tools": "tools", "__end__": "__end__"},
# tools->chatbot边
graph_builder.add_edge("tools", "chatbot")
# 创建记忆
memory = SqliteSaver.from_conn_string("agents/stores/memory.db")
# 编译
graph = graph_builder.compile(checkpointer=memory)
实现图的调用
# 创建聊天输入框
user_query = st.chat_input(placeholder="开始聊天吧!")
config = {"configurable": {"thread_id": "thread-1"}}
if user_query:
st.session_state.langgraph_messages.append({"role": "user", "content": user_query})
st.chat_message("user").write(user_query)
# 打印所有step输出,包括ToolMessage和AIMessage
with st.chat_message("assistant"):
for event in graph.stream({"messages": ("user", user_query)}, config):
for value in event.values():
response = value["messages"][-1].content
st.write(response)
st.session_state.langgraph_messages.append({"role": "assistant", "content": response})
# 把每个step的state数据显示在expander里面
with view_messages:
state_his = list(graph.get_state_history(config))
for v_state in state_his:
st.write(str(v_state))
实现效果
初始加载页面
随便聊一聊,试下非工具调用下的效果
问下他会不会查快递,试试工具调用
已经识别了,但是因为我们没给他快递单号,所以chatbot需要让我们进一步提供快递单号。到这里,如果我下一步直接输入快递单号的话,如果没实现记忆的话,大模型可能无法识别,但是我这里实现了记忆,所以直接提供一个快递单号给他就行,不要再说其他的。
可以看到,大模型调用了快递查询接口,并根据接口返回,整理并回复了快递最新物流信息。
以下是完整代码
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START
from langgraph.graph.message import add_messages
from langchain_community.chat_models import ChatTongyi
import streamlit as st
from langchain_core.tools import tool
import requests
import json
from langchain_core.messages import ToolMessage
from typing import Literal
from langgraph.checkpoint.sqlite import SqliteSaver
st.set_page_config(page_title="Langgraph chatbot", layout="wide")
st.title("Langgraph chatbot")
# 定义一个state
class State(TypedDict):
# messages属性
messages: Annotated[list, add_messages]
graph_builder = StateGraph(State)
@tool
def get_logistics_info(no: str) -> str:
"""根据快递单号查询快递信息"""
# 基本参数配置
api_url = 'http://v.juhe.cn/exp/index' # 接口请求URL
api_key = 'your api key' # 在个人中心->我的数据,接口名称上方查看
express_no_map = {
"jt": "jtexpress", # 极兔速递
"yt": "yt", # 圆通
"sf": "sf", # 顺丰
}
express_com = express_no_map[no[:2].lower()]
# 接口请求入参配置
request_params = {
'key': api_key,
'com': express_com,
'no': no, # 快递单号
# 'senderPhone': '',
# 'receiverPhone': '',
'dtype': 'json',
}
# 发起接口网络请求
response = requests.get(api_url, params=request_params)
# 解析响应结果
if response.status_code == 200:
response_json = response.json()
return response_json
else:
# 网络异常等因素,解析结果异常。可依据业务逻辑自行处理。
print('请求异常')
# 定义大模型
llm = ChatTongyi()
tools = [get_logistics_info]
llm_with_tools = llm.bind_tools(tools)
def chatbot(state: State):
return {"messages": [llm_with_tools.invoke(state["messages"])]}
class BasicToolNode:
def __init__(self, tools: list) -> None:
# 创建一个以工具名称为KEY,工具对象为Value的MAP
self.tools_by_name = {tool.name: tool for tool in tools}
def __call__(self, inputs: dict):
if messages := inputs.get("messages", []):
message = messages[-1]
else:
raise ValueError("No message found in input")
outputs = []
for tool_call in message.tool_calls:
tool_result = self.tools_by_name[tool_call["name"]].invoke(
tool_call['args']
)
# json中包含中文一定要加上ensure_ascii=False参数,否则输出ascii编码,大模型不认识。
content = json.dumps(tool_result, ensure_ascii=False)
outputs.append(
ToolMessage(
content=content,
name=tool_call["name"],
tool_call_id=tool_call["id"],
)
)
return {"messages": outputs}
tool_node = BasicToolNode(tools=tools)
def route_tools(state: State) -> Literal["tools", "__end__"]:
if isinstance(state, list):
ai_message = state[-1]
elif messages := state.get("messages", []):
ai_message = messages[-1]
else:
raise ValueError(f"No messages found in input state to tool_edge: {state}")
if hasattr(ai_message, "tool_calls") and len(ai_message.tool_calls) > 0:
return "tools"
return "__end__"
graph_builder.add_node("chatbot", chatbot)
graph_builder.add_node("tools", tool_node)
graph_builder.add_edge(START, "chatbot")
graph_builder.add_conditional_edges(
"chatbot",
route_tools,
{"tools": "tools", "__end__": "__end__"},
)
graph_builder.add_edge("tools", "chatbot")
memory = SqliteSaver.from_conn_string("agents/stores/memory.db")
graph = graph_builder.compile(checkpointer=memory)
# 把当前构建的图显示在侧边栏
graph_image = st.sidebar.image(graph.get_graph().draw_mermaid_png())
# 为了方便查看state数据,用一个expander显示state数据
view_messages = st.expander("查看State")
# 初始化历史聊天记录
if st.sidebar.button("清空聊天记录") or "langgraph_messages" not in st.session_state:
st.session_state["langgraph_messages"] = [
{"role": "assistant", "content": "您好,我是langgraph实现的聊天机器人"}]
# 加载历史聊天记录
for msg in st.session_state.langgraph_messages:
st.chat_message(msg["role"]).write(msg["content"])
# 创建聊天输入框
user_query = st.chat_input(placeholder="开始聊天吧!")
config = {"configurable": {"thread_id": "thread-1"}}
if user_query:
st.session_state.langgraph_messages.append({"role": "user", "content": user_query})
st.chat_message("user").write(user_query)
# 打印所有step输出,包括ToolMessage和AIMessage
with st.chat_message("assistant"):
for event in graph.stream({"messages": ("user", user_query)}, config):
for value in event.values():
response = value["messages"][-1].content
st.write(response)
st.session_state.langgraph_messages.append({"role": "assistant", "content": response})
with view_messages:
state_his = list(graph.get_state_history(config))
for v_state in state_his:
st.write(str(v_state))
总结
- 用langgraph实现的聊天机器人,在工具调用上会比langchain更灵活,整体表现也更自然
- langchain之前在复杂的工具调用上实现记忆是比较复杂的,现在在langgraph只需要创建一个checkpoint即可自动支持记忆
- 官网示例中还有HIL(Human In Loop)的实现,这也是langgraph的核心理念之一,即我在之前文章提到的人机协同,官网实现的不是很直观,后续有时间考虑下怎么在streamlit上实现。