前端如何调用文本大模型API

307 阅读7分钟

通过DeepSeek大模型官网的示例可以看出:我们提出一个问题,只要服务器完成一点点,客户端就会接收一点点。 接下来,我们就来学习使用流式传输减少用户等待时间,使用NodeJS服务模拟SSE通信,给我们的VUE项目调用大模型。

准备工作

首先,我们准备一个最基础的vue项目,我这里通过Trae builder直接生成了。

image.png

在一级目录下增加一个文件.env.local,增加调用DeepSeek的API KEY:

VITE_DEEPSEEK_API_KEY="你的API KEY"

第一个知识点:简单调用DeepSeek的API,完成流式传输:

我们直接在APP.vue里Fetch请求,完整代码如下:

<script setup lang="ts">
import { ref } from "vue";

const question = ref("你好");
const content = ref("");
const stream = ref(true);

const update = async () => {
  if (!question) return;
  content.value = "思考中...";

  const endpoint = "https://api.deepseek.com/chat/completions";
  const headers = {
    "Content-Type": "application/json",
    Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
  };

  const response = await fetch(endpoint, {
    method: "POST",
    headers: headers,
    body: JSON.stringify({
      model: "deepseek-chat",
      messages: [{ role: "user", content: question.value }],
      stream: stream.value, // 这里 stream.value 值如果是 true,采用流式传输
    }),
  });

  if (stream.value) {
    // https://developer.mozilla.org/zh-CN/docs/Web/API/Streams_API
    content.value = "";

    const reader = response.body?.getReader(); // 利用 ReadableStream API 通过 getReader() 获取一个读取器
    const decoder = new TextDecoder(); // 创建 TextDecoder 准备对二进制数据进行解码
    let done = false; //控制流标志 done
    let buffer = ""; //buffer 变量来缓存数据,因为 Stream 数据返回给前端时,不一定传输完整。

    // 开始循环读取数据,通过 TextDecoder 解析数据,将数据转换成文本并按行拆分。
    while (!done) {
      const { value, done: doneReading } = await (reader?.read() as Promise<{
        value: any;
        done: boolean;
      }>);
      done = doneReading;
      const chunkValue = buffer + decoder.decode(value);
      buffer = "";

      // 因为 API 返回流式数据的协议是每一条数据以 “data:” 开头,后续是一个有效的 JSON 或者[DONE]表示传输结束,
      // 所以我们要对每一行以"data:"开头的数据进行处理。
      const lines = chunkValue
        .split("\n")
        .filter((line) => line.startsWith("data: "));

      for (const line of lines) {
        const incoming = line.slice(6);
        if (incoming === "[DONE]") {
          done = true;
          break;
        }

        // 如果数据传输完整,且不是[DONE],那么它就是合法 JSON,我们从中读取 data.choices[0].delta.content,就是需要增量更新的内容,
        // 否则说明数据不完整,将它存入缓存,以便后续继续处理。
        try {
          const data = JSON.parse(incoming);
          const delta = data.choices[0].delta.content;
          if (delta) content.value += delta;
        } catch (ex) {
          buffer += `data: ${incoming}`;
        }
      }
    }
  } else {
    const data = await response.json();
    content.value = data.choices[0].message.content;
  }
};
</script>

<template>
  <div class="container">
    <div>
      <label>输入:</label><input class="input" v-model="question" />
      <button @click="update">提交</button>
    </div>
    <div class="output">
      <div>
        <label>Streaming</label><input type="checkbox" v-model="stream" />
      </div>
      <div>{{ content }}</div>
    </div>
  </div>
</template>

<style scoped>
.container {
  display: flex;
  flex-direction: column;
  align-items: start;
  justify-content: start;
  height: 100vh;
  font-size: 0.85rem;
}
.input {
  width: 200px;
}
.output {
  margin-top: 10px;
  min-height: 300px;
  width: 100%;
  text-align: left;
}
button {
  padding: 0 10px;
  margin-left: 6px;
}
</style>

