Appearance
第八阶段:用 LangGraph 重写客服 Agent
一、为什么换 LangGraph
第七阶段手写的 while 循环在简单场景够用,但流程一复杂(转人工、审批、多步骤……),循环和 if 判断会越写越乱。
LangGraph 把 Agent 画成一张图:
- 节点(Node):一个处理步骤,比如“模型思考”“执行工具”;
- 边(Edge):节点之间怎么走,可以带条件;
- 状态(State):整张图共享的数据,这里就是聊天记录。
bash
pip install langgraph langchain-openai二、流程图
text
START ──▶ agent ──(要调用工具吗?tools_condition)──┬── 是 ──▶ tools ──┐
▲ │ │
└─────────────────────────────────────────┼──────────────────┘
└── 否 ──▶ ENDagent 节点让模型思考;需要调用工具就走到 tools 节点执行,执行完再回到 agent,直到模型给出最终回答,走向 END。
三、08_langgraph_agent.py 完整代码
以下代码与仓库中的文件一致(运行前把 model_name 改成你 LM Studio 中的 Embedding 模型名):
python
import os
from typing import Annotated, TypedDict
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain_core.messages import SystemMessage, HumanMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.memory import MemorySaver
from llama_index.core import Settings, StorageContext, load_index_from_storage
from llama_index.embeddings.openai_like import OpenAILikeEmbedding
load_dotenv()
# ---------- 知识库(和 07 一样,只用来检索)----------
Settings.embed_model = OpenAILikeEmbedding(model_name="这里填你的 Embedding 模型名",
api_base="http://localhost:1234/v1", api_key="lm-studio")
index = load_index_from_storage(StorageContext.from_defaults(persist_dir="./kb_storage"))
retriever = index.as_retriever(similarity_top_k=3)
# ---------- 工具:用 @tool 装饰器,函数的说明文字自动变成工具描述,不用再手写 JSON ----------
ORDERS = {"1001": "已发货,顺丰 SF123456,预计明天到", "1002": "待付款"}
@tool
def query_order(order_id: str) -> str:
"""根据订单号查询订单状态和物流"""
return ORDERS.get(order_id, "查无此订单")
@tool
def create_ticket(order_id: str, problem: str) -> str:
"""用户反映商品质量问题、要求售后时,创建售后工单"""
return f"工单已创建,编号 T{order_id}01,问题:{problem},客服 24 小时内联系您"
@tool
def search_manual(question: str) -> str:
"""查询折叠键盘的产品说明书,用于回答配对、指示灯、充电、保修等产品使用问题"""
return "\n---\n".join(n.node.get_content() for n in retriever.retrieve(question))
@tool
def transfer_to_human(reason: str) -> str:
"""用户明确要求人工客服,或者情绪激动、问题无法解决时,转接人工"""
return f"已为您转接人工客服(原因:{reason}),请稍候"
tools = [query_order, create_ticket, search_manual, transfer_to_human]
# ---------- 大模型:bind_tools 把工具说明书交给模型 ----------
llm = ChatOpenAI(model=os.getenv("LLM_MODEL"), base_url=os.getenv("LLM_BASE_URL"),
api_key=os.getenv("LLM_API_KEY"), temperature=0).bind_tools(tools)
SYSTEM = SystemMessage("你是折叠键盘的电商客服。订单问题用 query_order,售后用 create_ticket,"
"产品问题必须先用 search_manual 查说明书,只根据查到的内容回答;"
"用户要求人工或你无法解决时用 transfer_to_human。不要编造。")
# ---------- 状态:整张图共享的数据,这里只有聊天记录 ----------
class State(TypedDict):
# add_messages 表示新消息会追加进去,而不是覆盖
messages: Annotated[list, add_messages]
# ---------- 节点 ----------
def agent(state: State):
"""思考节点:让模型决定是直接回答,还是调用工具"""
reply = llm.invoke([SYSTEM] + state["messages"])
return {"messages": [reply]}
# ---------- 画图 ----------
graph = StateGraph(State)
graph.add_node("agent", agent)
graph.add_node("tools", ToolNode(tools)) # ToolNode 自动执行工具,代替 07 里的 FUNCS
graph.add_edge(START, "agent") # 一开始先进入思考节点
graph.add_conditional_edges("agent", tools_condition) # 要调用工具就去 tools,否则结束
graph.add_edge("tools", "agent") # 工具执行完回到思考节点
app = graph.compile(checkpointer=MemorySaver()) # MemorySaver:自动保存多轮对话的记忆
config = {"configurable": {"thread_id": "user-001"}} # 同一个 thread_id 就是同一段对话
while True:
q = input("\n你:")
if q == "exit":
break
# stream 逐步输出每个节点的执行结果,方便观察流程
for step in app.stream({"messages": [HumanMessage(q)]}, config, stream_mode="updates"):
for node, out in step.items():
msg = out["messages"][-1]
if node == "agent" and msg.tool_calls:
for c in msg.tool_calls:
print(f" [agent 决定调用 {c['name']},参数 {c['args']}]")
elif node == "tools":
print(f" [tools 执行完毕:{msg.content[:40]}...]")
else:
print("客服:", msg.content)四、与第七阶段对照
| 第七阶段(手写) | 第八阶段(LangGraph) |
|---|---|
| 手写 JSON 函数说明书 | @tool 装饰器 + docstring 自动生成描述 |
FUNCS 字典 + 执行循环 | ToolNode 自动执行工具 |
内层 while 循环 | agent ↔ tools 两条边 |
手动维护 messages 列表 | State(add_messages)+ MemorySaver + thread_id |
五、实测结果(真实运行数据)
你:订单 1001 到哪了?
text
[agent 决定调用 query_order,参数 {'order_id': '1001'}]
[tools 执行完毕:已发货,顺丰 SF123456,预计明天到...]客服随后给出物流回答。
你:怎么配对?
text
[agent 决定调用 search_manual,参数 {'question': '折叠键盘怎么配对'}]客服给出开机、选择通道等配对步骤。
你:你们这破键盘什么都不行,我要找人工!
text
[agent 决定调用 transfer_to_human,参数 {'reason': '用户情绪激动,明确要求人工客服'}]客服先安抚用户,并告知已转接人工。
你:我要反馈,不好看
模型自己记得前面提过的订单 1001——这是 MemorySaver 的记忆在起作用。但它调用了:
text
[agent 决定调用 create_ticket,参数 {'order_id': '1001', 'problem': '用户反馈:产品外观不好看,希望反馈该意见(主观外观评价,非性能故障)'}]开出了售后工单 T100101。
六、重点案例:工具误用
“外观不好看”是意见,不是质量故障,但模型还是开了售后工单,售后工单里就混进了意见反馈。
原理:模型只能从给定的工具里挑。没有“意见反馈”工具,它只好凑合用最接近的 create_ticket——它自己在参数里都写了“非性能故障”,说明它知道不完全对口。工具缺失或描述含糊时,模型就会凑合。
修法(本节只讲原理,不给改好的代码):
- 加一个专门的工具,如
submit_feedback(content),用来收集意见反馈。推荐这种,每个工具职责单一,模型更好选。 - 把
create_ticket的描述写严,明确“仅限商品质量故障,外观意见等非故障问题不要使用”。
下一节预告
进入项目 3:AutoGen 多 Agent 数据分析:让“分析师、写代码的、审核员”多个 Agent 协作分析销售数据。
👉 下一阶段:第九阶段:AutoGen 多 Agent 团队