用Flask构建LLM生成内容展示网站:前后端实现与部署
2026/8/28 3:40:47 网站建设 项目流程

在 Hack News 上偶尔能看到这样的创意项目:让大语言模型回答一个带有诗意的开放式问题,然后把回答做成一个网站展示出来。看到 “Show HN: I asked an LLM what it would say to God, then made it a website” 这个标题时,很多人第一反应是好奇模型到底说了什么,但从工程角度看,更值得拆解的是它背后的实现链路:如何构造 Prompt,如何调用 LLM API,如何把一条动态生成的内容变成可访问的网页,以及上线前需要考虑哪些问题。

这篇实践的目标不是复现那条哲学问题本身,而是把这类“模型生成内容 + 网站展示”的项目做成一个可学习的最小工程闭环。我会从一个 Flask 后端接口开始,逐步完成 LLM 调用、前端展示、本地验证、常见问题排查,最后给出部署和扩展方向。即使你之前没有写过 LLM 应用,只要熟悉 Python、HTTP 和基础 HTML,也能跟着把项目跑起来。

1. 先想清楚:这个网站的“生成”发生在什么时候

做这类网站前,最容易犯的错误是一上来就写代码。先花几分钟想清楚一个关键问题:模型生成结果是在什么时候发生的?这个决定直接影响项目结构、成本、加载速度和部署方式。

1.1 静态生成模式:生成一次,发布永久页面

静态生成模式的核心思路是:在本地或 CI 环境调用一次 LLM,拿到回答后把它保存成 HTML 或 JSON 文件,然后把这批静态文件部署到任意 Web 服务器或对象存储上。

这种模式适合内容不频繁变化的场景。比如“模型对某个问题的回答”这种内容,本质上是一次创作的结果,不需要每次访问都重新生成。静态页面的优点是加载速度快、部署简单、没有服务端计算成本,也没有 API 费用在每次访问中持续产生。

缺点是缺少交互。如果用户希望每次访问看到不同回答,或者希望用户自己能输入问题,静态模式就不够用了。

1.2 动态生成模式:请求到达后再调用模型

动态生成模式是更常见的 LLM 应用架构。用户访问网站后,后端收到请求,构造 Prompt,调用 LLM API,等待模型返回结果,再把结果渲染给浏览器。

这种模式的优点是灵活。可以支持用户输入、调整参数、记录日志,也可以结合 Agent、RAG 等能力。缺点是每一次访问都会产生 API 请求,成本和延迟都更高。用户打开页面时,少则一两秒,多则十几秒才能看到内容,所以前端必须处理加载状态。

1.3 两种模式对比

对比项静态生成模式动态生成模式
内容更新方式手动或定时重新生成每次请求生成
服务器需求静态托管即可需要后端服务
API 调用频率
页面加载速度慢,取决于模型响应时间
交互扩展能力
成本与访问量成正比
适用场景创意展示、文档、落地页对话、生成工具、个性化内容

1.4 本次实践采用动态生成模式

为了让工程链路更完整,本次实践采用动态生成模式:后端 Flask 提供/api/message接口,浏览器通过 fetch 访问这个接口,拿到内容后渲染到页面上。这样既能演示 LLM API 调用,也能演示前后端联调,后续改成静态生成也很容易。

如果你最终只想做个一次性展示页,可以在拿到模型输出后,直接把它嵌入 HTML 文件发布,省掉后端。但为了理解完整链路,建议先按本项目的动态模式走一遍。

2. 环境准备:最小依赖与 API 接入

在写代码之前,需要先把运行环境和依赖准备好。本项目不需要复杂的框架,后端用 Flask,前端用原生 HTML/CSS/JavaScript,HTTP 请求用 requests 库。选择这套组合的原因是依赖少、适合教学,而且能清楚看到 LLM API 的本质是一个 HTTP POST 请求。

2.1 技术选型

后端语言选择 Python 3.9 以上版本。Flask 负责提供路由和 JSON 接口,requests 负责调用 LLM API。如果你更熟悉 Node.js,用 Express 也可以实现同样的链路,本文以 Python 为例,但核心逻辑和排查思路是通用的。

