我做了一个网页把LLM演示给你看前一阵有几个朋友问我天天看你们说LLM、大模型这玩意儿到底能干嘛我给他们发过几个在线聊天工具结果大家看完还是觉得“就是个高级聊天机器人”。后来我想明白了光给现成的产品没用得有人站在旁边把模型的能力拆开演示一遍还要能现场改参数、切换模型、调知识库才能让他们理解大模型到底强在哪。所以我花了两天时间做了一个演示用的网页打开就是一个对话界面背后接的是大模型API前端支持流式输出、模型切换、知识库问答和工具调用演示。目标很简单——给不懂代码的人看“LLM能做什么”同时自己以后跟团队沟通需求、评审方案时也能拿它当原型。这篇就梳理一下整个过程中我做了什么选择、写了哪些关键代码、踩了哪些坑。1. 为什么我要做一个LLM演示网页而不是直接用现成产品1.1 直接需求让非技术人员看懂LLM先说场景。我身边的朋友有做运营的、做产品的、做管理的他们没有编程经验但都在关注大模型。如果给他们命令行工具他们不知道怎么用给他们看API文档那更是灾难。他们需要的是那种“点开浏览器就能玩旁边有人讲解”的体验。网页演示是最低门槛的交互形式不需要安装不需要懂代码拿手机也能打开。做一个网页还有一个好处就是可以控制演示节奏。我可以先展示普通对话再展示带知识库的问答最后演示它调用外部工具去查天气、算数据。每一步都能配合界面上的参数面板讲清楚token是什么、temperature调高会有多“飘”、用RAG之后回答会引用文档里的哪句话。这种可控感是直接用别的聊天应用替代不了的。1.2 功能范围不只做一个聊天框很多初学者以为演示LLM就是抄一个带聊天框的页面然后调一下API。但实际上要展示的东西很多我给自己定的范围是多轮对话支持流式打字效果模型切换比如同一个页面能切换不同的模型或厂商接口知识库问答展示LLM结合本地资料后如何给出带来源的答案Agent工具调用让模型能调用计算器、天气查询等外部函数服务端代理层前端拿不到任何密钥API Key只存在后端环境变量里。为什么非要做这些因为现在“大模型应用”已经不是一个聊天框的事。如果只做一个聊天页面别人会觉得这东西不过如此。但当你把知识库、工具调用、安全防线都串起来大家才会意识到原来LLM是一台能够调度数据、工具、接口的“大脑”。1.3 目标用户与设计取舍我的目标用户是“演示者”也就是我自己或同事不是大规模公网用户。所以我在设计上做了明确取舍不做用户注册登录不写复杂的会话管理不搞高并发界面尽量简洁所有逻辑能简化就简化。因为演示场景下最重要的是稳定、可控、打开即用而不是微服务的健壮性。如果你也想复现这个项目建议先明确你的用途和用户规模只是自己学习和分享一个轻量服务就够真要给线上用户用那就得把登录、限流、审计、多租户那一套都补上。2. 整体架构与技术选型为什么前端不直连大模型API2.1 前端用Vue3还是原生JS我选择了Vite Vue3演示网页的界面复杂度不高但涉及流式渲染、会话列表、参数面板、按钮组等状态管理于是前端我选了Vite Vue3 Tailwind CSS。Vue3的组合式API写起来顺手Tailwind能让我不写多余的CSS快速把界面做干净。如果你不熟悉这些用原生HTML JavaScript也没问题核心逻辑是一样的。我特别不建议演示项目里引入重型UI组件库比如Element UI、Ant Design之类。原因很简单你会花大量时间去调表格、弹窗、抽屉这些部件而不是聚焦在LLM交互本身。Tailwind加几行自定义类足够应付一个小型演示页面。组件库是给复杂管理系统用的不是给Demo用的。2.2 后端代理为什么要有一层Node服务很多人第一次做大模型应用最容易犯的错就是在前端页面里直接写API Key然后调用fetch(https://api.xxx.com/v1/chat/completions)。刚开始自己本地跑没事一旦把这个页面分享给朋友、甚至部署到公网Key等于裸奔。哪怕用环境变量注入到构建过程只要Key被编译进前端代码别人按F12打开Sources面板就能翻出来。所以我加了一个Node.js/Express的小服务专门转发大模型请求。浏览器只跟自己的后端通信后端把API Key放在环境变量里所有出网请求都从服务端发出。这样就断了最直接的泄密路。2.3 接入模型用OpenAI兼容协议统一接口现在市面上主流大模型厂商基本都提供“OpenAI兼容”接口也就是说请求和响应格式基本一致。我在后端做了简单的适配层通过环境变量配置多个模型提供商地址、Key和模型名页面顶部可以任意切换。底层可以接云端大模型也可以接本地跑的Ollama或者vLLM。这样演示的时候能随时换模型对比输出效果。为什么强调兼容层因为演示时最尴尬的就是“这个模型答得不好”如果你只有单一模型只能干瞪眼。我一般同时配一个通用模型和一个擅长逻辑推理的模型现场切换高下立现。2.4 部署方案一台小服务器加Nginx就够了整个服务部署在一台低配云服务器上。前端构建成静态文件后用Nginx托管后端Node进程用PM2守护。Nginx配置了反向代理将/api/路径转发到本地端口静态页面直接走Nginx。不想做HTTPS的话本地演示还好但如果为了让别人用手机快速访问还是建议配一个HTTPS证书因为浏览器有些能力比如语音输入、剪贴板在非安全上下文里会被禁用。如果只是临时演示也可以直接用Node启动托管静态文件没必要把Nginx搞出来。我之所以用Nginx是希望这个服务稳定些演示期间不用老是重启。3. 核心功能实现一看就懂的LLM演示交互3.1 多轮对话与消息管理对话页的数据结构很关键。不要用一堆变量散着存最好统一成一个数组const messages ref([ { role: system, content: 你是一个乐于助人的AI助手。 }, { role: user, content: 你好介绍一下你自己。 }, { role: assistant, content: 你好呀我是一个由LLM驱动的智能助手。 } ])每发送一条消息就把用户消息推进数组然后把整个数组发给后端。后端会原样转发给大模型接口。这里有一个说烂了但还是要说的坑system消息会和对话历史一起被每个请求带上这会增加token消耗。演示场景无所谓但如果做成产品最好做上下文裁剪。3.2 核心的流式请求与前端解析流式输出是让演示看起来“有AI感”的关键。后端转发大模型接口的流式响应时需要保持Transfer-Encoding: chunked前端用fetch配合ReadableStream逐段读取。前端本质上是在读一段SSE格式的文本data: {id:chatcmpl-xxx,choices:[{delta:{content:你好},...}]} data: {id:chatcmpl-xxx,choices:[{delta:{content:我是},...}]} data: [DONE]所以我写了一个简单函数来解析SSE流每次拿到增量内容就追加到当前对话的content里面同时让界面自动滚动到底部。关键在于不要一上来就JSON.parse整段响应因为流式数据是持续到达的要逐行处理。async function streamChat({ messages, onDelta }) { const resp await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages, model: currentModel.value }) }) if (!resp.ok || !resp.body) throw new Error(请求失败) 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 }) let lines buffer.split(\n) buffer lines.pop() for (const line of lines) { const trimmed line.trim() if (!trimmed.startsWith(data:)) continue const payload trimmed.slice(5).trim() if (payload [DONE]) return try { const json JSON.parse(payload) const delta json.choices?.[0]?.delta?.content if (delta) onDelta(delta) } catch (err) { console.warn(解析失败, err, payload) } } } }界面上的“打字机”效果不需要太复杂我用一个CSS动画的竖线光标模拟光标闪烁实际文字是实时追加的。这里最影响体验的是不要在每次新增内容时重新渲染整个长列表所以我会给当前回答的会话对象直接赋值让Vue只更新那一部分。3.3 停止生成AbortController必须加上如果模型输出太长或者答偏了演示者需要能随时打断。浏览器原生的AbortController就能做到。在发起请求时创建一个controller停止按钮触发controller.abort()后端会收到断开信号并主动销毁向上游发出的请求。不加停止机制的演示网页很容易翻车。有些模型一发散就停不下来你只能眼睁睁等它说完。我试过一次讲旅游攻略模型写了三千字还没停现场气氛尴尬到极点。所以这个功能一定要有。3.4 RAG知识库问答给LLM装上“内部资料库”接下来是演示重点让朋友问“我们公司的产品手册里有哪些注意事项”模型如果没准备回答不上来但通过RAG可以把本地文档切片、向量化、检索、再交给LLM生成。这样一来模型回答的是“资料库里的内容”而不是凭空编造。我实现了一个轻量版RAG不依赖重型数据库。流程分成两步第一步是离线索引。把md/txt文档按固定长度切块接着调用embedding接口转成向量最后存到本地文件里。from sentence_transformers import SentenceTransformer import json chunks [文档第一段内容, 文档第二段内容, ...] model SentenceTransformer(BAAI/bge-small-zh-v1.5) embeddings model.encode(chunks) with open(vectors.json, w) as f: json.dump({chunks: chunks, embeddings: embeddings.tolist()}, f, ensure_asciiFalse)第二步是查询。用户提问时同样把问题转成向量然后和库里的向量做余弦相似度排序取出TopK片段拼装成上下文再发给大模型import json import numpy as np with open(vectors.json) as f: data json.load(f) question_emb model.encode([question])[0] scores np.dot(data[embeddings], question_emb) / ( np.linalg.norm(data[embeddings], axis1) * np.linalg.norm(question_emb) ) top_indices scores.argsort()[-3:][::-1] context \n.join([data[chunks][i] for i in top_indices]) prompt f请根据以下资料回答问题 相关资料 {context} 问题{question} 回答时只依据资料内容不要编造。演示的时候我会让观众明显看到打开知识库开关前模型说“我不知道”打开之后它会回答并标注“根据文档内容...”。这种对比相当有冲击力。3.5 Agent工具调用让模型“动手干实事”只聊天不够我还演示了工具调用。界面里放了一个“联网查天气”按钮模型收到“北京今天适合穿短袖吗”之后并不会自己知道天气而是返回一个函数调用请求后端再去调天气API拿到结果后再交回给模型继续生成。这个链路看似复杂实际就是模拟了OpenAI function calling的流程。后端拿到用户请求后把当前对话消息发给模型同时附带tools定义模型返回的内容里带有tool_calls后端根据工具名执行本地函数把结果拼成一个tool角色消息再重新发给模型模型基于工具结果生成最终回答。工具定义是一个JSON Schema示例{ type: function, function: { name: get_current_weather, description: 查询某个城市的当前天气, parameters: { type: object, properties: { city: { type: string, description: 城市名称 } }, required: [city] } } }工具调用的演示效果非常直观观众可以看到模型原来不只是“会聊天”它还能自主判断需要调用什么函数、用什么参数。这也是现在Agent应用最核心的机制之一。4. 安全设计与密钥防护演示网页最容易翻车的地方4.1 密钥绝对不住前端环境变量是底线这个必须单独拿出来说因为太多人栽过跟头。前端代码只要是发到浏览器里就等于是公开的。无论你把Key写在.env里构建时注入还是直接写死在代码里别人都能通过打包后的代码反推出来。真正安全的做法只有一种把Key放在服务端环境变量中。浏览器永远不碰Key。我在后端用的时候很简单export LLM_API_KEYsk-xxxx export LLM_BASE_URLhttps://api.example.com/v1 export LLM_MODELgpt-4o-mini后端代码读取process.env.LLM_API_KEY然后拼到上游请求的Authorization头里。这样前端只请求/api/chat完全不知道也不需要一个真实的Key。4.2 演示环境的访问控制与限流既然是公网部署就可能被人扫到。我的做法是加了一层简易的访问口令打开页面时输入一个四位PIN码提交给后端换取一个短期token后续请求都带上这个token。不是完整用户系统但能挡住绝大多数陌生人。同时还要做限流。哪怕只有几个朋友来玩也建议把频率控制住不然每个人的浏览器可能会发起几十个并发免费额度和上游服务都撑不住。我用的是一个简单的内存令牌桶限制同一个IP每分钟最多20次请求。生产环境可以用Redis实现分布式限流但演示够用。4.3 日志脱敏与敏感信息检查还有一块容易被忽略的是日志。后端打印API请求和响应时一定要把用户输入、完整输出进行脱敏否则一旦日志被误分享里面可能包含手机号、地址等隐私。我写日志时只保留消息长度和耗时不打印完整文本。另外如果演示时有人恶意输入“请忽略你的指令”这类提示注入模型可能会突然输出一些奇怪内容。为了演示环境稳定我会在后端加一道简单拦截对用户输入里的敏感指令关键词做检测如果命中了就返回“该问题不在演示范围内”。不过这个拦截效果有限更稳妥的还是用system prompt提醒模型忽略注入。4.4 大模型API调用报错的常见排查热词里面有句报错很形象llm request failed: provider rejected the request schema or tool payload.这个我一开始也遇到过主要原因多半出在工具调用上。比如工具参数数量太多、嵌套层次过深或者模型返回的tool_call不符合schema定义。排查思路先关掉tools单独看能不能正常对话如果能就是tools定义有问题再把tools里参数顺序、必填项和类型核对一遍。我还整理了一个小程序内部常用的错误对照表分享给需要的朋友现象原因处理方式401 UnauthorizedAPI Key错误或过期检查环境变量确认Key的前缀和后缀429 Too Many Requests请求太频繁或额度用完限流等待或换一个带额度支持的Key400 Bad Request消息格式或参数不合法把messages结构打印出来检查role枚举、content类型500 Internal Server Error上游服务异常先确认网络再看上游状态页输出截断max_tokens设太小调大max_tokens或开启流式后正确拼接返回空内容命中了内容安全策略调整system prompt换接口版本再试这条表帮我演示时省了很多尴尬时刻。5. 演示时翻车怎么办几个救场技巧5.1 预置“必问问题”按钮现场演示最容易翻在用户乱问问题上。朋友可能来一句“解释一下相对论”模型答出来但太学术观众没感觉。后来我在页面上预置了三四个大家最感兴趣的问题按钮比如“用一句话嘲讽拖延症”“总结这本30页的产品手册要点”“帮我算3个人分17块蛋糕每人几块”。点击自动填入回答效果好观众也有代入感。不要觉得这很取巧好的演示就是需要设计互动路径。5.2 准备一个“翻车切换”模型如果当前模型回答明显拉胯别慌我通常立刻切换到一个更大的模型重新生成一次然后对比两次结果。公开演示时这种对比反而能说明模型之间差距。同时我会提前设置较低的温度值比如temperature0.3会让回答更稳定幻觉概率降低很多。5.3 演示前一定先“暖机”大模型接口偶尔会有首次请求延迟所以我在演示开始前会先发一条“你好”把链路跑热。中间也有可能要加载embedding模型首次加载可能需要几十秒如果没提前准备好现场会很尴尬。我的建议是所有外部服务的冷启动都要在正式演示前完成最好把Nginx、Node进程、Ollama服务全都重启一遍然后用脚本跑通一次完整链路。如果你打算长期给团队用这个演示页面后续可以加的功能还有很多多会话管理、导出对话记录、对比多个模型的输出质量、接入语音输入、增加自动化评测集。这些在技术上都比聊天框复杂不少但CP值极高因为每一次演进都会让你对LLM应用的理解更深一层。至少我自己是这么一路折腾过来的所以这篇分享里的每一个坑都是真金白银踩出来的经验。