前端手摸手跑路之 AI 应用开发(九)

Vue 与 FastAPI 接入 DeepSeek:普通调用、流式输出和多轮对话

终于要到这里了吗?登录认证完成后,可以把用户请求接入真实模型。本文从一次普通 API 调用开始,将它封装为需要登录的聊天接口,再实现逐段显示和多轮上下文

最终保留两个接口:POST /chat 返回完整 JSON,POST /chat/stream 返回纯文本流。Vue 页面使用流式接口,消息历史暂时保存在内存中

一、先确定调用链路

text 复制代码
Vue 携带登录令牌提交消息
→ FastAPI 校验用户身份和请求结构
→ Service 使用后端密钥调用 DeepSeek
→ 普通响应或文本流
→ Vue 展示回答

用户的 Bearer Token 用于访问自己的后端,DeepSeek API Key 用于后端访问模型服务,两者用途不同。API Key 不放进 Vue 源码或前端环境变量,也不发送给浏览器

本文使用 DeepSeek 支持的 Responses API,通过 openai Python 客户端调用。客户端包名并不决定请求发往哪个服务,真正的目标由 base_url 指定

本文使用的模型配置为 deepseek-flash,调用方式以 DeepSeek Responses API 文档为准。模型名与兼容能力可能变化,接入时应核对提供商文档

二、依赖和环境配置

在 Git Bash 执行:

bash 复制代码
cd /d/code/ai-workspace-rebuild/backend  # cd 切换到后端目录
uv add openai python-dotenv  # add 添加客户端和 .env 读取依赖,同步配置与锁文件
touch .env  # touch 创建本地配置文件,已有内容不会被清空

backend/.env:

dotenv 复制代码
DEEPSEEK_API_KEY=替换为自己的密钥
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-flash

项目根目录的 .gitignore 应包含:

gitignore 复制代码
backend/.env

在 backend 目录确认规则生效:

bash 复制代码
git check-ignore -v .env  # check-ignore 检查忽略规则,-v 显示规则来源

真实生成请求会消耗 API 用量,密钥留在本地,不输出到日志、截图或公开仓库

三、创建模型客户端

bash 复制代码
mkdir -p app/ai  # mkdir 创建目录,-p 补齐父目录并允许目录已存在
touch app/ai/__init__.py app/ai/client.py  # 创建包标记和客户端模块

app/ai/client.py:

python 复制代码
import os

from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()

DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY")
DEEPSEEK_BASE_URL = os.getenv(
    "DEEPSEEK_BASE_URL",
    "https://api.deepseek.com",
)
DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-flash")

if not DEEPSEEK_API_KEY:
    raise RuntimeError("DEEPSEEK_API_KEY is not configured")

deepseek_client = OpenAI(
    api_key=DEEPSEEK_API_KEY,
    base_url=DEEPSEEK_BASE_URL,
)

先用 load_dotenv 读取配置,再用 os.getenv 获取变量。默认情况下,dotenv 不覆盖进程中已有的同名环境变量

创建客户端不会直接生成回答。缺少密钥时提前报错,可以在应用加载阶段发现配置问题

保持从 backend 目录启动,确保配置发现与当前项目布局一致。以后改变启动目录或部署方式时,应同时检查配置文件定位

四、发送第一条普通请求

先在后端目录独立验证,排除路由和前端因素:

bash 复制代码
# python -c 执行引号内的代码,这次会产生真实模型调用
uv run python -c '
from app.ai.client import DEEPSEEK_MODEL, deepseek_client

response = deepseek_client.responses.create(
    model=DEEPSEEK_MODEL,
    instructions="请用简短中文回答。",
    input="用一句话解释 FastAPI 是什么。",
)
print(response.output_text)
'

model 选择模型,instructions 提供后端设定的回答要求,input 是本次输入。普通调用等待生成完成后返回响应对象,output_text 取出回答文本

得到真实回答,说明配置、密钥、网络和模型调用已经连通

五、统一消息结构

创建业务模块:

