乐于分享
好东西不私藏

AI客服:LangChain 是什么?在这个 AI 客服项目里,它负责把大模型调用变成“可编排的链”

AI客服:LangChain 是什么?在这个 AI 客服项目里,它负责把大模型调用变成“可编排的链”

如果你想把这篇文章和源码对着看,可以先留意文中出现的几个文件名。这个系列会继续按源码往后拆;如果这种拆解方式对你有帮助,也可以先把文章收着,或者在评论区留个小信号,我会优先把后面的调用链继续整理出来。

很多人第一次听到 LangChain,会以为它是一个很神秘的 AI 框架。

但如果结合这个 AI 客服项目来看,它其实可以先理解成一句话:

TEXT

LangChain 不是大模型本身,而是帮我们更有结构地调用大模型。

在本项目里,大模型底层用的是 DeepSeek。项目没有在业务代码里到处手写 fetch('https://api.deepseek.com/...'),而是用 LangChain 把这些事情拆成几块:

  • 模型对象怎么创建

  • Prompt 怎么组织

  • 对话历史怎么传进去

  • 意图识别怎么拿到 JSON

  • 客服回复怎么流式输出

所以,LangChain 在这个项目里的角色更像“调用大模型的管道系统”。

先看项目里的位置

和 LangChain 相关的核心文件主要有这几个:

文件
作用
server/src/services/modelProvider.js
创建 DeepSeek 对应的 LangChain 模型对象
server/src/services/llmService.js
准备两个模型实例:一个流式回复,一个结构化识别
server/src/services/promptService.js
定义 Prompt 模板,并组装成 Chain
server/src/services/deepseekService.js
对外提供 recognize() 和 chat() 两个高层方法
server/src/services/customerServiceAgent.js
在 LangGraph 节点里调用 deepseekService.chat()
server/src/routes/chat.js
把流式回复通过 SSE 返回给前端

整体链路可以画成这样:

项目调用链静态示意

用户消息

↓

/api/chat

server/src/routes/chat.js

↓

customerServiceAgent.prepareReply()

↓

分支一:意图识别

intentService.process()

↓

deepseekService.recognize()

↓

buildIntentChain()

Prompt + llmJson + JsonOutputParser

分支二:回复生成

deepseekService.chat()

↓

buildReplyChain()

Prompt + llm

↓

LangChain stream

↓

SSE 返回给前端

模型配置来源

modelProvider.js

ChatOpenAI + DeepSeek 配置

↓

llmService.js

llm / llmJson

这张图里最重要的是两条链:

TEXT

意图识别链:Prompt -> 非流式模型 -> JSON 解析器
回复生成链:Prompt -> 流式模型 -> SSE 输出

LangChain 到底解决了什么问题

如果不用 LangChain,直接调用大模型,代码通常会变成这样:

JS

const res = await fetch('https://api.deepseek.com/v1/chat/completions', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    model: 'deepseek-chat',
    messages: [
      { role: 'system', content: '你是客服助手' },
      { role: 'user', content: '这款还有货吗?' },
    ],
  }),
});

这当然也能跑。

但项目变复杂后,问题会慢慢出现:

  • 每个地方都要关心模型地址、API Key、模型名

  • Prompt 容易散落在各个文件里

  • 有的调用要返回 JSON,有的调用要流式返回文本

  • 对话历史、业务数据、知识库参考都要拼进 Prompt

  • 出错兜底、超时控制、结果解析都要自己写

LangChain 的价值就是把这些步骤结构化。

在这个项目里,它把大模型调用拆成几个稳定零件:

TEXT

模型 Model
Prompt 模板
输入变量
输出解析器
Chain 调用链
Stream 流式输出

第一步:用 ChatOpenAI 包装 DeepSeek

项目里的模型创建代码在:

TEXT

server/src/services/modelProvider.js

核心代码是:

JS

const { ChatOpenAI } = require('@langchain/openai');

function createChatModel({ streaming = false } = {}) {
  return new ChatOpenAI({
    ...getDeepSeekConfig(),
    streaming,
  });
}

这里有一个容易误解的点:

TEXT

为什么用的是 ChatOpenAI,而不是 DeepSeekModel?

因为 DeepSeek 提供的是 OpenAI 兼容接口。也就是说,它的调用方式和 OpenAI Chat Completions 很像,只要把 baseURL 换成 DeepSeek 的地址即可。

项目里的配置来自环境变量:

JS

return {
  model: process.env.DEEPSEEK_MODEL || 'deepseek-chat',
  apiKey,
  configuration: {
    baseURL: process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/v1',
  },
};

所以这段代码的意思是:

TEXT

用 LangChain 的 ChatOpenAI 适配器,去调用 DeepSeek 的 OpenAI-compatible API。

如果写成更通俗的话:

TEXT

ChatOpenAI 是插头规格,DeepSeek 是接上的电源。

第二步:准备两个模型实例

项目里没有只创建一个模型对象,而是创建了两个。

文件位置:

TEXT

server/src/services/llmService.js

代码是:

JS

const llm = createChatModel({ streaming: true });
const llmJson = createChatModel({ streaming: false });