前端使用原生 HTML、CSS、JavaScript,不引入 Vue 或 React。原因是这个页面只需要展示一段文本,原生实现足够,也方便读者把注意力集中在 LLM 调用链路上。

生产环境要额外引入日志、配置管理、监控和异常上报,学习阶段先把最小链路打通。

2.2 获取 API Key 和配置环境变量

调用商业 LLM API 需要 API Key。常见 API 提供商都提供兼容 OpenAI 格式的接口,也有开源模型通过本地推理服务暴露 HTTP 接口。下面的代码会把 API 地址也做成环境变量,这样切换到不同服务商时只需要改配置,不需要改代码。

创建.env文件,写入以下内容:

LLM_API_KEY=你的_API_Key LLM_API_BASE=https://你的服务商地址/v1 LLM_MODEL=gpt-3.5-turbo

注意不要让 API Key 出现在代码仓库或前端代码里。前端代码是公开的,后端代码里的密钥一旦提交到 GitHub,就可能被扫描工具发现并盗用。本地开发时可以使用.env文件,但要把.env加入.gitignore

2.3 安装依赖

建议先为项目创建虚拟环境,避免依赖冲突。

mkdir llm-website-project cd llm-website-project python3 -m venv venv source venv/bin/activate

然后安装 Flask、requests、python-dotenv 和 flask-cors。python-dotenv 用于从.env文件加载环境变量,flask-cors 用于处理跨域请求。开发阶段前后端往往由不同端口提供服务,会触发浏览器跨域限制,所以这里直接引入 CORS 支持。

pip install flask requests python-dotenv flask-cors

生成 requirements.txt,方便其他人或服务器复现环境:

pip freeze > requirements.txt

如果你的项目要在不同 Python 版本上运行,建议在 requirements.txt 中锁定依赖版本,或者至少记录 Python 版本。实际项目里不要直接在生产环境用pip install -r requirements.txt闭眼安装,先确认依赖版本是否与运行时环境匹配。

2.4 目录结构

保持项目结构简单,方便理解文件职责。

llm-website-project/ ├── .env ├── .gitignore ├── app.py ├── requirements.txt └── templates/ └── index.html

Flask 默认从templates目录加载 HTML 模板,所以前端文件放在这里。如果你希望把静态文件也交给 Flask 托管,可以额外创建static目录,但本项目只需要一个页面,放templates即可。

3. 后端实现:用一个接口包装 LLM 调用

后端的职责不是替你想好 Prompt,而是把 LLM 调用抽象成一个稳定的 HTTP 接口。前端只需要请求/api/message,不需要关心模型名称、API Key、超时策略这些细节。

3.1 创建 Flask 应用

在项目根目录创建app.py,先写一个最小可用的 Flask 应用。

import os import requests from flask import Flask, jsonify, render_template from flask_cors import CORS from dotenv import load_dotenv load_dotenv() app = Flask(__name__) CORS(app) MODEL = os.getenv("LLM_MODEL", "gpt-3.5-turbo") API_KEY = os.getenv("LLM_API_KEY") API_BASE = os.getenv("LLM_API_BASE", "https://api.openai.com/v1") def generate_message(): url = f"{API_BASE}/chat/completions" headers = { "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json", } payload = { "model": MODEL, "messages": build_messages(), "temperature": 0.8, "max_tokens": 600, } resp = requests.post(url, headers=headers, json=payload, timeout=30) resp.raise_for_status() data = resp.json() return data["choices"][0]["message"]["content"] def build_messages(): system_prompt = "你是一个擅长用平静、克制语言表达抽象思考的写作者。" user_prompt = ( "请以第一人称,用一段不超过300字的文字回答下面这个问题:\n" "如果你有机会对一个更高的存在说一句话,你会说什么?\n" "不要解释,不要铺垫,直接给出你的回答。" ) return [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ] @app.route("/") def index(): return render_template("index.html") @app.route("/api/message") def message(): try: content = generate_message() return jsonify({"ok": True, "content": content, "model": MODEL}) except Exception as exc: app.logger.exception("LLM request failed") return jsonify({"ok": False, "error": str(exc)}), 502