其中,endpoint是我们请求DeepSeek的API地址,如果我们是本地企业级项目,显然直接访问外网是不合理的;headers是请求头参数,Authorization中携带了API KEY,这意味着这个接口一定要POST请求,才能加密传输;请求参数body中的stream如果设置为true,你就能在控制台看到该接口的响应Content-Type是text/event-stream。所以,所谓的流式传输是要求服务端返回。

  const endpoint = "https://api.deepseek.com/chat/completions";
  const headers = {
    "Content-Type": "application/json",
    Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
  };

  const response = await fetch(endpoint, {
    method: "POST",
    headers: headers,
    body: JSON.stringify({
      model: "deepseek-chat",
      messages: [{ role: "user", content: question.value }],
      stream: stream.value, // 这里 stream.value 值如果是 true,采用流式传输
    }),
  });

对于服务器返回的流式,我们通过 HTML5 标准的Streams API来处理数据主要逻辑如下:

    const reader = response.body?.getReader(); // 利用 ReadableStream API 通过 getReader() 获取一个读取器
    const decoder = new TextDecoder(); // 创建 TextDecoder 准备对二进制数据进行解码
    let done = false; //控制流标志 done
    let buffer = ""; //buffer 变量来缓存数据,因为 Stream 数据返回给前端时,不一定传输完整。

    // 开始循环读取数据,通过 TextDecoder 解析数据,将数据转换成文本并按行拆分。
    while (!done) {
      const { value, done: doneReading } = await (reader?.read() as Promise<{
        value: any;
        done: boolean;
      }>);
      done = doneReading;
      const chunkValue = buffer + decoder.decode(value);
      buffer = "";

      // 因为 API 返回流式数据的协议是每一条数据以 “data:” 开头,后续是一个有效的 JSON 或者[DONE]表示传输结束,
      // 所以我们要对每一行以"data:"开头的数据进行处理。
      const lines = chunkValue
        .split("\n")
        .filter((line) => line.startsWith("data: "));

      for (const line of lines) {
        const incoming = line.slice(6);
        if (incoming === "[DONE]") {
          done = true;
          break;
        }

        // 如果数据传输完整,且不是[DONE],那么它就是合法 JSON,我们从中读取 data.choices[0].delta.content,就是需要增量更新的内容,
        // 否则说明数据不完整,将它存入缓存,以便后续继续处理。
        try {
          const data = JSON.parse(incoming);
          const delta = data.choices[0].delta.content;
          if (delta) content.value += delta;
        } catch (ex) {
          buffer += `data: ${incoming}`;
        }
      }
    }

那流式传输是如何结束的呢?打开请求,可以看到最后会有一个Done,这就代表该回答结束了,我们需要监听并处理。

image.png

以上,就是最简单实现前端直接调用大模型API的示例。

流式_20250629_183256.gif

第二个知识点:客户端使用SSE监听,完成通信;

首先,执行pnpm i dotenv express安装dotenv和express,在根目录下创建server.js,执行node server.js启动服务。完整代码如下:

// 在 server 端处理大模型 API 的流式响应,并将数据仍以兼容 SSE(以"data: "开头)的形式逐步发送给浏览器端。
import * as dotenv from "dotenv";
import express from "express";

dotenv.config({
  path: [".env.local", ".env"],
});

const openaiApiKey = process.env.VITE_DEEPSEEK_API_KEY;
const app = express();
const port = 3000;
const endpoint = "https://api.deepseek.com/v1/chat/completions";

const systemPrompt = `你是前端工程师,熟悉vue和react、nodejs、python、java、c++、go等语言,请根据用户提问,给出简洁、准确的回答。
输出以下JSON格式内容:
{
“question”:"vue和React的区别?",
"answer":"vue和React的区别是..."
}
`;