module.exports = { llm, llmJson };

为什么要两个?

因为这个客服系统有两类完全不同的大模型任务。

第一类:意图识别。

它需要稳定返回一个结构化 JSON,例如:

JSON

{
  "intent": "PRODUCT_INQUIRY",
  "entities": {
    "product_name": "蓝牙耳机",
    "order_id": null,
    "reason": null,
    "content": null
  },
  "confidence": 0.82,
  "emotion": "neutral",
  "needs_clarification": false
}

这种任务不适合流式返回。系统需要等完整 JSON 出来,再决定下一步做什么。

所以它用:

TEXT

llmJson:streaming false

第二类:客服回复生成。

它要像聊天一样一点点返回给用户。

所以它用:

TEXT

llm:streaming true

这就是项目里两个模型实例的分工:

模型实例
是否流式
用途
llmJson
否
意图识别,返回 JSON
llm
是
客服回复,边生成边返回

第三步:用 Prompt 模板管理输入

LangChain 里很重要的一个概念是 Prompt Template。

它不是简单字符串拼接,而是一个“带变量的消息模板”。

项目里定义 Prompt 的文件是:

TEXT

server/src/services/promptService.js

意图识别 Prompt 使用:

JS

const intentPromptTemplate = ChatPromptTemplate.fromMessages([
  [
    'system',
    [
      '你是单商家 AI 客服的意图识别器,只返回 JSON,不要输出解释。',
      '可选 intent:QUERY_ORDER、CANCEL_ORDER、REFUND、COMPLAINT、PRODUCT_INQUIRY、GENERAL_QA、UNKNOWN。',
      '返回 JSON 结构:',
      '{{"intent":"PRODUCT_INQUIRY","entities":{{"order_id":null,"product_name":null,"reason":null,"content":null}},"confidence":0.0,...}}',
    ].join('\n'),
  ],
  [
    'human',
    '店铺设置:{settings}\n\n对话历史:{context}\n\n用户消息:{message}',
  ],
]);

这里有几个关键点。

第一,系统消息里明确告诉模型:

TEXT

你是意图识别器,只返回 JSON,不要输出解释。

第二,项目限制了意图枚举:

TEXT

QUERY_ORDER
CANCEL_ORDER
REFUND
COMPLAINT
PRODUCT_INQUIRY
GENERAL_QA
UNKNOWN

第三,用户消息不是单独传进去的,还会带上:

  • 店铺设置 settings

  • 对话历史 context

  • 当前消息 message

这就让模型不是孤立判断一句话,而是结合上下文判断。

第四步:把 Prompt、模型和解析器串成 Chain

LangChain 的 Chain 可以理解成一条流水线。

项目里的意图识别链是这样写的:

JS

function buildIntentChain() {
  return intentPromptTemplate.pipe(llmJson).pipe(new JsonOutputParser());
}

这行代码可以拆成三步:

TEXT

intentPromptTemplate
-> llmJson
-> JsonOutputParser

对应含义是:

TEXT

把输入变量填进 Prompt
-> 调用 DeepSeek
-> 把模型输出解析成 JSON 对象

这就是 LangChain 最直观的“链”。

再看回复生成链:

JS

function buildReplyChain() {
  return replyPromptTemplate.pipe(llm);
}

这里没有接 JsonOutputParser,因为客服回复就是自然语言文本,不需要解析成 JSON。

而且它使用的是流式模型 llm,后面可以调用:

JS

chain.stream(...)

示例一:用户问“这款还有货吗”,LangChain 怎么参与意图识别

假设用户前面问过:

TEXT

用户:蓝牙耳机多少钱?
客服:这款蓝牙耳机价格是 199 元。
用户:这款还有货吗?

这句话里“这款”是一个指代。单看当前句,模型不知道是哪款商品。

项目会先在 intentService.process() 里读取三类数据:

JS

const [history, settings, intentContext] = await Promise.all([
  contextService.getPlainContext(session.id),
  actionService.getSettings(),
  contextService.getIntentContext(session.id),
]);

然后调用:

JS

const recognized = await deepseekService.recognize(message, history, settings);

deepseekService.recognize() 里会创建意图识别链:

JS

const chain = buildIntentChain();

const result = await chain.invoke({
  message,
  context: JSON.stringify(context),
  settings: JSON.stringify(settings || {}),
});

于是 LangChain 实际做了这件事:

TEXT

把店铺设置、历史对话、当前消息填进 Prompt
-> 调 DeepSeek 识别意图
-> 用 JsonOutputParser 把结果变成对象

模型可能返回:

JSON

{
  "intent": "PRODUCT_INQUIRY",
  "entities": {
    "product_name": null,
    "order_id": null,
    "reason": null,
    "content": null
  },
  "confidence": 0.75,
  "emotion": "neutral",
  "needs_clarification": false,
  "reference_to_previous": true
}

后面项目自己的规则再接着处理:

JS

if (referenceToPrevious) {
  collectedEntities = mergeEntities(
    intentContext.collected_entities || {},
    collectedEntities
  );
}

也就是说:

TEXT

LangChain 帮项目调用模型识别“这是商品咨询、它引用了上一轮”;
项目自己的业务代码再把上一轮的商品名继承过来。

