Next.js + LangChain.js + DeepSeek 实现一个 AI 智能助手

前言

LangChain 框架最初以 Python 版本为主,随后推出 JavaScript 版本,这也是官方持续维护的两个主要版本。

LangChain 旨在简化基于大型语言模型(LLM )的应用程序开发,可以说是开发 LLM 应用的首选开源框架,其的核心目标是通过提供模块化的工具和抽象层,帮助开发者轻松集成 LLM 与外部数据源、工具和上下文管理机制,从而构建智能、交互式的应用程序。

本文以Next.js 全栈框架为主并结合 LangChain.jsDeepSeek 构建一个简易聊天机器人,其功能主要包含流式输出动态提示模板记忆功能支持 Markdown 渲染代码高亮,后续再逐步探索更高级的功能。

初始化

在命令行终端输入 npx create-next-app@latest 初始化一个 Next.js 项目。

按照提示一步一步操作即可,其脚手架会帮我们自动安装所需要的依赖。

安装依赖

安装 LangChain.js 所需要的依赖包

安装 LangChain.jsLangChain.js 提供的对 DeepSeek 的官方集成包。

在终端命令行输入如下命令:pnpm install langchain @langchain/deepseek

安装 UI 组件库和图标库

安装 shadcn/ui UI 组件库。

在终端命令行输入如下命令:pnpm dlx shadcn@latest init

安装 Lucide 图标库。

在终端命令行输入如下命令:pnpm install lucide

项目配置

配置 API_KEY

在项目根目录新建 .env.local,输入从 DeepSeek 开放平台获取到的 API_KEY

ini 复制代码
DEEPSEEK_API_KEY=sk-xxxxxxxxx

配置样式

在终端命令行输入 pnpm add sonner tailwindcss-animate @tailwindcss/typography

在项目根目录新建 tailwind.config.ts 文件并输入如下内容:

ts 复制代码
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './src/**/*.{js,ts,jsx,tsx}' // 根据你的项目结构调整
  ],
  theme: {
    extend: {}
  },
  plugins: [
    require('tailwindcss-animate'), // 添加此行
    require('@tailwindcss/typography')
  ]
}

构建业务逻辑

新建 /src/lib/chatbot.ts 文件,在该文件下输入如下内容:

ts 复制代码
// lib/chatbot.ts
import { ChatDeepSeek } from '@langchain/deepseek'
import { HumanMessage, AIMessage, SystemMessage } from '@langchain/core/messages'
import { BufferMemory } from 'langchain/memory'

// 初始化 DeepSeek 模型(全局单例,避免重复实例化)
const model = new ChatDeepSeek({
  apiKey: process.env.DEEPSEEK_API_KEY,
  model: 'deepseek-chat',
  temperature: 0.7, // 控制生成文本的随机性,值越高越有创意
  maxTokens: 500, // 限制每次响应的最大 token 数量
  streaming: true // 启用流式输出
})

// 定义系统提示模板,提供机器人角色和行为
const defaultSystemPrompt = `
你是一个友好的助手,名字叫"小智"。请用简洁、自然的中文回答用户问题。
如果用户问到你的身份,告诉他们你是 星途 AI 人工智能助手。
始终保持礼貌,避免生成冗长的回答。
返回的内容以 Markdown 格式返回结果(如列表、标题、代码块)。
如果回答涉及代码,请使用 \`\`\` 包裹代码并指定语言(如 \`\`\`javascript)。
`

// 初始化内存,用于保存对话历史
const memory = new BufferMemory({
  returnMessages: true, // 返回完整消息对象,而非纯文本
  memoryKey: 'history' // 内存中的键名
})

// 流式输出函数
export async function getChatResponseStream(userInput: string, customSystemPrompt?: string): Promise<ReadableStream> {
  try {
    // 构造包含历史和当前输入的消息
    const { history } = await memory.loadMemoryVariables({})
    console.log('Memory variables:', history)
    // 支持动态提示
    const systemPrompt = customSystemPrompt || defaultSystemPrompt
    const messages = [
      new SystemMessage(systemPrompt), // 系统提示
      ...(history as (HumanMessage | AIMessage)[]), // 历史消息
      new HumanMessage(userInput) // 当前用户输入
    ]

    // 获取流式响应
    const stream = await model.stream(messages)

    // 用于保存完整回复
    let fullResponse = ''

    return new ReadableStream({
      async start(controller) {
        try {
          for await (const chunk of stream) {
            // console.log('Stream chunk:', chunk)
            const text = chunk.content as string
            fullResponse += text
            controller.enqueue(new TextEncoder().encode(text))
          }
          // 保存到内存
          await memory.saveContext({ input: userInput }, { output: fullResponse })
          controller.close()
        } catch (error) {
          console.error('Stream error:', error)
          controller.enqueue(new TextEncoder().encode('抱歉,流式处理出错!'))
          controller.close()
        }
      },
      cancel() {
        console.log('Stream cancelled')
      }
    })
  } catch (error) {
    console.error('Chat stream error:', error)
    return new ReadableStream({
      start(controller) {
        const errorMessage = '抱歉,我遇到了一些问题,请稍后再试!'
        controller.enqueue(new TextEncoder().encode(errorMessage))
        controller.close()
      }
    })
  }
}

