如果你想把这篇文章和源码对着看,可以先留意文中出现的几个文件名。这个系列会继续按源码往后拆;如果这种拆解方式对你有帮助,也可以先把文章收着,或者在评论区留个小信号,我会优先把后面的调用链继续整理出来。
很多人第一次听到 LangChain,会以为它是一个很神秘的 AI 框架。
但如果结合这个 AI 客服项目来看,它其实可以先理解成一句话:
TEXT
LangChain 不是大模型本身,而是帮我们更有结构地调用大模型。在本项目里,大模型底层用的是 DeepSeek。项目没有在业务代码里到处手写 fetch('https://api.deepseek.com/...'),而是用 LangChain 把这些事情拆成几块:
模型对象怎么创建
Prompt 怎么组织
对话历史怎么传进去
意图识别怎么拿到 JSON
客服回复怎么流式输出
所以,LangChain 在这个项目里的角色更像“调用大模型的管道系统”。
先看项目里的位置
和 LangChain 相关的核心文件主要有这几个:
server/src/services/modelProvider.js | |
server/src/services/llmService.js | |
server/src/services/promptService.js | |
server/src/services/deepseekService.js | recognize() 和 chat() 两个高层方法 |
server/src/services/customerServiceAgent.js | deepseekService.chat() |
server/src/routes/chat.js |
整体链路可以画成这样:
项目调用链静态示意
用户消息
↓
/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 | ||
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”,而是先组织上下文、业务数据和知识库,再让大模型生成可控的客服回复。
夜雨聆风