通义千问1.5-1.8B-Chat-GPTQ-Int4 WebUI开发:Node.js后端服务调用实战

最近在折腾一些AI应用的原型,发现很多有意思的模型都提供了WebUI界面,比如通义千问的这个轻量级版本。WebUI用起来是方便,点一点就行,但如果你想把它集成到自己的项目里,比如做个智能客服机器人、或者给内部系统加个问答助手,总不能每次都让用户去打开那个网页界面吧?

这时候,后端服务调用就派上用场了。通过Node.js写个服务,直接和模型的WebUI后端“对话”,把AI能力变成你应用里的一行API调用,这才是真正的工程化落地。今天我就结合自己的踩坑经验,聊聊怎么用Node.js来搞定这件事,从环境搭建到流式响应处理,给你一个能直接抄作业的实战方案。

1. 项目起点:环境准备与模型服务假设

在开始写代码之前,我们得先把“舞台”搭好。这里有两个前提需要明确,因为本文重点在于Node.js如何调用,而不是模型本身的部署。

首先,你需要有一个已经启动并运行起来的通义千问WebUI服务。这通常意味着你已经通过Docker、源码安装或者其他方式,成功部署了模型,并且可以通过浏览器访问一个本地地址(比如 http://localhost:7860)来使用聊天界面。我们的Node.js服务就是要和这个地址背后的API打交道。

其次,我们来准备Node.js的开发环境。这件事现在简单多了。

1.1 Node.js环境安装与配置

如果你还没装Node.js,可以去官网下载最新的长期支持版本。安装过程就是一路下一步,没什么坑。安装完成后,打开终端,用下面两行命令验证一下:

node --version
npm --version

能看到版本号,比如 v18.x.x9.x.x,就说明安装成功了。

接下来,为我们的项目创建一个新的目录,并初始化它:

mkdir qwen-nodejs-integration
cd qwen-nodejs-integration
npm init -y

这个 npm init -y 命令会快速生成一个 package.json 文件,里面记录了项目的基本信息和依赖。

1.2 安装核心依赖包

我们这个项目主要需要两个依赖:一个用来创建Web服务器,另一个用来发送HTTP请求。这里我选择Express和Axios,因为它们生态丰富,用的人多,遇到问题也容易找到解决方案。

在项目根目录下运行:

npm install express axios

如果你更喜欢Koa或者Fetch API,思路也是完全相通的,只是语法稍有不同。安装完成后,你的 package.jsondependencies 部分应该能看到这两个包。

环境这就准备好了,接下来我们进入正题,看看怎么跟WebUI的API“对话”。

2. 核心交互:调用WebUI聊天接口

WebUI通常会在后端暴露一个标准的API接口供前端调用,我们的目标就是模拟这个调用过程。经过对常见WebUI(如Ollama、OpenAI WebUI等)的观察,这类接口通常是一个接收POST请求的聊天端点。

2.1 理解API请求格式

我们先来写一个最简单的调用函数,看看如何与WebUI服务通信。在项目根目录创建一个 apiClient.js 文件:

const axios = require('axios');

// 假设你的通义千问WebUI服务运行在本地7860端口
const API_BASE_URL = 'http://localhost:7860';

async function sendChatMessage(message) {
  try {
    const response = await axios.post(`${API_BASE_URL}/api/chat`, {
      // 这是最常见的请求体结构,具体字段可能需要根据实际WebUI调整
      model: 'Qwen1.5-1.8B-Chat-GPTQ-Int4', // 指定模型名称
      messages: [
        {
          role: 'user',
          content: message
        }
      ],
      stream: false // 先关闭流式响应,看看一次性返回的结果
    });

    console.log('AI回复:', response.data);
    return response.data;
  } catch (error) {
    console.error('调用API失败:', error.message);
    if (error.response) {
      // 服务器返回了错误状态码(4xx, 5xx)
      console.error('错误详情:', error.response.data);
    }
    throw error;
  }
}

// 测试一下
(async () => {
  try {
    const reply = await sendChatMessage('你好,请介绍一下你自己。');
    console.log('成功收到回复:', reply.choices[0].message.content);
  } catch (e) {
    console.log('测试失败');
  }
})();

这段代码做了几件事:

  1. 定义了API的基础地址。
  2. 创建了一个 sendChatMessage 函数,它用Axios向 /api/chat 发送POST请求。
  3. 请求体里包含了模型名、消息历史(这里只有用户最新的一条)以及是否流式输出的标志。
  4. 用了一个自执行的异步函数来立即测试。

关键点/api/chat 和请求体的具体格式(如 model, messages 的键名)需要根据你实际使用的通义千问WebUI的API文档来确定。如果不对,通常会返回404或400错误,根据错误信息调整即可。stream: false 意味着我们让服务器一次性生成完整回复再返回,适合调试。

2.2 处理流式响应

上面是一次性获取回复,但AI生成文字通常是一点点“吐”出来的,为了更好的用户体验(像打字机效果),我们需要处理流式响应。这稍微复杂一点,但原理是监听数据块。

修改 apiClient.js,增加一个流式对话函数:

const axios = require('axios');
const { Readable } = require('stream');

const API_BASE_URL = 'http://localhost:7860';

async function sendChatMessageStream(message, onDataChunk) {
  try {
    const response = await axios({
      method: 'post',
      url: `${API_BASE_URL}/api/chat`,
      data: {
        model: 'Qwen1.5-1.8B-Chat-GPTQ-Int4',
        messages: [{ role: 'user', content: message }],
        stream: true // 关键:开启流式输出
      },
      responseType: 'stream' // 关键:告诉Axios我们期待一个流
    });

    const stream = response.data;
    let fullContent = '';

    stream.on('data', (chunk) => {
      // 流式数据通常是一行行的JSON文本,以 "data: " 开头
      const chunkStr = chunk.toString();
      const lines = chunkStr.split('\n').filter(line => line.trim());

      for (const line of lines) {
        if (line.startsWith('data: ')) {
          const dataStr = line.slice(6); // 去掉 "data: " 前缀
          if (dataStr === '[DONE]') {
            console.log('流式传输结束');
            return;
          }
          try {
            const parsed = JSON.parse(dataStr);
            // 假设回复内容在 parsed.choices[0].delta.content
            const contentChunk = parsed.choices?.[0]?.delta?.content || '';
            if (contentChunk) {
              fullContent += contentChunk;
              // 调用回调函数,将新的内容块传递出去
              if (onDataChunk) {
                onDataChunk(contentChunk);
              }
            }
          } catch (e) {
            // 忽略非JSON行或解析错误
          }
        }
      }
    });

    stream.on('end', () => {
      console.log('Stream ended. Full reply:', fullContent);
      if (onDataChunk) {
        onDataChunk(null, fullContent); // 传递结束信号和完整内容
      }
    });

    stream.on('error', (err) => {
      console.error('Stream error:', err);
      if (onDataChunk) {
        onDataChunk(err);
      }
    });

  } catch (error) {
    console.error('请求失败:', error.message);
    throw error;
  }
}

// 测试流式调用
(async () => {
  console.log('开始流式对话...');
  await sendChatMessageStream('写一首关于春天的短诗', (chunk, full) => {
    if (chunk && typeof chunk === 'string') {
      process.stdout.write(chunk); // 逐块打印到控制台,模拟打字效果
    } else if (chunk instanceof Error) {
      console.error('出错:', chunk.message);
    } else if (full) {
      console.log('\n\n完整回复已生成。');
    }
  });
})();

这段代码的核心变化是设置了 responseType: 'stream'stream: true。我们不再等待整个响应,而是监听 data 事件,像接水管一样,来一块数据就处理一块。这里假设WebUI返回的是Server-Sent Events格式,每行以 data: 开头。你需要根据实际API返回的数据格式来调整解析逻辑。

3. 构建业务层:用Express创建代理服务器

直接从前端调用模型服务会有跨域问题,而且把API密钥或内部服务地址暴露给浏览器也不安全。更好的做法是搭建一个Node.js后端作为代理,统一处理这些请求,并可以在这里添加认证、限流、日志等业务逻辑。

3.1 创建基础的Express服务器

在项目根目录创建 server.js 文件:

const express = require('express');
const axios = require('axios');
const app = express();
const port = 3000;

// 中间件:解析JSON格式的请求体
app.use(express.json());

// 中间件:处理跨域请求(根据需求调整)
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', '*'); // 生产环境应指定具体域名
  res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
  if (req.method === 'OPTIONS') {
    return res.sendStatus(200);
  }
  next();
});

