ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

AI对话网站一键生成系统源码:从模型网关到Docker部署的完整实现

2026/9/11 22:35:58 拓冰建站 浏览量
AI对话网站一键生成系统源码:从模型网关到Docker部署的完整实现 简介这是一款基于PHP开发的AI对话网站一键生成系统源码面向有一定建站能力、希望快速部署轻量AI聊天页面或将其嵌入个人博客实现引流的用户。系统采用新拟态界面风格支持自定义网站名称、AI默认开场白、头像昵称也可以绑定站长自己的推广链接所有生成的网页都会完整保存到服务器便于长期维护与二次开发。整套源码打包为zip压缩包仅7KB共6个文件核心包含一个PHP主程序、一个CSS样式文件以及TXT格式的搭建说明和URL快捷链接结构精炼。搭建时只需按文档提示将PHP主程序index.php第112行的预设文字替换为自己的域名或网站目录即可完成部署适合有PHP基础的开发者快速上手也是学习AI对话接口整合的不错开源样本。目前已有176人学习或下载无论是作为个人工具箱里的实用组件还是作为搭建轻量引流站的现成方案这套源码都有不错的参考和实用价值。1. AI对话网站一键生成系统源码把“对话能力”和“网站外壳”拆开做“AI对话网站一键生成”里的“一键”指的不该只是点一个按钮弹出一个网页而是把交付链路压缩成三步填写一份站点配置、执行一条生成命令、用 Docker Compose 把站点拉起来。这套系统源码真正要解决的是批量交付与统一收口的成本问题不是做一个演示用聊天页面。实现的关键在于把对话能力和网站外壳拆成两层对话能力接统一模型网关网站外壳由配置数据驱动渲染。模型商更换、欢迎语修改、主题色调整都只改配置不动代码。面向的人群很明确接单做企业站点的独立开发者、需要集中管理多个对话站的平台团队、以及做产品验证时想快速拿到真实对话界面的技术负责人。下文按服务端对话接口、配置渲染、部署自动化、生产验证四段展开每一段都是可落地的代码单元。2. 服务端最小闭环AI对话接口的流式实现与参数取舍2.1 为什么先做模型网关这层抽象常见误区是在业务代码里直接调模型厂商 SDK密钥散落在路由层和工具类里。模型一换改动涉及整条链路新增站点时还得复制一套服务。实际做法是在服务端入口统一封装一个模型网关所有站点请求都走同一个接口站点配置里只保留模型标识、接口地址和密钥三项选择。国内主流模型服务DeepSeek、通义千问、智谱AI、Kimi 等基本都提供 OpenAI 兼容的/v1/chat/completions接口差别仅在base_url、api_key和模型名。把这三项提升为系统源码里的配置项服务端就不用再针对某一家做定制接口层、部署层、前端渲染层全部保持同一个协议。这里要注意模型切换的容错逻辑不要放在对话接口里否则每次请求都多走一遍降级分支出了问题很难定位。模型层面的 failover 放到任务级调度去处理更合适。2.2 FastAPI 流式对话接口从请求到 SSE 逐块写出服务端选 FastAPI 是因为它原生支持 async流式输出时不会阻塞事件循环同时自带 OpenAPI 文档生成新站点后直接在/docs里模拟请求排查配置问题效率高。下面这个chat_core.py是最小可用的模型网关实现。# chat_core.py import os from typing import AsyncGenerator from openai import AsyncOpenAI class ChatService: 统一的模型网关管理模型服务商连接对外提供流式对话能力 def __init__(self): self.client AsyncOpenAI( base_urlos.getenv(LLM_BASE_URL, https://api.deepseek.com/v1), api_keyos.getenv(LLM_API_KEY, ), timeoutfloat(os.getenv(LLM_TIMEOUT_SEC, 60)), max_retriesint(os.getenv(LLM_MAX_RETRIES, 2)), ) async def stream_chat( self, messages: list[dict], model: str deepseek-chat, temperature: float 0.7, max_tokens: int 2048, ) - AsyncGenerator[str, None]: stream await self.client.chat.completions.create( modelmodel, messagesmessages, streamTrue, temperaturetemperature, max_tokensmax_tokens, ) async for chunk in stream: if not chunk.choices: continue delta chunk.choices[0].delta content getattr(delta, content, None) if content: yield content这里展开说三个参数。LLM_BASE_URL必须以/v1结尾不同服务商的兼容模式路径有差异例如 DeepSeek 用https://api.deepseek.com/v1通义千问的兼容模式是https://dashscope.aliyuncs.com/compatible-mode/v1。LLM_TIMEOUT_SEC建议 60 秒不要低于 30否则长思考链模型在生成中间结果时容易由超时引发重试重试又会重复计费。max_retries控制客户端侧自动重试系统源码里设置 2 次就够值过大会在网络抖动时放大上游压力。接下来写 Web 层接口把生成器包装成 SSE 流。# main.py import json from fastapi import FastAPI, Request from fastapi.responses import StreamingResponse from chat_core import ChatService app FastAPI() service ChatService() app.post(/api/chat) async def chat(request: Request): body await request.json() messages body.get(messages) if not messages: return {detail: messages 不能为空} model body.get(model) or deepseek-chat temperature float(body.get(temperature, 0.7)) max_tokens int(body.get(max_tokens, 2048)) async def event_source(): try: async for text in service.stream_chat( messagesmessages, modelmodel, temperaturetemperature, max_tokensmax_tokens, ): payload json.dumps({content: text}, ensure_asciiFalse) yield fdata: {payload}\n\n except Exception as exc: payload json.dumps({error: str(exc)}, ensure_asciiFalse) yield fdata: {payload}\n\n yield data: [DONE]\n\n return StreamingResponse( event_source(), media_typetext/event-stream, headers{ Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no, }, ) app.get(/health) async def health(): return {status: ok}参数上注意两个点。X-Accel-Buffering: no是给 Nginx 看的响应头如果部署层没关缓冲SSE 数据会在 Nginx 攒包前端看到的就不是打字机效果而是一次性整段出现。ensure_asciiFalse保证 JSON 里的中文明文输出到 SSE 帧虽然前端 JSON.parse 也能消化\uXXXX但抓日志时明文可读性能省很多时间。2.3 SSE 响应头、超时与客户端断连的三个易错点第一处易错是响应头里的Connection: keep-alive在某些网关层被改写。逆向代理层如果强制Connection: closeSSE 长连接会被频繁断开这种情况下要在负载均衡配置级别显式设置。第二处是后端的读取超时和整体的超时对齐FastAPI 这里StreamingResponse本身没有超时限制超时由底层客户端控制。第三处是客户端断连后生成器仍然继续在跑。手机浏览器切后台、页面刷新都会中断 fetch但服务端可能并不知道连接已死模型输出仍然在消耗 token。处理方式是在生成器内部监听request.is_disconnected()。app.post(/api/chat) async def chat(request: Request): body await request.json() messages body.get(messages) async def event_source(): async for text in service.stream_chat(messagesmessages): if await request.is_disconnected(): break yield fdata: {json.dumps({content: text}, ensure_asciiFalse)}\n\n yield data: [DONE]\n\n return StreamingResponse(event_source(), media_typetext/event-stream)这个检查要在每次 yield 前执行能及时中止模型回调。它不解决全部问题模型 API 内部的异步流已经拉起来的部分会继续走完但至少 HTTP 层不会再向死连接写数据。3. 站点配置模型与前端渲染一键生成的“前端半场”3.1 站点配置 JSON把对话站的“可变量”全部收进来一键生成的思路是让稳定性高的事情进代码让变动频繁的事情进配置。一个对话网站的变动点集中在站点名、域名、主题色、模型参数、提示词、历史记录开关这几处。下面这份 JSON 是最小字段集也是生成脚本的输入标准。{ site_name: 内部知识问答助手, domain: kb.example.com, public_port: 8080, logo_text: KBAI, theme_color: #2563eb, welcome_message: 你好我是内部知识库助手, input_placeholder: 输入问题例如出差报销流程是什么, model: deepseek-chat, base_url: https://api.deepseek.com/v1, api_key: sk-xxxx, system_prompt: 你是公司内部知识库客服回答要简洁不确定时说明不知道, temperature: 0.3, max_tokens: 1024, enable_history: true, history_limit: 10, fixed_questions: [如何申请调休, 如何打印发票] }字段与类型映射在下表这几个字段包含“不需要登录、打开即用”和“不限制多轮对话”的产品需求分别落在enable_history和history_limit上。字段名类型作用是否必填site_namestring浏览器标题、页面头部名称必填domainstringNginx server_name 来源必填public_portint宿主机对外端口默认 80可选modelstring模型标识例如 deepseek-chat必填base_urlstring模型网关的 OpenAI 兼容地址必填api_keystring模型服务密钥必填system_promptstring预设人格、知识边界、回答风格建议填temperaturefloat抽样随机度知识库问答压到 0.3 以下可选max_tokensint单次回复最大输出 token可选enable_historybool是否开启多轮上下文可选history_limitint携带的历史消息条数可选fixed_questionsarray首屏快捷提问按钮可选配置里的base_url和api_key会让 JSON 变成敏感文件生成完站点后脚本默认把它写入.env而非直接嵌进 Nginx 直出的目录密钥不落页面目录。system_prompt是灵活度最高的字段改它等于换一个 AI Agent 场景同一套源码既能做客服 Agent也能做单据审核 Agent。3.2 Jinja2 模板渲染对话页面不做前端打包的理由一次生成几十个站点时每个站点用 Vue 或 React 打包一遍的成本不低构建时间会随站点数量线性增长。而 AI 对话站的页面形态非常固定一个标题、一段欢迎语、一个对话列表、一个输入框。所以这里选择用 Jinja2 把配置直接渲染进 HTML 模板共享的前端逻辑抽成独立静态 JS 文件不需要构建步骤。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title{{ site.site_name }}/title style :root { --primary: {{ site.theme_color }}; } body { font-family: -apple-system, PingFang SC, Microsoft YaHei, sans-serif; margin: 0; background: #f7f8fa; display: flex; flex-direction: column; height: 100vh; } header { padding: 14px 20px; background: #fff; border-bottom: 1px solid #eee; display: flex; align-items: center; gap: 8px; } #chat-panel { flex: 1; overflow-y: auto; padding: 20px; } footer { display: flex; padding: 16px; background: #fff; border-top: 1px solid #eee; } #input-box { flex: 1; border: 1px solid #ddd; border-radius: 8px; padding: 10px 12px; font-size: 14px; } #send-btn { background: var(--primary); border: none; color: #fff; border-radius: 8px; margin-left: 10px; padding: 0 18px; cursor: pointer; } .msg { margin-bottom: 14px; } .msg .role { font-size: 12px; color: #888; margin-bottom: 4px; } .msg .bubble { background: #fff; border-radius: 8px; padding: 10px 14px; line-height: 1.7; white-space: pre-wrap; word-break: break-word; } .msg.user .bubble { background: var(--primary); color: #fff; } /style /head body header span classlogo{{ site.logo_text }}/span span{{ site.site_name }}/span /header main idchat-panel/main footer input idinput-box placeholder{{ site.input_placeholder }} / button idsend-btn发送/button /footer script window.SITE_CONFIG {{ site_config | tojson }}; /script script src/static/app.js/script /body /html模板里{{ site.site_name }}渲染站点名称{{ site.theme_color }}注入 CSS 变量页面主题色的调整只改配置不碰样式。最后一行{{ site_config | tojson }}把整个配置对象转成 JSON 注入页面的window.SITE_CONFIG前端 JS 从这里读取模型参数和系统提示词。这里不需要额外做 XSS 转义Jinja2 默认对 HTML 内容做了 escapetojson会处理 JSON 字符串里的引号和特殊字符。3.3 fetch 流式读取 SSE前端逐字拼装与历史截断前端不采用EventSource对象因为原生 EventSource 只支持 GET 请求对话消息体放不进 GET URL而且自定义请求头也受限制。生产里更常见的是用 fetch 配合ReadableStream读 SSE身体可以直接塞 POST JSON。// static/app.js const history []; function addMessage(role, text) { const panel document.getElementById(chat-panel); const row document.createElement(div); row.className msg role; row.innerHTML div classrole (role user ? 我 : AI) /divdiv classbubble/div; row.querySelector(.bubble).textContent text || ; panel.appendChild(row); panel.scrollTop panel.scrollHeight; return row.querySelector(.bubble); } async function sendMessage() { const input document.getElementById(input-box); const text input.value.trim(); if (!text) return; input.value ; history.push({ role: user, content: text }); addMessage(user, text); const messages window.SITE_CONFIG.enable_history ? history.slice(-(window.SITE_CONFIG.history_limit || 10)) : [{ role: user, content: text }]; const resp await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ model: window.SITE_CONFIG.model, messages: messages, temperature: window.SITE_CONFIG.temperature || 0.7, max_tokens: window.SITE_CONFIG.max_tokens || 2048, }), }); const bubble addMessage(assistant, ); const reader resp.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const frames buffer.split(\n\n); buffer frames.pop(); for (const frame of frames) { handleFrame(frame, bubble); } } } function handleFrame(frame, bubble) { if (!frame.startsWith(data:)) return; const data frame.slice(5).trim(); if (data [DONE]) return; try { const obj JSON.parse(data); if (obj.content) { bubble.textContent obj.content; const panel document.getElementById(chat-panel); panel.scrollTop panel.scrollHeight; } } catch (e) { console.error(帧解析失败, frame, e); } } document.getElementById(send-btn).addEventListener(click, sendMessage); document.getElementById(input-box).addEventListener(keydown, (e) { if (e.key Enter) sendMessage(); });这里的history.slice(-(window.SITE_CONFIG.history_limit || 10))是在前端先做一轮消息截断只把最近 10 条传给后端避免多轮对话后请求体无限制膨胀。buffer.split(\n\n)按 SSE 的帧分隔符切分数据余下半个包留在 buffer 里等下一轮读入这是流式解析里最常见的边界处理。bubble.textContent obj.content采用追加文本节点的写法避免使用 innerHTML防止模型输出的特殊字符被当成 HTML 解析。4. 部署链路自动化一键生成 Docker 编排与 Nginx 站点配置4.1 生成器主脚本配置校验、模板渲染、目录落盘一键生成脚本的输入是第 3 章的配置 JSON输出是一个可以直接部署的目录。命令行调用形式如下./sites/kb.json是站点配置./out/kb是生成目录。python3 generate_site.py ./sites/kb.json ./out/kb find ./out/kb -type f输出目录结构固定为四块Nginx 站点配置、静态 HTML、共享前端脚本、Docker 编排文件。out/kb/ ├── docker-compose.yml ├── nginx/ │ └── kb.example.com.conf └── html/ ├── index.html └── static/ └── app.js生成器主脚本负责四件事校验必填项、渲染 HTML、拷贝静态文件、生成 Nginx 与 Docker Compose。# generate_site.py import argparse import json import shutil from pathlib import Path from jinja2 import Environment, FileSystemLoader, select_autoescape REQUIRED_FIELDS (site_name, domain, model, base_url, api_key) def validate(config: dict) - list: errors [] for field in REQUIRED_FIELDS: if not str(config.get(field, )).strip(): errors.append(f缺少必填字段: {field}) temperature config.get(temperature) if temperature is not None and ( not isinstance(temperature, (int, float)) or not (0 temperature 2) ): errors.append(temperature 取值应在 0 到 2 之间) return errors def render_all(env: Environment, config: dict, out_dir: Path): out_dir.mkdir(parentsTrue, exist_okTrue) html_template env.get_template(index.html.j2) html html_template.render(siteconfig, site_configconfig) (out_dir / html / index.html).write_text(html, encodingutf-8) static_src Path(static) if static_src.exists(): shutil.copytree(static_src, out_dir / html / static, dirs_exist_okTrue) nginx_template env.get_template(nginx.conf.j2) nginx_conf nginx_template.render(siteconfig) (out_dir / nginx / f{config[domain]}.conf).write_text(nginx_conf, encodingutf-8) compose_template env.get_template(docker-compose.yml.j2) compose_yml compose_template.render(siteconfig) (out_dir / docker-compose.yml).write_text(compose_yml, encodingutf-8) def main(): parser argparse.ArgumentParser(descriptionAI对话网站一键生成) parser.add_argument(config, help站点配置文件 JSON 路径) parser.add_argument(out, help输出目录路径) args parser.parse_args() config json.loads(Path(args.config).read_text(encodingutf-8)) errors validate(config) if errors: for err in errors: print(f[校验失败] {err}) raise SystemExit(1) env Environment( loaderFileSystemLoader(templates), autoescapeselect_autoescape([html, j2]), ) render_all(env, config, Path(args.out)) print(f[生成完成] 输出目录: {args.out}) if __name__ __main__: main()校验逻辑在生成前拦截常见错误缺少必填字段直接中断temperature 超出合理范围给出失败提示。目录落盘时先建html再建nginx保证 Nginx 模板引用root目录时路径一定存在。脚本里把static目录完整复制一份而不是软链接这样每个站点目录都是自洽的迁移时只要打包out/kb这一个目录。4.2 Docker Compose 模板与服务编排Docker Compose 用两个服务把前端和后端拆开backend 运行 FastAPI 模型网关frontend 运行 Nginx 提供静态页面并反向代理/api到 backend。下面这份是生成器使用的docker-compose.yml.j2模板。services: backend: build: context: ./api env_file: - .env environment: LLM_BASE_URL: {{ site.base_url }} LLM_API_KEY: {{ site.api_key }} restart: always networks: - ai-site frontend: image: nginx:stable-alpine volumes: - ./html:/usr/share/nginx/html:ro - ./nginx:/etc/nginx/conf.d:ro ports: - {{ site.public_port | default(80) }}:80 depends_on: - backend restart: always networks: - ai-site networks: ai-site: driver: bridge两个服务在同一个ai-site网络内backend 没有对宿主机映射端口只有 frontend 暴露public_port避免模型网关被外部直接访问。env_file引用站点根目录下的.env文件这个文件由生成脚本写入内容包含LLM_BASE_URL和LLM_API_KEY。restart: always保证容器退出后被 Docker 拉起对话服务不能接受启动后挂掉的单次失败。注意生成的.env和docker-compose.yml会明文保留 api_key。这套设计适合内网演示和私有化交付如果站点要上公网生产环境建议改用 Docker Secret 或云厂商的密钥管理服务。4.3 Nginx 站点配置模板与生产参数说明Nginx 配置模板直接由生成器渲染每个站点一个独立 conf 文件。server { listen 80; server_name {{ site.domain }}; root /usr/share/nginx/html; index index.html; gzip on; gzip_types text/plain text/css application/json application/javascript; gzip_min_length 512; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend:8000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_buffering off; proxy_cache off; proxy_read_timeout 120s; proxy_send_timeout 120s; } }proxy_buffering off的作用是禁止 Nginx 在后端响应时攒包。前文提到的X-Accel-Buffering: no是后端主动告知 Nginx 当前响应不要缓冲这里是反向代理侧主动关掉。两处同时配置SSE 流式输出才能顺畅到达浏览器。proxy_read_timeout 120s与后端LLM_TIMEOUT_SEC的 60 秒之间留了一倍余量防止极端情况下 Nginx 先于模型服务断开连接。proxy_cache off禁用代理缓存避免流式响应被缓存后二次访问拿到旧内容。部署时进入生成目录执行cd out/kb docker compose up -d --build docker compose logs -f backend --tail50up -d后台拉起logs -f跟随后端日志。如果服务器上是旧的docker-compose独立版把命令换成docker-compose up -d --build即可二者差在一个横杠Generators 生成的 compose 文件两版都兼容。5. 生成结果验证与多轮上下文控制的落地顺序5.1 用一条 curl 验证新站点对话通路生成完成后先验证后端服务是否是活的再验证站点页面是否被 Nginx 正确伺服。第一件事用 curl 直接打后端接口观察 SSE 流式响应。curl -N http://127.0.0.1:8080/api/chat \ -H Content-Type: application/json \ -d {messages:[{role:user,content:你好}],model:deepseek-chat}-N参数关闭 curl 缓冲流式数据到达时立刻在终端打印。如果返回内容以data:开头逐条输出并以[DONE]结束说明模型网关和网络链路是通的。然后访问http://127.0.0.1:8080/页面上应该出现配置的站点名、欢迎语、快捷提问按钮。如果页面能开但接口 502查看 Nginx 日志定位是proxy_pass地址解析失败还是 backend 容器没起来。5.2 多轮上下文压缩阈值与短连接保护上线后多轮对话的另一个问题是上下文长度不受控。系统源码里需要做一层服务端截断按消息条数和粗略 token 成本双重控制。def trim_messages(messages: list[dict], max_tokens: int 4096) - list[dict]: system [m for m in messages if m[role] system] rest [m for m in messages if m[role] ! system] budget max_tokens kept [] for msg in reversed(rest): cost len(str(msg.get(content, ))) * 2 if budget - cost 0: break kept.append(msg) budget - cost return system list(reversed(kept))这个逻辑保留 system 提示词从最新消息往前逐条估算 token 开销直到预算耗尽。len * 2是粗略估算中文场景一个汉字按 2 token、英文按 1.3 token实际误差不大。调用时把它挂在event_source生成器之前先 trim 再发给模型网关。另做一个接口层的滑动窗口限流按客户端 IP 记录请求时间戳一分钟内超过 20 次直接返回 429内存版适用于单机部署多副本生产环境把计数器换到 Redis 实现原子递增。5.3 验证“生成即复用”的输出一致性一键生成的价值不只是生成一次而是同一套模板多次生成结果一致且不相互污染。验证方法比较直接把同一份配置生成两次到两个不同目录对比页面 HTML、JS 文件哈希、Nginx conf 差异结果应该只有目录名不同页面内容完全一致。如果出现随机参数嵌入 HTML可以考虑是不是模板里包含了时间戳或进程号。保证生成确定性交付给客户的站点才敢说“一键”。本文还有配套的精品资源点击获取