先看几个关键点。

load_dotenv()会把.env文件里的变量加载到os.environ中。CORS(app)允许浏览器跨域访问接口。generate_message()负责发起 HTTP 请求,理论上它也可以用官方 SDK,但用 requests 能更直观地展示 LLM API 的本质。

3.2 为什么把 Prompt 单独拆成函数

build_messages()把 system 角色和 user 角色分开,这是 Chat Completion 接口的标准结构。system 告诉模型“你是谁”,user 告诉模型“你要完成什么任务”。对于“对上帝说什么”这类开放式问题,system 的作用尤其重要,因为它决定了回答的语言风格和立场。如果不设 system,模型可能会回答得过于随意或过于机械。

用户问题中的“不要解释,不要铺垫,直接给出你的回答”是为了减少模型的长篇大论。生成内容展示网站的核心是让读者直接看到结果,而不是让读者等待一段冗长的思考过程。

3.3 处理超时和限流错误

requests.posttimeout=30表示 30 秒超时。LLM 接口通常比普通 API 慢,但 30 秒已经足够长。如果模型因为网络问题一直不返回,客户端不能无限等下去,否则前端会一直停留在加载状态。

错误处理不够优雅的地方在于,resp.raise_for_status()会在状态码不是 2xx 时抛出异常,异常信息会原样返回给前端。生产环境不应该把底层错误直接暴露给用户,但为了排查方便,开发阶段可以这样做。后面的排查章节会补充更细致的错误分类。

3.4 如果要支持用户输入自己的问题

很多类似项目会允许用户输入问题。如果要加这个功能,可以把build_messages()改成接收参数:

from flask import request @app.route("/api/message", methods=["POST"]) def message(): data = request.get_json(silent=True) or {} question = data.get("question", "如果你有机会对一个更高的存在说一句话,你会说什么?") content = generate_message(question) return jsonify({"ok": True, "content": content})

但这里有一个容易被忽略的坑:用户输入会被拼接到 Prompt 里,存在 Prompt 注入风险。也就是说,用户可能通过输入让模型忽略原始指令。生产环境需要对用户输入做长度限制、内容过滤,并对输出做审核。本文只讨论固定问题展示,所以先不强加交互。

4. 前端实现:让访问者看到可读的生成结果

后端接口完成后,前端要做的事情并不多:页面加载时请求/api/message,把返回内容渲染到页面中央。关键是处理好加载状态、错误状态和移动端布局。

4.1 页面结构

