前端转agent-【python】-08 用 LangGraph 把 Agent 做成状态机:像写 Vue 3 状态管理一样编排 AI 流程
如果你写过复杂的前端交互,一定用过状态管理------Vuex、Pinia、甚至是组合式函数里的
reactive+computed来驱动 UI 流转。今天用 Python 的 LangGraph 把 AI Agent 也变成一张"图"------每个节点是一个操作,边是条件分支。
我们用本地模型 Qwen3:4b 跑一个带决策的客服机器人,看看状态图怎么让 Agent 变得像你熟悉的 Vue 3 状态管理一样可控。
为什么需要 LangGraph?
一般的 Agent 写法是写一个 while 循环,手动拼装 messages,调用 LLM,解析输出,硬编码一堆 if-else。当逻辑变复杂(需要重试、分支路由、工具调用循环)时,代码很快就变成意大利面条。
LangGraph 的核心思想 :把 Agent 的工作流建模成一张有向图 ------节点是执行步骤(调用 LLM、处理输入、返回结果),边是状态转移(带条件判断)。
这就像你在 Vue 3 里用 Pinia 或 组合式 API 管理一个多步骤向导:当前状态驱动下一步往哪走,每个步骤对应一个处理逻辑,状态一改,界面自动更新。
Vue 3 视角:LangGraph ≈ 用 Pinia 定义一个带条件跳转的流程 store,只不过节点里运行的是 LLM 调用而不是组件渲染。
环境准备
bash
pip install langgraph ollama
确保模型已拉取(注意是 4b,稍大一点,但仍然本地可跑):
bash
ollama pull qwen3:4b
第一个状态图:极简聊天机器人
我们先写一个最基本的"接收消息 → 调模型 → 返回回复"的图,感受一下结构。
python
# simple_graph_chat.py
import ollama
from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, END
MODEL = "qwen3:4b"
# 1. 定义状态(State),就像你在 Pinia 里定义的 state 类型
class ChatState(TypedDict):
messages: Annotated[list, "对话历史"]
user_input: str
response: str
# 2. 定义节点(Node),每个节点是一个纯函数,接收 state 返回部分更新的 state
def call_llm(state: ChatState) -> dict:
"""调用 LLM,并把结果写入 response 字段"""
messages = state["messages"] + [
{"role": "user", "content": state["user_input"]}
]
result = ollama.chat(model=MODEL, messages=messages)
return {"response": result["message"]["content"]}
def prepare_next_turn(state: ChatState) -> dict:
"""把本轮的问答追加到历史里,为下一轮做准备"""
updated_messages = state["messages"] + [
{"role": "user", "content": state["user_input"]},
{"role": "assistant", "content": state["response"]}
]
return {"messages": updated_messages}
# 3. 构建图
graph = StateGraph(ChatState)
graph.add_node("llm", call_llm) # 添加节点
graph.add_node("history", prepare_next_turn)
graph.set_entry_point("llm") # 入口
graph.add_edge("llm", "history") # llm → history
graph.add_edge("history", END) # history → 结束
app = graph.compile() # 编译成可运行的应用
# 4. 运行一次对话
state = {"messages": [], "user_input": "你好,我叫小明"}
result = app.invoke(state)
print("🤖:", result["response"])
print("📝 当前历史:", len(result["messages"]), "条")
输出:
makefile
🤖: 你好小明!有什么可以帮你的?
📝 当前历史: 2 条
Vue 3 对比 :StateGraph 就像你定义一个 useAgentFlow 的组合式函数,state 是 reactive 对象,add_node 是添加处理函数,add_edge 是指定下一步操作。invoke() 相当于执行一次流程,并返回更新后的状态。
进阶:带条件分支的客服 Agent
真实场景里,我们需要根据用户意图走不同分支------比如"问产品"走知识库,"投诉"走安抚流程,"其他"走通用问答。
这正适合用 LangGraph 的条件边(conditional edges)。
场景设计
- 意图分类 :让 LLM 判断用户输入是
product、complaint还是general。 - 产品节点:回答产品相关问题(模拟固定回复)。
- 投诉节点:安抚用户并转人工(打印一条消息)。
- 通用节点:调用 LLM 自由回复。
代码实现
python
# customer_service_agent.py
import ollama
from typing import TypedDict, Annotated, Literal
from langgraph.graph import StateGraph, END
MODEL = "qwen3:4b"
class AgentState(TypedDict):
messages: Annotated[list, "完整对话历史"]
user_input: str
intent: str
response: str
# ---- 意图分类节点 ----
def classify_intent(state: AgentState) -> dict:
"""让 LLM 判断意图,输出三个类别之一"""
prompt = f"""你是一个客服意图分类器。用户消息是:
"{state['user_input']}"
请只输出以下三个单词之一:product, complaint, general。
不要输出任何其他内容。"""
result = ollama.generate(model=MODEL, prompt=prompt)
intent = result["response"].strip().lower()
# 简单容错
if intent not in ["product", "complaint", "general"]:
intent = "general"
return {"intent": intent}
# ---- 产品咨询节点 ----
def product_handler(state: AgentState) -> dict:
response = "感谢您的产品咨询!我们的智能手表续航可达7天,支持IP68防水。请问还有什么需要了解的吗?"
return {"response": response}
# ---- 投诉节点 ----
def complaint_handler(state: AgentState) -> dict:
response = "非常抱歉给您带来不便。我们已经记录下您的问题,客服代表将在2小时内联系您。"
return {"response": response}
# ---- 通用聊天节点 ----
def general_handler(state: AgentState) -> dict:
messages = state["messages"] + [{"role": "user", "content": state["user_input"]}]
result = ollama.chat(model=MODEL, messages=messages)
return {"response": result["message"]["content"]}
# ---- 历史更新节点 ----
def update_history(state: AgentState) -> dict:
updated = state["messages"] + [
{"role": "user", "content": state["user_input"]},
{"role": "assistant", "content": state["response"]}
]
return {"messages": updated}
# ---- 路由函数(条件边) ----
def route_by_intent(state: AgentState) -> Literal["product", "complaint", "general"]:
return state["intent"]
# ---- 构建状态图 ----
builder = StateGraph(AgentState)
builder.add_node("classify", classify_intent)
builder.add_node("product", product_handler)
builder.add_node("complaint", complaint_handler)
builder.add_node("general", general_handler)
builder.add_node("update_history", update_history)
builder.set_entry_point("classify")
# 条件边:根据 intent 字段走不同分支
builder.add_conditional_edges(
"classify",
route_by_intent,
{
"product": "product",
"complaint": "complaint",
"general": "general"
}
)
# 所有处理节点结束后都去更新历史
builder.add_edge("product", "update_history")
builder.add_edge("complaint", "update_history")
builder.add_edge("general", "update_history")
builder.add_edge("update_history", END)
agent = builder.compile()
# ---- 测试 ----
if __name__ == "__main__":
state = {"messages": [], "user_input": ""}
print("🤖 智能客服 (product / complaint / general)")
while True:
user = input("\n🧑 你: ")
if user == "/bye":
break
state["user_input"] = user
state = agent.invoke(state)
print(f"🎯 意图: {state['intent']}")
print(f"🤖 客服: {state['response']}")
运行效果:
erlang
🧑 你: 你们的表防水吗?
🎯 意图: product
🤖 客服: 感谢您的产品咨询!我们的智能手表续航可达7天...
🧑 你: 我的订单三天还没发货,太慢了!
🎯 意图: complaint
🤖 客服: 非常抱歉给您带来不便。我们已经记录下您的问题...
🧑 你: 今天天气不错
🎯 意图: general
🤖 客服: 是啊,阳光明媚,很适合出去走走!
Vue 3 开发者横向对比
如果你在 Vue 3 里用 Pinia 管理类似的多分支流程,核心逻辑会是这样的:
typescript
// useAgentStore.ts
import { defineStore } from 'pinia';
import { reactive, computed } from 'vue';
interface AgentState {
messages: Array<{ role: string; content: string }>;
userInput: string;
intent: 'product' | 'complaint' | 'general' | '';
response: string;
}
export const useAgentStore = defineStore('agent', () => {
const state = reactive<AgentState>({
messages: [],
userInput: '',
intent: '',
response: ''
});
// 意图分类(模拟异步 LLM 调用)
async function classifyIntent() {
const intent = await callLLMClassify(state.userInput); // 伪代码
state.intent = intent;
}
// 各分支处理函数
function handleProduct() {
state.response = '产品回复...';
}
function handleComplaint() {
state.response = '投诉安抚...';
}
function handleGeneral() {
state.response = '通用聊天...';
}
function updateHistory() {
state.messages.push(
{ role: 'user', content: state.userInput },
{ role: 'assistant', content: state.response }
);
}
// 主流程:可以根据 intent 的计算属性来触发下一步(这里用 watch 或手动调用)
async function runFlow(userInput: string) {
state.userInput = userInput;
await classifyIntent();
// 根据 intent 分发
if (state.intent === 'product') handleProduct();
else if (state.intent === 'complaint') handleComplaint();
else handleGeneral();
updateHistory();
}
return { state, runFlow };
});
在组件中使用:
vue
<template>
<div>
<p v-for="msg in agent.state.messages" :key="msg.content">
{{ msg.role }}: {{ msg.content }}
</p>
<input v-model="userInput" @keyup.enter="send" />
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useAgentStore } from './useAgentStore';
const agent = useAgentStore();
const userInput = ref('');
async function send() {
await agent.runFlow(userInput.value);
userInput.value = '';
}
</script>
本质上,LangGraph 的 StateGraph 就是把 Pinia 里的 状态、动作、条件分发 抽象成了图节点和边。
区别在于 :Pinia 的流程靠手动调用函数和 if/else 组织;而 LangGraph 通过声明式图结构,让复杂的条件跳转、循环、错误重试更清晰,也更容易可视化。
为什么用状态图做 Agent?
- 可视化:LangGraph 可以导出图结构,团队沟通更清晰。
- 可测试:每个节点是纯函数(或接近纯),单独测试。
- 容错与重试:可以加错误处理节点,形成循环,比如"分类失败 → 重试"。
- 强制结构:避免手写 while 循环导致的状态不可预测。
- 前端友好:如果你熟悉 Vue 的响应式状态管理和 Pinia 的 action 编排,那么理解 LangGraph 的状态图只有一步之遥。
更进一步:加入循环与工具调用
LangGraph 天生支持循环(工具调用 Agent 的标准模式)。例如,加入一个"搜索知识库"的工具节点,LLM 决定是否要调用工具,然后回到 LLM 节点,直到生成最终答案。
这就是 OpenAI 的 function calling 循环模式,LangGraph 用几张图就能搭出来。
python
# tool_loop_agent.py
"""
带工具调用循环的 Agent 示例(独立文件)
演示 LangGraph 的"LLM ⇄ 工具"循环模式
"""
import ollama
import json
from typing import TypedDict, Annotated, Literal
from langgraph.graph import StateGraph, END
MODEL = "qwen3:4b"
# ----- 状态定义 -----
class ToolAgentState(TypedDict):
messages: Annotated[list, "对话历史"]
user_input: str
tool_call: dict | None
tool_result: str
final_answer: str
# ----- 工具定义(示例) -----
def get_weather(city: str) -> str:
"""模拟天气查询"""
weather_db = {
"北京": "晴天,25°C",
"上海": "小雨,22°C",
"深圳": "多云,28°C",
}
return weather_db.get(city, f"未找到{city}的天气信息")
def calculator(expression: str) -> str:
"""安全计算数学表达式"""
try:
allowed = set("0123456789+-*/().% ")
if not all(c in allowed for c in expression):
return "错误:包含非法字符"
return str(eval(expression))
except Exception as e:
return f"计算错误:{str(e)}"
TOOLS = {
"get_weather": (get_weather, {"city": "str"}),
"calculator": (calculator, {"expression": "str"}),
}
SYSTEM_PROMPT = """你是一个能使用工具的助手。可用工具:
- get_weather(city): 查天气
- calculator(expression): 算数学表达式
需要工具时输出 JSON:{"tool": "工具名", "args": {...}}
不需要工具时直接回答。"""
# ----- 节点函数 -----
def call_llm(state: ToolAgentState) -> dict:
"""LLM 思考节点"""
messages = [{"role": "system", "content": SYSTEM_PROMPT}] + state["messages"] + [
{"role": "user", "content": state["user_input"]}
]
response = ollama.chat(model=MODEL, messages=messages)
content = response["message"]["content"].strip()
# 尝试解析工具调用
try:
tool_call = json.loads(content)
if "tool" in tool_call and "args" in tool_call:
return {"tool_call": tool_call}
except:
pass
return {"final_answer": content}
def execute_tool(state: ToolAgentState) -> dict:
"""执行工具节点"""
tc = state["tool_call"]
tool_name = tc["tool"]
args = tc["args"]
func, _ = TOOLS[tool_name]
if tool_name == "get_weather":
result = func(args["city"])
elif tool_name == "calculator":
result = func(args["expression"])
else:
result = "未知工具"
# 将工具执行结果加入历史
new_messages = state["messages"] + [
{"role": "user", "content": state["user_input"]},
{"role": "assistant", "content": f"调用工具:{tool_name}({json.dumps(args)})"},
{"role": "tool", "content": result}
]
return {
"messages": new_messages,
"tool_result": result,
"tool_call": None # 清除,防止再次触发
}
def should_use_tool(state: ToolAgentState) -> Literal["tool", "end"]:
if state.get("tool_call"):
return "tool"
return "end"
# ----- 构建图 -----
builder = StateGraph(ToolAgentState)
builder.add_node("llm", call_llm)
builder.add_node("tools", execute_tool)
builder.set_entry_point("llm")
builder.add_conditional_edges("llm", should_use_tool, {
"tool": "tools",
"end": END
})
builder.add_edge("tools", "llm") # 工具结果返回 LLM 继续思考
agent = builder.compile()
# ----- 运行 -----
if __name__ == "__main__":
state = {"messages": [], "user_input": "", "tool_call": None, "tool_result": "", "final_answer": ""}
print("🤖 带工具循环的 Agent (输入 /bye 退出)")
while True:
user = input("\n🧑 你: ")
if user == "/bye":
break
state["user_input"] = user
state["tool_call"] = None
state["final_answer"] = ""
final_state = agent.invoke(state)
print(f"🤖 Agent: {final_state.get('final_answer', '无响应')}")
state["messages"] = final_state["messages"]
bash
## 运行示例
启动代理后,可以尝试以下对话,观察工具调用循环的执行过程:
🧑 你: 北京今天天气怎么样?
🤖 Agent: 北京今天晴天,25°C
🧑 你: 计算 123 * 456
🤖 Agent: 123 * 456 = 56088
🧑 你: 深圳天气如何?顺便算一下 78 + 22
🤖 Agent: 深圳天气多云,28°C;78 + 22 = 100
在内部,LangGraph 自动经历了以下循环:
1. LLM 收到 "深圳天气如何?顺便算一下 78 + 22",判断需要两个工具,先输出 `{"tool": "get_weather", "args": {"city": "深圳"}}` → 执行工具 → 返回天气结果
2. 工具结果返回给 LLM,LLM 发现还需要计算,输出 `{"tool": "calculator", "args": {"expression": "78 + 22"}}` → 执行工具 → 返回计算结果
3. LLM 再次收到结果,综合输出最终回答
整个过程无需手动编写 while 循环,完全由状态图的 `conditional_edges` 自动驱动。
如果放到 Vue 3 的思维里,这就像一个 Pinia store 里有 llmAction 和 toolAction,用 watch 监听状态变化,决定是继续调用工具还是结束,形成一个"请求-判断-再请求"的闭环。
总结
- LangGraph 把 Agent 抽象成状态图,状态 = 对话上下文 + 中间结果 ,节点 = 处理函数 ,边 = 流转规则。
- 用 Ollama + Qwen3:4b 本地跑,无需外部 API,成本零。
- 对 Vue 3 开发者来说,LangGraph 就像是把 Pinia 的 action 编排和条件分发变成了声明式的图,心智模型高度一致。
- 从简单的顺序聊天,到复杂的分支路由、工具循环,状态图都能优雅表达。
想试试?复制上面的 customer_service_agent.py,运行起来,感受一下"画图即编程"的 Agent 开发体验。
bash
python customer_service_agent.py