概述

时序图(豆包生成):

html页面展示:

在 AI 应用爆发的当下,搭建一个兼具实用性与可扩展性的智能聊天系统,成为许多开发者的实践目标。本文将以一个完整的全栈聊天项目为例,从后端架构设计、工具层实现到前端交互开发,分步骤拆解系统的核心实现逻辑,并通过流程图直观呈现各模块的协作关系,帮助开发者快速掌握全栈 AI 应用的构建思路。

一、项目整体架构:模块化设计的 “分层协作” 模式

项目的核心文件结构如下,各文件对应不同层级的功能:

  • 前端层index.html(负责用户交互界面与前端逻辑)

  • 后端入口层run.py(Flask 应用初始化与配置)

  • API 接口层api.py(聊天接口实现与请求处理)

  • 工具核心层tools.py(LLM 模型、搜索工具与 Agent 构建)

  • 配置层config.yaml(集中管理模型参数与第三方密钥)

简单说,用户在前端输入消息后,请求经后端接口层传递到工具层,由 Agent 调用模型或搜索工具生成回复,再经接口层返回前端展示。

各层可独立开发(前端无需关注后端模型细节,后端无需修改前端样式)

二、后端实现

后端是系统的 “能力中枢”,负责接收前端请求、调用 AI 模型并返回结果。

应用入口(run.py):Flask 应用的 “启动器”

run.py是项目的启动入口,主要完成 Flask 应用的初始化、蓝图注册与运行配置,很简单的几行代码:

from flask import Flask
from api import bp   

app = Flask(__name__,static_folder='.', static_url_path='')
app.register_blueprint(bp, url_prefix="/api")   

if __name__ == "__main__":
    app.run(host="0.0.0.0",port=5000,debug= True)

API 接口层(api.py):请求处理的 “中转站”

api.py通过 Flask 蓝图实现聊天接口,负责接收前端请求、校验参数、调用 Agent 并返回结果,核心流程如下:

步骤 1:创建蓝图与初始化 Agent

先定义蓝图bp(名称为 “chat”),再通过tools.build_agent()得到一个实例化的 agetn:

from flask import Blueprint, request, jsonify
from tools import build_agent

bp = Blueprint("chat", __name__)
agent = build_agent()  # 初始化Agent

步骤 2:定义聊天接口(/chat)

然后是接口定义,支持POST方式请求

@bp.route("/chat", methods=["POST"])

接口逻辑分为 “参数校验”“Agent 调用”“结果返回” 三部分:

参数校验:通过request.get_json()获取前端传入的 JSON 数据,提取message字段,若为空则返回 400 错误:

    """
    请求体 JSON:
    {
      "message": "用户问题"
    }
    返回:
    {
      "response": "...",
      "success": true
    }
    """
    data = request.get_json(silent=True) or {}
    user_msg = data.get("message", "").strip()
    if not user_msg:
        return jsonify({"error": "message 不能为空", "success": False}), 400

调用 Agent 获取回复:通过agent.run(user_msg)调用 Agent 处理用户问题,获取 AI 回复;

异常处理与结果返回:使用try-except捕获异常(如模型调用失败),正常情况返回response(AI 回复),异常情况返回错误信息与 500 状态码:

try:
    answer = agent.run(user_msg)
    return jsonify({"response": answer, "success": True})
except Exception as e:
    return jsonify({"error": str(e), "success": False}), 500

工具层与配置层

config.yaml集中管理模型与工具的参数,tools.py则基于这些配置构建 LLM 模型、搜索工具与智能 Agent。

配置层(config.yaml)

本地大模型配置(Ollama/LM Studio)

支持两种本地部署工具(Ollama 与 LM Studio),配置项包括服务地址(base_url)、模型名称(model)、生成参数(temperature控制随机性,max_tokens控制回复长度):

ollama:
  base_url: ""  # Ollama服务地址
  model: ""  # 加载的模型
  temperature: 0.7  # 随机性(0-1)
  max_tokens: 2048  # 最大回复长度

第三方大模型配置(Kimi)

若需使用外部 API(如 Kimi),则配置其接口地址(base_url)、API 密钥(api_key)与模型名称(model_name),现在很多商家都是兼容openai格式的:

kimi:
  base_url: "https://api.moonshot.cn/v1"
  api_key: ""
  model_name: ""
搜索工具配置(Tavily)

配置 Tavily 搜索的 API 密钥(api_key)与最大搜索结果数(max_results),用于 Agent 联网获取实时信息:

tavily:
  api_key: ""
  max_results: 3  # 每次搜索返回3条结果

工具层(tools.py)

构建 LLM 模型(支持多类型模型)

通过build_llm()函数,根据PRIMARY变量(可切换为ollama/kimi/lmstudio)选择模型类型,读取对应配置并初始化模型:

def build_llm():
    if PRIMARY == "ollama":
        return ChatOllama(
            base_url=cfg["ollama"]["base_url"],
            model=cfg["ollama"]["model"],
            temperature=cfg["ollama"]["temperature"],
            max_tokens=cfg["ollama"]["max_tokens"]
        )
    elif PRIMARY == "kimi":
        kimi_models = OpenAI(
            openai_api_base=cfg["kimi"]["base_url"],
            openai_api_key=cfg["kimi"]["api_key"],
            model_name=cfg["kimi"]["model_name"],
            temperature=cfg["kimi"]["temperature"],
            max_tokens=cfg["kimi"]["max_tokens"]
        )
        return kimi_models
    else:   # lmstudio
        return OpenAI(
            openai_api_base=cfg["lmstudio"]["base_url"],
            openai_api_key="lm-studio",
            model_name=cfg["lmstudio"]["model"],
            temperature=cfg["lmstudio"]["temperature"],
            max_tokens=cfg["lmstudio"]["max_tokens"]
        )
构建 Agent(串联模型与工具)

Agent 是 “决策者”—— 它能根据用户问题判断是否需要调用工具(如搜索),再结合模型生成最终回复。通过initialize_agent()函数初始化 Agent,传入 “工具列表”“LLM 模型”“Agent 类型” 等参数:

def build_agent():
    search_tool = build_search_tool()
    llm = build_llm()
    tools = [
        Tool(
            name="TavilySearch",
            func=search_tool.run,
            description="当问题涉及2023年之后的新闻、数据、事件动态(如游戏发布日期、政策变化、科技进展等),需要联网搜索获取最新信息时使用。"
        )
    ]
    agent = initialize_agent(
        tools=tools,
        llm=llm,
        agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
        verbose=True,
        handle_parsing_errors=True
    )
    return agent

四、前端实现(豆包)

页面采用 “顶部操作栏 + 聊天容器 + 输入区” 的经典布局

就直接放下面了

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8" />
    <title>one</title>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <!-- 外部样式引入 -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
    <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
    <style>
        :root {
            /* 浅色主题变量 */
            --bg-chat: #ffffff;
            --bg-user: #e3f2fd;
            --bg-agent: #e8f5e9;
            --text-user: #0d47a1;
            --text-agent: #1b5e20;
            --bg-chat-box: #f8f9fa;
            --text-timestamp: #888;
        }
        [data-theme="dark"] {
            /* 深色主题变量 */
            --bg-chat: #1e1e1e;
            --bg-user: #0d47a1;
            --bg-agent: #2e7d32;
            --text-user: #fff;
            --text-agent: #fff;
            --bg-chat-box: #2d2d2d;
            --text-timestamp: #ccc;
        }
        body {
            background: var(--bg-chat);
            transition: background 0.3s;
        }
        #chatBox {
            height: 75vh;
            overflow-y: auto;
            scroll-behavior: smooth;
            background: var(--bg-chat-box);
            transition: background 0.3s;
        }
        .msg {
            max-width: 70%;
            word-break: break-word;
            animation: fadeIn 0.3s;
            transition: background 0.3s, color 0.3s;
        }
        @keyframes fadeIn {
            from {opacity: 0; transform: translateY(10px);}
            to {opacity: 1; transform: none;}
        }
        .avatar {
            width: 36px;
            height: 36px;
            border-radius: 50%;
            object-fit: cover;
        }
        .timestamp {
            font-size: 0.7rem;
            color: var(--text-timestamp);
            transition: color 0.3s;
        }
        .type-cursor::after {
            content: "▋";
            animation: blink 1s infinite;
        }
        @keyframes blink {
            50% { opacity: 0; }
        }
        /* 新增:统一头像与气泡的间距样式 */
        .avatar-gap {
            margin: 0 8px; /* 左右间距8px,与AI保持一致 */
        }
    </style>
</head>
<body class="container-fluid p-3">
    <!-- 顶部操作栏 -->
    <div class="d-flex justify-content-between align-items-center mb-3">
        <h4 class="mb-0">ONE</h4>
        <div>
            <button class="btn btn-sm btn-outline-secondary me-2" onclick="toggleTheme()">
                <i id="themeIcon" class="fas fa-moon"></i>
            </button>
            <button class="btn btn-sm btn-outline-success me-2" onclick="exportChat()">
                <i class="fas fa-download"></i>
            </button>
            <button class="btn btn-sm btn-outline-danger" onclick="clearChat()">
                <i class="fas fa-trash"></i>
            </button>
        </div>
    </div>

    <!-- 聊天记录容器 -->
    <div id="chatBox" class="border rounded p-3" data-theme="light"></div>

    <!-- 消息输入区 -->
    <div class="input-group mt-3">
        <input id="msgInput" class="form-control" placeholder="请输入问题…" />
        <button id="sendBtn" class="btn btn-primary">
            <i class="fas fa-paper-plane"></i> 发送
        </button>
    </div>

<script>
// ---------- 主题切换功能 ----------
function toggleTheme() {
    const html = document.documentElement;
    const isDark = html.getAttribute('data-theme') === 'dark';
    html.setAttribute('data-theme', isDark ? 'light' : 'dark');
    document.getElementById('themeIcon').className = isDark ? 'fas fa-moon' : 'fas fa-sun';
    updateAllBubbleTheme();
}

