AI 应用开发实战(1):从零搭建你的第一个 AI 应用 —— FastAPI + LLM API 调用完整教程

前言

市面上 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>

前端要点

  1. 流式渲染 :用 fetch + ReadableStream 读取 SSE,每收到一个 chunk 就追加到页面,实现打字机效果
  2. 打字光标 :回复中用 typing-cursor CSS 类显示闪烁的光标,回复完成后移除
  3. 自动滚动:每次内容更新都滚动到底部

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:流式输出不流畅怎么办?

排查步骤:

  1. 确认 X-Accel-Buffering: no 头已设置(Nginx 默认会缓冲 SSE)
  2. 确认 proxy_buffering off 已配置
  3. 检查 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 篇。 系列目录:

  1. ✅ 从零搭建你的第一个 AI 应用 ← 你在这里
  2. 📝 Prompt 工程实战
  3. 📝 多轮对话------对话管理与记忆
  4. 📝 从零实现 RAG 系统
  5. 📝 AI Agent------工具调用与自主决策

本文发布于 Zyentor(智元界) ------ AI 开发者社区 原文链接:www.zyentor.com/news/3220

相关推荐
绕过江河错落1 小时前
深度拆解 Claude Code 系列(二):上下文窗口管理
agent
把你拉进白名单1 小时前
4.OpenClaw源码解析_路由
llm·agent
Coder小相1 小时前
LangChain 1.0 第七篇 - Pydantic结构化输出
人工智能·agent·ai编程
沉默王二1 小时前
比 DeepSeek 便宜 24 倍,SkyClaw v1.0 值得用吗?
agent·ai编程
Coder小相1 小时前
LangChain 1.0 第六篇 - 从Prompt模板到角色设计
人工智能·agent·ai编程
星之尘10212 小时前
Claude Code 安装与 MiniMax 配置指南
ai·agent·claude·minimax·vibe coding
DO_Community2 小时前
Claude Code 的开源替代方案:用 OpenCode + DigitalOcean 实现模型自由
人工智能·开源·agent·claude·deepseek
Artech2 小时前
[MAF预定义ChatClient中间件-01]LoggingChatClient——在调用前后输出日志
ai·logging·agent·maf·ichatclient
孟华苏3 小时前
AI Agent 编排框架对比:LangChain vs LangGraph vs Spring AI Alibaba Graph
java·人工智能·python·agent