// SSE 端点
app.get("/stream", async (req, res) => {
  // 设置响应头部
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");
  res.flushHeaders(); // 发送初始响应头

  try {
    // 发送 OpenAI 请求
    const response = await fetch(endpoint, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${openaiApiKey}`,
      },
      body: JSON.stringify({
        model: "deepseek-chat", // 选择你使用的模型
        // messages: [{ role: 'user', content: req.query.question }],
        messages: [
          { role: "system", content: systemPrompt },
          { role: "user", content: req.query.question },
        ],
        stream: true, // 开启流式响应
        // response_format: { type: "json_object" }, //JSON 格式来输出
      }),
    });

    if (!response.ok) {
      throw new Error("Failed to fetch from OpenAI");
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let done = false;
    let buffer = "";

    // 读取流数据并转发到客户端
    while (!done) {
      const { value, done: doneReading } = await reader.read();
      done = doneReading;
      const chunkValue = buffer + decoder.decode(value, { stream: true });
      buffer = "";

      // 按行分割数据,每行以 "data: " 开头,并传递给客户端
      const lines = chunkValue
        .split("\n")
        .filter((line) => line.trim() && line.startsWith("data: "));
      for (const line of lines) {
        const incoming = line.slice(6);
        if (incoming === "[DONE]") {
          done = true;
          break;
        }
        try {
          const data = JSON.parse(incoming);
          const delta = data.choices[0].delta.content;
          if (delta) res.write(`data: ${delta}\n\n`); // 发送数据到客户端
        } catch (ex) {
          buffer += `data: ${incoming}`;
        }
      }
    }
    res.write("event: end\n"); // 发送结束事件
    res.write("data: [DONE]\n\n"); // 通知客户端数据流结束
    res.end(); // 关闭连接
  } catch (error) {
    console.error("Error fetching from OpenAI:", error);
    res.write("data: Error fetching from OpenAI\n\n");
    res.end();
  }
});

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

修改APP.vue如下:

<script setup lang="ts">
import { ref } from "vue";

// const question = ref("你好");
const time1 = ref(0); //计时器
const spend = ref(0);
const question = ref("vue和react的区别?");
const content = ref("");
const answer = ref("");

const update = async () => {
  if (!question) return;
  content.value = "思考中...";

  time1.value = new Date().getTime();

  const endpoint = "/api/stream";

  content.value = "";
  // server 端处理转发关键代码
  /**
   * SSE 在浏览器内置了自动重连机制。
   * 这意味着当网络、服务器或者客户端连接出现问题,恢复后将自动完成重新连接,
   * 不需要用户主动刷新页面,这让 SSE 特别适合长时间保持连接的应用场景。
   * 此外,SSE 还支持通过 lastEventId 来支持数据的续传,
   * 这样在错误恢复时,能大大节省数据传输的带宽和接收数据的响应时间。
   * **/
  const eventSource = new EventSource(`${endpoint}?question=${question.value}`);
  eventSource.onmessage = (e: any) => {
    content.value += e.data;
  };
  eventSource.addEventListener("end", () => {
    eventSource.close();
    spend.value = new Date().getTime() - time1.value;
    answer.value = JSON.parse(content.value).answer;
  });
  eventSource.onerror = (err) => {
    console.error("EventSource failed:", err);
  };
};
</script>

<template>
  <div class="container">
    <div>
      <label>输入:</label><input class="input" v-model="question" />
      <button @click="update">提交</button>
    </div>
    <div class="output">
      <div>从提交到开始显示消耗时长(ms):{{ spend }}</div>
      <div>实际显示在页面上:{{ answer }}</div>
      <div>接收到的流式结果:{{ content }}</div>
    </div>
  </div>
</template>

<style scoped>
.container {
  display: flex;
  flex-direction: column;
  align-items: start;
  justify-content: start;
  height: 100vh;
  font-size: 0.85rem;
}
.input {
  width: 200px;
}
.output {
  margin-top: 10px;
  min-height: 300px;
  width: 100%;
  text-align: left;
}
button {
  padding: 0 10px;
  margin-left: 6px;
}
</style>

再修改vite.config.js,这样 server 请求就被转发到了 /api/stream。:

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

// https://vitejs.dev/config/
export default defineConfig({
  server: {
    allowedHosts: true,
    port: 7773,
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        secure: false,
        rewrite: path => path.replace(/^\/api/, ''),
      },
    },
  },
  plugins: [
    vue()
  ],
});

可以看到,前端的代码简单多了,只需要关注SSE——Server-Sent Events 是一种允许服务器主动推送实时更新给浏览器的技术,属于 Web 标准,它除了实时推送数据外,还可以支持自定义事件 (Custom Events)和内容的续传。

const eventSource = new EventSource(`${endpoint}?question=${question.value}`);
  eventSource.onmessage = (e: any) => {
    content.value += e.data;
  };
  eventSource.addEventListener("end", () => {
    eventSource.close();
    spend.value = new Date().getTime() - time1.value;
    answer.value = JSON.parse(content.value).answer;
  });
  eventSource.onerror = (err) => {
    console.error("EventSource failed:", err);
  };

1.gif

做到这里,咱们先总结一下关键点:

  1. 通过流式传输减少用户等待时间的方法是通过设置stream参数为true,实现AI以流式传输的方式输出内容,从而减少用户的等待时间。
  2. Server-Sent Events (SSE) 是一种允许服务器主动推送实时更新给浏览器的技术,除了实时推送数据外,还可以支持自定义事件和内容的续传,适合长时间保持连接的应用场景。

上面的这个例子,运行之后可以看到,我在提示词中要求DeepSeek返回格式是个对象,前端实际显示到页面上只需要answer属性,但实际效果是,前端对 JSON 的解析必须等待 JSON 数据全部传输完成,否则会因为 JSON 数据不完整而导致解析报错。这就导致一个问题,即使我们在前端用流式获取 JSON 数据,我们也得等待 JSON 完成后才能解析数据并更新 UI,这就让原本流式数据快速响应的特性失效了。

所以我们需要对不完整的json做parser解析,然后利用这个 parser 来动态解析返回的数据流。

第三个知识点:传递JSON格式并要求前端实时解析

这里parser代码参考极客时间月影老师的,咱们就不二次开发了。在 src 目录的外边建立一个 parser 目录,将 github.com/WeHomeBot/l… 的内容复制过来。目录如下:

image.png

执行pnpm i jsonuri jiti安装jsonuri和jiti(为了兼容一个项目里ts和js),修改server.js:

// 在 server 端处理大模型 API 的流式响应,并将数据仍以兼容 SSE(以"data: "开头)的形式逐步发送给浏览器端。
import * as dotenv from "dotenv";
import express from "express";
import { JSONParser } from "./parser/index.ts";

dotenv.config({
  path: [".env.local", ".env"],
});

const openaiApiKey = process.env.VITE_DEEPSEEK_API_KEY;
const app = express();
const port = 3001;
const endpoint = "https://api.deepseek.com/v1/chat/completions";

// 系统提示词
const systemPrompt = `你是前端工程师,熟悉vue和react、nodejs、python、java、c++、go等语言,请根据用户提问,给出简洁、准确的回答。
输出以下JSON格式内容:
{
“question”:"vue和React的区别?",
"answer":"vue和React的区别是..."
}
`;

// SSE 端点
app.get("/stream", async (req, res) => {
  // 设置响应头部
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");
  res.flushHeaders(); // 发送初始响应头

  try {
    // 发送 OpenAI 请求
    const response = await fetch(endpoint, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${openaiApiKey}`,
      },
      body: JSON.stringify({
        model: "deepseek-chat", // 选择你使用的模型
        // messages: [{ role: 'user', content: req.query.question }],
        messages: [
          { role: "system", content: systemPrompt },
          { role: "user", content: req.query.question },
        ],
        stream: true, // 开启流式响应
        response_format: { type: "json_object" }, //JSON 格式来输出
      }),
    });

    if (!response.ok) {
      throw new Error("Failed to fetch from OpenAI");
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let done = false;
    let buffer = "";

    const jsonParser = new JSONParser({
      autoFix: true,
      onError: (error) => {
        console.error("JSON Parser Error:", error);
      },
    });

    // 在每次接收到新的流数据片段时触发(比如 SSE 的一行行 JSON 增量数据)。
    // 是最原始的事件,可能只是一个片段,还没拼成完整的 JSON。
    jsonParser.on("data", (data) => {
      if (data.uri) res.write(`data: ${JSON.stringify(data)}\n\n`); // 发送数据到客户端
    });

    // 当解析器解析出完整的 JSON 对象时触发。
    jsonParser.on("string-resolve", ({ uri, delta }) => {
      console.log("string-resolve",uri,delta);
    });

    // 读取流数据并转发到客户端
    while (!done) {
      const { value, done: doneReading } = await reader.read();
      done = doneReading;
      const chunkValue = buffer + decoder.decode(value, { stream: true });
      buffer = "";

      // 按行分割数据,每行以 "data: " 开头,并传递给客户端
      const lines = chunkValue
        .split("\n")
        .filter((line) => line.trim() && line.startsWith("data: "));
      for (const line of lines) {
        const incoming = line.slice(6);
        if (incoming === "[DONE]") {
          done = true;
          break;
        }
        try {
          const data = JSON.parse(incoming);
          const delta = data.choices[0].delta.content;
          jsonParser.trace(delta);
          // if (delta) res.write(`data: ${delta}\n\n`); // 发送数据到客户端
        } catch (ex) {
          // buffer += `data: ${incoming}`;
          buffer += incoming;
        }
      }
    }
    res.write("event: end\n"); // 发送结束事件
    res.write("data: [DONE]\n\n"); // 通知客户端数据流结束
    res.end(); // 关闭连接
  } catch (error) {
    console.error("Error fetching from OpenAI:", error);
    res.write("data: Error fetching from OpenAI\n\n");
    res.end();
  }
});

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

