通义千问1.5-1.8B-Chat-GPTQ-Int4与Node.js后端集成:构建全栈AI聊天应用

最近在折腾一些AI应用,发现很多朋友对如何把大模型真正用起来,特别是集成到自己的Web项目里,感觉有点无从下手。正好前段时间在星图GPU平台上部署了通义千问的一个轻量版模型,就想着能不能把它包装成一个聊天服务,然后用Node.js搭个后端,再做个简单的前端界面,串起来玩一玩。

没想到整个过程比预想的要顺畅不少。今天我就把这个从模型API调用到全栈应用搭建的完整过程分享出来,如果你也想给自己的网站或者内部工具加个智能聊天功能,这篇内容应该能给你一个清晰的路线图。我们不用搞得太复杂,就从最基础的开始,一步步来。

1. 项目准备与环境搭建

在开始写代码之前,我们需要先把“战场”准备好。这里主要分两块:一是确保你的Node.js环境是OK的,二是你得有一个已经部署好并能提供API服务的通义千问模型。

1.1 Node.js环境检查与配置

首先,打开你的终端,检查一下Node.js的版本。这个项目对版本要求不算苛刻,但建议用比较新的长期支持版。

node --version

如果显示版本号(比如v18.x或v20.x),那就可以继续了。如果还没安装,可以去Node.js官网下载安装包,过程很简单,一路下一步就行。

安装好后,我们新建一个项目目录,并初始化它。

mkdir qwen-chat-app && cd qwen-chat-app
npm init -y

这个命令会生成一个package.json文件,它就像我们项目的“身份证”和“购物清单”,记录了项目信息和需要的工具包。

接下来,安装我们马上要用到的几个核心工具包。我们用一个命令搞定:

npm install express axios cors dotenv

我简单说一下这几个包是干嘛的:

  • express:这是Node.js里最流行的Web框架,我们用它来快速搭建后端服务器和定义API接口。
  • axios:一个非常好用的HTTP客户端,我们靠它去调用星图平台上那个通义千问模型的API。
  • cors:一个处理跨域资源共享的中间件。因为我们的前端和后端通常运行在不同的端口或地址上,浏览器会有安全限制,这个包能帮我们解决这个问题。
  • dotenv:用来管理环境变量。我们把像API地址、密钥这类敏感信息放在一个单独的.env文件里,而不是硬写在代码中,这样更安全,也方便切换不同环境。

1.2 获取模型API访问信息

这一步的前提是,你已经在星图GPU平台上成功部署了“通义千问1.5-1.8B-Chat-GPTQ-Int4”这个镜像。部署成功后,平台通常会提供一个API访问的端点(Endpoint)和必要的认证信息(比如API Key)。

你需要找到并记下这两样东西:

  1. API Base URL:看起来像 https://your-instance-id.region.example.com/v1 这样的一个网址。
  2. API Key:一长串字符,用于验证你的请求身份。

为了安全起见,我们不在代码里直接写死这些信息。在项目根目录下创建一个名为 .env 的文件:

touch .env

然后,用文本编辑器打开这个.env文件,把刚才的信息填进去,格式如下:

QWEN_API_BASE_URL=https://your-actual-api-endpoint/v1
QWEN_API_KEY=your-actual-api-key-here
PORT=3000

注意把 your-actual-api-endpointyour-actual-api-key-here 替换成你从星图平台获取的真实信息。PORT 是我们后端服务要监听的端口号,这里先设为3000。

2. 构建Node.js后端服务

环境准备好之后,我们就可以动手搭建服务的“大脑”——后端了。后端主要负责接收前端的聊天请求,然后去调用真正的模型API,拿到回复后再传回给前端。

2.1 创建Express服务器与基础路由

我们在项目根目录下创建一个 server.js 文件,这是后端服务的入口。

// server.js
require('dotenv').config(); // 加载.env文件中的环境变量
const express = require('express');
const axios = require('axios');
const cors = require('cors');

const app = express();
const port = process.env.PORT || 3000;

// 使用中间件
app.use(cors()); // 允许跨域请求
app.use(express.json()); // 解析JSON格式的请求体

// 从环境变量读取模型API配置
const QWEN_API_BASE = process.env.QWEN_API_BASE_URL;
const QWEN_API_KEY = process.env.QWEN_API_KEY;

// 配置axios实例,用于调用模型API
const qwenApiClient = axios.create({
  baseURL: QWEN_API_BASE,
  headers: {
    'Authorization': `Bearer ${QWEN_API_KEY}`,
    'Content-Type': 'application/json',
  },
});