bash 复制代码
touch app/schemas/chat.py app/services/chat.py app/routers/chat.py  # 创建聊天数据模型、业务和路由模块

app/schemas/chat.py:

python 复制代码
from typing import Literal

from pydantic import BaseModel, Field


class ChatMessage(BaseModel):
    role: Literal["user", "assistant"]
    content: str = Field(min_length=1, max_length=4000)


class ChatRequest(BaseModel):
    messages: list[ChatMessage] = Field(min_length=1, max_length=20)


class ChatResponse(BaseModel):
    answer: str

Literal 限定具体取值:客户端只能传用户消息和历史模型回复,后端系统指令不作为可提交角色开放。固定指令并不意味着可以消除提示注入风险

content 限制每条消息的字符数,messages 限制条数,两者都不是 Token 限制

请求体统一使用消息数组,单轮先放一条:

json 复制代码
{
  "messages": [
    {"role": "user", "content": "用一句话解释 FastAPI 是什么"}
  ]
}

这样普通调用、流式调用和后续多轮对话可以共用 Schema

六、普通调用放进 Service

app/services/chat.py:

python 复制代码
from openai import APIError

from app.ai.client import DEEPSEEK_MODEL, deepseek_client
from app.schemas.chat import ChatMessage


class LLMServiceError(Exception):
    pass


def generate_reply(messages: list[ChatMessage]) -> str:
    model_input = [
        {"role": message.role, "content": message.content}
        for message in messages
    ]

    try:
        response = deepseek_client.responses.create(
            model=DEEPSEEK_MODEL,
            instructions="你是 AI Workspace 助手,请用清晰、简洁的中文回答。",
            input=model_input,
        )
    except APIError as exc:
        raise LLMServiceError("AI service request failed") from exc

    return response.output_text

列表推导式将 Pydantic 消息对象转换成接口接受的字典列表。SDK 异常转换为项目异常,Router 再决定 HTTP 响应,避免向客户端直接返回上游错误细节

七、聊天接口需要登录

app/routers/chat.py:

python 复制代码
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, status

from app.dependencies.auth import get_current_user
from app.schemas.chat import ChatRequest, ChatResponse
from app.schemas.users import UserResponse
from app.services.chat import LLMServiceError, generate_reply

router = APIRouter(prefix="/chat", tags=["chat"])


@router.post("", response_model=ChatResponse)
def chat(
    request: ChatRequest,
    current_user: Annotated[UserResponse, Depends(get_current_user)],
) -> ChatResponse:
    try:
        answer = generate_reply(request.messages)
        return ChatResponse(answer=answer)
    except LLMServiceError as exc:
        raise HTTPException(
            status_code=status.HTTP_502_BAD_GATEWAY,
            detail=str(exc),
        ) from exc

current_user 即使未在函数体中读取,认证依赖仍会执行。认证失败返回 401,不进入模型调用;上游调用失败在这里统一映射为 502

使用同步客户端,因此路由保持同步 def。在 main.py 导入并注册:

python 复制代码
from app.routers.chat import router as chat_router

# 放在 app 创建之后,与其他 Router 一起注册
app.include_router(chat_router)

通过 Swagger 登录并 Authorize 后测试 /chat,应得到包含 answer 的 JSON;取消授权再请求,应返回 401

八、前端普通请求

创建前端 API 模块:

bash 复制代码
cd /d/code/ai-workspace-rebuild/frontend  # 切换到前端目录
touch src/api/chat.ts  # 创建聊天请求模块

复用已有的 readErrorMessage:

ts 复制代码
import { readErrorMessage } from "./users";

export type ChatMessage = {
  role: "user" | "assistant";
  content: string;
};

