码道:零后端纯前端打造「拾言 AI 对话小站」的完整实战记录
码道:零后端纯前端打造「拾言 AI 对话小站」的完整实战记录
从一段 Python 参考代码出发,用 HTML5 + CSS3 + JavaScript 构建一个开箱即用的流式 AI 对话应用,并在迁移过程中让模型学会「说人话、讲结构」——这是我在这篇文章里想与你完整分享的全部过程。
—在这里插入图片描述

一、缘起:为什么我要做这样一个项目
1.1 一次意外的「脚手架」任务
大概在一个寻常的工作日,我收到一个很有趣的挑战式需求:用纯前端技术栈,实现一个 AI 对话应用。需求清单并不复杂:能对话、能流式输出、能多轮上下文、界面要好看。真正让人眼前一亮的是需求里附带的参考实现——一段十几行的 Python 脚本,调用某个大模型 API,通过 SSE(Server-Sent Events)协议流式接收回复。
这段 Python 代码本身很简单:
def query(payload):
response = requests.post(API_URL, headers=headers, json=payload, stream=True)
for line in response.iter_lines():
if not line.startswith(b"data:"):
continue
if line.strip() == b"data:[DONE]" or line.strip() == b"data: [DONE]":
return
yield json.loads(line.decode("utf-8").lstrip("data:").rstrip("/n"))
但恰恰是这份简洁,让我意识到一个有趣的问题:如果把这个逻辑搬到浏览器端,会发生什么? 它并不难,但它会逼迫你去思考很多纯后端脚本里根本不会出现的问题——跨域、流式解析、用户中断、错误恢复、界面的实时反馈……于是,「拾言 AI 对话小站」这个项目就诞生了。
1.2 为什么叫「拾言」
起名向来是项目里最耗费脑细胞的一步。我想要的不是一个冷冰冰的「ChatBox」或者「AI Chat」,而是有一点温度的名字。后来我想到了「拾言」——拾取文字,对话交流。它有两层含义:
- 拾:从零散的知识碎片中捡拾有价值的信息,这与 AI 对话「从海量知识中提取答案」的本质暗合;
- 言:语言、对话,点出产品的核心交互形式。
副标题定为「拾取文字,对话交流」。整个产品希望传达的是一种轻松、友善、愿意交流的氛围,所以配色、文案、交互细节都围绕「温暖、克制、有设计感」来打磨。
1.3 技术路线的三次抉择
在动手之前,我先做了技术选型的推演,其实是在三条路线之间反复权衡:
路线一:重前端框架方案(React / Vue)
成熟、生态强大,但对这个需求来说有点「杀鸡用牛刀」——一个单页对话应用,没有复杂状态树,没有路由,没有组件复用压力。而且需要引入 Node 工具链、构建配置、依赖管理,交付物会变重,部署也要多一步构建。
路线二:纯 HTML5 + CSS3 + JavaScript
零依赖,零构建,浏览器打开 index.html 就能跑,任何静态托管平台都能直接部署。对于「工具型」「展示型」的项目,这反而是最优雅的答案——代码即交付物。
路线三:加后端代理的服务端渲染方案
能保护 API 令牌、能绕开 CORS,但多了一个要维护的服务端,部署成本翻倍,与「轻量」的初衷背道而驰。
最终我选择了路线二。这是一个刻意为之的取舍:在「能跑」和「好跑」之间,我选择让交付物足够简单,让任何拿到代码的人都能在十秒内跑起来。至于令牌安全、跨域这类问题,我在 README 里做了明确的说明,也给出了生产环境的建议。技术债可以有,但必须是「明示」的技术债,而不是「隐藏」的技术债。
二、架构设计:一个页面如何撑起一个完整的 AI 对话产品
2.1 总体结构
整个项目只有四个文件,却覆盖了一个完整对话产品的所有能力。我坚持一个原则:单一职责、文件各自清晰。
PickTalkAI/
├── index.html # 页面骨架:侧边栏 + 聊天区 + 输入区
├── css/
│ └── style.css # 全部样式:布局、主题、动画、响应式
├── js/
│ ├── config.js # 唯一配置入口:API、模型参数、系统提示词
│ └── app.js # 全部业务逻辑:SSE 解析、渲染、会话管理
└── README.md # 完整使用与开发文档
2.2 页面骨架:index.html
我刻意让 HTML 保持「骨架态」——它只负责定义结构,不负责任何业务逻辑。页面被划分为三个视觉区域:
- 左侧侧边栏:品牌区(拾言 Logo + 标题)、「新对话」按钮、历史会话列表、底部模型状态与「清空全部对话」入口;
- 右侧聊天区:顶部的对话标题与在线状态,中部的消息流,底部的输入框与发送按钮;
- 整个应用用一个
.app容器约束为经典的「侧边栏 + 主区」双栏布局。
值得一提的细节是 textarea 输入框的设计:单行展示,但支持自动增高、Enter 发送、Shift+Enter 换行,几乎复刻主流对话产品的输入体验。这听起来简单,但实际上「自动增高」与「Enter/Shift+Enter 的键位冲突处理」是不少新手项目会踩坑的地方。
2.3 样式层:CSS 变量驱动的主题系统
如果你翻过 style.css,会发现在文件最顶部定义了一组 CSS 变量:
:root {
--bg: ...;
--panel: ...;
--text: ...;
--accent: ...;
...
}
这是我在设计阶段就定下的规矩:所有颜色、间距、圆角、字号,全部走设计令牌(Design Tokens)。好处是显而易见的——后续任何一次改色,只需要改几个变量;做暗色模式,也只需要切换一套变量。
具体到视觉风格,我的目标是「克制的温度感」:整体以柔和的浅色为底,品牌色选了一个不刺眼的蓝紫渐变色;气泡、头像、按钮全部使用适度的圆角;流式输出的光标「▍」设置了闪烁动画,让「正在生成」的状态显得生动而不焦虑;消息出现时有极轻的上浮淡入动画,增强反馈的节奏感。
响应式方面,@media (max-width: 768px) 断点下侧边栏自动隐藏,聊天区占满全屏,移动端同样可用。
2.4 配置层:config.js 的职责边界
很多人会把配置写死在业务代码里,我选择单独抽出 js/config.js。它的内容包括:
| 配置项 | 作用 |
|---|---|
API_URL | 大模型 API 地址(OpenAI 兼容格式) |
AUTH_TOKEN | API 鉴权令牌 |
MODEL | 模型名称,如 deepseek-ai/DeepSeek-V4-Flash |
MAX_TOKENS | 单次回复最大生成 token 数 |
TEMPERATURE | 采样温度,默认 0.6 |
TOP_P | 核采样概率,默认 0.95 |
THINKING_BUDGET | 思考预算,控制模型深度思考长度 |
SYSTEM_PROMPT | 系统提示词,这是整个项目最有意思的部分 |
代码一旦写好,以后换模型、换令牌、调参,都只改这一个文件,业务代码零改动。配置与逻辑分离,是工程化最基本的体面。
三、核心难点一:Python 流式请求的 JavaScript 化
3.1 逐行对照:从 requests 到 fetch + ReadableStream
需求里那一段 Python 代码,本质上做了四件事:发起 POST 请求、按行读取响应流、过滤出 data: 前缀的数据、把 [DONE] 当作结束标记。我把这四步逐一映射到浏览器环境:
| Python 参考实现 | JavaScript 对应实现 |
|---|---|
requests.post(..., stream=True) | fetch(url, { method: 'POST', body: JSON.stringify(payload) }) |
response.iter_lines() | response.body.getReader() + TextDecoder 按行切分 |
line.startswith(b"data:") | line.trim().startsWith("data:") |
data:[DONE] 结束标记 | payload === "[DONE]" 终止循环 |
chunk["choices"] | json.choices[0].delta |
但这里有一个非常关键的工程细节:Python 的 iter_lines() 天然会按 \n 切好行,而浏览器流不会。ReadableStream 返回的 chunk 是任意长度的二进制块,一条 SSE 事件可能被分成多个 chunk 送达,也可能多个事件被合并在一个 chunk 里。处理不当,解析就会错乱。
我的解决方式是维护一个 buffer 字符串:
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop(); // 最后一段可能是不完整行,暂存
for (const line of lines) {
// 解析 data: 行
}
}
每轮循环先 split("\n"),把最后一段不完整的行重新放回 buffer,等待下一块数据补全。这样无论网络怎么分包,解析永远不会错位。这段代码虽然是「翻译」,但其实是移植过程中最需要动脑筋的地方。
3.2 更隐蔽的问题:TextDecoder 与审计字符集
还有一个容易被忽略但很现实的坑:中文内容的 UTF-8 多字节字符可能被 chunk 边界切断。一个汉字在 UTF-8 下占 3 个字节,如果某一块数据正好把这个汉字拦腰截断,直接 decode 会产生乱码。
解决办法是给 TextDecoder 传入 { stream: true }:
const decoder = new TextDecoder("utf-8");
buffer += decoder.decode(value, { stream: true });
stream: true 会让解码器把不完整的字节序列暂存在内部,等下一块数据补齐后再解码,从根上规避乱码。这也是移植 Python 代码时 Python 侧完全不需要关心、而浏览器侧必须处理的问题。
3.3 用户中断:AbortController
Python 脚本里没有「用户点击停止」这个概念,但网页必须考虑。我用 AbortController 实现了生成中断:
- 每次开始流式请求前
abortController = new AbortController(),并把signal传给fetch; - 「切换会话」「新对话」「清空」等操作触发
abortController.abort(); - 捕获
AbortError后,把已经生成的部分内容规整保存,实现「停在该停的地方」。
这保证了用户永远有「反悔权」:AI 说歪了,或无聊了,随时可以打断,而不是干瞪眼看它跑完 2048 个 token。
四、核心难点二:系统提示词工程——让模型开口就是结构化 JSON
4.1 为什么要结构化输出
对话应用最常见的丑陋体验是什么?——模型在正文里乱塞 **加粗** 的碎片、动不动把整个返回包进 ```json 代码块里、回答末尾「热情」地反问一串问题。这些行为本身没错,但它们会让前端渲染非常痛苦,也让「推荐追问」这类高级功能无从谈起。
于是我想:能不能在系统提示词层面,约束模型始终返回一个固定结构的 JSON?这样前端解析就变得稳定、可预期,还能顺势实现「主题标签」「智能追问」等花活。这正是需求里明确提到的方向——把系统提示词改造成项目所需的 JSON 数据返回格式。
4.2 提示词怎么写才「稳」
我写的系统提示词核心内容如下(节选):
你的每一次回答必须以严格的 JSON 对象返回,不要输出任何额外文字、
不要用 markdown 代码块包裹 JSON,格式如下:
{
"reply": "回答正文,使用 Markdown 语法,内容详实准确",
"suggestions": ["追问一", "追问二", "追问三"],
"topic": "用 2-6 个字概括本次回复主题的关键词标签"
}
几个关键设计决策:
- 字段语义清晰:
reply(正文)、suggestions(推荐追问)、topic(主题标签),一眼能懂; - 给出强约束的否定式指令:「不要输出任何额外文字」「不要用 markdown 代码块包裹」——否定式明确指令对约束模型格式比肯定式更有效;
- 给出完整示例:人类学格式靠示范,模型也一样,示例让模型「照葫芦画瓢」;
- 行为准则兜底:始终用简体中文、不确定时坦诚说明、内容具体实用——保证 JSON 里的内容质量。
4.3 前端如何「容忍」模型的任性
即便提示词写得再好,模型偶尔还是会「越界」——吐出个 ```json 包边,或者开头加一句「好的,回复如下:」。所以我在前端做了一个三级容错解析链:
function parseReplyJson(src) {
// 第一级:整体直接 JSON.parse
// 第二级:剥掉 ```json ... ```代码块再解析
// 第三级:掐头去尾,从第一个 { 到最后一个 } 截取再解析
}
三级策略层层兜底,把模型「99% 听话 + 1% 调皮」的风险降到最低。而流式过程中,我还写了一个 extractReplyRaw 函数,用正则实时抓取 "reply" 字段的进行时内容,让用户在生成过程中就看到正文逐字出现,而不是等到整个 JSON 结束。这一行细节,直接决定了流式体验的成色。
4.4 多轮上下文的「清扫」
还有一个隐藏设计:发送给 API 的历史消息里,assistant 的历史回复我并不会把整个 JSON 原样塞回去(那会污染上下文、浪费 token),而是用 plainOf 把历史回复剥壳成纯 reply 正文再送进上下文。系统提示词 + 用户消息按原文、助手消息按正文摘要,这种「上下文净化」让多轮对话既准确又省 token。
五、核心难点三:零依赖 Markdown 渲染器
5.1 为什么不用 Marked / Showdown
引入一个 Markdown 库只需要一行 CDN,但这次我选择手写轻量渲染器。理由很朴素:
- 零依赖是项目立项时的硬约束,尽量不打破;
- 对话场景对 Markdown 的需求有限:标题、粗斜体、行内代码、代码块、列表、引用、链接、分割线,覆盖了就够;
- 手写渲染器意味着完全可控——我可以确保任何情况下 XSS 都被正确转义(事件属性、javascript: 链接等一律被干掉),也能针对流式场景做增量优化。
5.2 实现思路:转义 → 正则 → 分段
我的渲染流程分三步:
- 先转义:把
& < > " '全部替换为 HTML 实体。这是一条不可逾越的安全底线,先转义再处理标记,从根上杜绝注入; - 再正则:用一系列精心排序的正则替换把 Markdown 标记转换成 HTML 标签。注意顺序很重要——必须先处理行内(行内代码、粗斜体),再处理块级(标题、列表、引用);先处理围栏代码块再处理行内代码,否则会互相干扰;
- 最后分段:按空行把内容切成段落,块级元素(
<pre><h1><ul>等)单独成段,普通文本包进<p>。
这样一个几百行的轻量渲染器,覆盖了对话场景 95% 的排版需求,而且行为完全可控、可测。
六、核心难点四:会话管理与持久化
6.1 localStorage 承载多会话
「多会话」是对话产品的基本素养。我用 localStorage 做持久化,数据模型非常简单:
{
"id": "s_时间戳_随机串",
"messages": [
{ "role": "user", "content": "..." },
{ "role": "assistant", "content": "{\"reply\":...}", "thinking": "..." }
]
}
会话列表、当前激活会话两个 key 分开存储,刷新页面后自动恢复。会话标题不单独存储,而是从第一条用户消息动态截取(超过 14 字加省略号)——少存一个字段,就少一个不一致的来源。
6.2 流式期间的状态一致性
流式生成时,draft(进行中的助手消息)会被 push 进当前会话的 messages,同时用一个引用接着,随时原地更新 content 和 thinking。这里有个大坑:API 请求的消息列表必须排除掉这个 draft,否则会把半截 JSON 送进上下文。我的实现里用 .filter(m => m !== draft) 做了引用级排除,干净利落。
6.3 思考过程的长截断
DeepSeek 系列模型的 reasoning_content(深度思考链路)可能非常长,我设置了 6000 字符的上限,超出后从尾部截断——既保留思考过程的价值(尾部的结论往往更有用),又避免 DOM 被撑爆。
七、视觉与体验:让细节经得起推敲
7.1 「思考过程」的可折叠展示
对话应用最常见的痛点之一:AI 在「想」的时候用户看不到任何反馈,要么干等,要么怀疑卡死。在我的实现里,模型输出的 reasoning_content 会实时累积,并以一个可折叠的 details 组件展示出来——生成中默认展开并带闪烁光标,完成后可收起。这既满足了「透明度」,又避免了冗长思考内容喧宾夺主。
7.2 「智能追问」按钮
每条助手消息渲染完成后,如果解析出了 suggestions,会在回复底部渲染三个胶囊按钮「💡 可能你还想问」。点击任意一个按钮,会直接以该问题发起新一轮对话。这个功能的成本和收益比极高——几行代码,却让「对话-追问-再对话」形成闭环,极大提升了产品的可玩性。
7.3 空状态的引导
首次进入、没有任何对话时,页面中央展示欢迎文案 + 四个预置问题卡片(「🌌 黑洞的秘密」「🍂 写一首秋天的诗」「💻 闭包是什么」「📚 高效学习方法」)。这既填充了空白画面的尴尬,又给了用户「不知道该聊什么」时的明确抓手。
7.4 状态机:在线 / 思考中 / 生成中
顶栏的状态指示器是三态切换的:空闲时「在线」绿点,请求发出后「思考中…」,收到首个内容 token 后切为「生成中…」。配合输入框禁用、发送按钮禁用,用户在任何时刻都能准确感知系统的当前状态,不会产生「我是不是卡了」的焦虑。
八、踩坑实录:那些我替读者趟过的雷
8.1 CORS 与 file:// 协议
项目做好后最大的坑:直接用浏览器打开 index.html(file:// 协议)调用 API 会因跨域被拦截。解决方案不是我绕开 CORS,而是在 README 里明确交代——本地用 python3 -m http.server 起个静态服务,或部署到任意静态托管平台。这不是偷懒,而是把「静态页面 + 远程 API」模式本来就该面对的约束摆在台面上。
8.2 流式 JSON 的「截断时刻」
流式过程中,最怕用户切走会话再切回来——此时 draft 可能保存了未闭合的 JSON。我在最后收尾时用 normalizeJson 做规整:从第一个 { 截取到最后一个 },再交给解析器容错处理;实在解析不出来的,也保证用户能看到原始文本,绝不白屏。
8.3 渲染性能
流式期间每次 onChunk 都会触发一次全量重渲染,如果消息很多,DOM 重建会有肉眼可见的卡顿感。我做了两个妥协:一是给新增消息加轻量 CSS 动画而非重排导致的位移;二是流式结束后采用非平滑滚动到底部,避免动画叠加造成的抖动。对于对话类应用,这个量级的性能优化已经够用。
8.4 令牌的前端暴露
必须诚实面对的一个事实:纯前端应用,任何令牌都会暴露在浏览器里。这是架构本身的固有限制,我在 README 安全提示一节里专门说明,并建议生产环境用后端代理转发。让用户知情的取舍,好过假装它不存在。
九、验证与交付:如何证明它真的「能跑」
在我认为项目完成之前,我做了一套完整的验证动作:
- 静态检查:对
config.js、app.js跑node --check,确认语法零错误; - 资源可达性:本地起 HTTP 服务,逐个请求
index.html、CSS、JS,全部 200; - DOM 引用核对:把 HTML 里所有
id与app.js中的getElementById/querySelector引用做交叉比对,保证零漏引; - API 实测:用脚本真实调用接口,确认 HTTP 200、SSE 流式正常返回;
- 格式实测:用完整系统提示词跑一轮「什么是黑洞?」,确认返回 JSON 能被
JSON.parse成功解析,且reply、suggestions(3 个)、topic三个字段全部齐全; - 思考链路验证:确认
reasoning_content确实随流式返回,前端思考折叠组件有真实数据可展示。
这六步验证里,第 4、5 步是最有说服力的——代码写完了不算完,接口真真实实地吐出了符合约定的结构化数据,这块拼图才算合上。
十、写在最后:这个项目教给我什么
回看整个「拾言 AI 对话小站」的创作过程,有几点体会很想和读者分享:
第一,需求里的参考代码往往是「锚点」,而不是「天花板」。 那段 Python 脚本只有十几行,但如果只做字面翻译,最终交付物也只是一个「会流式输出的控制台」。真正让项目脱胎换骨的,是围绕「浏览器这个特殊宿主」做的那些增量设计——AbortController、TextDecoder stream 模式、JSON 结构化容错、缓存行 buffer……这些才是移植的灵魂。
第二,系统提示词是产品体验的一部分,而不是一句可有可无的开场白。 一个认真设计的 SYSTEM_PROMPT(结构化字段、否定式约束、示例)能让"推荐追问""主题标签"这类产品功能从「约等于没有」变成「稳定可用」。提示词工程的每一分投入,最终都会变成用户看得见的产品差异。
第三,零依赖不是复古,而是一种清醒。 在这个 npm 包比代码还多的时代,选择「HTML + CSS + JS 三件套」有时候反而是最优雅的答案——交付物即代码、打开即用、部署零门槛。它不是万能的,但对「工具型、展示型、教学型」的项目,它恰到好处。
第四,交付不是写完代码,而是跑通闭环。 直到 API 实测返回了合规的结构化 JSON,直到每条推荐追问都能点击生效,直到刷新页面会话还在,我才觉得自己真的交付了。验证的六个步骤里,任何一环失败,我都不好意思说「项目完成了」。
如果你也对「纯前端 + AI 对话」的组合感兴趣,欢迎直接 clone 这个仓库,改一改配置里的令牌和模型名,它就是你的了。也可以顺着 README 里的对照表,亲手感受 Python 到 JavaScript 的思维迁移过程。期待看到你的「拾言」,对话愉快。
项目地址:https://atomgit.com/gcw_aQD64pbk/PickTalkAI
技术栈:HTML5 + CSS3 + 原生 JavaScript(零依赖)
一句话总结:从一段 Python 流式脚本出发,我在浏览器里重建了一个完整的 AI 对话产品,并让模型学会了用结构化 JSON「开口说话」。
更多推荐


所有评论(0)