// 可选:清空对话历史
export function clearChatHistory() {
  memory.clear()
}

通过上述代码分析可知:

在业务逻辑层面,我们能够使用 model.stream 并结合 new ReadableStream 返回流式响应结果。

构建 API 路由

新建 /src/app/api/chat/route.ts 文件,在该文件下输入如下内容:

ts 复制代码
// app/api/chat/route.ts
import { getChatResponseStream } from '@/lib/chatbot'
import { NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  const { message, systemPrompt }: { message: string; systemPrompt?: string } = await request.json()

  if (!message) {
    return new Response(JSON.stringify({ error: 'No message provided' }), {
      status: 400,
      headers: { 'Content-Type': 'application/json' }
    })
  }

  try {
    const stream = await getChatResponseStream(message, systemPrompt)
    return new Response(stream, {
      headers: {
        'Content-Type': 'text/plain; charset=utf-8', // 返回纯文本流
        'Transfer-Encoding': 'chunked' // 支持分块传输
      }
    })
  } catch (error) {
    return new Response(JSON.stringify({ error: (error as Error).message }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' }
    })
  }
}

新建 /src/app/api/chat/clear/route.ts 文件,在该文件下输入如下内容:

ts 复制代码
// app/api/chat/clear/route.ts
import { clearChatHistory } from '@/lib/chatbot'
import { NextResponse } from 'next/server'

export async function POST() {
  try {
    clearChatHistory()
    return NextResponse.json({ message: 'Chat history cleared' }, { status: 200 })
  } catch (error) {
    return NextResponse.json({ error: (error as Error).message }, { status: 500 })
  }
}

构建前端页面

安装必要的 UI 组件:

在命令行终端输入:pnpm dlx shadcn@latest add button input scroll-area select avatar

安装必要的 Markdown 渲染组件:

在命令行终端输入:react-markdown remark-gfm rehype-highlight highlight.js

为配合 Mrakdown 渲染需要在 src/app/globals.css 文件顶部替换为如下内容:

css 复制代码
@tailwind utilities;
@import 'tailwindcss/preflight';
@import 'tailwindcss';
@import 'tw-animate-css';

@import 'highlight.js/styles/github.css'; /* GitHub 风格 */

最后,在项目中新建 /src/app/chat/page.tsx 文件,在该文件下输入如下内容:

tsx 复制代码
// app/chat/page.tsx
'use client'