// 一个简单的健康检查路由,用来测试服务是否启动
app.get('/health', (req, res) => {
  res.json({ status: 'OK', message: 'Chat backend is running.' });
});

// 启动服务器
app.listen(port, () => {
  console.log(`Server is running on http://localhost:${port}`);
});

现在,你可以运行 node server.js 来启动服务。如果看到控制台打印出运行在3000端口的信息,并且在浏览器访问 http://localhost:3000/health 能看到返回的JSON数据,说明基础服务搭建成功了。

2.2 实现核心聊天API接口

健康检查通了,我们来写最关键的聊天接口。这个接口需要做两件事:接收用户发来的消息,然后转发给通义千问模型。

server.js 文件中,在健康检查路由后面添加新的路由:

// server.js (接上文)

// 核心聊天接口 - 非流式(一次性返回完整回复)
app.post('/api/chat', async (req, res) => {
  try {
    const userMessage = req.body.message;
    
    if (!userMessage || userMessage.trim() === '') {
      return res.status(400).json({ error: 'Message cannot be empty.' });
    }

    // 构造发送给通义千问API的请求体
    const requestBody = {
      model: 'qwen1.5-1.8b-chat', // 根据实际部署的模型名称调整
      messages: [
        {
          role: 'user',
          content: userMessage
        }
      ],
      stream: false // 非流式输出
    };

    // 调用模型API
    const response = await qwenApiClient.post('/chat/completions', requestBody);
    
    // 从模型响应中提取回复内容
    const aiReply = response.data.choices[0]?.message?.content || 'No response from model.';

    // 将回复返回给前端
    res.json({
      reply: aiReply,
      model: response.data.model,
      usage: response.data.usage // 可选,包含token消耗信息
    });

  } catch (error) {
    console.error('Error calling Qwen API:', error.response?.data || error.message);
    res.status(500).json({ 
      error: 'Failed to get response from AI model.',
      details: error.message 
    });
  }
});

这个接口已经可以工作了。你可以在终端里用 curl 命令测试一下:

curl -X POST http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "你好,请介绍一下你自己。"}'

如果一切配置正确,你应该能收到一段来自通义千问模型的自我介绍。

2.3 实现流式对话输出接口

一次性返回所有内容虽然简单,但用户需要等待模型完全生成完毕才能看到回复,体验不够好。流式输出可以让回复像打字一样一个字一个字地显示出来,体验更自然。这对于一个聊天应用来说,算是个“加分项”。

实现流式输出的原理是,模型API会返回一个数据流(Server-Sent Events),我们的后端需要把这个流转发(pipe)给前端。我们来添加这个流式接口:

// server.js (接上文)

// 流式聊天接口
app.post('/api/chat/stream', async (req, res) => {
  const userMessage = req.body.message;
  
  if (!userMessage || userMessage.trim() === '') {
    return res.status(400).json({ error: 'Message cannot be empty.' });
  }

  const requestBody = {
    model: 'qwen1.5-1.8b-chat',
    messages: [{ role: 'user', content: userMessage }],
    stream: true // 关键:开启流式输出
  };

  try {
    // 设置响应头,告诉前端这是一个事件流
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');

    // 向模型API发起流式请求
    const apiResponse = await qwenApiClient.post('/chat/completions', requestBody, {
      responseType: 'stream' // 告诉axios我们期待一个流式响应
    });

    // 将模型API返回的数据流,直接转发给前端
    apiResponse.data.pipe(res);

    // 处理流错误
    apiResponse.data.on('error', (err) => {
      console.error('Stream error from model API:', err);
      if (!res.headersSent) {
        res.status(500).end();
      }
    });

  } catch (error) {
    console.error('Error setting up stream:', error);
    if (!res.headersSent) {
      res.status(500).json({ error: 'Failed to establish stream.' });
    }
  }
});

这样,后端服务就具备了两种聊天模式。你可以根据前端的需求选择使用哪个接口。

3. 开发前端聊天界面

后端搞定了,我们得有个界面让用户能输入和看到对话。为了简单快速,我们不用复杂的Vue或React框架,就用最基础的HTML、CSS和一点JavaScript,写一个单页应用。这样你也能更清楚地看到前后端是如何交互的。

在项目根目录下创建一个 public 文件夹,然后在里面创建 index.htmlapp.js

3.1 构建聊天界面HTML

