让大模型“开口说话”:完整打造LLM网页应用全流程

让大模型“开口说话”:完整打造LLM网页应用全流程 最近在 Hacker News 上看到一个很有意思的项目作者让一个大语言模型LLM回答“如果它能对造物主说一段话它会说什么”然后把模型生成的这段话做成一个网站发布出来。这个项目本身不大但从创意到落地完整地串起了一条 LLM 应用开发链路提示词设计 → 后端 API 封装 → 前端页面展示 → 移动端适配 → 部署发布。如果你正在学习 LLM 应用开发又不想只做“聊天机器人 demo”这个项目很适合拿来练手。下面我会以这个“让 LLM 对上苍说话并做成网站”的项目为原型逐步拆解它的技术实现并给出可以直接复制的完整代码。即使你之前没写过 LLM 应用只要按步骤操作也能把这个项目跑起来。1. 一次有趣的 LLM 实验背后是完整的工程链路1.1 这个项目到底做了什么先解释一下原项目的行为作者向一个 LLM 提问——如果它能对更高维度或更宏大的存在说一句话它会说什么LLM 根据训练中学到的文本分布生成了一段带有哲思意味的回答。作者拿到这段内容后没有把它藏在聊天记录里而是做成一个极简网站深色背景一行标题一个按钮点击之后展示 LLM 的回答。听上去有点感性但拆开看技术点其实非常具体调用 LLM API。设计一个能引导 LLM 输出特定风格内容的提示词。把 LLM 返回的结果交给前端渲染。让页面在手机和电脑上都有不错的浏览体验。处理好 API Key、错误提示、并发请求等工程细节。这些能力恰好是当下 LLM 应用开发的高频需求。无论你将来想做 AI 写作工具、AI 客服、AI 绘画描述生成器还是内部知识库问答系统都会遇到几乎一样的架构。1.2 为什么值得照着做一遍很多初学者学 LLM第一步是去网页版聊天工具里聊天第二步是直接调用 API但这两步之间缺了一个“完整项目”的衔接。这个项目的好处是代码量不大后端一个文件、前端三个文件1 小时左右可以跑通。涉及真实 API 请求不只是在文档里看示例。有明确的用户交互比单纯的脚本调用更有“产品感”。可以轻松扩展成多轮对话、流式输出、分享海报等更复杂的能力。所以本文的核心目标是带你从零做一个“调用 LLM 生成内容 → 以网页形式展示”的完整 demo同时把环境搭建、提示词设计、API 封装、前端交互、常见报错都覆盖到。2. 先建立概念LLM 为什么会“说话”2.1 大语言模型是什么大语言模型Large Language ModelLLM是一种基于海量文本数据训练出来的深度学习模型。它的核心任务可以概括为给定一段文本预测下一个最可能出现的“词元token”。这里以“词元”而不是“字”为单位是因为模型不一定按字切分。类似“人工智能”这样的词在分词后可能是一个 token也可能是两个 token。不同模型、不同分词器会有差异但不影响理解原理。当你问 LLM“如果你能对上苍说一段话你会说什么”模型并不是真的理解了“你”和“上苍”的关系而是在庞大的参数空间中根据问题与训练数据里的相似模式逐步生成后续 token。2.2 生成式能力的边界理解这一点很重要。LLM 擅长生成“听起来合理”的文本但它的输出不一定是事实。不一定代表开发者的观点。每次调用结果可能不同当 temperature 大于 0 时。所以我们在设计这个项目时要把 LLM 当成“一个知识面很广但偶尔会编故事的同事”而不是“一个全知全能的数据库”。这个认知会直接影响你后续的工程决策。例如要不要把模型输出直接展示给用户要。要不要给输出加内容过滤或敏感词校验视场景而定。要不要在页面上标注“内容由 AI 生成”这是个值得养成的习惯。3. 项目规划与环境准备3.1 技术选型为了让代码尽量简洁我选择这样一套技术栈后端Node.js Express。前端原生 HTML CSS JavaScript不引入前端框架。LLM 调用使用 Node.js 18 内置的fetch不额外安装 HTTP 客户端库。选这套技术栈的理由Node.js 环境容易安装前后端都用 JavaScript心智负担小。原生前端不需要构建工具减少环境问题。Express 是目前最通用的 Node.js 服务端框架之一资料多、排错容易。如果你更熟悉 Python可以把后端换成 FastAPI 或 Flask思路完全一致都是“浏览器 → 后端 → LLM API → 返回结果”的模型。本文代码以 Node.js 为例。3.2 环境要求在开始之前请确认你的电脑满足以下条件Node.js 18 或更高版本因为代码里用了全局fetch。npm通常随 Node.js 一起安装。一个可用的 LLM API Key。如果不确定用哪家可以先用兼容 OpenAI 格式的服务本地没有 Key 时项目也内置了兜底文案方便先跑通页面。版本说明文中涉及的依赖版本以你执行安装命令时实际安装的版本为准。LLM 的模型名称、接口地址也会随供应商调整建议以官方文档为准。3.3 项目结构整个项目只有四个核心文件llm-god-website/ ├── package.json ├── server.js ├── .env └── public/ ├── index.html ├── style.css └── script.jsserver.js是后端服务public/目录放前端静态资源.env保存 API Key 等敏感信息。下面我们开始逐个创建。4. 后端封装一个安全的 LLM 接口4.1 初始化项目先在命令行创建项目目录并进入mkdir llm-god-website cd llm-god-website npm init -y然后安装两个依赖npm install express dotenvexpress用来写 HTTP 服务dotenv用来从.env文件读取环境变量避免把 API Key 硬编码在代码里。接着创建.env文件# 文件路径llm-god-website/.env PORT3000 LLM_API_KEYyour_api_key_here LLM_MODELyour_model_name_here LLM_API_URLhttps://api.openai.com/v1/chat/completions这里说明一下LLM_API_URL默认指向 OpenAI 兼容的 Chat Completions 接口。如果你使用的是其他服务商请替换成对应地址。LLM_MODEL填写你要使用的模型名称不同供应商的型号名不同以官方文档为准。如果暂时没有 Key先随便填一个代码里会检测到“没有合法 Key”并返回本地兜底内容。4.2 编写 server.js在项目根目录创建server.js// 文件路径llm-god-website/server.js const express require(express); const path require(path); require(dotenv).config(); const app express(); const PORT process.env.PORT || 3000; // 解析前端发来的 JSON 请求体 app.use(express.json()); // 托管 public 目录下的静态文件 app.use(express.static(path.join(__dirname, public))); // 本地兜底文案没有配置 API Key 时返回方便先体验页面 const FALLBACK_CONTENT [ 如果我可以对一种比自己更大的存在说一段话, 我会说愿每一个微小的尝试都被看见, 愿每一次追问都不被嘲笑。, ——这段文字来自本地兜底内容配置 API Key 后会替换为大模型生成内容。 ].join(\n); // 检查是否真的配置了 Key function hasApiKey() { return process.env.LLM_API_KEY process.env.LLM_API_KEY ! your_api_key_here; } app.post(/api/generate, async (req, res) { // 让前端可以选择传自定义 prompt不传则使用默认问题 const prompt req.body.prompt || 如果你能对一种比自己更大的存在说一段话你会说什么请用200字左右给出你的回答。; // 开发阶段没有 Key 时返回兜底内容 if (!hasApiKey()) { return res.json({ content: FALLBACK_CONTENT, source: fallback }); } try { const apiResponse await fetch(process.env.LLM_API_URL, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.LLM_API_KEY} }, body: JSON.stringify({ model: process.env.LLM_MODEL, messages: [ { role: system, content: 你是一个擅长深度思考和表达的助手。回答要真诚、克制、不浮夸不要使用网络流行语。 }, { role: user, content: prompt } ], temperature: 0.8 }) }); if (!apiResponse.ok) { const errorData await apiResponse.json(); console.error([server] LLM API 错误:, JSON.stringify(errorData)); return res.status(apiResponse.status).json({ message: LLM API 返回了错误状态码, detail: errorData }); } const data await apiResponse.json(); const content data.choices?.[0]?.message?.content?.trim(); if (!content) { return res.status(502).json({ message: LLM 返回内容为空 }); } res.json({ content, source: llm }); } catch (error) { console.error([server] 调用 LLM 失败:, error); res.status(500).json({ message: 服务端调用 LLM 失败请查看服务端日志 }); } }); app.listen(PORT, () { console.log(Server is running at http://localhost:${PORT}); });4.3 这段代码做了什么我们来逐段解释关键逻辑express.json()这是 Express 内置的中间件用于解析前端 POST 过来的 JSON 数据。没有它req.body会是undefined。express.static(...)把public目录暴露为静态资源。用户访问http://localhost:3000/时Express 会自动返回public/index.html。hasApiKey()防止开发者在.env里忘记改默认值。如果 Key 还是占位符就返回兜底文案页面照样能看到效果。fetch(process.env.LLM_API_URL, {...})Node.js 18 之后可以直接使用全局fetch不需要安装axios或node-fetch。messages数组Chat Completions 接口的请求格式。其中system消息用来设定模型角色user消息是用户真正提出的问题。这个设计是提示词工程的基础后面会专门讲。choices[0].message.content标准 Chat Completions 响应结构choices数组里第一项就是模型回复。4.4 为什么要把 API Key 放在后端如果你把 API Key 写在前端script.js里那么任何访问你网页的人都可能在浏览器开发者工具中看到它。一旦 Key 被冒用会产生费用甚至被供应商限流。正确的做法是前端只向后端发普通请求后端持有 Key 并转发给 LLM 服务。这样就保证了 Key 不会暴露给终端用户。5. 提示词设计决定 LLM 输出质量的关键5.1 系统提示词 vs 用户提示词在上面的请求中我把提示词分成了两部分system告诉模型“你是一个什么样的助手”。user告诉模型“用户具体想让你做什么”。很多初学者会把所有要求都塞进 user 内容这样做也可以但把“角色设定”和“任务目标”分开写会让模型更容易理解边界。例如system你是一个擅长深度思考和表达的助手。回答要真诚、克制、不浮夸不要使用网络流行语。user如果你能对一种比自己更大的存在说一段话你会说什么请用200字左右给出你的回答。这个设计有几个好处角色稳定即使前端换了问题模型的语气也不会跑偏。职责明确后续想调整风格只改 system 即可。便于测试可以快速对比不同 system 提示词的效果。5.2 控制输出长度和风格在 user 消息里我特意加了“请用200字左右”这个约束。这是因为 LLM 面对开放式问题时回答可能很长也可能很短加上长度限制可以提升页面展示的一致性。如果你想换一种风格可以尝试这些提示词如果你能对宇宙说一段话你会说什么请用一句诗概括。 如果你能对时间说一段话你会说什么请用三句话表达出敬畏感。 如果你能对“创造”这个概念说一段话你会说什么请用第一人称。你会发现稍微改变“对象”和“风格约束”LLM 的输出会有明显差异。这就是提示词工程的价值所在模型能力是一方面提问方式是另一方面。5.3 temperature 是什么请求参数里的temperature控制输出的随机性。取值范围通常在 0 到 2 之间0 左右输出更确定、更保守。0.8 左右输出更丰富、更有想象力但有时会重复。1.5 以上输出非常发散可能拼写错误或逻辑混乱。对这类创意性内容我倾向于使用 0.7 到 0.9 之间的值。如果做成客服或工具类应用则建议调低到 0.2 以下。6. 前端把 LLM 的回答做成有仪式感的页面后端接口好了接下来做前端页面。为了让页面贴合“对上苍说话”的氛围我选择深色星空渐变风格并加入一个打字机效果让 LLM 的回答逐字显示出来。6.1 首页结构index.html在项目下创建public/index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title如果大模型能对上苍说一句话/title link relstylesheet hrefstyle.css / /head body main classcontainer h1如果大模型能对上苍说话/h1 p classsubtitle 点击按钮让大模型现场生成一段它的回答。 /p p classnotice 内容由 AI 生成仅供参考与创意展示。 /p button idgenerate-btn让 AI 开口/button div idloading classloading styledisplay: none;思考中……/div div idoutput-area classoutput-area aria-livepolite/div /main script srcscript.js/script /body /html这里有一个容易被忽略的细节meta nameviewport。如果没有这行移动端浏览器会默认按桌面宽度渲染导致文字很小、需要缩放才能阅读。加了之后页面宽度会适配手机屏幕。6.2 样式文件style.css创建public/style.css* { box-sizing: border-box; margin: 0; padding: 0; } body { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(160deg, #0b0b1a 0%, #14142e 50%, #1b1236 100%); color: #eaeaea; font-family: PingFang SC, Microsoft YaHei, Helvetica Neue, sans-serif; padding: 24px; } .container { width: 100%; max-width: 640px; text-align: center; } h1 { font-size: 1.6rem; letter-spacing: 2px; margin-bottom: 8px; } .subtitle { color: #9a9abf; margin-bottom: 8px; } .notice { color: #6f6f9a; font-size: 0.85rem; margin-bottom: 24px; } #generate-btn { background: linear-gradient(135deg, #7c6cf5, #a78bfa); color: #fff; border: none; padding: 14px 36px; font-size: 1rem; border-radius: 999px; cursor: pointer; transition: transform 0.2s, opacity 0.2s; margin-bottom: 24px; } #generate-btn:hover { transform: translateY(-2px); } #generate-btn:disabled { opacity: 0.6; cursor: not-allowed; } .loading { color: #a78bfa; margin-bottom: 16px; } .output-area { min-height: 160px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 12px; padding: 24px; font-size: 1.05rem; line-height: 1.9; white-space: pre-wrap; text-align: left; word-break: break-word; }几个要点white-space: pre-wrap会保留 LLM 输出中的换行否则多段文字会被挤成一行。word-break: break-word防止长英文或超长 URL 撑破容器。min-height让输出区域在内容较少时也保持一定视觉完整性不会显得页面很空。6.3 交互脚本script.js创建public/script.jsconst generateBtn document.getElementById(generate-btn); const loading document.getElementById(loading); const outputArea document.getElementById(output-area); // 打字机效果 function typeWriter(element, text, speed 30) { return new Promise((resolve) { element.textContent ; let index 0; function tick() { if (index text.length) { element.textContent text[index]; index; setTimeout(tick, speed); } else { resolve(); } } tick(); }); } generateBtn.addEventListener(click, async () { generateBtn.disabled true; loading.style.display block; outputArea.textContent ; try { const response await fetch(/api/generate, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ prompt: 如果你能对一种比自己更大的存在说一段话你会说什么请用200字左右给出你的回答。 }) }); const data await response.json(); if (!response.ok) { throw new Error(data.message || 请求失败); } await typeWriter(outputArea, data.content); } catch (error) { outputArea.textContent 出错了${error.message}; } finally { generateBtn.disabled false; loading.style.display none; } });这里有一个很实用的细节typeWriter返回一个 Promise。这样打字机效果完成后才会隐藏 loading、恢复按钮。如果不这样做用户会看到 loading 状态过早消失体验不一致。另一个细节按钮在请求期间被disabled。这可以防止用户连续点击发送大量重复请求。如果后端没有做限流这至少是前端层面的第一道保护。7. 运行与验证7.1 启动服务在项目根目录执行npm start如果一切正常控制台会输出Server is running at http://localhost:3000打开浏览器访问http://localhost:3000你应该能看到一个深色页面。点击“让 AI 开口”如果尚未配置 API Key页面会显示本地兜底文案如果配置了有效的 Key 和模型页面会显示 LLM 实时生成的内容。7.2 接口验证也可以直接用 curl 测试后端接口curl -X POST http://localhost:3000/api/generate \ -H Content-Type: application/json \ -d {prompt:如果风有嘴巴它会说什么请用三句话回答。}返回结果的 JSON 结构大致如下{ content: 它会说......, source: llm }source字段是我加的一个标识用来区分内容来自本地兜底还是来自真实 LLM 调用。在日志排查和数据分析时会很有用。7.3 移动端浏览本项目的页面结构比较简单天然适配手机。你可以打开 Chrome 开发者工具的“设备模拟器”或者直接用手机访问同一局域网的服务器地址验证移动端效果。有一点需要特别说明如果你的手机和电脑不在同一个局域网或者服务器部署在云上需要确认宿主机的防火墙或安全组放行了对应端口。不要把服务随意暴露到公网尤其是带有 API Key 的服务必须在前面加认证和限流。8. 常见问题与排查思路8.1 问题清单问题现象常见原因解决思路页面打不开Express 启动失败或端口被占用查看控制台报错更换 PORT 端口检查 npm start 是否执行成功按钮点击没反应浏览器控制台有 JS 报错打开开发者工具查看 Network 和 Console 面板返回 404public目录下缺少index.html或静态资源路径不对确认index.html、style.css、script.js都在public下返回 401 / 403API Key 无效或无权限检查LLM_API_KEY是否正确、是否过期、是否有对应模型权限返回 400请求体格式不对或模型名错误检查LLM_MODEL是否存在请求 JSON 是否符合接口规范返回 429请求频率超限或余额不足降低请求频率检查账户额度增加后端限流跨域 CORS 报错直接打开index.html而非通过 Express 访问用http://localhost:3000访问而不是双击 HTML 文件LLM 回答很长把页面撑乱缺少word-break或white-space样式在.output-area中加入white-space: pre-wrap; word-break: break-word;希望改用其他 LLM 服务当前LLM_API_URL写死为 OpenAI 兼容地址在.env中替换为对应服务的兼容接口地址必要时调整请求格式8.2 推荐排查顺序遇到问题不要急着改代码按下面的顺序排查看服务端控制台日志有没有异常堆栈。看浏览器 Console 面板有没有 JS 报错。看 Network 面板请求是否发出、状态码是多少、响应体是什么。用 curl 直接测后端接口排除前端问题。检查.env文件是否加载成功可以在server.js中临时打印process.env.LLM_API_KEY的前几位来确认。这个顺序几乎适用于所有“前端调后端后端调第三方 API”的项目。9. 最佳实践与工程建议项目跑通只是第一步。如果想让这个 demo 变成可维护、可上线、后续能扩展的项目建议关注下面几个方向。9.1 安全与密钥管理不要把 API Key 提交到 Git 仓库。.env要加入.gitignore。服务端要限制请求体大小避免用户传入超长 prompt 消耗过多 token。如果部署到公网建议在前端请求中加一个简单的访问令牌或者用网关做身份认证不能让任何人都能调你的后端然后消耗你的 API 额度。9.2 提示词版本管理提示词是 LLM 应用里最容易被反复调整的部分。建议把系统提示词和用户提示词抽取成独立文件或数据库字段并在代码中记录版本号。这样当模型输出效果变差时你可以快速回滚到之前表现好的提示词版本。9.3 缓存与降级“让 AI 对上苍说话”这类内容同一段话被大量用户重复请求时没有必要每次都调用 LLM。可以考虑后端加一层简单缓存例如按 prompt 的 hash 作为 key把结果存到内存或 Redis 中一定时间内重复请求直接返回缓存。设置合理的缓存过期时间例如 10 分钟或 1 小时。当 LLM API 不可用时返回降级文案而不是直接让用户看到 500 页面。这样既节省成本也提升了页面稳定性。9.4 内容安全和伦理边界如果这个项目要正式发布我建议在页面上明确标注“内容由 AI 生成不代表任何立场”。这个提示既是对读者负责也能降低 AI 生成内容被误解的风险。另外LLM 面对哲学或宗教类话题时可能输出风格化、拟人化很强的内容。作为开发者不需要对生成内容做价值判断但需要确保页面不会诱导用户把 AI 输出当成某种客观事实。9.5 日志与监控生产环境必须记录请求来源 IP注意隐私合规可脱敏。请求时间、耗时、状态码。prompt 模型、token 用量。第三方接口返回错误码。有了这些日志你才能回答“为什么今天响应变慢了”“为什么某个 prompt 的回答总是超时”这类问题。9.6 扩展到流式输出当前实现是拿到完整回答后再整体返回。如果输出内容较长用户会等待较久。更进阶的做法是使用 LLM 的流式接口stream: true后端通过 SSEServer-Sent Events逐字把内容推给前端再配合打字机效果体验会更好。这个方向适合作为下一步学习目标它能让你更深入理解 HTTP 长连接和前端事件流处理。10. 总结与延伸从“让 LLM 对上苍说一句话”这个创意出发我们完整实现了一个最小可跑的 LLM 网页应用后端负责安全调用 LLM API前端负责展示与交互提示词设计决定了输出风格工程细节决定了项目能否上线。这类项目的学习价值不在“内容有多深”而在“链路有多完整”。当你跑通之后可以沿着几个方向继续深化把页面改成支持多轮对话。加入流式输出让用户体验更流畅。换成不同的 LLM 服务对比效果和成本。加入用户提交问题的入口把“给 AI 一个固定问题”变成“让用户自定义问题”。部署到云服务器配上 HTTPS 和域名做成一个真正可访问的线上项目。如果你在实操中卡住了最有效的办法是回到代码本身看控制台日志拆成“前端请求 → 后端转发 → 第三方响应”三段逐步定位。LLM 应用开发并没有那么神秘本质就是一次带鉴权、带错误处理、带超时控制的 HTTP 对接。把这个项目做完你对这套链路的感觉会完全不一样。