import { useState, useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { toast } from 'sonner'
import { Send, User, Bot, Trash2, Loader2 } from 'lucide-react'
import ReactMarkdown from 'react-markdown' // 引入 Markdown 渲染
import remarkGfm from 'remark-gfm' // 支持 GFM
import rehypeHighlight from 'rehype-highlight'

// 定义消息类型
interface Message {
  text: string
  sender: 'user' | 'bot'
}

export default function Home() {
  const [messages, setMessages] = useState<Message[]>([])
  const [input, setInput] = useState<string>('')
  const [isLoading, setIsLoading] = useState<boolean>(false)
  const [mode, setMode] = useState<string>('friendly') // 动态模式
  const scrollAreaRef = useRef<HTMLDivElement>(null)

  // 自动滚动到底部
  useEffect(() => {
    if (scrollAreaRef.current) {
      scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight
    }
  }, [messages])

  const getSystemPrompt = () => {
    switch (mode) {
      case 'formal':
        return '你是一个正式的助手,请用专业、礼貌的中文回答问题。'
      case 'funny':
        return '你是一个幽默的助手,请用风趣的中文回答问题,尽量让人开心。'
      default:
        return undefined // 使用默认提示
    }
  }

  const handleSend = async () => {
    if (!input.trim()) return

    const newMessage: Message = {
      text: input,
      sender: 'user'
    }
    setMessages((prev) => [...prev, newMessage])
    setInput('')
    setIsLoading(true)

    try {
      const res = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: input, systemPrompt: getSystemPrompt() })
      })

      if (!res.ok) {
        throw new Error('API request failed')
      }

      const reader = res.body!.getReader()
      const decoder = new TextDecoder()
      let botMessage = ''

      setMessages((prev) => [...prev, { text: '', sender: 'bot' }])

      while (true) {
        const { done, value } = await reader.read()
        if (done) break

        const chunk = decoder.decode(value, { stream: true })
        console.log('Received chunk:', chunk)
        botMessage += chunk
        setIsLoading(false)

        setMessages((prev) => {
          const updated = [...prev]
          updated[updated.length - 1] = {
            ...updated[updated.length - 1],
            text: botMessage
          }
          return updated
        })
      }
    } catch (error) {
      console.error('Error fetching chat response:', error)
      toast.error('无法获取回复,请稍后再试。', {
        description: '发生了网络错误或 API 问题。'
      })
      setMessages((prev) => [
        ...prev,
        {
          text: '抱歉,出了点问题!',
          sender: 'bot'
        }
      ])
    } finally {
      setIsLoading(false)
    }
  }

  const handleClear = async () => {
    setMessages([])
    try {
      const res = await fetch('/api/chat/clear', {
        method: 'POST'
      })
      if (!res.ok) throw new Error('Failed to clear chat history')
      toast.success('聊天记录已清除', {
        description: '所有消息已成功删除。'
      })
    } catch (error) {
      console.error('Error clearing chat history:', error)
      toast.error('清除聊天记录失败', {
        description: '请稍后再试。'
      })
    }
  }

  return (
    <div className="max-w-[1000px] mx-auto p-4 h-screen flex flex-col">
      <div className="flex justify-between items-center mb-4">
        <h1 className="text-2xl font-bold">简易聊天机器人</h1>
        <div className="flex gap-2">
          <Select value={mode} onValueChange={setMode}>
            <SelectTrigger className="w-[120px]">
              <SelectValue placeholder="选择模式" />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="friendly">友好模式</SelectItem>
              <SelectItem value="formal">正式模式</SelectItem>
              <SelectItem value="funny">幽默模式</SelectItem>
            </SelectContent>
          </Select>
          <Button variant="ghost" size="icon" onClick={handleClear}>
            <Trash2 className="w-4 h-4" />
          </Button>
        </div>
      </div>
      <ScrollArea className="flex-1 border rounded-md p-4 mb-4" ref={scrollAreaRef}>
        {messages.length === 0 && !isLoading && <p className="text-center text-muted-foreground">开始聊天吧!</p>}
        {messages.map((msg, index) => (
          <div key={index} className={`flex ${msg.sender === 'user' ? 'justify-end' : 'justify-start'} mb-4`}>
            {msg.sender === 'bot' && (
              <Avatar className="mr-2">
                <AvatarFallback>
                  <Bot className="w-6 h-6 flex-shrink-0" />
                </AvatarFallback>
              </Avatar>
            )}
            <div className={`flex flex-wrap items-start gap-2 max-w-[70%] p-3 rounded-lg ${msg.sender === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>
              <div className="prose prose-sm max-w-none">
                <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
                  {msg.text}
                </ReactMarkdown>
              </div>
            </div>
            {msg.sender === 'user' && (
              <Avatar className="ml-2">
                <AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
                <AvatarFallback>
                  <User className="w-5 h-5 flex-shrink-0" />
                </AvatarFallback>
              </Avatar>
            )}
          </div>
        ))}
        {isLoading && messages.length > 0 && (
          <div className="flex justify-start mb-4">
            <Avatar className="mr-2">
              <AvatarFallback>
                <Bot className="w-6 h-6 flex-shrink-0" />
              </AvatarFallback>
            </Avatar>
            <div className="flex items-start gap-2 max-w-[70%] p-3 rounded-lg bg-muted">
              <div className="flex items-center gap-2">
                <Loader2 className="w-4 h-4 animate-spin" />
                <span className="text-sm text-muted-foreground">思考中...</span>
              </div>
            </div>
          </div>
        )}
      </ScrollArea>
      <div className="flex gap-2">
        <Input value={input} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setInput(e.target.value)} onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => e.key === 'Enter' && !isLoading && handleSend()} placeholder="输入消息..." className="flex-1" disabled={isLoading} />
        <Button onClick={handleSend} disabled={isLoading} className="w-[50px]">
          {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
        </Button>
      </div>
    </div>
  )
}

效果演示

演示示例

项目源码

Github 仓库链接

本文总结

通过上述代码分析,我们可以发现我们已经实现一个基本的聊天对话界面,但是优化的空间还有很多,如调用外部工具、搜索、文件上传与分析等,我们可以充分利用 LangChain 所提供的能力实现强大的功能,为最终实现 AI Agent 做铺垫。

相关推荐
weixin_457885828 小时前
DeepSeek的神经元革命:穿透搜索引擎算法的下一代内容基建
人工智能·算法·搜索引擎·deepseek·虎跃办公
奇舞精选9 小时前
DeepSeek V3-0324探索-使用prompt生成精美PPT
aigc·deepseek
weixin_4578858210 小时前
DeepSeek:穿透行业知识壁垒的搜索引擎攻防战
人工智能·搜索引擎·ai·deepseek·虎跃办公
fleur11 小时前
私有化DeepSeek+ollama+langchain实现RAG的问答知识库
langchain
小尹呀12 小时前
LangGraph 架构详解
架构·langchain·aigc
wu~97012 小时前
图片文本识别OCR+DeepSeekapi实现提取图片关键信息
ocr·腾讯云·文字识别·deepseek
在线打码15 小时前
禅道MCP Server开发实践与功能全解析
python·ai·禅道·deepseek·mcp·zentao·mcp server
恶霸不委屈15 小时前
重新定义健康监护!基于DeepSeek的人体生理状况智能检测装置技术解析
人工智能·python·deepseek·生理监测
海风极客16 小时前
这是一份简洁优雅的Prompt教程
langchain·ai编程
李二苟17 小时前
Windows环境下本地部署deepseek-r1或其他大模型 【保姆级教程】
ai·本地部署·deepseek·qwq·olloma