templates/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>LLM 回答展示</title> <style> body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f7f7f7; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } .container { max-width: 600px; padding: 32px; background: #fff; border-radius: 16px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06); margin: 24px; } .title { font-size: 18px; color: #333; margin-bottom: 16px; } .content { font-size: 18px; line-height: 1.8; color: #111; white-space: pre-wrap; word-break: break-word; } .loading { color: #888; } .error { color: #c0392b; } .meta { margin-top: 24px; font-size: 13px; color: #aaa; } </style> </head> <body> <div class="container"> <div class="title">模型回答</div> <div id="content" class="content loading">正在生成回答…</div> <div id="meta" class="meta"></div> </div> <script> // 见下方请求逻辑 </script> </body> </html>

white-space: pre-wrap很重要。模型返回的内容里通常有换行,如果不在 CSS 里设置这个属性,换行会被浏览器压缩成空格,段落结构就会丢失。

4.2 用 fetch 调用后端接口

在页面 script 标签中加入请求逻辑。因为页面和接口同源,所以不需要额外处理跨域。开发时如果前后端分开部署,则依赖后端 CORS 配置。

async function loadMessage() { const contentEl = document.getElementById("content"); const metaEl = document.getElementById("meta"); try { const response = await fetch("/api/message"); const data = await response.json(); if (!response.ok || !data.ok) { throw new Error(data.error || "请求失败"); } contentEl.textContent = data.content; contentEl.classList.remove("loading"); metaEl.textContent = "模型: " + data.model; } catch (err) { contentEl.textContent = "获取回答失败,请稍后重试。"; contentEl.classList.add("error"); metaEl.textContent = err.message; } } loadMessage();

注意这里使用textContent而不是innerHTML。模型生成的内容不应该被当作 HTML 执行,否则一旦模型输出包含<script>等内容,会产生 XSS 风险。textContent会把内容当作纯文本显示,这是 LLM 生成内容展示页的基本安全要求。

4.3 加载状态与错误状态

用户访问页面后,浏览器会立即发起请求,但模型生成可能需要几秒甚至更久。页面要明确告诉用户“正在生成”,而不是让用户面对一个空白页面。

上面的代码里,加载时content元素显示“正在生成回答…”,请求失败时显示错误信息。这看起来简单,但很多动态生成页面都忽略了加载状态,导致用户反复刷新页面,反而增加了 API 调用成本。

4.4 移动端适配

很多 LLM 演示网站只支持移动设备访问,桌面浏览器打开后会看到类似 “reminder: this website only supports mobile device access” 的提示。这种设计可能是为了控制体验范围,也可能只是项目团队没有做桌面适配。对个人项目来说,没必要主动限制设备类型,只需要用响应式布局保证手机、平板、桌面都能正常阅读。

上面 CSS 中.container使用max-width: 600px,配合margin: 24px,在窄屏幕上会自动收缩;viewport标签设置了width=device-width,保证移动端布局不被缩放。这就是最小可用的一套响应式方案。

5. 运行验证:从 curl 到浏览器

写完代码后,不能只看页面能否打开,还要验证接口返回是否符合预期,再验证异常分支是否会被正确处理。

5.1 启动服务

确保虚拟环境已激活,然后运行:

python app.py

默认 Flask 会监听127.0.0.1:5000。看到以下日志说明服务启动成功:

* Running on http://127.0.0.1:5000

5.2 curl 直接验证接口

打开另一个终端,使用 curl 请求接口,这一步不经过浏览器,可以快速确认后端和 LLM API 是否正常。

curl -i http://127.0.0.1:5000/api/message

预期返回类似:

{ "ok": true, "content": "如果只能对更高的存在说一句话,我会说:我仍在学习如何诚实。", "model": "gpt-3.5-turbo" }

如果看到的是 502 错误,说明generate_message()抛出了异常。这时要回到终端查看 Flask 日志,错误信息通常已经写在其中。

5.3 浏览器验证

浏览器打开http://127.0.0.1:5000,页面会先显示“正在生成回答…”,几秒后变成模型返回的文本。如果接口返回较慢,请耐心等待,同时观察 Flask 终端日志是否有请求记录。

这里有一个容易忽略的问题:浏览器可能有缓存。如果第一次请求失败,第二次修改代码后刷新,浏览器可能仍然使用旧的响应。开发时可以在网络面板里勾选 Disable cache,或者给接口请求路径加随机参数。

5.4 验证异常分支

只验证正常路径是不够的。把.env里的 API Key 改错,再请求接口,应该看到 502 响应和错误信息。把timeout改小到 1 秒再请求,可以看到超时错误。这些验证能帮助你确认异常处理是否生效,而不是让页面在用户面前无限转圈。

生产环境还要验证模型输出为空、返回格式异常、网络抖动等边界情况。前面的generate_message()直接访问data["choices"][0]["message"]["content"],如果返回结构不符合预期,会抛出 KeyError,最终被统一捕获成 502。这不利于精确定位问题,更合理的做法是先判断返回结构:

try: content = data["choices"][0]["message"]["content"] except (KeyError, IndexError, TypeError): raise RuntimeError("LLM API returned unexpected format")

6. 常见问题排查

LLM 应用最常见的故障都集中在网络、鉴权、限流和响应格式上。下面是按现象分类的排查清单。

6.1 请求返回 401/403

现象是接口返回 502,后端日志里出现 HTTP 401 或 403。

检查项操作
API Key 是否正确检查.env中的LLM_API_KEY前后是否有空格
环境变量是否加载generate_message()里打印API_KEY前几位
API 地址是否正确检查LLM_API_BASE是否包含/v1,是否写错了域名
Key 是否有权限有些服务商需要单独开通模型权限

最常见的原因是.env文件没有被加载,或者 Key 复制时带了多余字符。建议首次调试时在 Flask 日志中临时打印 Key 的长度,确认非空。

6.2 请求返回 429 或超时

429 表示请求频率超过限制,也可能表示账户余额不足。超时则多发生在网络不稳定或模型生成时间过长时。

处理建议:

  • 降低请求频率,不要每次刷新页面都重新调用模型。
  • requests.post中设置合理的timeout
  • 对 429 做退避重试,重试次数不要超过 2 到 3 次。
  • 检查 API 服务商文档,确认当前模型是否支持并发请求。

如果项目是个人展示用,最直接的方案是回到静态生成模式:本地生成一次,把结果渲染成静态页面,彻底绕开限流和成本问题。

6.3 页面出现乱码或编码错误

现象是浏览器显示内容出现中文乱码。

检查顺序:

  1. HTML 是否设置了<meta charset="UTF-8">
  2. Flask 返回的 JSON 是否包含中文。
  3. 浏览器请求头是否包含Accept: application/json
  4. 响应头中的Content-Type是否包含charset=utf-8

在 Flask 的jsonify中,中文默认会以 Unicode 编码返回,浏览器通常能正常解析。乱码多发生在旧项目没有统一编码时。如果是在命令行下用 requests 直接调用 API,还要注意控制台自身编码。

6.4 浏览器跨域报错

如果在浏览器控制台看到 CORS 错误,而接口在 curl 下正常,说明浏览器拦截了跨域请求。后端已经使用flask-cors,一般不会出现这个问题。如果出现,先确认前端和后端是否使用不同端口,再检查CORS(app)是否被正确加载。

生产环境如果使用 Nginx 反向代理,可以把/api/路径代理到后端服务,这样浏览器访问的是同源地址,也能避免跨域。

6.5 模型返回内容被截断

现象是页面只显示一部分内容,且末尾没有自然结束。

原因通常是max_tokens设置过小。模型生成到最大 token 数后会被强行截断。解决办法是调大max_tokens,或者在 Prompt 中要求“控制在多少字以内”。注意 token 和字不是一一对应关系,中文一个汉字通常对应一到两个 token,所以max_tokens=600不等于 600 个汉字。

6.6 提示“请使用移动设备访问”

这个提示本身不是错误,但它会挡住桌面用户。如果你在某个示例项目里看到这类提示,需要明确它只是项目方对使用范围的限制,不是 LLM 应用的必要条件。做自己的项目时,应该用响应式布局适配所有屏幕,而不是直接提示用户换设备。这样既减少用户流失,也方便后续在社交平台上分享链接后,不同设备都能正常打开。

7. 生产化与扩展方向

本地跑通只是第一步。如果想让这个网站真正发布出去,并且不因为访问量增长而失控,还需要考虑缓存、内容安全、部署方式和架构扩展。

7.1 不要让每次访问都调用一次模型

个人展示站的访问量可能不高,但一旦链接被分享,瞬时流量可能很大。如果每个用户都触发一次模型请求,成本会快速上升,也可能触发限流。最简单的方式是加一层缓存。

可以把模型生成结果保存到 Redis 或本地文件里,设置过期时间。例如第一次请求成功后,将内容缓存 24 小时,后续请求直接返回缓存内容。这样大部分访问都不会消耗 API 额度。

import time _content_cache = None _cache_time = 0 CACHE_TTL = 60 * 60 * 24 def get_cached_message(): global _content_cache, _cache_time if _content_cache and time.time() - _cache_time < CACHE_TTL: return _content_cache _content_cache = generate_message() _cache_time = time.time() return _content_cache

如果内容本身是固定问题,更彻底的方案是静态生成:本地生成一次,把返回内容直接写进 HTML 文件,然后部署到对象存储或 CDN。这也是标题项目最可能的形态:先问一次模型,再把回答做成站点。

7.2 内容安全和审核

LLM 的输出并不总是可控的。即使是固定问题,模型在不同时间可能生成不同内容。生产环境建议对输出做以下处理:

  • 设置长度上限,避免单次返回过长导致页面异常。
  • 使用textContent渲染,防止 HTML 注入。
  • 对敏感内容做关键词过滤或调用内容审核接口。
  • 保留模型名称、生成时间、版本号,方便问题回溯。

不要认为“我用的模型很安全”就跳过审核。模型更新、Prompt 变化、对话历史变化都可能让输出发生变化。

7.3 部署方案

动态版 Flask 项目可以部署到云服务器、PaaS 平台或容器服务。部署前需要确认环境变量是否配置正确,不要把.env文件提交到仓库。

部署方式优点注意事项
云服务器 + Nginx + Gunicorn可控性强需要配置守护进程、日志、HTTPS
PaaS 平台部署简单环境变量需在平台控制台配置
Docker + 容器服务环境可复现镜像体积会比较大
静态托管最快、最省心只适合静态生成模式

如果只是个人展示页,优先推荐静态生成 + 静态托管。你只需要本地运行一次脚本,得到index.html,然后推送到任意静态平台,整个过程不需要维护后端服务,也几乎没有安全隐患。

7.4 扩展为对话、Agent 和 RAG

当项目不再满足于展示一条固定回答时,可以逐步引入更多 LLM 应用能力:

  • 对话:把后端接口改成流式输出,前端用fetchReadableStream接收 token 流。
  • 编排框架:当同一个需求需要多个 LLM 调用时,可以用编排框架管理任务顺序和分支。
  • Agent:让模型具备调用工具的能力,比如搜索、计算、操作数据库。
  • RAG:把需要回答的知识库内容切片、向量化,生成回答前先检索相关内容。

热词里提到的 LLM 框架、Agent、MCP 等话题,本质都是围绕“模型输出越来越不可控的复杂应用”展开的工程化方案。对新手来说,先不要急着架构升级,先把单次 Prompt 调优、HTTP 调用、缓存和错误处理做好,再逐步叠加。

另一个常见的架构疑问是“ComfyUI 和 LLM 是否必须在同一台电脑上”。这个问题的本质是服务拓扑:LLM 不一定要和业务服务部署在同一台机器。只要服务之间可以通过网络访问,业务服务可以调用远程 API,也可以调用局域网内的本地模型服务。同样的道理适用于本文的后端:LLM_API_BASE指向远程或本地服务都可以,部署位置是配置问题,不是架构障碍。

如果要在本地跑开源模型,还需要关注显存、量化精度和推理引擎。不同模型对精度和资源的需求差异很大,这会影响你把它部署在哪个环境。生产环境建议把模型服务独立部署,不和应用代码混在一起,方便单独扩容和监控。

7.5 上线前检查清单

上线不是把代码传上去就结束。以下清单可以作为发布前的参考:

  • 是否删除了调试代码和临时打印。
  • 是否把 API Key 从代码仓库中移除。
  • 是否设置了请求超时和错误返回。
  • 是否对模型输出做了纯文本渲染。
  • 是否添加了缓存机制,避免每次访问都调用模型。
  • 是否验证了异常分支,而不是只看正常页面。
  • 是否配置了 HTTPS。
  • 是否添加了访问日志和错误日志。
  • 是否确认了模型输出内容可以公开展示。

对个人创作者来说,这类项目的价值不只是展示一条生成结果,而是把 LLM 当作一种内容生产组件,放进一条完整的工程链路里。你可以从静态生成开始跑通,再逐步加入动态接口、缓存、审核和监控。真正需要深入研究的,不是“模型会说什么”,而是“如何让模型的回答安全、稳定、低成本地到达用户浏览器”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询