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 预算与历史摘要
- 后端还没有用户级模型调用限流、额度和成本管理
- 回答仍是自由文本,没有结构化输出、工具调用或知识库检索
下一篇使用结构化输出,将自然语言需求转换成经过校验的任务计划,并在页面上分字段展示