export async function sendChat(
  messages: ChatMessage[],
  accessToken: string,
): Promise<string> {
  const response = await fetch("http://127.0.0.1:8000/chat", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${accessToken}`,
    },
    body: JSON.stringify({ messages }),
  });

  if (!response.ok) {
    throw new Error(await readErrorMessage(response));
  }

  const body: unknown = await response.json();
  if (
    typeof body !== "object" ||
    body === null ||
    !("answer" in body) ||
    typeof body.answer !== "string"
  ) {
    throw new Error("Invalid chat response");
  }
  return body.answer;
}

{ messages } 是 { messages: messages } 的属性简写,发送的是包含 messages 的对象,不是裸数组

普通请求可以直接把返回值赋给页面回答状态。下面保留这个接口作为完整 JSON 调用方式,页面最终切换到流式请求

九、后端逐段产出文本

在 Service 顶部增加 from collections.abc import Iterator,再增加:

python 复制代码
def generate_reply_stream(messages: list[ChatMessage]) -> Iterator[str]:
    model_input = [
        {"role": message.role, "content": message.content}
        for message in messages
    ]

    try:
        stream = deepseek_client.responses.create(
            model=DEEPSEEK_MODEL,
            instructions="你是 AI Workspace 助手,请用清晰、简洁的中文回答。",
            input=model_input,
            stream=True,
        )
    except APIError as exc:
        raise LLMServiceError("AI service request failed") from exc

    def text_chunks() -> Iterator[str]:
        try:
            with stream:
                for event in stream:
                    if event.type == "response.output_text.delta":
                        yield event.delta
                    elif event.type in (
                        "response.failed",
                        "response.incomplete",
                    ):
                        raise LLMServiceError("AI response did not complete")
        except APIError as exc:
            raise LLMServiceError("AI streaming request failed") from exc

    return text_chunks()

stream=True 让接口返回事件流。只有 output_text.delta 的 delta 才作为新增回答发给前端,其他生命周期事件不直接显示

yield 每次产出一段文本,等待消费者继续读取。with stream 在正常结束或异常退出时关闭上游流

外层先发起请求,内层生成器随后读取事件。这让请求建立阶段的 APIError 可以在返回 HTTP 流式响应前转换为项目异常

十、StreamingResponse 与纯文本协议

Router 补充导入:

python 复制代码
from fastapi.responses import StreamingResponse
from app.services.chat import generate_reply_stream

增加流式路由:

python 复制代码
@router.post("/stream", response_class=StreamingResponse)
def stream_chat(
    request: ChatRequest,
    current_user: Annotated[UserResponse, Depends(get_current_user)],
) -> StreamingResponse:
    try:
        chunks = generate_reply_stream(request.messages)
    except LLMServiceError as exc:
        raise HTTPException(
            status_code=status.HTTP_502_BAD_GATEWAY,
            detail=str(exc),
        ) from exc

    return StreamingResponse(
        chunks,
        media_type="text/plain; charset=utf-8",
    )

StreamingResponse 持续读取生成器,把内容写入同一个响应。上游 DeepSeek 返回事件流,后端提取文字后,向浏览器返回的是纯文本流,前端不需要解析 DeepSeek 的事件格式

流式响应一旦发送响应头,就不能在中途失败时把已经发出的 200 改成 502。这里的异常捕获只覆盖创建上游请求的阶段,读取过程中的异常会让流中断

因此,纯文本流不能可靠地向前端表达所有完成状态。连接正常结束不一定证明模型业务上完整回答,后续可以设计带完成、失败标记的 SSE 或其他流协议

十一、前端读取字节流并解码

在 src/api/chat.ts 增加:

ts 复制代码
export async function sendChatStream(
  messages: ChatMessage[],
  accessToken: string,
  onChunk: (text: string) => void,
): Promise<void> {
  const response = await fetch("http://127.0.0.1:8000/chat/stream", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${accessToken}`,
    },
    body: JSON.stringify({ messages }),
  });

  if (!response.ok) {
    throw new Error(await readErrorMessage(response));
  }
  if (!response.body) {
    throw new Error("Response stream is unavailable");
  }

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

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

      const text = decoder.decode(value, { stream: true });
      if (text) onChunk(text);
    }

    const remaining = decoder.decode();
    if (remaining) onChunk(remaining);
  } finally {
    reader.releaseLock();
  }
}

response.body 是可读流,getReader 获取读取器。每次 read 等待下一块字节,value 通常是 Uint8Array,done 表示流是否结束

