Skip to content

第八阶段:用 LangGraph 重写客服 Agent ​

一、为什么换 LangGraph ​

第七阶段手写的 while 循环在简单场景够用,但流程一复杂(转人工、审批、多步骤……),循环和 if 判断会越写越乱。

LangGraph 把 Agent 画成一张图:

  • 节点(Node):一个处理步骤,比如"模型思考""执行工具";
  • 边(Edge):节点之间怎么走,可以带条件;
  • 状态(State):整张图共享的数据,这里就是聊天记录。
bash
pip install langgraph langchain-openai

二、流程图 ​

text
START ──▶ agent ──(要调用工具吗?tools_condition)──┬── 是 ──▶ tools ──┐
            ▲                                         │                  │
            └─────────────────────────────────────────┼──────────────────┘
                                                      └── 否 ──▶ END

agent 节点让模型思考;需要调用工具就走到 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="<redacted>")
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=<redacted>("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——它自己在参数里都写了"非性能故障",说明它知道不完全对口。工具缺失或描述含糊时,模型就会凑合。

修法(本节只讲原理,不给改好的代码):

  1. 加一个专门的工具,如 submit_feedback(content),用来收集意见反馈。推荐这种,每个工具职责单一,模型更好选。
  2. 把 create_ticket 的描述写严,明确"仅限商品质量故障,外观意见等非故障问题不要使用"。

下一节预告 ​

进入项目 3:AutoGen 多 Agent 数据分析:让"分析师、写代码的、审核员"多个 Agent 协作分析销售数据。

👉 下一阶段:第九阶段:AutoGen 多 Agent 团队