// 健康检查端点
app.get('/health', (req, res) => {
  res.json({ status: 'ok', service: 'Qwen API Proxy' });
});

// 核心:聊天代理接口
app.post('/api/proxy/chat', async (req, res) => {
  const userMessage = req.body.message;
  const useStream = req.body.stream || false;

  if (!userMessage) {
    return res.status(400).json({ error: '缺少 message 参数' });
  }

  console.log(`收到用户消息: ${userMessage}, 流式模式: ${useStream}`);

  try {
    // 这里是调用我们之前封装的函数,或者直接写Axios调用
    // 为了演示,这里直接写调用逻辑
    const response = await axios.post('http://localhost:7860/api/chat', {
      model: 'Qwen1.5-1.8B-Chat-GPTQ-Int4',
      messages: [{ role: 'user', content: userMessage }],
      stream: useStream
    }, {
      responseType: useStream ? 'stream' : 'json' // 根据前端需求决定响应类型
    });

    if (useStream) {
      // 设置流式响应头
      res.setHeader('Content-Type', 'text/event-stream');
      res.setHeader('Cache-Control', 'no-cache');
      res.setHeader('Connection', 'keep-alive');

      // 将模型服务的流直接管道到客户端响应
      response.data.pipe(res);

      // 处理源流错误,防止服务器崩溃
      response.data.on('error', (err) => {
        console.error('模型流错误:', err);
        if (!res.headersSent) {
          res.status(500).end();
        }
      });
    } else {
      // 一次性响应
      res.json(response.data);
    }

  } catch (error) {
    console.error('代理请求失败:', error.message);
    const statusCode = error.response?.status || 500;
    const errorData = error.response?.data || { error: 'Internal Server Error' };
    res.status(statusCode).json(errorData);
  }
});