这也说明一个重要原则:

TEXT

LangChain 负责让模型调用更顺滑,但业务判断不能全丢给模型。

示例二:查询商品后,LangChain 怎么生成客服回复

再看一个更完整的例子。

用户问:

TEXT

蓝牙耳机还有库存吗?

项目识别出意图:

TEXT

PRODUCT_INQUIRY

然后 actionService.queryProduct() 会查 MySQL:

JS

const [rows] = await pool.execute(
  "SELECT * FROM products WHERE status = 'active' AND name LIKE ? LIMIT 10",
  [`%${keyword}%`]
);

假设查到:

JSON

{
  "name": "蓝牙耳机 Pro",
  "price": 199,
  "stock": 36,
  "status": "active"
}

同时,知识库可能检索到一段商品说明:

TEXT

蓝牙耳机 Pro 支持主动降噪,续航 30 小时,适合通勤和运动场景。

这些数据会被传给 deepseekService.chat():

JS

const stream = await deepseekService.chat(messages, {
  businessData: state.actionResult && state.actionResult.data,
  actionMessage: state.actionResult && state.actionResult.message,
  retrievedKnowledge: state.retrievedKnowledge,
  intent: state.intentResult.intent,
  entities: state.intentResult.entities,
  emotion: state.intentResult.emotion,
  sootheMode: state.intentResult.sootheMode,
  settings: state.intentResult.settings,
});

在 deepseekService.chat() 里,会调用回复生成链:

JS

const chain = buildReplyChain();

const stream = await chain.stream({
  system_prompt,
  intent: intent || 'UNKNOWN',
  entities: JSON.stringify(entities || {}),
  business_data: JSON.stringify(businessData || null),
  action_message: actionMessage || '无',
  retrieved_knowledge: formatRetrievedKnowledge(retrievedKnowledge),
  emotion_instruction,
  chat_history,
  message: (lastMessage && lastMessage.content) || '',
});

这里有一个非常关键的 Prompt 规则:

TEXT

业务数据来自 MySQL,是价格、库存、订单、退款状态的最高优先级依据。
凡是业务数据里包含价格、库存、订单金额、订单状态,回答必须写出原始数值和状态。
知识库参考只用于补充 FAQ、政策、话术、商品说明;如果与业务数据冲突,以业务数据为准。

所以 AI 最终应该回答类似:

TEXT

有货的。蓝牙耳机 Pro 当前库存是 36 件,价格是 199 元。
另外这款支持主动降噪,续航约 30 小时,通勤和运动都比较适合。

这个回答不是模型“凭空知道”的,而是项目把这些信息组织进 Prompt 后生成的。

LangChain 和 SSE 是怎么连起来的

deepseekService.chat() 返回的是一个流:

JS

return stream;

在 server/src/routes/chat.js 里,后端会遍历这个流:

JS

for await (const chunk of stream) {
  const token =
    typeof chunk === 'string'
      ? chunk
      : (chunk && chunk.content) || '';

  if (token) {
    fullReply += token;
    sendEvent(res, { type: 'token', content: token });
  }
}

这就把 LangChain 的流式输出接到了 SSE 上:

TEXT

LangChain stream
-> 后端 for await 读取 token
-> SSE data: {"type":"token","content":"..."}
-> 前端聊天气泡逐字更新

也就是说,用户看到的“AI 正在打字”,前面其实是:

TEXT

LangChain 流式调用 DeepSeek

后面才是:

TEXT

Express 用 SSE 推给浏览器

为什么接口层不直接调 DeepSeek

这个项目没有在 routes/chat.js 里直接写大模型调用,而是分成:

TEXT

routes/chat.js
-> customerServiceAgent.js
-> deepseekService.js
-> promptService.js
-> llmService.js
-> modelProvider.js

这样看起来文件多了一点,但好处很明显:

  • 路由层只关心 HTTP 和 SSE

  • Agent 层只关心客服流程编排

  • DeepSeek 服务层只暴露 recognize() 和 chat()

  • Prompt 集中在 promptService.js,方便调整话术

  • 模型配置集中在 modelProvider.js,方便换模型

如果以后要把 DeepSeek 换成别的 OpenAI-compatible 模型,优先改的就是:

TEXT

modelProvider.js
.env 里的 DEEPSEEK_BASE_URL / DEEPSEEK_MODEL

业务代码不用大面积改。

最后用一句话记住

在这个项目里:

TEXT

LangChain 负责把“大模型调用”变成一条条清晰的链:
Prompt 填变量,模型生成结果,解析器处理输出,流式结果交给 SSE。

它不是替你完成业务逻辑的魔法,而是让模型调用更稳定、更可维护、更容易组合。

如果你读源码,建议按这个顺序看:

TEXT

modelProvider.js
-> llmService.js
-> promptService.js
-> deepseekService.js
-> customerServiceAgent.js
-> routes/chat.js

读完这条链,你就能明白:这个 AI 客服不是简单地“把用户问题丢给 DeepSeek”,而是先组织上下文、业务数据和知识库,再让大模型生成可控的客服回复。

相关学习资料