返回笔记首页

第一章:LLM 核心概念与 LangChain.js 快速上手

主题配置

课程:AI 全栈开发实战 — 基于 LangChain.js + Vue3 + Node.js 定位:有 Node.js 基础,首次接触 LLM 开发 实战 Demo:极速购客服系统 — 基础对话模块

代码

langchain-course-ch1.zip


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 项目初始化

本课程采用前后端分离结构,目录如下:

markdown
langchain-course/
├── server/
│   ├── src/
│   │   ├── models/      # 模型封装
│   │   ├── prompts/     # Prompt 模板
│   │   ├── chains/      # LangChain 链模块
│   │   ├── routes/      # Express 路由
│   │   └── index.js     # 服务入口
│   ├── .env
│   └── package.json
└── client/
    └── src/
        ├── composables/
        └── views/

执行以下命令完成初始化:

bash
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 文件:

bash
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 数组,每条消息包含 rolecontent

javascript
// 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 实现结构化、可复用的提示词管理:

javascript
// 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)用 | 操作符将组件串联:

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

数据流说明:

  1. invoke({ chat_history, user_input, current_time }) 传入 Prompt 模板
  2. Prompt 模板格式化为 messages 数组,传入 Model
  3. Model 调用 DeepSeek API,返回 AIMessage 对象
  4. StringOutputParserAIMessage 中提取纯文本字符串

1.6 流式输出实现

流式输出是 AI 应用的标配体验,用户看到文字逐字出现,而不是等待后一次性返回。后端使用 SSE(Server-Sent Events)实现最为简单。

1.6.1 后端 SSE 接口

javascript
// 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 后端入口

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

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

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

bash
# 终端 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

检查 .envDEEPSEEK_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。