大模型输出的 JSON 内容,我们通过 jsonParser 进行处理,发送给客户端。

    jsonParser.on('data', (data) => {
        if (data.uri) res.write(`data: ${JSON.stringify(data)}\n\n`); // 发送数据到客户端
    });

在package.json中增加"jiti": "jiti server.js",然后使用pnpm jiti启动Server。

APP.vue修改如下:

<script setup lang="ts">
import { ref, watch } from "vue";
import { set, get } from "jsonuri";

const time1 = ref(0);//计时器
const spend = ref(0);
const question = ref("vue和react的区别?");
const content = ref({
  question: "",
  answer: "",
});
const answer = ref("");
watch(
  () => content.value.answer,
  (newVal, oldVal) => {
    if (oldVal === "" && newVal !== "") {
      spend.value = new Date().getTime() - time1.value;
    }
    answer.value = newVal;
  }
);

const update = async () => {
  if (!question) return;

  time1.value = new Date().getTime();

  const endpoint = "/api/stream";

  const eventSource = new EventSource(`${endpoint}?question=${question.value}`);
  eventSource.onmessage = (e: any) => {
    // content.value += e.data;
    const { uri, delta } = JSON.parse(e.data);
    const str = get(content.value, uri);
    set(content.value, uri, (str || "") + delta);
  };
  eventSource.addEventListener("end", () => {
    eventSource.close();
  });
  eventSource.onerror = (err) => {
    console.error("EventSource failed:", err);
  };
};
</script>