public/index.html 文件内容如下:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>通义千问聊天演示</title>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
        body { background-color: #f5f5f7; color: #1d1d1f; line-height: 1.6; padding: 20px; max-width: 800px; margin: 0 auto; }
        header { text-align: center; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 1px solid #d2d2d7; }
        h1 { color: #0066cc; margin-bottom: 10px; }
        .subtitle { color: #86868b; font-size: 0.95em; }
        .chat-container { background: white; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); overflow: hidden; margin-bottom: 20px; }
        #chat-history { height: 400px; overflow-y: auto; padding: 20px; }
        .message { margin-bottom: 16px; padding: 12px 16px; border-radius: 18px; max-width: 80%; clear: both; }
        .user-message { background-color: #007aff; color: white; float: right; border-bottom-right-radius: 4px; }
        .ai-message { background-color: #e5e5ea; color: black; float: left; border-bottom-left-radius: 4px; }
        .ai-thinking { color: #8e8e93; font-style: italic; }
        .input-area { display: flex; padding: 20px; background: #fbfbfd; border-top: 1px solid #d2d2d7; }
        #message-input { flex-grow: 1; padding: 12px 16px; border: 1px solid #c7c7cc; border-radius: 10px; font-size: 16px; margin-right: 10px; }
        #message-input:focus { outline: none; border-color: #007aff; }
        button { padding: 12px 24px; background-color: #007aff; color: white; border: none; border-radius: 10px; font-size: 16px; cursor: pointer; transition: background-color 0.2s; }
        button:hover { background-color: #0056cc; }
        button:disabled { background-color: #a7a7ad; cursor: not-allowed; }
        .controls { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; font-size: 0.9em; color: #86868b; }
        .mode-switch label { margin-right: 15px; cursor: pointer; }
        footer { text-align: center; margin-top: 30px; color: #86868b; font-size: 0.85em; }
    </style>
</head>
<body>
    <header>
        <h1>🤖 通义千问聊天演示</h1>
        <p class="subtitle">基于 Qwen-1.8B-Chat 模型与 Node.js 全栈应用</p>
    </header>

    <main>
        <div class="chat-container">
            <div id="chat-history">
                <!-- 聊天记录会动态插入到这里 -->
                <div class="message ai-message">
                    你好!我是通义千问,一个AI助手。有什么可以帮你的吗?
                </div>
            </div>
            
            <div class="input-area">
                <input type="text" id="message-input" placeholder="输入你的问题..." autocomplete="off">
                <button id="send-button">发送</button>
            </div>
        </div>

        <div class="controls">
            <div class="mode-switch">
                <label><input type="radio" name="mode" value="stream" checked> 流式输出(打字机效果)</label>
                <label><input type="radio" name="mode" value="normal"> 普通输出(一次性返回)</label>
            </div>
            <button id="clear-button">清空对话</button>
        </div>
    </main>

    <footer>
        <p>模型:通义千问 1.5-1.8B-Chat-GPTQ-Int4 | 后端:Node.js + Express</p>
    </footer>

    <script src="app.js"></script>
</body>
</html>

3.2 实现前端交互逻辑

现在来写JavaScript逻辑,让这个静态页面活起来。创建 public/app.js 文件:

// public/app.js
document.addEventListener('DOMContentLoaded', function() {
    const chatHistory = document.getElementById('chat-history');
    const messageInput = document.getElementById('message-input');
    const sendButton = document.getElementById('send-button');
    const clearButton = document.getElementById('clear-button');
    const modeRadios = document.querySelectorAll('input[name="mode"]');
    
    // 当前对话模式,默认为流式
    let currentMode = 'stream';
    // 当前正在进行的流式响应处理函数,用于中止请求
    let currentStreamController = null;

    // 监听模式切换
    modeRadios.forEach(radio => {
        radio.addEventListener('change', (e) => {
            currentMode = e.target.value;
            console.log(`切换到 ${currentMode} 模式`);
        });
    });

    // 发送消息函数
    async function sendMessage() {
        const message = messageInput.value.trim();
        if (!message) return;
        
        // 禁用输入和按钮,防止重复发送
        messageInput.disabled = true;
        sendButton.disabled = true;
        
        // 在界面上显示用户消息
        appendMessage(message, 'user');
        // 清空输入框
        messageInput.value = '';
        
        // 根据模式调用不同的后端接口
        if (currentMode === 'stream') {
            await handleStreamResponse(message);
        } else {
            await handleNormalResponse(message);
        }
        
        // 重新启用输入和按钮
        messageInput.disabled = false;
        sendButton.disabled = false;
        messageInput.focus(); // 聚焦到输入框
    }

    // 处理普通(非流式)响应
    async function handleNormalResponse(userMessage) {
        // 先显示一个“思考中”的占位符
        const thinkingId = appendThinkingIndicator();
        
        try {
            const response = await fetch('/api/chat', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ message: userMessage })
            });
            
            const data = await response.json();
            
            // 移除“思考中”占位符
            removeElement(thinkingId);
            
            if (response.ok) {
                // 显示AI回复
                appendMessage(data.reply, 'ai');
            } else {
                // 显示错误信息
                appendMessage(`抱歉,出错了:${data.error || '未知错误'}`, 'ai');
            }
        } catch (error) {
            removeElement(thinkingId);
            appendMessage(`网络请求失败:${error.message}`, 'ai');
            console.error('Request failed:', error);
        }
    }

    // 处理流式响应
    async function handleStreamResponse(userMessage) {
        // 为AI回复创建一个新的消息容器,初始为空
        const aiMessageId = `ai-msg-${Date.now()}`;
        const aiMessageElem = document.createElement('div');
        aiMessageElem.className = 'message ai-message';
        aiMessageElem.id = aiMessageId;
        chatHistory.appendChild(aiMessageElem);
        
        // 滚动到底部
        chatHistory.scrollTop = chatHistory.scrollHeight;
        
        try {
            // 使用AbortController以便可以中止请求
            const controller = new AbortController();
            currentStreamController = controller;
            
            const response = await fetch('/api/chat/stream', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ message: userMessage }),
                signal: controller.signal
            });
            
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            
            // 处理服务器发送的事件流
            const reader = response.body.getReader();
            const decoder = new TextDecoder();
            let aiReply = '';
            
            while (true) {
                const { done, value } = await reader.read();
                if (done) break;
                
                // 解码数据块
                const chunk = decoder.decode(value);
                // 事件流格式是 "data: {...}\n\n",我们需要解析它
                const lines = chunk.split('\n').filter(line => line.trim() !== '');
                
                for (const line of lines) {
                    if (line.startsWith('data: ')) {
                        const dataStr = line.slice(6); // 去掉 "data: " 前缀
                        if (dataStr === '[DONE]') {
                            // 流结束
                            currentStreamController = null;
                            return;
                        }
                        
                        try {
                            const data = JSON.parse(dataStr);
                            const content = data.choices[0]?.delta?.content || '';
                            if (content) {
                                aiReply += content;
                                // 更新DOM,显示累积的回复
                                aiMessageElem.textContent = aiReply;
                                // 滚动到底部,确保新内容可见
                                chatHistory.scrollTop = chatHistory.scrollHeight;
                            }
                        } catch (e) {
                            console.warn('Failed to parse stream data:', e);
                        }
                    }
                }
            }
            
            currentStreamController = null;
            
        } catch (error) {
            if (error.name === 'AbortError') {
                console.log('Stream request was aborted.');
                aiMessageElem.textContent += ' (已中止)';
            } else {
                console.error('Stream error:', error);
                aiMessageElem.textContent = `流式响应出错:${error.message}`;
            }
            currentStreamController = null;
        }
    }

    // 工具函数:在聊天记录中添加一条消息
    function appendMessage(text, sender) {
        const messageElem = document.createElement('div');
        messageElem.className = `message ${sender}-message`;
        messageElem.textContent = text;
        chatHistory.appendChild(messageElem);
        // 滚动到底部
        chatHistory.scrollTop = chatHistory.scrollHeight;
    }

    // 工具函数:添加“思考中”指示器
    function appendThinkingIndicator() {
        const thinkingId = `thinking-${Date.now()}`;
        const thinkingElem = document.createElement('div');
        thinkingElem.className = 'message ai-message ai-thinking';
        thinkingElem.id = thinkingId;
        thinkingElem.textContent = '思考中...';
        chatHistory.appendChild(thinkingElem);
        chatHistory.scrollTop = chatHistory.scrollHeight;
        return thinkingId;
    }

    // 工具函数:移除指定ID的元素
    function removeElement(id) {
        const elem = document.getElementById(id);
        if (elem) elem.remove();
    }

    // 清空对话历史
    function clearChat() {
        // 如果有正在进行的流式请求,中止它
        if (currentStreamController) {
            currentStreamController.abort();
            currentStreamController = null;
        }
        
        // 保留第一条AI欢迎消息
        const welcomeMsg = chatHistory.querySelector('.ai-message:first-child');
        chatHistory.innerHTML = '';
        if (welcomeMsg) {
            chatHistory.appendChild(welcomeMsg);
        } else {
            // 如果没有欢迎消息,加一条
            appendMessage('你好!我是通义千问,一个AI助手。有什么可以帮你的吗?', 'ai');
        }
    }

    // 事件监听
    sendButton.addEventListener('click', sendMessage);
    messageInput.addEventListener('keypress', (e) => {
        if (e.key === 'Enter' && !e.shiftKey) {
            e.preventDefault();
            sendMessage();
        }
    });
    clearButton.addEventListener('click', clearChat);
    
    // 初始聚焦到输入框
    messageInput.focus();
});

3.3 让Express提供静态文件

最后,我们需要修改一下 server.js,让Express能够把我们刚写好的 public 目录作为静态资源服务出去。这样,当我们访问后端根地址时,就能看到这个聊天页面了。

server.js 文件的开头(在定义 app 之后),添加这行代码:

// server.js (在 app.use(cors()) 附近添加)
app.use(express.static('public')); // 提供public目录下的静态文件

现在,重启你的后端服务 (node server.js),然后在浏览器中访问 http://localhost:3000。你应该能看到一个简洁的聊天界面。试着输入一些问题,切换一下“流式输出”和“普通输出”模式,感受一下两者的区别。

4. 项目优化与部署建议

一个能跑起来的Demo完成了,但如果你想把它用在更正式的场景,或者分享给别人用,可能还需要考虑下面几点。

4.1 添加对话历史与上下文管理

我们现在的实现,每次对话都是独立的,模型不知道之前的聊天内容。这对于多轮对话来说体验不好。优化思路是,在后端维护一个简单的会话存储(比如用内存对象或者Redis),把每次对话的用户消息和AI回复都存起来。下次请求时,把整个历史记录都发给模型,这样它就能根据上下文来回答了。

这需要修改后端的 /api/chat 接口,让它能接受一个 sessionId 参数,并根据这个ID来获取和存储历史消息列表。

4.2 增加基础的安全与限流措施

开放给公网的API,安全是必须考虑的。

  1. API Key保护:确保你的 .env 文件不被提交到Git等代码仓库(记得把它加入 .gitignore)。在部署时,使用服务器环境变量来配置。
  2. 请求限流:可以使用 express-rate-limit 这样的中间件,防止同一个IP地址在短时间内发送大量请求,消耗你的API额度或服务器资源。
  3. 输入验证与清理:对用户输入的消息内容做基本的检查和清理,防止注入攻击或处理异常数据。

4.3 前端体验优化点

  1. 支持Markdown渲染:如果模型回复中包含了代码块或列表,前端可以用 marked.js 这样的库将其渲染成富文本,更美观。
  2. 添加发送快捷键:我们已经支持了回车发送,可以再加一个 Ctrl+Enter 换行的功能。
  3. 对话导出:增加一个按钮,允许用户将当前对话历史以文本或JSON格式导出。
  4. 响应中断:在流式输出时,提供一个“停止生成”按钮,点击后可以中止正在进行的请求。

4.4 部署到生产环境

当你准备把这个应用部署到云服务器让更多人访问时:

  1. 使用进程管理工具:不要直接用 node server.js 运行。推荐使用 pm2,它可以守护进程,在应用崩溃时自动重启,还能做日志管理。
    npm install -g pm2
    pm2 start server.js --name "qwen-chat-app"
    
  2. 配置反向代理:通常我们不会让Node.js服务直接暴露在80或443端口。可以用Nginx或Caddy这样的Web服务器作为反向代理,处理静态文件、SSL加密(HTTPS)、负载均衡等,再把请求转发给Node.js应用。
  3. 环境变量管理:在服务器上,通过 export 命令或使用 .env 文件(确保其权限安全)来设置 QWEN_API_BASE_URLQWEN_API_KEY

5. 写在最后

走完这一趟,你会发现把一个AI模型集成到自己的全栈应用里,并没有想象中那么神秘。核心就是三步:后端作为“中间人”去调用模型API,前端提供一个友好的交互界面,中间通过HTTP协议把两者连接起来。

通义千问1.5-1.8B这个版本虽然参数量不大,但在轻量级对话、创意写作、代码辅助这些场景下,表现已经足够让人惊喜,而且推理速度很快,对硬件要求也友好。通过这种集成方式,你可以把它变成你网站的一个智能客服,内部工具的一个问答助手,或者任何你需要对话交互的地方。

这次我们用最基础的技术栈实现了一个完整链路,你可以在这个基础上,根据自己的需求去添加更多功能,比如用户登录、多会话管理、更精美的UI组件等等。最重要的是,你亲手打通了从模型到应用的“最后一公里”,这其中的经验和理解,是光看文档学不来的。希望这个实践能给你带来一些启发,如果有问题,欢迎随时交流。


获取更多AI镜像

想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