function updateAllBubbleTheme() {
    const chatBox = document.getElementById('chatBox');
    const userBubbles = chatBox.querySelectorAll('.msg[data-role="user"]');
    const agentBubbles = chatBox.querySelectorAll('.msg[data-role="agent"]');

    userBubbles.forEach(bubble => {
        bubble.style.background = getComputedStyle(document.documentElement).getPropertyValue('--bg-user');
        bubble.style.color = getComputedStyle(document.documentElement).getPropertyValue('--text-user');
    });
    agentBubbles.forEach(bubble => {
        bubble.style.background = getComputedStyle(document.documentElement).getPropertyValue('--bg-agent');
        bubble.style.color = getComputedStyle(document.documentElement).getPropertyValue('--text-agent');
    });
}

// ---------- 聊天记录本地存储功能 ----------
const STORAGE_KEY = 'chat_history';

function loadHistory() {
    const history = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
    history.forEach(item => renderBubble(item));
    updateAllBubbleTheme();
}

function saveBubble(role, text) {
    const history = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
    history.push({
        role: role,
        text: text,
        ts: Date.now()
    });
    localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
}

function clearChat() {
    localStorage.removeItem(STORAGE_KEY);
    document.getElementById('chatBox').innerHTML = '';
}

function exportChat() {
    const history = localStorage.getItem(STORAGE_KEY) || '[]';
    const blob = new Blob([history], { type: 'application/json; charset=utf-8' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `chat_history_${new Date().toLocaleDateString()}.json`;
    a.click();
    URL.revokeObjectURL(url);
}

// ---------- 消息渲染功能(核心:调整头像间距) ----------
function renderBubble({ role, text, ts }, animate = false) {
    const chatBox = document.getElementById('chatBox');
    const msgContainer = document.createElement('div');
    // 角色对齐:用户靠右,AI靠左
    msgContainer.className = `d-flex ${role === 'user' ? 'justify-content-end' : 'justify-content-start'} mb-3`;
    const timeStr = new Date(ts).toLocaleTimeString();

    // 头像位置与间距逻辑:
    // - 统一使用 avatar-gap 类控制间距(8px)
    // - 通过 order 控制显示顺序,实现用户头像在右、AI头像在左
    let avatarHtml = '';
    let bubbleWrapperClass = '';
    if (role === 'user') {
        // 用户消息:头像在气泡右侧,间距通过 avatar-gap 控制
        avatarHtml = `<img class="avatar avatar-gap order-1" src="/static/img/white_dog.png" alt="用户头像">`;
        bubbleWrapperClass = 'order-0'; // 气泡排在头像前面(视觉上头像在右)
    } else {
        // AI消息:头像在气泡左侧,间距通过 avatar-gap 控制
        avatarHtml = `<img class="avatar avatar-gap order-0" src="/static/img/black_dog.png" alt="AI头像">`;
        bubbleWrapperClass = 'order-1'; // 气泡排在头像后面(视觉上头像在左)
    }

    msgContainer.innerHTML = `
        <div class="d-flex align-items-start">
            ${avatarHtml}
            <div class="${bubbleWrapperClass}">
                <div class="msg p-2 rounded"
                     data-role="${role}"
                     style="background: var(--bg-${role}); color: var(--text-${role});">
                    ${animate ? `<span class="type-cursor">${text}</span>` : text}
                    <div class="timestamp mt-1 text-end">${timeStr}</div>
                </div>
            </div>
        </div>
    `;

    chatBox.appendChild(msgContainer);
    chatBox.scrollTop = chatBox.scrollHeight;
}

// ---------- 打字机效果功能 ----------
async function typeText(element, text) {
    element.textContent = '';
    for (let i = 0; i < text.length; i++) {
        element.textContent = text.slice(0, i + 1);
        await new Promise(resolve => setTimeout(resolve, 25));
    }
    element.classList.remove('type-cursor');
}

// ---------- 发送消息功能 ----------
async function sendMessage() {
    const input = document.getElementById('msgInput');
    const userText = input.value.trim();

    if (!userText) return;

    renderBubble({ role: 'user', text: userText, ts: Date.now() });
    saveBubble('user', userText);
    input.value = '';

    const loadingTs = Date.now();
    renderBubble({ role: 'agent', text: '▍思考中…', ts: loadingTs }, true);
    const aiMsgElement = document.querySelector('#chatBox .msg:last-child span');

    try {
        const response = await fetch('/api/chat', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ message: userText })
        });
        const result = await response.json();
        const aiReply = result.response || result.error || '抱歉,暂时无法获取回复';

        await typeText(aiMsgElement, aiReply);
        saveBubble('agent', aiReply);

    } catch (error) {
        aiMsgElement.textContent = `网络错误:${error.message}`;
        aiMsgElement.classList.remove('type-cursor');
        saveBubble('agent', `网络错误:${error.message}`);
    }
}

// ---------- 事件绑定 ----------
document.getElementById('sendBtn').addEventListener('click', sendMessage);
document.getElementById('msgInput').addEventListener('keydown', (e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
        e.preventDefault();
        sendMessage();
    }
});

// ---------- 页面初始化 ----------
window.onload = function() {
    loadHistory();
};
</script>
</body>
</html>

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