<template>
  <div class="container">
    <div>
      <label>输入:</label><input class="input" v-model="question" />
      <button @click="update">提交</button>
    </div>
    <div class="output">
      <div>从提交到开始显示消耗时长(ms):{{ spend }}</div>
      <div>实际显示在页面上:{{ answer }}</div>
      <div>接收到的流式结果:{{ content }}</div>
    </div>
  </div>
</template>

<style scoped>
.container {
  display: flex;
  flex-direction: column;
  align-items: start;
  justify-content: start;
  height: 100vh;
  font-size: 0.85rem;
}
.input {
  width: 200px;
}
.output {
  margin-top: 10px;
  min-height: 300px;
  width: 100%;
  text-align: left;
}
button {
  padding: 0 10px;
  margin-left: 6px;
}
</style>

在 EventSource 中,我们使用 jsonuri 处理服务端返回的数据。

const eventSource = new EventSource(`${endpoint}?question=${question.value}`);
  eventSource.onmessage = (e: any) => {
    // content.value += e.data;
    const { uri, delta } = JSON.parse(e.data);
    const str = get(content.value, uri);
    set(content.value, uri, (str || "") + delta);
  };

运行之后,就可以看到我们实现了即时Stream流传给我们不完整的json,前端依然能够实时解析并立刻显示在页面上。

流式+JSON_20250629_182038.gif