返回笔记首页

第四章:LangGraph 多 Agent 编排

主题配置

课程:AI 全栈开发实战 — 基于 LangChain.js + Vue3 + Node.js 实战 Demo:极速购智能客服中枢 — 自动路由 + 多 Agent 协作

代码

langchain-course-ch4.zip


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 结构:

plain
{
  messages:     [],      // 完整对话历史
  userInput:    '',      // 当前用户输入
  intent:       '',      // 路由节点判断的意图:order / knowledge / general
  orderResult:  null,    // 订单 Agent 的查询结果
  ragResult:    '',      // RAG 节点的检索结果
  finalAnswer:  '',      // 最终回答
}

4.3.3 Node(节点)

一个普通的 async 函数,接收当前 State,返回需要更新的字段(部分更新,不需要返回完整 State)。

javascript
const myNode = async (state) => {
  // 读取状态
  const { userInput } = state;
  // 处理...
  // 返回需要更新的字段
  return { someField: result };
};

4.3.4 Edge 和 Conditional Edge

普通边:固定流转,A 节点完成后一定去 B 节点。

条件边:根据 State 的值决定下一个节点,用一个函数返回节点名称字符串。

javascript
// 条件边函数:根据 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 设计

极速购客服中枢,工作流如下:

plain
START
  ↓
intentRouter(意图识别,判断问题类型)
  ↓(条件路由)
  ├─ order     → orderAgent(查订单/物流)
  ├─ knowledge → ragNode(查知识库)
  └─ general   → generalChat(通用对话)
  ↓(三条路径汇合)
answerSynthesizer(综合结果,生成最终回答)
  ↓
END

这个结构的好处:

  • 每个节点职责单一,互不干扰
  • 意图识别和具体处理分离,路由逻辑集中在一处,便于维护
  • 后续扩展新的意图类型,只需增加节点和边,不影响其他部分

4.5 安装依赖

bash
cd server
pnpm add @langchain/langgraph

4.6 定义 State

LangGraph 的 State 用 Annotation 定义,MessagesAnnotation 是内置的消息列表注解,自动处理消息的追加逻辑。

javascript
// 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 字段,供条件边路由使用。

javascript
// 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。

javascript
// 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。

javascript
// 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 通用对话节点

兜底节点,处理不需要查询数据的一般性对话。

javascript
// 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 答案综合节点

把各个处理节点的结果汇合,生成最终面向用户的回答。

javascript
// 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

javascript
// 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 的接口需要支持流式推送节点执行状态,让前端能实时展示工作流进度。

javascript
// 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 中注册:

plain
import graphRouter from './routes/graph.js';
app.use('/api/graph', graphRouter);

4.10 前端展示工作流状态

Graph 场景的前端重点是可视化工作流的执行过程,让用户看到当前处于哪个节点。

4.10.1 节点状态映射

plain
// 节点名称 → 用户友好的展示文案
const NODE_LABELS = {
  intentRouter:      '意图识别中...',
  orderAgent:        '查询订单信息...',
  ragNode:           '检索知识库...',
  generalChat:       '思考回答...',
  answerSynthesizer: '整理回答...',
};

const INTENT_LABELS = {
  order:     '订单查询',
  knowledge: '知识库问答',
  general:   '通用对话',
};

4.10.2 useGraph Composable

javascript
// 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 组件

vue
<!-- 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

bash
# 确保第三章的 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 全栈开发链路。