课程:AI 全栈开发实战 — 基于 LangChain.js + Vue3 + Node.js 定位:有 Node.js 基础,首次接触 LLM 开发 实战 Demo:极速购客服系统 — 基础对话模块
代码
1.1 本章目标
完成本章学习后,你将能够:
- 理解 LLM 的工作原理,知道它能做什么、不能做什么
- 掌握 Token、上下文窗口、Temperature 等核心参数的含义
- 搭建 Node.js + LangChain.js 开发环境,完成第一次 API 调用
- 理解 LangChain.js 的核心抽象:Model / Prompt / Chain
- 实现一个支持流式输出的对话接口,并在 Vue3 前端展示
1.2 什么是 LLM
作为前端工程师,可以把 LLM 理解成一个"超级智能的 API":给它发一段文字(Prompt),它返回一段文字(Completion)。但它和普通 REST API 有几个关键区别:
| 维度 | 普通 REST API | LLM API |
|---|---|---|
| 输入 | 固定结构 JSON | 自然语言文本 |
| 输出 | 固定结构 JSON | 自然语言文本(非确定性) |
| 响应方式 | 一次性返回 | 支持流式 Streaming |
| 状态 | 无状态 | 无记忆,需手动传上下文 |
| 费用 | 按请求计费 | 按 Token 数量计费 |
1.2.1 Token
Token 是 LLM 处理文本的基本单位,理解它对控制成本和上下文长度非常重要。
- 英文:1 个单词 ≈ 1.3 个 Token,比如
hello= 1 token,tokenization= 3 tokens - 中文:1 个汉字 ≈ 1.5~2 个 Token,
你好≈ 3 tokens - 代码:符号较多,消耗 Token 比普通文本更多
开发建议:
- 调用 API 前可以用
js-tiktoken库估算 Token 数 - DeepSeek API 定价参考:输入 1M tokens ≈ ¥1,输出 1M tokens ≈ ¥2
- 多轮对话含历史消息,Token 消耗随轮数快速增长,后续需要设计截断策略
1.2.2 上下文窗口
LLM 每次调用都是无状态的,它不记得上次聊了什么。要实现多轮对话,需要把历史消息一并传入,这些历史消息加上当前输入就是"上下文",其总 Token 数量受"上下文窗口"限制。
- DeepSeek-V3 上下文窗口:128K tokens,约 10 万汉字
- GPT-4o 上下文窗口:128K tokens
上下文越长,API 响应越慢、费用越高。LangChain.js 的 Memory 模块可以自动管理上下文,第三章详细讲解。
1.2.3 Temperature 与 Top_p
这两个参数控制模型输出的随机性:
temperature = 0:输出最确定、最保守,适合代码生成、数据提取等精确任务temperature = 0.7(默认):平衡创造性和准确性,适合对话、问答temperature = 1.0+:输出更发散,适合故事创作、头脑风暴
Top_p 控制采样范围,通常只调其中一个即可,两个同时调节效果难以预测。
1.3 为什么选 LangChain.js
直接调用 LLM API 完全可行,但当应用变复杂后,你会遇到以下问题:
- 每次手动拼接 System Prompt + 历史消息 + 用户输入,代码重复且易出错
- 流式输出需要自己处理 SSE / Chunk 解析
- 切换模型(DeepSeek → Claude → 本地 Ollama)需要大幅重构代码
- 接入向量数据库、实现 RAG 流程需要大量胶水代码
LangChain.js 解决了以上所有问题,提供统一的抽象层,让你专注业务逻辑而不是底层 API 细节。
核心价值:
- 模型无关性:同一套代码,轻松切换 OpenAI / DeepSeek / Claude / Ollama
- 组合式设计:用
|操作符将 Prompt、Model、Parser 像管道一样串联,即 LCEL 语法 - 生态完整:Memory、RAG、Agent、Tool 开箱即用
- 前端友好:TypeScript 优先,Promise/Stream 符合 JS 习惯
1.4 环境搭建
1.4.1 前置要求
- Node.js >= 18,建议 20 LTS
- pnpm >= 8,课程统一使用 pnpm
- DeepSeek API Key,在 https://platform.deepseek.com 注册后免费获取
- VS Code,推荐安装 ESLint 和 Prettier 插件
1.4.2 项目初始化
本课程采用前后端分离结构,目录如下:
langchain-course/
├── server/
│ ├── src/
│ │ ├── models/ # 模型封装
│ │ ├── prompts/ # Prompt 模板
│ │ ├── chains/ # LangChain 链模块
│ │ ├── routes/ # Express 路由
│ │ └── index.js # 服务入口
│ ├── .env
│ └── package.json
└── client/
└── src/
├── composables/
└── views/
执行以下命令完成初始化:
mkdir langchain-course && cd langchain-course
# 初始化后端
mkdir server && cd server
pnpm init
pnpm add langchain @langchain/core @langchain/openai express dotenv cors
pnpm add -D nodemon
# 初始化前端,回到根目录
cd ..
pnpm create vue@latest client
# 选项:TypeScript: No,Router: Yes,Pinia: Yes,ESLint: Yes
cd client && pnpm install
pnpm add axios
1.4.3 配置环境变量
在 server/ 目录下创建 .env 文件:
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
MODEL_NAME=deepseek-chat
PORT=3000
.env 文件不能提交到 Git,在 .gitignore 中加入 .env。
1.5 LangChain.js 三大核心概念
LangChain.js 的设计围绕三个基础抽象展开,理解它们是掌握后续所有高级功能的前提:
| 概念 | 说明 | 类比 |
|---|---|---|
| Model | 与 LLM 通信的标准接口 | 类似 fetch()的统一封装 |
| Prompt | 结构化的输入模板 | 类似模板字符串 ${variable} |
| Chain | 将多个组件串联成工作流 | 类似 Promise 链 .then().then() |
1.5.1 Model
LangChain.js 将所有 LLM 统一为 ChatModel 接口,接收 messages 数组,每条消息包含 role 和 content:
// server/src/models/deepseek.js
import { ChatOpenAI } from '@langchain/openai';
import 'dotenv/config';
// DeepSeek 兼容 OpenAI 协议,使用 ChatOpenAI 并替换 baseURL 即可
export const createModel = (options = {}) => {
return new ChatOpenAI({
modelName: process.env.MODEL_NAME,
openAIApiKey: process.env.DEEPSEEK_API_KEY,
configuration: {
baseURL: process.env.DEEPSEEK_BASE_URL,
},
temperature: 0.7,
streaming: false,
...options,
});
};
export const model = createModel();
export const streamingModel = createModel({ streaming: true });
1.5.2 Prompt
硬编码 Prompt 字符串是新手最常犯的错误。ChatPromptTemplate 实现结构化、可复用的提示词管理:
// server/src/prompts/customer-service.js
import { ChatPromptTemplate } from '@langchain/core/prompts';
export const customerServicePrompt = ChatPromptTemplate.fromMessages([
[
'system',
`你是极速购电商平台的专业客服助手小购。
规则:
1. 只回答与购物、订单、物流、商品、售后相关的问题
2. 语气友好、专业,称呼用户为"亲"
3. 回复简洁,不超过 150 字
4. 遇到需要人工处理的问题,引导用户拨打 400-888-8888
当前时间:{current_time}`,
],
['placeholder', '{chat_history}'],
['human', '{user_input}'],
]);
说明:
system消息定义角色和规则,是控制 LLM 行为最重要的地方{变量名}占位符在invoke()时动态填充,避免字符串拼接placeholder用于插入对话历史数组,是实现多轮对话的关键
1.5.3 Chain(LCEL 语法)
LCEL(LangChain Expression Language)用 | 操作符将组件串联:
// server/src/chains/basic-chat.js
import { StringOutputParser } from '@langchain/core/output_parsers';
import { createModel } from '../models/deepseek.js';
import { customerServicePrompt } from '../prompts/customer-service.js';
const model = createModel({ temperature: 0.5 });
const parser = new StringOutputParser();
// Prompt → Model → Parser
export const customerServiceChain = customerServicePrompt | model | parser;
// 格式化历史消息:前端传来的 { role, content } 转为 LangChain 格式
export const formatHistory = (history = []) =>
history
.map((msg) => {
if (msg.role === 'user') return ['human', msg.content];
if (msg.role === 'assistant') return ['assistant', msg.content];
return null;
})
.filter(Boolean);
数据流说明:
invoke({ chat_history, user_input, current_time })传入 Prompt 模板- Prompt 模板格式化为 messages 数组,传入 Model
- Model 调用 DeepSeek API,返回
AIMessage对象 StringOutputParser从AIMessage中提取纯文本字符串
1.6 流式输出实现
流式输出是 AI 应用的标配体验,用户看到文字逐字出现,而不是等待后一次性返回。后端使用 SSE(Server-Sent Events)实现最为简单。
1.6.1 后端 SSE 接口
// server/src/routes/chat.js
import express from 'express';
import { customerServiceChain, formatHistory } from '../chains/basic-chat.js';
const router = express.Router();
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 = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
try {
const stream = await customerServiceChain.stream({
user_input: message,
chat_history: formatHistory(history),
current_time: new Date().toLocaleString('zh-CN'),
});
for await (const chunk of stream) {
if (chunk) send({ content: chunk });
}
send({ done: true });
res.end();
} catch (err) {
send({ error: '生成回复时出错,请重试' });
res.end();
}
});
export default router;
1.6.2 后端入口
// server/src/index.js
import express from 'express';
import cors from 'cors';
import 'dotenv/config';
import chatRouter from './routes/chat.js';
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
app.use('/api/chat', chatRouter);
app.listen(PORT, () => {
console.log(`server running on http://localhost:${PORT}`);
});
1.6.3 前端 useChat Composable
// client/src/composables/useChat.js
import { ref, nextTick } from 'vue';
const API_BASE = 'http://localhost:3000/api';
export function useChat() {
const messages = ref([]);
const streaming = ref(false);
const streamText = ref('');
const error = ref('');
const sendMessage = async (userInput, scrollCallback) => {
if (!userInput.trim() || streaming.value) return;
messages.value.push({ role: 'user', content: userInput });
scrollCallback?.();
streaming.value = true;
streamText.value = '';
error.value = '';
try {
const history = messages.value
.slice(-10)
.map(({ role, content }) => ({ role, content }));
const response = await fetch(`${API_BASE}/chat/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.error) { error.value = parsed.error; break; }
if (parsed.done) break;
if (parsed.content) {
streamText.value += parsed.content;
await nextTick();
scrollCallback?.();
}
} catch {}
}
}
messages.value.push({ role: 'assistant', content: streamText.value });
} catch (err) {
error.value = `请求失败:${err.message}`;
} finally {
streaming.value = false;
streamText.value = '';
}
};
const clearMessages = () => {
messages.value = [];
error.value = '';
};
return { messages, streaming, streamText, error, sendMessage, clearMessages };
}
1.6.4 前端 ChatView 组件
<!-- client/src/views/ChatView.vue -->
<template>
<div class="chat-page">
<header class="chat-header">
<div class="header-left">
<div class="avatar">购</div>
<div>
<h1>极速购智能客服</h1>
<span :class="['status', { active: !streaming }]">
{{ streaming ? '回复中...' : '在线' }}
</span>
</div>
</div>
<button @click="clearMessages">清空对话</button>
</header>
<main class="messages-wrap" ref="messagesRef">
<div v-if="messages.length === 0" class="welcome">
<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="bubble">{{ msg.content }}</div>
</div>
<div v-if="streaming" class="message-row assistant">
<div class="avatar-sm">购</div>
<div class="bubble">
{{ streamText }}<span class="cursor">|</span>
</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="streaming"
@keydown.enter.exact.prevent="handleSend"
rows="1"
/>
<button
class="send-btn"
:disabled="streaming || !inputText.trim()"
@click="handleSend"
>
{{ streaming ? '回复中...' : '发送' }}
</button>
</footer>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue';
import { useChat } from '../composables/useChat.js';
const { messages, streaming, streamText, error, sendMessage, clearMessages } = useChat();
const inputText = ref('');
const messagesRef = ref(null);
const quickQuestions = ['我的订单在哪里?', '如何申请退款?', '物流多久到?'];
const scrollToBottom = async () => {
await nextTick();
if (messagesRef.value)
messagesRef.value.scrollTop = messagesRef.value.scrollHeight;
};
const handleSend = async () => {
const text = inputText.value.trim();
if (!text || streaming.value) return;
inputText.value = '';
await sendMessage(text, scrollToBottom);
};
const handleQuick = (q) => {
inputText.value = q;
handleSend();
};
</script>
1.7 运行 Demo
# 终端 1:启动后端
cd server
cp .env.example .env
# 编辑 .env,填入 DeepSeek API Key
pnpm install
pnpm dev
# 终端 2:启动前端
cd client
pnpm install
pnpm dev
访问 http://localhost:5173,在对话框中输入问题,观察流式响应效果。
1.8 常见问题
Q:调用 API 返回 401
检查 .env 中 DEEPSEEK_API_KEY 是否正确,注意没有多余空格。确认 DEEPSEEK_BASE_URL 结尾是 /v1。
Q:流式输出中文乱码
确保 TextDecoder 使用默认 UTF-8 编码,检查响应头是否包含 charset=utf-8。
Q:CORS 报错
确认后端已引入 cors 中间件 app.use(cors())。生产环境应限制 origin,开发阶段可以不限制。
Q:模型响应很慢
DeepSeek 服务器负载较高时会有延迟,流式输出可以改善用户感知。本地测试可用 Ollama + qwen2.5,响应更快。
1.9 本章小结
| 知识点 | 掌握内容 |
|---|---|
| LLM 基础 | Token、上下文窗口、Temperature,LLM 与普通 API 的本质差异 |
| 开发环境 | Vue3 + Node.js 分离项目搭建,DeepSeek API 接入 |
| Model | ChatOpenAI+ DeepSeek 兼容接口封装, createModel工厂函数 |
| Prompt | ChatPromptTemplate.fromMessages,占位符,历史注入 |
| Chain(LCEL) | prompt -> model -> parser管道语法, invoke()调用,数据流说明 |
| Streaming | chain.stream()+ SSE 后端, ReadableStream前端消费 |
下一章:Agents + Function Calling,让 AI 学会调用工具,实战极速购订单查询和物流跟踪 Agent。