前言
市面上 AI 教程很多,但大多只讲"调 API",不讲"搭应用"。这篇文章的目标是:从零开始,搭建一个完整的 AI 对话应用,包含后端、前端、API 封装、流式输出、生产部署全流程。
最终效果:一个可以对话的 AI 聊天应用,支持流式打字输出,可以在浏览器中直接使用。
Step 1:项目初始化
目录结构
bash
ai-chat-app/
├── main.py # FastAPI 入口
├── llm.py # LLM API 封装
├── requirements.txt
├── .env # 配置文件(不提交 Git)
├── static/
│ └── index.html # 前端页面
└── .gitignore
安装依赖
bash
mkdir ai-chat-app && cd ai-chat-app
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
txt
# requirements.txt
fastapi==0.115.0
uvicorn[standard]==0.30.0
python-dotenv==1.0.0
openai==1.50.0
jinja2==3.1.4
python-multipart==0.0.12
bash
pip install -r requirements.txt
环境变量
bash
# .env
LLM_API_KEY=sk-your-api-key-here
LLM_BASE_URL=https://api.deepseek.com/v1
LLM_MODEL=deepseek-chat
SITE_NAME=AI Chat
Step 2:封装 LLM API
创建一个统一的 LLM 调用层,而不是在业务代码里直接写 API 调用。这样以后换模型只需改这一个文件。
python
# llm.py
import os
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
client = OpenAI(
api_key=os.getenv("LLM_API_KEY"),
base_url=os.getenv("LLM_BASE_URL"),
)
MODEL = os.getenv("LLM_MODEL", "deepseek-chat")
def chat_sync(messages, temperature=0.7, max_tokens=2048):
# 同步调用 LLM,返回完整响应。
response = client.chat.completions.create(
model=MODEL,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
)
return response.choices[0].message.content
def chat_stream(messages, temperature=0.7, max_tokens=2048):
# 流式调用 LLM,返回迭代器。
response = client.chat.completions.create(
model=MODEL,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
stream=True,
)
for chunk in response:
delta = chunk.choices[0].delta if chunk.choices else None
if delta and delta.content:
yield delta.content
def count_tokens(text):
# 估算 token 数(粗略,中英文混合按 1.5 字符/token)
return int(len(text) / 1.5)
为什么这样设计?
chat_sync:适合不需要实时的场景(如批量处理、后台任务)chat_stream:适合对话场景,用户体验更好(打字机效果)count_tokens:用来控制上下文长度,防止超出模型限制
Step 3:搭建 FastAPI 后端
python
# main.py
import os
import time
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from dotenv import load_dotenv
from llm import chat_sync, chat_stream, count_tokens
load_dotenv()
app = FastAPI(title=os.getenv("SITE_NAME", "AI Chat"))
# 挂载静态文件
app.mount("/static", StaticFiles(directory="static"), name="static")
# 会话历史存储(生产环境应该用 Redis)
sessions = {}
MAX_HISTORY_TOKENS = 4000 # 上下文窗口上限
def get_session(session_id: str) -> list:
# 获取或创建会话。
if session_id not in sessions:
sessions[session_id] = []
return sessions[session_id]
def trim_history(messages: list, max_tokens: int = MAX_HISTORY_TOKENS):
# 截断对话历史,保留最近的对话。
total = 0
trimmed = []
for msg in reversed(messages):
tokens = count_tokens(msg["content"])
if total + tokens > max_tokens:
break
total += tokens
trimmed.append(msg)
trimmed.reverse()
# 如果截断后第一条不是 system,补一个
if trimmed and trimmed[0]["role"] != "system":
trimmed.insert(0, {"role": "system", "content": "你是一个有用的 AI 助手。"})
return trimmed
@app.get("/", response_class=HTMLResponse)
async def index():
with open("static/index.html", "r", encoding="utf-8") as f:
return f.read()
@app.post("/api/chat")
async def chat(request: Request):
# 对话接口(非流式)。
body = await request.json()
session_id = body.get("session_id", "default")
user_message = body.get("message", "")
if not user_message.strip():
return JSONResponse({"error": "消息不能为空"}, status_code=400)
history = get_session(session_id)
history.append({"role": "user", "content": user_message})
# 截断上下文
messages = trim_history(history)
# 调用 LLM
reply = chat_sync(messages)
history.append({"role": "assistant", "content": reply})
return JSONResponse({"reply": reply})
@app.post("/api/chat/stream")
async def chat_stream_endpoint(request: Request):
# 对话接口(流式)。
body = await request.json()
session_id = body.get("session_id", "default")
user_message = body.get("message", "")
if not user_message.strip():
return JSONResponse({"error": "消息不能为空"}, status_code=400)
history = get_session(session_id)
history.append({"role": "user", "content": user_message})
messages = trim_history(history)
async def generate():
full_reply = ""
for chunk in chat_stream(messages):
full_reply += chunk
yield f"data: {chunk}
"
# 保存完整回复到历史
history.append({"role": "assistant", "content": full_reply})
yield f"data: [DONE]
"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # 禁用 Nginx 缓冲
},
)
@app.post("/api/clear")
async def clear_session(request: Request):
# 清空对话历史。
body = await request.json()
session_id = body.get("session_id", "default")
sessions[session_id] = []
return JSONResponse({"status": "ok"})
# 启动
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
关键设计说明
1. 上下文管理(trim_history)
这是 AI 应用最容易忽略的问题。模型有上下文长度限制(如 8K/32K/128K tokens),对话不断累积早晚会超限。
我们的策略:
- 按 token 数从后往前截断,保留最近的对话
- 确保 system prompt 始终在(被截掉了就补一个默认的)
- 留出余量,不等到真正超限才截断
2. 流式响应(Server-Sent Events)
用 SSE 而不是 WebSocket,因为:
- SSE 是单向的(服务端→客户端),正好符合"流式输出"的场景
- SSE 基于 HTTP,兼容性好,不会被防火墙拦截
- WebSocket 是双向的,适合需要客户端推送的场景(如实时协作)
3. 会话管理
这里用内存字典存储会话,生产环境应该换成 Redis:
- Redis 天然支持过期时间(TTL),可以自动清理长时间不活跃的会话
- 多进程/多实例时可以共享会话状态
Step 4:前端界面
html
<!-- static/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>AI Chat</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: #f5f5f5; min-height: 100vh; }
.container { max-width: 800px; margin: 0 auto; padding: 20px;
display: flex; flex-direction: column; height: 100vh; }
h1 { text-align: center; padding: 20px 0; font-size: 1.5rem; color: #333; }
.chat-box { flex: 1; background: white; border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
padding: 20px; overflow-y: auto; margin-bottom: 16px;
display: flex; flex-direction: column; gap: 16px; }
.message { max-width: 80%; padding: 12px 16px; border-radius: 12px;
line-height: 1.6; font-size: 14px; }
.user { background: #007aff; color: white; align-self: flex-end;
border-bottom-right-radius: 4px; }
.assistant { background: #f0f0f0; color: #333; align-self: flex-start;
border-bottom-left-radius: 4px; }
.assistant pre { background: #e8e8e8; padding: 12px; border-radius: 8px;
overflow-x: auto; margin: 8px 0; font-size: 13px; }
.assistant code { background: #e8e8e8; padding: 2px 6px;
border-radius: 4px; font-size: 13px; }
.loading { opacity: 0.6; }
.input-area { display: flex; gap: 8px; padding: 12px;
background: white; border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
#message-input { flex: 1; border: none; outline: none; padding: 8px;
font-size: 15px; resize: none; }
#send-btn { background: #007aff; color: white; border: none;
padding: 8px 20px; border-radius: 8px; cursor: pointer;
font-size: 15px; transition: background 0.2s; }
#send-btn:hover { background: #0056b3; }
#send-btn:disabled { background: #ccc; cursor: not-allowed; }
.header-bar { display: flex; justify-content: space-between;
align-items: center; }
#clear-btn { background: none; border: 1px solid #ddd;
padding: 6px 14px; border-radius: 6px; cursor: pointer;
font-size: 13px; color: #666; }
#clear-btn:hover { background: #f5f5f5; }
.typing-cursor::after { content: '|'; animation: blink 0.8s infinite; }
@keyframes blink { 50% { opacity: 0; } }
</style>
</head>
<body>
<div class="container">
<div class="header-bar">
<h1>AI Chat</h1>
<button id="clear-btn" onclick="clearChat()">清空对话</button>
</div>
<div class="chat-box" id="chat-box"></div>
<div class="input-area">
<textarea id="message-input" rows="1" placeholder="输入消息..."
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage()}"></textarea>
<button id="send-btn" onclick="sendMessage()">发送</button>
</div>
</div>
<script>
const sessionId = 'session_' + Date.now();
function addMessage(role, content) {
const box = document.getElementById('chat-box');
const div = document.createElement('div');
div.className = 'message ' + role;
div.textContent = content;
box.appendChild(div);
box.scrollTop = box.scrollHeight;
return div;
}
function updateMessage(el, content) {
el.textContent = content;
document.getElementById('chat-box').scrollTop =
document.getElementById('chat-box').scrollHeight;
}
async function sendMessage() {
const input = document.getElementById('message-input');
const btn = document.getElementById('send-btn');
const text = input.value.trim();
if (!text) return;
input.value = '';
addMessage('user', text);
btn.disabled = true;
// 添加占位消息
const assistantMsg = addMessage('assistant', '');
assistantMsg.classList.add('typing-cursor');
try {
const resp = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, message: text }),
});
const reader = resp.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('
');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') continue;
assistantMsg.textContent += data;
}
}
document.getElementById('chat-box').scrollTop =
document.getElementById('chat-box').scrollHeight;
}
assistantMsg.classList.remove('typing-cursor');
} catch (e) {
updateMessage(assistantMsg, '请求失败,请检查网络连接');
assistantMsg.classList.remove('typing-cursor');
}
btn.disabled = false;
input.focus();
}
async function clearChat() {
await fetch('/api/clear', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId }),
});
document.getElementById('chat-box').innerHTML = '';
document.getElementById('message-input').focus();
}
</script>
</body>
</html>
前端要点
- 流式渲染 :用
fetch+ReadableStream读取 SSE,每收到一个 chunk 就追加到页面,实现打字机效果 - 打字光标 :回复中用
typing-cursorCSS 类显示闪烁的光标,回复完成后移除 - 自动滚动:每次内容更新都滚动到底部
Step 5:运行与部署
本地运行
bash
python main.py
浏览器打开 http://localhost:8000,就可以看到对话界面了。
Docker 部署
dockerfile
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
bash
docker build -t ai-chat-app .
docker run -d -p 8000:8000 --env-file .env ai-chat-app
Nginx 反向代理
nginx
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 300s;
# SSE 需要禁用缓冲
proxy_buffering off;
chunked_transfer_encoding on;
}
}
常见问题
Q:流式输出不流畅怎么办?
排查步骤:
- 确认
X-Accel-Buffering: no头已设置(Nginx 默认会缓冲 SSE) - 确认
proxy_buffering off已配置 - 检查 LLM API 本身的响应速度(有些模型首 Token 延迟很高)
Q:API Key 安全吗?
.env 文件不要提交到 Git,用 .gitignore 忽略它。生产环境建议用环境变量或密钥管理服务(如 AWS Secrets Manager、Vault)。
Q:如何支持多模型?
在 llm.py 中扩展:
python
MODELS = {
"deepseek": OpenAI(api_key=..., base_url=...),
"openai": OpenAI(api_key=...),
"claude": Anthropic(api_key=...),
}
前端传入 model 参数,后端根据参数选择对应的客户端。
总结
今天我们完成了:
| 组件 | 说明 |
|---|---|
| LLM API 封装层 | 同步 + 流式接口,方便切换模型 |
| 后端 API | 对话、流式响应、上下文管理、会话控制 |
| 前端界面 | 流式渲染、打字机效果、对话体验 |
| 部署方案 | Docker + Nginx 反向代理 |
这个应用虽然简单,但包含了 AI 应用的核心架构。下一篇文章我们会在此基础上,深入 Prompt 工程------如何设计可靠的结构化 Prompt,让模型的输出更可控。
本文是 《AI 应用开发实战》系列 的第 1 篇。 系列目录:
- ✅ 从零搭建你的第一个 AI 应用 ← 你在这里
- 📝 Prompt 工程实战
- 📝 多轮对话------对话管理与记忆
- 📝 从零实现 RAG 系统
- 📝 AI Agent------工具调用与自主决策
本文发布于 Zyentor(智元界) ------ AI 开发者社区 原文链接:www.zyentor.com/news/3220