TextDecoder 默认按 UTF-8 解码。一个中文字符可能被拆进不同网络块,stream=true 会保留未完整接收的字节,下次继续解码,避免每块独立解码产生乱码

最后一次无参数 decode 刷新残余字节。finally 中 releaseLock 释放读取器锁,它不等于主动取消请求,停止生成需要额外的取消逻辑

onChunk 是页面传入的回调,每收到文字就更新状态。函数本身返回 Promise,不再返回完整答案,也不能调用 response.json 读取这个纯文本响应

网络块不保证一字一块,可能合并多段文字。流式也不保证立刻出现内容,模型开始输出前仍可能有等待

十二、多轮对话靠每次携带历史

当前采用客户端显式传入历史的方式,不依赖服务端会话记忆。第二轮请求包含:

json 复制代码
{
  "messages": [
    {"role": "user", "content": "我的项目代号是石山,只回答收到"},
    {"role": "assistant", "content": "收到"},
    {"role": "user", "content": "我的项目代号是什么?"}
  ]
}

模型能够联系上文,是因为历史再次出现在请求中,不是因为登录令牌保存了聊天内容

页面使用 chatMessages 保存已完成问答,chatAnswer 保存正在生成的回答。二者分开,才能在流式结束后将临时回答移入历史,避免重复显示

历史发送策略为最近 18 条加上当前问题,正常成对保存时相当于最近 9 轮加一个新问题,共 19 条,满足后端最多 20 条的约束

为了避免历史回答超过每条 4000 字符的请求限制,发送前取前 4000 个字符。这个策略简单但会丢失信息,字符数也不等于 Token 数,后续需要更合理的上下文预算

十三、页面状态与发送逻辑

在 App.vue 保留注册和登录逻辑,增加聊天导入和状态:

ts 复制代码
import { sendChatStream, type ChatMessage } from "./api/chat";

const chatInput = ref("");
const chatAnswer = ref("");
const chatMessages = ref<ChatMessage[]>([]);
const chatError = ref("");
const isSending = ref(false);

下面继续使用登录时保存的 accessToken 和 currentUser:

ts 复制代码
async function submitChat() {
  if (isSending.value) return;

  chatError.value = "";
  if (!accessToken.value) {
    chatError.value = "请先登录";
    return;
  }

  const content = chatInput.value.trim();
  if (!content) {
    chatError.value = "请输入问题";
    return;
  }

  isSending.value = true;
  chatAnswer.value = "";

  try {
    const userMessage: ChatMessage = { role: "user", content };
    const messages = [
      ...chatMessages.value.slice(-18),
      userMessage,
    ].map((message) => ({
      ...message,
      content: message.content.slice(0, 4000),
    }));

    await sendChatStream(messages, accessToken.value, (text) => {
      chatAnswer.value += text;
    });

    if (!chatAnswer.value.trim()) {
      throw new Error("模型未返回有效回答");
    }

    chatMessages.value.push(userMessage, {
      role: "assistant",
      content: chatAnswer.value,
    });
    chatInput.value = "";
    chatAnswer.value = "";
  } catch (err: unknown) {
    chatError.value = err instanceof Error ? err.message : "发送失败";
  } finally {
    isSending.value = false;
  }
}

trim 拒绝空白输入,slice 取历史副本,展开语法拼接当前消息,map 创建发送用的副本,页面中保存的原始历史不因此被截断

回调使用 += 追加文字,流读取完成且答案非空后,才把本轮问答加入历史。捕获到失败时保留临时文本供查看,但不会作为完整问答加入历史。纯文本协议对中途失败的检测仍有前文所述限制

登录函数开头也需要避免正在生成时切换账号,并清空旧会话:

ts 复制代码
// 替换 submitLogin 开头原来的重复提交判断
if (isLoggingIn.value || isSending.value) return;

// 放在判断之后、发起登录请求之前
chatMessages.value = [];
chatAnswer.value = "";
chatError.value = "";

十四、历史与当前回答都要展示

