课程:AI 全栈开发实战 — 基于 LangChain.js + Vue3 + Node.js 实战 Demo:极速购智能客服中枢 — 自动路由 + 多 Agent 协作
代码
4.1 本章目标
完成本章学习后,你将能够:
- 理解 LangGraph 的核心概念:StateGraph、节点、边、条件路由
- 知道 LangGraph 和 LangChain AgentExecutor 的本质区别,以及各自的适用场景
- 用 LangGraph 构建一个有状态的多节点工作流
- 实现一个智能路由节点,自动判断用户问题应该交给哪个 Agent 处理
- 在 Vue3 前端实时展示工作流的执行状态
4.2 为什么需要 LangGraph
前三章的工具已经能覆盖大部分场景,但有一类问题它们处理不好:需要多个专业模块协作、流程有分支、中间状态需要在步骤间传递。
举个例子,用户发来一条消息:"我买的蓝牙耳机有质量问题,订单 ORD-001,想申请退款,另外问一下退款政策是什么"。
这条消息同时涉及:订单查询(第二章的 Agent)、知识库检索(第三章的 RAG)、最后还需要综合两者给出回答。
用 AgentExecutor 处理这个场景的问题:
- 工具列表会越来越长,LLM 在一个 Prompt 里管理所有工具,决策质量下降
- 无法精确控制流程分支,比如"只有确认是质量问题才执行退款流程"
- 多个步骤的中间状态难以追踪和传递
LangGraph 的解法是把工作流建模成一张有向图:每个节点(Node)是一个处理单元,边(Edge)定义节点之间的流转关系,条件边(Conditional Edge)根据状态决定走哪条路径,整个图共享一个状态对象(State),信息在节点间流动。
4.3 核心概念
4.3.1 StateGraph
LangGraph 的核心类。定义图的结构,包括节点和边。图运行时维护一个共享状态对象,每个节点读取状态、处理后更新状态。
4.3.2 State(状态)
贯穿整个工作流的数据结构。每个节点可以读取 State 的任意字段,也可以更新字段。后续节点能看到前面节点写入的所有数据。
本章 Demo 的 State 结构:
{
messages: [], // 完整对话历史
userInput: '', // 当前用户输入
intent: '', // 路由节点判断的意图:order / knowledge / general
orderResult: null, // 订单 Agent 的查询结果
ragResult: '', // RAG 节点的检索结果
finalAnswer: '', // 最终回答
}
4.3.3 Node(节点)
一个普通的 async 函数,接收当前 State,返回需要更新的字段(部分更新,不需要返回完整 State)。
const myNode = async (state) => {
// 读取状态
const { userInput } = state;
// 处理...
// 返回需要更新的字段
return { someField: result };
};
4.3.4 Edge 和 Conditional Edge
普通边:固定流转,A 节点完成后一定去 B 节点。
条件边:根据 State 的值决定下一个节点,用一个函数返回节点名称字符串。
// 条件边函数:根据 intent 字段决定路由方向
const routeByIntent = (state) => {
if (state.intent === 'order') return 'orderAgent';
if (state.intent === 'knowledge') return 'ragNode';
return 'generalChat';
};
4.3.5 START 和 END
START 是图的入口,第一个节点从 START 开始。END 是图的出口,到达 END 表示工作流结束。
4.4 本章 Demo 设计
极速购客服中枢,工作流如下:
START
↓
intentRouter(意图识别,判断问题类型)
↓(条件路由)
├─ order → orderAgent(查订单/物流)
├─ knowledge → ragNode(查知识库)
└─ general → generalChat(通用对话)
↓(三条路径汇合)
answerSynthesizer(综合结果,生成最终回答)
↓
END
这个结构的好处:
- 每个节点职责单一,互不干扰
- 意图识别和具体处理分离,路由逻辑集中在一处,便于维护
- 后续扩展新的意图类型,只需增加节点和边,不影响其他部分
4.5 安装依赖
cd server
pnpm add @langchain/langgraph
4.6 定义 State
LangGraph 的 State 用 Annotation 定义,MessagesAnnotation 是内置的消息列表注解,自动处理消息的追加逻辑。
// server/src/graphs/state.js
import { Annotation, MessagesAnnotation } from '@langchain/langgraph';
export const GraphState = Annotation.Root({
// 继承内置的消息列表管理
...MessagesAnnotation.spec,
// 当前用户输入
userInput: Annotation({
reducer: (_, next) => next,
default: () => '',
}),
// 意图识别结果:order | knowledge | general
intent: Annotation({
reducer: (_, next) => next,
default: () => '',
}),
// 订单 Agent 结果
orderResult: Annotation({
reducer: (_, next) => next,
default: () => null,
}),
// RAG 检索结果
ragResult: Annotation({
reducer: (_, next) => next,
default: () => '',
}),
// 最终回答
finalAnswer: Annotation({
reducer: (_, next) => next,
default: () => '',
}),
});
4.7 各节点实现
4.7.1 意图识别节点
这是整个工作流的入口节点,负责判断用户问题属于哪个类型,结果写入 State 的 intent 字段,供条件边路由使用。
// server/src/graphs/nodes/intent-router.js
import { createModel } from '../../models/deepseek.js';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';
const intentPrompt = ChatPromptTemplate.fromMessages([
[
'system',
`你是一个意图分类器。
根据用户的问题,返回以下三个分类之一,只返回分类词,不要有任何其他内容:
- order:用户询问订单状态、物流信息、退款进度等需要查询订单数据的问题
- knowledge:用户询问商品介绍、规格参数、售后政策、退换货规则等可从知识库获取的问题
- general:其他类型的对话、闲聊、无法归类的问题
只输出一个词:order 或 knowledge 或 general`,
],
['human', '{userInput}'],
]);
const model = createModel({ temperature: 0 });
const parser = new StringOutputParser();
const chain = intentPrompt | model | parser;
export const intentRouterNode = async (state) => {
const { userInput } = state;
const raw = await chain.invoke({ userInput });
const intent = raw.trim().toLowerCase();
// 容错处理:如果模型返回了不在预期内的值,默认走 general
const validIntents = ['order', 'knowledge', 'general'];
const finalIntent = validIntents.includes(intent) ? intent : 'general';
console.log(`[intentRouter] "${userInput}" → ${finalIntent}`);
return { intent: finalIntent };
};
// 条件边函数:根据 intent 决定下一个节点
export const routeByIntent = (state) => {
const map = {
order: 'orderAgent',
knowledge: 'ragNode',
general: 'generalChat',
};
return map[state.intent] || 'generalChat';
};
4.7.2 订单 Agent 节点
复用第二章的工具,但这里不用 AgentExecutor,而是直接调用工具并把结果写入 State。
// server/src/graphs/nodes/order-agent.js
import { createReactAgent } from 'langchain/agents';
import { AgentExecutor } from 'langchain/agents';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { createModel } from '../../models/deepseek.js';
import { allTools } from '../../tools/order-tools.js';
const agentPrompt = ChatPromptTemplate.fromMessages([
[
'system',
`你是极速购的订单查询助手。
根据用户的问题,调用相应工具查询订单或物流信息。
只查询数据,不需要生成最终的客服回答,查到什么返回什么,保持数据的完整性。
工具列表:{tools}
工具名称:{tool_names}`,
],
['human', '{input}'],
['placeholder', '{agent_scratchpad}'],
]);
const model = createModel({ temperature: 0 });
const createOrderExecutor = () => {
const agent = createReactAgent({ llm: model, tools: allTools, prompt: agentPrompt });
return new AgentExecutor({
agent,
tools: allTools,
maxIterations: 4,
returnIntermediateSteps: true,
verbose: false,
});
};
export const orderAgentNode = async (state) => {
const { userInput } = state;
try {
const executor = createOrderExecutor();
const result = await executor.invoke({ input: userInput });
return {
orderResult: {
answer: result.output,
steps: result.intermediateSteps?.map(s => ({
tool: s.action.tool,
input: s.action.toolInput,
obs: s.observation,
})) || [],
},
};
} catch (err) {
console.error('[orderAgentNode]', err.message);
return { orderResult: { answer: '查询订单信息时出错', steps: [] } };
}
};
4.7.3 RAG 检索节点
复用第三章的 RAG Chain,把检索结果写入 State。
// server/src/graphs/nodes/rag-node.js
import { ragChain } from '../../chains/rag-chain.js';
export const ragNode = async (state) => {
const { userInput } = state;
try {
const result = await ragChain.invoke(userInput);
return { ragResult: result };
} catch (err) {
console.error('[ragNode]', err.message);
return { ragResult: '查询知识库时出错' };
}
};
4.7.4 通用对话节点
兜底节点,处理不需要查询数据的一般性对话。
// server/src/graphs/nodes/general-chat.js
import { createModel } from '../../models/deepseek.js';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';
const prompt = ChatPromptTemplate.fromMessages([
[
'system',
'你是极速购电商平台的客服助手小购。语气友好,称呼用户为"亲",回复简洁。',
],
['placeholder', '{chat_history}'],
['human', '{userInput}'],
]);
const chain = prompt | createModel({ temperature: 0.7 }) | new StringOutputParser();
export const generalChatNode = async (state) => {
const { userInput, messages } = state;
// 从 messages 里取历史(过滤掉系统消息)
const chatHistory = (messages || [])
.slice(-8)
.map((m) => [m._getType?.() === 'human' ? 'human' : 'assistant', m.content])
.filter(Boolean);
const result = await chain.invoke({ userInput, chat_history: chatHistory });
return { finalAnswer: result };
};
4.7.5 答案综合节点
把各个处理节点的结果汇合,生成最终面向用户的回答。
// server/src/graphs/nodes/answer-synthesizer.js
import { createModel } from '../../models/deepseek.js';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';
const prompt = ChatPromptTemplate.fromMessages([
[
'system',
`你是极速购电商平台的客服助手小购。
根据以下查询结果,为用户生成一个清晰、友好的回答。
称呼用户为"亲",回复语气专业,内容简洁准确。
订单查询结果(如有):{orderResult}
知识库查询结果(如有):{ragResult}`,
],
['human', '{userInput}'],
]);
const chain = prompt | createModel({ temperature: 0.5 }) | new StringOutputParser();
export const answerSynthesizerNode = async (state) => {
const { userInput, orderResult, ragResult, finalAnswer, intent } = state;
// general 意图已经在 generalChatNode 生成了 finalAnswer,直接透传
if (intent === 'general' && finalAnswer) {
return { finalAnswer };
}
const result = await chain.invoke({
userInput,
orderResult: orderResult ? JSON.stringify(orderResult.answer) : '无',
ragResult: ragResult || '无',
});
return { finalAnswer: result };
};
4.8 组装 StateGraph
// server/src/graphs/customer-graph.js
import { StateGraph, START, END } from '@langchain/langgraph';
import { GraphState } from './state.js';
import { intentRouterNode, routeByIntent } from './nodes/intent-router.js';
import { orderAgentNode } from './nodes/order-agent.js';
import { ragNode } from './nodes/rag-node.js';
import { generalChatNode } from './nodes/general-chat.js';
import { answerSynthesizerNode } from './nodes/answer-synthesizer.js';
export const buildCustomerGraph = () => {
const graph = new StateGraph(GraphState)
// 注册节点
.addNode('intentRouter', intentRouterNode)
.addNode('orderAgent', orderAgentNode)
.addNode('ragNode', ragNode)
.addNode('generalChat', generalChatNode)
.addNode('answerSynthesizer', answerSynthesizerNode)
// 入口:START → 意图识别
.addEdge(START, 'intentRouter')
// 条件路由:意图识别完成后,根据 intent 分流
.addConditionalEdges('intentRouter', routeByIntent, {
orderAgent: 'orderAgent',
ragNode: 'ragNode',
generalChat: 'generalChat',
})
// 三条路径都汇入答案综合节点
.addEdge('orderAgent', 'answerSynthesizer')
.addEdge('ragNode', 'answerSynthesizer')
.addEdge('generalChat', 'answerSynthesizer')
// 出口
.addEdge('answerSynthesizer', END);
return graph.compile();
};
4.9 路由接口
Graph 的接口需要支持流式推送节点执行状态,让前端能实时展示工作流进度。
// server/src/routes/graph.js
import express from 'express';
import { HumanMessage } from '@langchain/core/messages';
import { buildCustomerGraph } from '../graphs/customer-graph.js';
const router = express.Router();
// 单例:避免每次请求都重新编译图
let graph = null;
const getGraph = () => {
if (!graph) graph = buildCustomerGraph();
return graph;
};
router.post('/stream', async (req, res) => {
const { message, history = [] } = req.body;
if (!message) return res.status(400).json({ error: 'message 不能为空' });
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
const send = (type, data) =>
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
try {
const g = getGraph();
// 把历史消息转成 LangChain Message 格式
const historyMessages = history.map((m) =>
m.role === 'user'
? new HumanMessage(m.content)
: { _getType: () => 'ai', content: m.content }
);
// 流式执行图,每个节点执行完毕后触发一次事件
const stream = await g.stream(
{
userInput: message,
messages: [...historyMessages, new HumanMessage(message)],
},
{ streamMode: 'updates' }
);
for await (const update of stream) {
const [nodeName, nodeState] = Object.entries(update)[0];
// 推送节点执行事件
send('node', {
node: nodeName,
intent: nodeState.intent || null,
});
// 推送订单步骤
if (nodeState.orderResult?.steps?.length) {
send('steps', { steps: nodeState.orderResult.steps });
}
// 推送最终答案
if (nodeState.finalAnswer) {
send('answer', { content: nodeState.finalAnswer });
}
}
send('done', {});
res.end();
} catch (err) {
console.error('[Graph Error]', err.message);
send('error', { content: '处理请求时出错,请重试' });
res.end();
}
});
export default router;
在 server/src/index.js 中注册:
import graphRouter from './routes/graph.js';
app.use('/api/graph', graphRouter);
4.10 前端展示工作流状态
Graph 场景的前端重点是可视化工作流的执行过程,让用户看到当前处于哪个节点。
4.10.1 节点状态映射
// 节点名称 → 用户友好的展示文案
const NODE_LABELS = {
intentRouter: '意图识别中...',
orderAgent: '查询订单信息...',
ragNode: '检索知识库...',
generalChat: '思考回答...',
answerSynthesizer: '整理回答...',
};
const INTENT_LABELS = {
order: '订单查询',
knowledge: '知识库问答',
general: '通用对话',
};
4.10.2 useGraph Composable
// client/src/composables/useGraph.js
import { ref, nextTick } from 'vue';
const API_BASE = 'http://localhost:3000/api';
const NODE_LABELS = {
intentRouter: '意图识别中',
orderAgent: '查询订单信息',
ragNode: '检索知识库',
generalChat: '思考回答',
answerSynthesizer: '整理回答',
};
const INTENT_LABELS = {
order: '订单查询',
knowledge: '知识库问答',
general: '通用对话',
};
export function useGraph() {
const messages = ref([]);
const loading = ref(false);
const currentNode = ref('');
const error = ref('');
const sendMessage = async (userInput, scrollCallback) => {
if (!userInput.trim() || loading.value) return;
error.value = '';
currentNode.value = '';
messages.value.push({ role: 'user', content: userInput });
scrollCallback?.();
loading.value = true;
const assistantIndex = messages.value.length;
messages.value.push({
role: 'assistant',
content: '',
nodes: [], // 经过的节点列表
intent: '',
steps: [],
loading: true,
});
try {
const history = messages.value
.slice(0, -1)
.slice(-8)
.filter((m) => !m.loading)
.map(({ role, content }) => ({ role, content }));
const response = await fetch(`${API_BASE}/graph/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: userInput, history }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
while (true) {
const { done, value } = await reader.read();
if (done) break;
const lines = decoder
.decode(value, { stream: true })
.split('\n')
.filter((l) => l.startsWith('data: '));
for (const line of lines) {
try {
const parsed = JSON.parse(line.slice(6));
if (parsed.type === 'node') {
currentNode.value = NODE_LABELS[parsed.node] || parsed.node;
const msg = messages.value[assistantIndex];
if (!msg.nodes.includes(parsed.node)) {
messages.value[assistantIndex] = {
...msg,
nodes: [...msg.nodes, parsed.node],
intent: parsed.intent
? INTENT_LABELS[parsed.intent] || parsed.intent
: msg.intent,
};
}
await nextTick();
scrollCallback?.();
}
if (parsed.type === 'steps') {
messages.value[assistantIndex] = {
...messages.value[assistantIndex],
steps: parsed.steps,
};
}
if (parsed.type === 'answer') {
messages.value[assistantIndex] = {
...messages.value[assistantIndex],
content: parsed.content,
loading: false,
};
currentNode.value = '';
await nextTick();
scrollCallback?.();
}
if (parsed.type === 'error') {
messages.value[assistantIndex] = {
...messages.value[assistantIndex],
content: parsed.content,
loading: false,
};
currentNode.value = '';
}
} catch {}
}
}
} catch (err) {
error.value = `请求失败:${err.message}`;
messages.value.pop();
} finally {
loading.value = false;
currentNode.value = '';
}
};
const clearMessages = () => {
messages.value = [];
currentNode.value = '';
error.value = '';
};
return {
messages,
loading,
currentNode,
error,
sendMessage,
clearMessages,
};
}
4.10.3 GraphView 组件
<!-- client/src/views/GraphView.vue -->
<template>
<div class="graph-page">
<header class="chat-header">
<div class="header-left">
<div class="avatar">购</div>
<div>
<h1>极速购智能客服中枢</h1>
<span class="subtitle">
{{ loading ? currentNode || '处理中...' : '多 Agent 协作模式' }}
</span>
</div>
</div>
<button @click="clearMessages">清空</button>
</header>
<main class="messages-wrap" ref="messagesRef">
<div v-if="messages.length === 0" class="welcome">
<p>您好,我是极速购智能客服中枢。</p>
<p>我会自动判断您的问题类型,调用最合适的模块为您服务。</p>
<div class="quick-btns">
<button v-for="q in quickQuestions" :key="q" @click="handleQuick(q)">
{{ q }}
</button>
</div>
</div>
<div
v-for="(msg, i) in messages"
:key="i"
class="message-row"
:class="msg.role"
>
<div class="avatar-sm">{{ msg.role === 'user' ? '我' : '购' }}</div>
<div class="message-content">
<!-- 工作流执行轨迹 -->
<div v-if="msg.nodes && msg.nodes.length" class="flow-trace">
<span
v-for="(node, ni) in msg.nodes"
:key="ni"
class="flow-node"
>
<span class="node-name">{{ NODE_DISPLAY[node] || node }}</span>
<span v-if="ni < msg.nodes.length - 1" class="arrow">→</span>
</span>
<span v-if="msg.intent" class="intent-tag">{{ msg.intent }}</span>
</div>
<!-- 工具调用步骤 -->
<div v-if="msg.steps && msg.steps.length" class="steps-wrap">
<div v-for="(step, si) in msg.steps" :key="si" class="step-item">
<span class="step-tool">{{ step.tool }}</span>
<span class="step-sep">·</span>
<span class="step-input">{{ formatStepInput(step.input) }}</span>
</div>
</div>
<!-- 加载动画 -->
<div v-if="msg.loading" class="bubble loading-bubble">
<span class="dot" /><span class="dot" /><span class="dot" />
</div>
<!-- 最终回答 -->
<div v-else class="bubble">{{ msg.content }}</div>
</div>
</div>
<div v-if="error" class="error-tip">{{ error }}</div>
</main>
<footer class="input-area">
<textarea
v-model="inputText"
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
:disabled="loading"
@keydown.enter.exact.prevent="handleSend"
rows="1"
/>
<button
class="send-btn"
:disabled="loading || !inputText.trim()"
@click="handleSend"
>
{{ loading ? '处理中...' : '发送' }}
</button>
</footer>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue';
import { useGraph } from '../composables/useGraph.js';
const {
messages, loading, currentNode, error, sendMessage, clearMessages,
} = useGraph();
const inputText = ref('');
const messagesRef = ref(null);
const NODE_DISPLAY = {
intentRouter: '意图识别',
orderAgent: '订单查询',
ragNode: '知识库检索',
generalChat: '通用对话',
answerSynthesizer: '整理回答',
};
const quickQuestions = [
'订单 ORD-001 发货了吗?',
'蓝牙耳机怎么保修?',
'退款需要多少天?',
'你好',
];
const scrollToBottom = async () => {
await nextTick();
if (messagesRef.value)
messagesRef.value.scrollTop = messagesRef.value.scrollHeight;
};
const handleSend = async () => {
const text = inputText.value.trim();
if (!text || loading.value) return;
inputText.value = '';
await sendMessage(text, scrollToBottom);
};
const handleQuick = (q) => {
inputText.value = q;
handleSend();
};
const formatStepInput = (input) => {
if (!input) return '';
return Object.values(input).join(' · ');
};
</script>
<style scoped>
.graph-page {
display: flex; flex-direction: column;
height: 100vh; max-width: 780px;
margin: 0 auto; background: #f8fafc;
font-family: -apple-system, 'PingFang SC', sans-serif;
}
.chat-header {
display: flex; align-items: center;
justify-content: space-between;
padding: 14px 20px; background: #fff;
border-bottom: 1px solid #e2e8f0;
}
.header-left { display: flex; align-items: center; gap: 12px; }
.avatar {
width: 42px; height: 42px; border-radius: 12px;
background: #7c3aed; color: #fff;
font-size: 18px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.header-left h1 { font-size: 16px; font-weight: 600; margin: 0; color: #1e293b; }
.header-left .subtitle { font-size: 12px; color: #94a3b8; }
.chat-header button {
padding: 6px 14px; border-radius: 8px;
border: 1px solid #e2e8f0; background: #fff;
color: #64748b; cursor: pointer; font-size: 13px;
}
.messages-wrap {
flex: 1; overflow-y: auto;
padding: 20px 16px;
display: flex; flex-direction: column; gap: 16px;
}
.welcome { text-align: center; padding: 40px 20px; color: #64748b; }
.welcome p { font-size: 15px; margin: 0 0 8px; }
.quick-btns {
display: flex; flex-wrap: wrap; gap: 8px;
justify-content: center; margin-top: 16px;
}
.quick-btns button {
padding: 7px 14px; border-radius: 20px;
border: 1px solid #ddd6fe; background: #f5f3ff;
color: #7c3aed; font-size: 13px; cursor: pointer;
}
.message-row { display: flex; gap: 8px; }
.message-row.user { justify-content: flex-end; flex-direction: row-reverse; }
.avatar-sm {
width: 32px; height: 32px; border-radius: 10px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700;
}
.message-row.user .avatar-sm { background: #2563eb; color: #fff; }
.message-row.assistant .avatar-sm { background: #f5f3ff; color: #7c3aed; }
.message-content {
display: flex; flex-direction: column; gap: 6px; max-width: 78%;
}
.message-row.user .message-content { align-items: flex-end; }
/* 工作流执行轨迹 */
.flow-trace {
display: flex; align-items: center;
flex-wrap: wrap; gap: 4px;
font-size: 11px; color: #94a3b8;
}
.flow-node { display: flex; align-items: center; gap: 4px; }
.node-name {
padding: 2px 8px; border-radius: 4px;
background: #f5f3ff; color: #7c3aed;
border: 1px solid #ddd6fe; font-size: 11px;
}
.arrow { color: #cbd5e1; }
.intent-tag {
padding: 2px 8px; border-radius: 4px;
background: #eff6ff; color: #2563eb;
border: 1px solid #bfdbfe; font-size: 11px; margin-left: 4px;
}
/* 工具调用步骤 */
.steps-wrap { display: flex; flex-direction: column; gap: 3px; }
.step-item {
display: flex; align-items: center; gap: 6px;
font-size: 12px; padding: 4px 10px;
background: #f8fafc; border-radius: 6px;
border: 1px solid #e2e8f0;
}
.step-tool { font-weight: 600; color: #475569; }
.step-sep { color: #cbd5e1; }
.step-input { color: #94a3b8; }
.bubble {
padding: 12px 16px; border-radius: 16px;
font-size: 14px; line-height: 1.7; white-space: pre-wrap;
}
.message-row.user .bubble {
background: #2563eb; color: #fff; border-bottom-right-radius: 4px;
}
.message-row.assistant .bubble {
background: #fff; color: #1e293b;
border-bottom-left-radius: 4px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.loading-bubble {
display: flex; gap: 5px; align-items: center;
padding: 12px 16px; background: #fff; border-radius: 16px;
border-bottom-left-radius: 4px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
min-width: 60px;
}
.dot {
width: 7px; height: 7px; border-radius: 50%;
background: #94a3b8; animation: bounce 1.2s infinite;
}
.dot:nth-child(2) { animation-delay: .2s; }
.dot:nth-child(3) { animation-delay: .4s; }
@keyframes bounce {
0%, 80%, 100% { transform: translateY(0); opacity: .4; }
40% { transform: translateY(-5px); opacity: 1; }
}
.error-tip {
text-align: center; padding: 10px 16px;
background: #fef2f2; color: #dc2626;
border-radius: 8px; font-size: 13px;
}
.input-area {
padding: 14px 16px; background: #fff;
border-top: 1px solid #e2e8f0;
display: flex; gap: 10px; align-items: flex-end;
}
textarea {
flex: 1; resize: none; border: 1px solid #e2e8f0;
border-radius: 12px; padding: 10px 14px;
font-size: 14px; font-family: inherit;
outline: none; background: #f8fafc;
min-height: 42px; max-height: 120px;
}
textarea:focus { border-color: #7c3aed; background: #fff; }
textarea:disabled { opacity: 0.6; }
.send-btn {
width: 80px; height: 42px; border-radius: 12px;
border: none; background: #7c3aed; color: #fff;
font-size: 14px; font-weight: 600; cursor: pointer;
}
.send-btn:hover:not(:disabled) { background: #6d28d9; }
.send-btn:disabled { background: #ddd6fe; color: #7c3aed; cursor: not-allowed; }
</style>
4.11 LangGraph vs AgentExecutor 选型参考
实际项目里这两个经常让人纠结,参考下面的判断标准:
用 AgentExecutor 的场景
- 工具数量少(5 个以内),目标单一
- 流程是线性的:想一想 → 用工具 → 得到答案
- 快速原型,不需要精细控制流程
用 LangGraph 的场景
- 有明显的分支逻辑(不同意图走不同流程)
- 多个专业模块需要协作,各模块结果需要汇合
- 需要在节点间传递状态(比如第一个节点的结果影响后续节点的行为)
- 需要循环(比如检索结果质量不够时重新检索)
- 需要 Human-in-the-loop(某个节点暂停等待人工确认)
4.12 扩展方向
本章的 Demo 是一个基础结构,生产环境可以在这个基础上扩展:
增加检索质量评估节点
在 ragNode 之后加一个评估节点,判断检索到的内容和问题的相关性是否足够高,不够高时重新检索或换用其他策略。
增加人工介入节点
在 answerSynthesizer 之前加一个审核节点,对于涉及退款、赔偿等敏感操作,暂停工作流等待人工确认。LangGraph 原生支持这个模式(interrupt)。
持久化 Checkpoints
LangGraph 支持把每一步的 State 存到数据库,中断后可以从断点继续执行。对于长时运行的工作流非常有用。
4.13 运行 Demo
# 确保第三章的 pgvector 已启动且知识库已入库
# 后端(nodemon 会自动热重载)
cd server
pnpm dev
# 前端注册路由
# { path: '/graph', component: GraphView }
pnpm dev
# 访问 http://localhost:5173/graph
测试用例:
| 输入 | 预期路由 | 预期结果 |
|---|---|---|
| 订单 ORD-001 发货了吗 | intentRouter → orderAgent → answerSynthesizer | 返回订单发货状态 |
| 退款需要多少天 | intentRouter → ragNode → answerSynthesizer | 返回退款流程说明 |
| 你好 | intentRouter → generalChat → answerSynthesizer | 友好的问候回复 |
4.14 常见问题
Q:条件边函数返回的节点名和注册的节点名不匹配
addConditionalEdges 的第三个参数是映射对象,key 是条件函数的返回值,value 是节点名称。两者必须一致,否则图编译时不报错但运行时会找不到节点。
Q:State 更新后下一个节点拿不到更新的值
每个节点返回的对象是部分更新,LangGraph 会自动合并到 State。确认节点返回的字段名和 Annotation.Root 里定义的字段名完全一致(区分大小写)。
Q:图编译报错 "cycle detected"
图里存在循环引用,A → B → A 这样的结构。检查 addEdge 的调用,确认没有形成环。如果业务上需要循环,要用 LangGraph 的条件边配合 END 来控制退出条件,不能直接形成环。
Q:streamMode: 'updates' 没有输出
确认 graph.stream() 的第二个参数传入了 { streamMode: 'updates' }。默认模式是 values,返回每步的完整 State 而不是增量更新,字段结构不同。
4.15 本章小结
| 知识点 | 掌握内容 |
|---|---|
| LangGraph 核心 | StateGraph、Node、Edge、Conditional Edge、START/END |
| State 设计 | Annotation.Root 定义,MessagesAnnotation,reducer 函数 |
| 节点实现 | 接收 State、处理、返回部分更新的函数模式 |
| 条件路由 | addConditionalEdges + 路由函数,实现意图分流 |
| 图组装 | addNode、addEdge、addConditionalEdges、compile |
| 流式执行 | graph.stream + streamMode: updates,前端分类型处理事件 |
| 选型参考 | LangGraph vs AgentExecutor 的使用场景区别 |
至此,四章核心内容全部完成。你现在掌握了从基础对话、工具调用、知识库检索到多 Agent 编排的完整 AI 全栈开发链路。