app.listen(port, () => {
  console.log(`Node.js代理服务器运行在 http://localhost:${port}`);
});

这个服务器做了几件有用的事:

  1. 提供了一个 /api/proxy/chat 接口,前端只需向它发送请求。
  2. 它内部去调用真正的模型服务,解决了跨域问题。
  3. 根据前端传来的 stream 参数,智能地处理一次性或流式响应。
  4. 添加了错误处理,将模型服务的错误信息合理地返回给前端。

3.2 添加对话状态管理

简单的问答没问题,但真正的对话需要记忆上下文。我们可以在服务器端维护一个简单的会话状态。注意:这是一个简单的内存存储示例,生产环境需要用数据库(如Redis)来持久化。

server.js 中增加会话管理:

// 简单的内存存储,用于演示。生产环境请使用数据库。
const sessions = new Map();

// 生成唯一会话ID
function generateSessionId() {
  return `sess_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}

// 增强的聊天代理接口,支持多轮对话
app.post('/api/proxy/chat-with-context', async (req, res) => {
  const { message, sessionId: clientSessionId, newSession } = req.body;
  const useStream = req.body.stream || false;

  if (!message) {
    return res.status(400).json({ error: '缺少 message 参数' });
  }

  let sessionId = clientSessionId;
  let messages = [];

  // 处理会话逻辑
  if (newSession || !sessionId || !sessions.has(sessionId)) {
    sessionId = generateSessionId();
    messages = [{ role: 'system', content: '你是一个乐于助人的AI助手。' }];
    sessions.set(sessionId, messages);
    console.log(`创建新会话: ${sessionId}`);
  } else {
    messages = sessions.get(sessionId);
    console.log(`继续会话: ${sessionId}, 历史消息数: ${messages.length}`);
  }

  // 将用户新消息加入历史
  messages.push({ role: 'user', content: message });

  try {
    const response = await axios.post('http://localhost:7860/api/chat', {
      model: 'Qwen1.5-1.8B-Chat-GPTQ-Int4',
      messages: messages, // 发送整个对话历史
      stream: useStream
    }, {
      responseType: useStream ? 'stream' : 'json'
    });

    if (useStream) {
      res.setHeader('Content-Type', 'text/event-stream');
      res.setHeader('Cache-Control', 'no-cache');
      res.setHeader('Connection', 'keep-alive');
      res.write(`data: ${JSON.stringify({ sessionId })}\n\n`); // 先发送sessionId

      let fullAIContent = '';
      response.data.on('data', (chunk) => {
        // 简化处理,实际应像之前一样解析SSE
        const chunkStr = chunk.toString();
        // ... 解析逻辑,提取 contentChunk ...
        // fullAIContent += contentChunk;
        res.write(chunk); // 将模型流直接转发
      });

      response.data.on('end', () => {
        // 流结束后,将AI回复加入内存中的历史
        // messages.push({ role: 'assistant', content: fullAIContent });
        // sessions.set(sessionId, messages);
        res.end();
      });

      response.data.pipe(res); // 更简单的管道方式,但需注意格式兼容

    } else {
      const aiReply = response.data.choices[0].message.content;
      // 将AI回复加入历史,并保存
      messages.push({ role: 'assistant', content: aiReply });
      sessions.set(sessionId, messages);

      res.json({
        sessionId,
        reply: aiReply,
        fullHistory: messages // 可选,返回完整历史用于调试
      });
    }

  } catch (error) {
    console.error('对话请求失败:', error.message);
    res.status(500).json({ error: '对话处理失败', details: error.message });
  }
});

这样,前端只需要在第一次请求时(或想开始新对话时)不传 sessionId 或设置 newSession: true,后续请求带上返回的 sessionId,就能进行连续的多轮对话了。服务器会维护这个会话的历史记录。

4. 前端调用示例与项目整合

后端服务准备好了,前端怎么用呢?这里给一个非常简单的HTML示例,展示如何调用我们刚搭建的代理接口。在项目根目录创建 public 文件夹,并在里面创建一个 index.html 文件。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>通义千问Node.js集成测试</title>
    <style>
        body { font-family: sans-serif; max-width: 800px; margin: 2rem auto; padding: 1rem; }
        #chatBox { border: 1px solid #ccc; height: 300px; overflow-y: auto; padding: 1rem; margin-bottom: 1rem; }
        .message { margin-bottom: 0.8rem; }
        .user { color: #0066cc; text-align: right; }
        .ai { color: #009933; }
        #inputArea { display: flex; gap: 0.5rem; }
        #userInput { flex-grow: 1; padding: 0.5rem; }
        button { padding: 0.5rem 1.5rem; }
        label { margin-right: 1rem; }
    </style>
</head>
<body>
    <h2>与通义千问对话 (通过Node.js代理)</h2>
    <div>
        <label><input type="checkbox" id="streamCheckbox"> 使用流式输出</label>
        <button onclick="startNewSession()">开始新会话</button>
    </div>
    <div id="chatBox"></div>
    <div id="inputArea">
        <input type="text" id="userInput" placeholder="输入你的问题..." onkeypress="handleKeyPress(event)">
        <button onclick="sendMessage()">发送</button>
    </div>

    <script>
        let currentSessionId = null;

        function addMessage(text, sender) {
            const chatBox = document.getElementById('chatBox');
            const msgDiv = document.createElement('div');
            msgDiv.className = `message ${sender}`;
            msgDiv.textContent = `${sender === 'user' ? '你' : 'AI'}: ${text}`;
            chatBox.appendChild(msgDiv);
            chatBox.scrollTop = chatBox.scrollHeight;
        }

        function appendToLastAIMessage(chunk) {
            const chatBox = document.getElementById('chatBox');
            const aiMessages = chatBox.querySelectorAll('.ai');
            const lastAiMsg = aiMessages[aiMessages.length - 1];
            if (lastAiMsg && lastAiMsg.dataset.streaming === 'true') {
                lastAiMsg.textContent = lastAiMsg.textContent.replace(/AI: /, '') + chunk;
                lastAiMsg.textContent = 'AI: ' + lastAiMsg.textContent;
            } else {
                // 创建新的AI消息块
                const msgDiv = document.createElement('div');
                msgDiv.className = 'message ai';
                msgDiv.dataset.streaming = 'true';
                msgDiv.textContent = `AI: ${chunk}`;
                chatBox.appendChild(msgDiv);
            }
            chatBox.scrollTop = chatBox.scrollHeight;
        }

        async function sendMessage() {
            const input = document.getElementById('userInput');
            const message = input.value.trim();
            const useStream = document.getElementById('streamCheckbox').checked;

            if (!message) return;

            addMessage(message, 'user');
            input.value = '';

            const payload = {
                message: message,
                sessionId: currentSessionId,
                stream: useStream
            };

            try {
                if (useStream) {
                    // 流式请求
                    addMessage('', 'ai'); // 先占位
                    const response = await fetch('http://localhost:3000/api/proxy/chat-with-context', {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify(payload)
                    });

                    const reader = response.body.getReader();
                    const decoder = new TextDecoder();
                    let buffer = '';

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

                        buffer += decoder.decode(value, { stream: true });
                        const lines = buffer.split('\n');
                        buffer = lines.pop(); // 最后一行可能不完整

                        for (const line of lines) {
                            if (line.startsWith('data: ')) {
                                const dataStr = line.slice(6);
                                if (dataStr.startsWith('{')) {
                                    try {
                                        const data = JSON.parse(dataStr);
                                        if (data.sessionId) {
                                            currentSessionId = data.sessionId;
                                            console.log('Session ID:', currentSessionId);
                                        }
                                    } catch(e) { /* 忽略非JSON数据 */ }
                                } else if (dataStr.trim()) {
                                    // 假设这是AI回复的文本块
                                    appendToLastAIMessage(dataStr);
                                }
                            }
                        }
                    }
                    // 流结束,标记完成
                    const lastAiMsg = document.querySelector('.ai[data-streaming="true"]');
                    if (lastAiMsg) lastAiMsg.dataset.streaming = 'false';

                } else {
                    // 非流式请求
                    const response = await fetch('http://localhost:3000/api/proxy/chat-with-context', {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify(payload)
                    });
                    const data = await response.json();
                    if (data.sessionId) currentSessionId = data.sessionId;
                    addMessage(data.reply, 'ai');
                }
            } catch (error) {
                console.error('请求出错:', error);
                addMessage(`抱歉,请求出错: ${error.message}`, 'ai');
            }
        }

        function handleKeyPress(event) {
            if (event.key === 'Enter') {
                sendMessage();
            }
        }

        function startNewSession() {
            currentSessionId = null;
            document.getElementById('chatBox').innerHTML = '<div class="message ai">已开始新会话。</div>';
        }
    </script>
</body>
</html>

为了让Express能提供这个静态页面,需要在 server.js 开头添加一行:

app.use(express.static('public')); // 提供静态文件服务

现在,运行 node server.js,打开浏览器访问 http://localhost:3000,就能看到一个简单的聊天界面,通过你的Node.js代理与背后的通义千问模型对话了。

5. 总结

走完这一趟,你会发现把通义千问这样的AI模型通过WebUI集成到Node.js应用里,核心思路就是“代理”和“适配”。我们搭建的Node.js服务就像一个翻译官和调度员,把前端友好的请求,转换成模型服务能理解的格式,再把模型生成的结果,以合适的方式(一次性或流式)返回给前端。

整个过程的关键点有几个:一是理解并正确调用WebUI暴露的API接口,格式要对;二是熟练处理流式响应,这是提升用户体验的重点;三是在代理层实现会话管理,让对话有记忆;最后是做好错误处理和日志,让整个流程更稳健。

这个方案的好处是灵活,你可以在代理服务器上做很多文章,比如加个API密钥验证、给请求限个流、或者把对话记录存到数据库里做分析。代码里用的内存存储只是图个方便,真要上线的话,换成Redis或者MySQL更靠谱。

模型部署和API调用有时候会有点小波折,主要是文档和实际接口可能对不上,多看看日志,调整一下请求参数,一般都能解决。希望这个实战指南能帮你顺利地把AI能力接进自己的项目里。


获取更多AI镜像

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

Logo

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

更多推荐