在现有注册和登录 section 之后增加聊天区:

vue 复制代码
<section>
  <h2>AI 对话</h2>
  <p v-if="!currentUser">请先登录后使用</p>

  <form @submit.prevent="submitChat">
    <textarea
      v-model="chatInput"
      placeholder="请输入问题"
      rows="4"
      maxlength="4000"
      :disabled="isSending || !currentUser"
      required
    ></textarea>
    <button type="submit" :disabled="isSending || !currentUser">
      {{ isSending ? "生成中..." : "发送" }}
    </button>
  </form>

  <div v-for="(message, index) in chatMessages" :key="index">
    <strong>{{ message.role === "user" ? "我" : "AI" }}</strong>
    <p class="chat-answer">{{ message.content }}</p>
  </div>

  <p v-if="chatError">{{ chatError }}</p>
  <p v-if="chatAnswer" class="chat-answer">{{ chatAnswer }}</p>
</section>

样式保留换行:

css 复制代码
.chat-answer {
  white-space: pre-wrap;
}

历史列表展示已完成的问答,临时回答区展示流式生成过程。只保留历史列表,就会看不到生成中的文字;生成完成后不清空 chatAnswer,又会把同一回答显示两遍

当前列表只追加并整体清空,使用 index 作为 key。以后支持删除、重排、重试分支时,应为消息增加稳定 ID

十五、检查普通、流式与上下文行为

本阶段的检查顺序如下:

操作 预期结果
独立执行普通模型请求 终端打印完整回答
已登录调用 /chat 返回包含 answer 的 JSON
无令牌调用聊天接口 返回 401
页面发送一个需要较长回答的问题 生成期间文字逐段出现,按钮保持禁用
生成结束 回答进入历史,不重复显示
先声明项目代号,再追问代号 第二轮回答能使用前一轮内容

可以使用"我的项目代号是石山,只回答收到",等第一轮完成后再问"我的项目代号是什么?",检查第二次请求中是否带上了前一轮的 user 和 assistant 消息

阶段结果

到这里,Vue 已能在登录后通过 FastAPI 调用 DeepSeek,后端同时提供完整 JSON 和纯文本流两种响应,前端能够正确解码并逐段显示中文回答,再通过消息数组实现多轮上下文。普通调用、流式显示和两轮上下文问答均已跑通

但当前完成的是最小聊天链路:

  • 聊天历史和令牌只保存在内存中,刷新页面后丢失,没有会话入库
  • 纯文本流没有显式完成和错误标记,不能可靠识别所有中途失败
  • 没有停止生成、自动重试、Markdown 渲染和消息分支
  • 上下文按消息条数和字符截断控制,没有 Token 预算与历史摘要
  • 后端还没有用户级模型调用限流、额度和成本管理
  • 回答仍是自由文本,没有结构化输出、工具调用或知识库检索

下一篇使用结构化输出,将自然语言需求转换成经过校验的任务计划,并在页面上分字段展示

相关推荐
全栈弄潮儿2 小时前
Python实战第8期:函数
后端·python·编程语言
码事漫谈2 小时前
你让 AI “尽力做”,它反而可能做不好
后端
孟健2 小时前
LLM API 安全防线:从 OpenCode 盗刷事件到 LiteLLM 多模型容灾实操
人工智能·后端·架构
程序员cxuan3 小时前
我用 WorkBuddy + ima 把公众号文章做成了知识库。
人工智能·后端·程序员
程序员cxuan3 小时前
玩了一天 REA,我号快没了
人工智能·后端·程序员
卷无止境3 小时前
暗流与破局:AI时代的代码安全审查工具全景指南
后端·python
中文呀3 小时前
三维 GIS 离线缓存实战:逆向 CesiumOfflineCache,并亲手为它补上"自动更新"能力
前端
smallyoung4 小时前
告别N+1:Spring Boot 一键数据翻译框架体系
java·后端·架构
程序猿DD4 小时前
OctaFuse Gateway 2.16.0:新增 ElevenLabs 的支持、厂商原生端点重构、后台管理优化
后端