码道实践|AI 环境角色闲谈系统:从零构建多角色流式对话的前端实战笔记
在这里插入图片描述

关键词:AI 对话、SSE 流式解析、结构化输出、System Prompt 设计、原生前端
一、引言:为什么我想做一个"会聊天的场景"
过去一年里,以 ChatGPT 为代表的大语言模型(LLM)已经深度融入到我们的日常开发与创作中。打开任何一个成熟的产品,对话框中冰冷的"AI 助手"几乎长着同一张脸:左侧一个圆圆的头像,右侧一段又一段规整的 Markdown 排版答案。它们高效、准确,但总少了点什么——人味。
于是我开始思考一个问题:对话产品能不能做得更像"遇见了某个人",而不是"在使用一个工具"?
如果 AI 是一个深夜咖啡馆里爱碎碎念的咖啡师,是一个在旧图书馆里话很多的图书管理员,是一个漂浮在空间站观察舱里的机器人助手,甚至是一个住在森林木屋里养狐狸的年轻女巫——当用户敲下第一句话时,得到的不是一段结构化清单,而是一句带着表情和动作的闲谈,那种体验会不会完全不同?
这个想法催生了本文的主角——「AI 环境角色闲谈系统」(FreeRole)。它是一款纯前端、零后端、零依赖的 AI 角色扮演对话页面,用户可以选择环境、选择角色、开启一段有画面感的闲聊。本文会完整记录这个项目从想法到落地的全过程,包括:为什么选择纯静态方案、如何把 Python 里的 requests 流式请求完整翻译成浏览器的 fetch + SSE 解析、如何通过系统提示词让模型"只输出 JSON"来驱动前端富媒体渲染、以及过程中踩过的坑和验证方法。
如果你也想做一个"会说话的界面",这篇文章应该能给你不少可复用的思路。
二、项目概览:它长什么样,能做什么
在展开技术细节之前,先花一点篇幅介绍项目最终呈现出来的样子,方便读者理解后面每一段代码的动机。
「AI 环境角色闲谈系统」的核心交互很简单:选择环境 → 选择角色 → 对话。
当前内置了四个主题环境:
| 环境 | 角色 | 氛围关键词 |
|---|---|---|
| ☕ 街角咖啡馆 | 咖啡师"阿黎"、小说作家"墨白" | 暖气熏香、爵士乐、咖啡机嗡鸣 |
| 📚 星尘图书馆 | 图书管理员"小芸"、老学者"程老" | 旧纸香、落地窗、金色阳光 |
| 🚀 深空空间站 | 女舰长"朱雀"、AI 助手"小飞" | 浩瀚星河、蓝色地球、失重 |
| 🌲 雾隐森林小屋 | 女巫"露娜"、小狐仙"阿茶" | 炉火噼啪、萤火虫飞舞 |
用户选定角色后,发送任意话题(比如"今天心情不太好",或"给我讲讲你最喜欢的故事"),大模型会完全以该角色的身份与口吻回应,而不是以"作为一个人工智能助手"的口吻说教。
对话体验上,项目实现了三个"加分项":
- 流式打字机效果:AI 的回复逐字渲染,像真人正在输入,而不是等待漫长加载后一次性冒出来。
- 结构化富媒体气泡:模型每轮回复都按约定的 JSON Schema 输出,前端解析后渲染出"表情头像 + 情绪徽章 + 台词正文 + 动作描写 + 场景变化提示"五位一体的气泡,聊天气息浓郁。
- 思考态感知:模型进入深度思考(reasoning)阶段时,状态栏实时提示"AI 正在思考…",用户不会误以为卡死。
这四个环境、八个角色、一条流式管道,构成了项目的全部。麻雀虽小,五脏俱全。
三、技术选型:为什么是"纯静态前端"
这个项目的技术栈几乎是"反向选择"出来的——没有框架、没有构建工具、没有后端服务器,就是一份 HTML、一份 CSS、三份 JS。
做出这个选择有几个理由:
其一,LLM 推理接口天然适合直连。 项目背后的大模型服务由 GitCode AI 平台提供,接口是标准的 OpenAI 兼容格式(/v1/chat/completions),支持 SSE 流式返回。相比某些需要密钥签名、需要服务端代理的云厂商,它可以直接从浏览器发起请求,这给了纯前端方案可行性。
其二,降低使用门槛。 很多对 AI 充满好奇的初学者,第一道坎是"装环境"。跑一个 Python 后端需要配虚拟环境、装依赖、管端口;而一个纯静态目录,用 python3 -m http.server 8080 甚至双击 index.html 就能跑起来。
其三,聚焦核心问题。 这个项目真正的难点不在"前后端联调",而在两个更本质的问题上:浏览器端如何消费 SSE 流,以及如何让模型输出能驱动 UI 的结构化数据。丢掉框架的干扰,正好把精力集中在这些真问题上。
当然,纯静态方案也有显而易见的代价:API 密钥必须硬编码在前端文件中(或至少可以被任何打开控制台的人看到)。这一点我在仓库 README 里专门做了安全提醒。如果未来要公开部署,合理的演进路径是加一层轻量服务端代理,把密钥藏在后端。
四、架构设计:三个模块各司其职
项目的 JS 代码被拆成了三个文件,职责边界非常清晰:
js/config.js:纯数据层。存放 API 地址、密钥、模型参数,以及全部场景与角色的描述(SCENES对象)。它把"数据"和"逻辑"分离——想新增一个环境或角色,改这一个文件就够了,一行业务代码都不用动。js/api.js:纯网络层。封装对推理接口的流式请求,对外只暴露一个方法AIApi.chatStream(payload, onChunk, onThinking)。onChunk在每收到一段正文增量时被回调,onThinking在收到思维链增量时被回调。UI 层完全不关心网络细节。js/app.js:纯视图层。负责渲染场景、管理角色选择、组装消息历史、解析 JSON、拼装气泡 DOM。它不知道请求是怎么发出去的,只消费AIApi的返回。
这种朴素的"数据 / 网络 / 视图"三层划分,即便是没有用过任何框架的人也能一眼看懂;模块之间通过 CONFIG、AIApi、App 三个全局对象松耦合,也方便后续迁移改造。
UI 层面则遵循一个简单的数据流:
用户输入
→ 追加进历史上下文 history
→ 组装 messages(system 提示词 + 最近 10 轮 + 本轮)
→ AIApi.chatStream 发起 SSE 流式请求
→ onChunk 逐字渲染(打字机)
→ 流结束 → 对完整文本 JSON.parse
├─ 成功 → 渲染富媒体气泡
└─ 失败 → 降级渲染纯文本气泡
这个数据流会在后面的章节逐一展开。
五、核心问题一:把 Python 的流式请求翻译成浏览器 JS
5.1 参考的 Python 实现
项目最初的接口调研是用 Python 完成的,参考代码长这样:
import requests
import json
API_URL = "https://api-ai.gitcode.com/v1/chat/completions"
headers = {"Authorization": f"Bearer {API_KEY}"}
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]":
return
yield json.loads(line.decode("utf-8").lstrip("data:"))
for chunk in query({...}):
print(chunk["choices"])
这段代码很短,但背后的机制值得品味:stream=True 让 requests 不再等待全部响应结束,而是以迭代器的方式按行吐出响应体;服务端按 SSE(Server-Sent Events)协议把内容切成一行一行的 data: {...} 帧;客户端过滤出以 data: 开头的行,剥掉前缀,json.loads 成对象;最后遇到 data:[DONE] 帧时结束。
SSE 协议本身很朴素:每条消息是 field: value\n\n 的格式,在这里服务端只用了 data 这一个字段。一次流式响应大约长这样:
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: [DONE]
5.2 JavaScript 的等价实现
浏览器里没有 requests,但有 fetch。fetch 返回的 response.body 是一个 ReadableStream,可以调用 getReader() 获得一个读取器,像 Python 迭代器一样一段一段地消费数据。问题在于:两边的"最小数据块"粒度不同。
- Python 的
iter_lines()按换行符切行,天然对齐 SSE 帧。 - 浏览器的
reader.read()按网络分块返回,一块可能包含多个 SSE 帧,也可能只包含一个帧的半句。
所以在 JS 里必须自己维护一个"行缓冲":把每次 read() 拿到的字节用 TextDecoder 解码并拼到 buffer 末尾,再按 \n 切出完整行来处理,把没切完的尾段留在 buffer 里等下一块。这是整个 SSE 解析最核心的一个细节,也是新手最容易写错的地方——很多人会假定"一次 read 就是一行",结果遇到跨块断行时数据就丢了。
以下是在 js/api.js 中的核心解析循环(做了简化以便阅读):
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let full = "";
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) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue; // 等价于 Python 的 startswith(b"data:")
const data = trimmed.slice(5).trim();
if (data === "[DONE]") continue; // 等价于 Python 的 [DONE] 判断
try {
const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta?.content;
if (delta) {
full += delta;
onChunk(delta); // 逐字回传给 UI
}
const reasoning = json.choices?.[0]?.delta?.reasoning_content;
if (reasoning && onThinking) {
onThinking(reasoning); // 思考链回传,驱动"思考中"状态
}
} catch (e) { /* 忽略无法解析的行 */ }
}
}
return full;
5.3 Python → JS 的逐项对照
把两段代码放在一起,不难整理出一份"翻译对照表":
| Python | JavaScript | 说明 |
|---|---|---|
requests.post(..., stream=True) | fetch(url, { method: "POST", body }) | 发起流式请求 |
response.iter_lines() | response.body.getReader() + 按行缓冲切分 | 按行消费响应体 |
line.decode("utf-8") | new TextDecoder().decode(value) | 字节 → 字符串 |
if line.startswith(b"data:") | if (trimmed.startsWith("data:")) | 过滤 SSE 帧 |
line.strip() == b"data:[DONE]" | if (data === "[DONE]") | 结束标记 |
json.loads(...) | JSON.parse(data) | 解析帧负载 |
chunk["choices"] | json.choices?.[0]?.delta?.content | 取出增量正文 |
两者没有本质差别,唯一的思维转换是:Python 帮你把"字节流→行"这件事做完了,而浏览器需要你亲手实现一个行缓冲器。 想通了这一点,整个翻译过程就没有任何悬念。
5.4 一个容易忽略的细节:可选链与容错
在解析 delta 时,我用了 json.choices?.[0]?.delta?.content 这样的可选链写法。原因很现实:流式响应里,服务端偶尔会先发一个 {"choices":[{"delta":{"role":"assistant"}}]} 之类的"元信息帧"(role 帧、usage 帧等),此时 delta.content 是 undefined。如果不做判空,onChunk(undefined) 会把 undefined 拼进正文,渲染出一个"undefined"的奇怪字符。
另外,JSON.parse 那一行用了 try/catch 静默忽略。虽然我们的行缓冲理论上保证了传入的一定是完整行,但防御性编程的成本极低——万一某条帧不完整导致解析失败,绝不能让它中断整个对话。
六、核心问题二:让模型"只输出 JSON"
6.1 为什么要结构化输出
如果模型按普通文本返回,那么"阿黎"的回复会混在一起:
我笑着对你说:"今天天气真不错,要不要试试我们的桂花拿铁?"然后我擦了擦杯子。
前端要从中拆出"台词、情绪、动作",就得靠正则匹配——脆、难维护、必然漏。
与其事后解析,不如事前约定:在系统提示词里明确要求模型每一轮只输出一个合法的 JSON 对象,按我们的 Schema 组织字段。这一思路在业界常被称为 Structured Output / Function Calling 的简版,虽然我们用的是"提示词约束"而非"API 强制约束",但对大多数主流模型来说,效果已经足够稳定。
6.2 系统提示词的设计
系统提示词由 buildSystemPrompt() 在每次请求前动态拼接,把"环境、角色、协议"三段式地喂给模型:
【当前环境】街角咖啡馆 ☕
【环境氛围】暖气熏香、爵士乐轻响、咖啡机嗡鸣的午后咖啡馆
【你的角色】阿黎 🧑🍳,身份:温柔健谈的咖啡师。请以这个角色的口吻说话,
偶尔可以提及环境氛围或场景细节,让对话更有画面感。
【严格输出要求】你每一轮都必须只输出一个合法的 JSON 对象,
不要输出代码块标记、不要输出任何多余文字或解释。结构如下:
{
"speaker": "你的角色名",
"emotion": "情绪关键词(如:开心、好奇、惊讶、温柔、调皮)",
"expression": "与情绪对应的一个 emoji 表情",
"text": "你的台词,自然口语化的中文闲聊内容",
"action": "简短的动作或神态描写,如 端起咖啡抿了一口",
"scene": "若环境发生变化则用一句话描写,否则填 null"
}
这个提示词有四个刻意为之的设计点:
- 把"规则"放在"角色扮演"之后。模型处理长篇提示词时,靠后的内容比重更高,但"身份锚定"在前、则越早建立的角色人格在后文越不容易漂移——顺序是"先做人,再守规矩"。
- 对每个字段都给了示例(如"端起咖啡抿了一口"),而不是只给字段名。示例的作用是锚定风格,防止模型把
action写成"进行了友好的肢体动作"这种废话。 - 明示"scene 可为 null",避免模型每一轮都强行编一个场景变化,制造出"每秒都在变天"的荒诞感。
- 结尾的注意强调"请确保输出的是纯 JSON,可被 JSON.parse 直接解析",这句话专门用来对抗模型偶尔输出的 Markdown 代码块包裹。
6.3 真实效果验证
在开发过程中,我用真实的 DeepSeek 模型跑过完整链路,模型确实忠实地按 Schema 返回。例如用户问"给我介绍一款特调咖啡吧",返回:
{
"speaker": "阿黎",
"emotion": "开心",
"expression": "😊",
"text": "我们店里最受欢迎的是“秋日桂花拿铁”哦!在香浓的拿铁里加入了手工桂花蜜……要试试看吗?",
"action": "轻轻晃了晃手中的拉花缸",
"scene": null
}
第一轮就能稳定命中 Schema,这给了"纯提示词约束"路线足够的信心。
6.4 JSON 解析的稳健性:三道防线
即便提示词写得再严谨,模型毕竟是概率模型,偶尔还是会"任性"一下。为此 parseAIJson() 里设了三道防线:
- 剥离代码块包裹:部分模型在少数请求下仍会输出
```json ... ````,因此先用正则^```(?:json)?` 和尾部 ```````````剥掉包裹再解析。 - 滑动裁剪策略:如果整体解析失败,找到文本中第一个
{和最后一个},把中间的子串再试一次。这一步极其有效——很多"失败"其实是模型在 JSON 前后多说了几个字,比如用自然语言说了句"好的!"打头。 - 内容校验:解析成功后还要检查
obj.text是否为非空字符串。有些模型会规规矩矩返回 JSON 但把 text 填成空串,这时也视为失败,交给降级路径。
三道防线之后,再失败的场景已经非常罕见——而即便真发生了,UI 也不会崩。
6.5 纯文本降级:永不中断对话
最后的兜底逻辑写在 send() 里:如果流结束后 parseAIJson 仍返回 null,就以"纯文本气泡"渲染原文,并保留角色名与头像,只是没有情绪徽章和动作描写。这保证了对话永不因为格式问题中断。对聊天产品而言,"永远能聊下去"是比"格式完美"更高的优先级。
七、思考态与上下文:让闲聊更接近真人
7.1 思考态提示
GitCode AI 的接口模型支持 thinking_budget 参数,会在 delta.reasoning_content 里推送模型"内心独白"。我特意处理了这个字段:当 onThinking 被触发时,底部的状态栏会切换为金色文字"AI 正在思考…"。这个体验细节不大,但心理学价值很高——用户等待时焦虑的根源是"不知道系统是否在工作",一个明确的中间态能显著提升耐心。
7.2 滑动窗口上下文
聊天应用必须能"记住"前面说了什么。项目使用了一个非常朴素的消息历史数组:
const messages = [
{ role: "system", content: buildSystemPrompt() },
...history.slice(-10),
{ role: "user", content: text }
];
history.slice(-10) 表示每次请求只携带最近 10 轮对话。这个数字是权衡的结果:携带太少,角色会"失忆",聊到第 11 句就忘了第 1 句的伏笔;携带太多,既浪费 token 又拖慢首字延迟。对"闲谈"这种轻量使用场景,10 轮是个不错的甜点值。
有一点值得注意:system 提示词是每次请求前由当前环境与角色动态生成的,这意味着即使用户中途切换角色,历史里旧角色的消息也不至于和新角色的指令产生剧烈冲突——虽然更严谨的做法是在切换时清空历史,这个项目里我的选择是直接清空 history 并重建欢迎语,逻辑最干净。
八、对外可配置的美漫:配置驱动的场景/角色体系
8.1 SCENES 数据结构
js/config.js 里的 SCENES 是整个项目的"世界观数据库":
forest: {
name: "雾隐森林小屋",
emoji: "🌲",
atmosphere: "炉火噼啪作响、窗外萤火虫飞舞的森林木屋,温暖而神秘",
characters: [
{ id: "witch", name: "露娜", emoji: "🧙♀️", style: "神秘又友善的年轻女巫" },
{ id: "fox", name: "阿茶", emoji: "🦊", style: "机灵话痨的森林小狐仙" }
]
}
每一个字段都在系统提示词里被引用:name 与 emoji 构成标题,atmosphere 构建环境氛围锚点,characters[].style 决定角色的行为基调。数据与逻辑彻底解耦的收益是:以后想加一个新环境,比如"落日海滩 🏖️",只需要在 SCENES 里加一个对象——角色按钮、欢迎语、提示词、聊天渲染全部自动生效,一行逻辑代码都不用动。
8.2 角色切换的交互
侧边栏渲染出当前环境的两个角色按钮,点击即切换当前对话对象。切换时我保留了一个"系统提示"气泡(“你已选择与「露娜」对话,TA 会以 TA 的身份回应你”),让用户明确感知到当前说话对象的变更,而不是一头雾水地盯着屏幕上突然换了性格的角色。
九、UI/UX:深色星空下的玻璃拟态
聊完逻辑,说说长相。任何对话产品,视觉是体验的第一层皮肤。项目采用了一套统一的深色主题设计语言:
- 星空渐变背景:页面背景是两个径向渐变(一紫一蓝)叠在深蓝底上,分别从右上角和左下角打光,模拟"宇宙深处有星云"的质感。配合
background-attachment: fixed,让渐变在滚动时保持稳定。 - 玻璃拟态面板:聊天区、侧栏使用
background: rgba(255,255,255,0.06)半透明底 +backdrop-filter: blur(12px)背景模糊 + 1px 半透明描边,营造"毛玻璃"的层次感。 - 信息层级分明:用户气泡用紫蓝渐变实底(主色调),AI 气泡用半透明白底;AI 气泡内部再做次级分层——角色名(弱灰)、情绪徽章(亮蓝胶囊)、台词(白色正文)、动作描写(斜体弱灰)、场景变化(暖金提示条)。用户一眼扫过去就能看懂"谁在说、情绪如何、发生了什么"。
- 动效克制:气泡用极短的
pop入场动画(透明度 + 上移 6px、0.25s),输入状态用三个呼吸小圆点表示"正在输入"。克制,是为了不抢戏。 - 响应式适配:桌面端是"侧栏 + 聊天区"双栏;
820px视口以下自动变为单栏纵向布局,侧栏压缩为横向的可换行按钮组,保证手机上也能舒服地聊。
这些细节单独看都很小,但组合起来构成了项目"让人愿意点进去聊两句"的第一印象。
十、测试与验证:没有框架如何保证质量
纯静态项目没有打包工具,也可以建立有效的验证手段。这个项目我做了两层验证:
10.1 真实接口链路验证
第一步是验证"网络侧"完全可用。我写了一个 Node 脚本,用与浏览器完全一致的解析逻辑(行缓冲 + SSE + DONE 处理)真实调用模型接口,确认三件事:
- 流式响应能完整接收,没有跨块断行丢失;
choices[0].delta.content能被正确累积成完整 JSON 文本;- 最终
JSON.parse成功,且text字段非空。
这一层验证锁死了"前后端通信"这个最大不确定因素。
10.2 jsdom 冒烟测试
第二步是验证"DOM 侧"逻辑。由于环境里没有可用浏览器,我引入了 jsdom 在 Node 中模拟 DOM:把 index.html 解析进虚拟文档,把三个 JS 文件合并后 eval 进虚拟窗口,再用一个假的 fetch 提前注入——假 fetch 返回一个预置好的一行式 SSE 流。
随后模拟用户操作,跑了 11 项断言,全部通过:
- 页面初始化后渲染出包含当前环境名的欢迎消息;
- 咖啡馆渲染出 2 个角色按钮;
- 切换场景到"空间站"后角色按钮更新为朱雀与小飞;
- 点击角色后发送消息,假流被消费;
- AI 气泡正确渲染出情绪徽章(开心)、角色名(阿黎)、动作描写(擦了擦杯子)与台词;
- 发送后输入框被清空。
这套冒烟测试的价值在于可重复:每次改动 app.js 的渲染逻辑后,跑一遍就能立刻知道有没有把老功能弄坏。对一个没有 CI 的小项目而言,这是性价比最高的守护。
十一、踩坑记录:那些"看起来简单"的地方
开发过程中有几个坑值得单独记录,它们都是"文档三行字、实现一晚上"的典型。
坑一:read() 的分块粒度不可控。 最初的实现没有行缓冲,直接假定一次 read() 就是一行 SSE,结果文本偶尔会莫名少几个字甚至乱码。排查了很久才发现是跨块断行——这是流式解析领域最经典的"初学者之坑",解决办法就是前面说的行缓冲。
坑二:reasoning_content 混入正文。 某次联调发现消息气泡里出现了一大段"思考过程",排查后发现 delta 里同时有 content 和 reasoning_content 两个字段,两个都处理就会混排。最终明确分工:content 走正文渲染,reasoning_content 只驱动状态栏的"思考中"提示,绝不让它进入最终文本。
坑三:jsdom 的 eval 作用域。 用 jsdom 做测试时,三个 JS 文件分别 eval 后,CONFIG/SCENES 这些 const 声明各自存在于独立的 eval 作用域里,互相看不见,导致 app.js 一跑就报 ReferenceError: SCENES is not defined。最终把三个文件合并成一个字符串再整体 eval 才解决。这个坑提醒我:模块化工程在引入"无打包器"的测试环境时,连"变量可见性"都会重新成为一个问题。
坑四:凭据与密钥的边界。 项目里的 API Key 直接存在于前端文件,这在"学习/演示"场景下可以接受,但一旦公开部署就等于公开密钥。这个教训我写进了 README 的安全提醒区,未来如果要上线,一定会补一个轻量代理把密钥下沉到服务端。
十二、复盘与思考:学到了什么
项目虽小,但最后一轮复盘还是提炼出了几点可以迁移到更大项目的经验:
- 结构化输出是 LLM 应用的隐藏杠杆。 提示词里多花 30 秒定义 Schema,能省掉 UI 层几个小时的脆弱解析代码。凡是"模型输出要驱动界面"的场景,都值得优先设计 JSON 协议而不是事后打补丁。
- 流式体验是聊天产品的隐形竞争力。 同样的模型、同样的答案,"逐字出现"和"等三秒一起出现"的用户体感完全不同。SSE 解析 + 行缓冲是一套随处可用的标准件,值得每个前端工程师掌握。
- 容错设计要排在"完美"前面。 格式降级、解析重试、静默忽略坏帧——这些"脏活"决定了产品在真实世界里的存活率,它们比那些炫技代码更重要。
- 数据与逻辑解耦让迭代成本骤降。 从头到尾我没有为"增加新角色"写过一行逻辑代码——这正是配置驱动设计的红利。
十三、未来展望
这个项目目前是一个可以演示、可以学习、可以二次开发的纯静态 DEMO。如果继续演进,我设想的路线大致是:
- 多角色同场对话:让同一环境下多个角色"一起"和用户聊天,模型一次输出多段不同 speaker 的 JSON,前端按角色渲染成交错的小剧场。
- 语音能力:接入 Web Speech API,让角色真的"说起话来",把闲谈从"读"变成"听"。
- 轻量后端 + 密钥托管:加一层 Node/Cloudflare Worker 代理,把密钥隐藏到服务端,支持多用户持久化聊天记录。
- 记忆系统:让角色记住用户的偏好(比如"用户喜欢桂花拿铁"),并在后续对话中自然回扣,把闲聊升级为"养成式"陪伴。
- Prompt 变体实验:给同一角色配置多版 System Prompt,做 A/B 对比,观察哪种写法下模型的"角色一致性"得分最高。
十四、结语
从一句"想把 AI 对话做得更有温度",到一份可运行的代码,再到这篇文章,这个项目教会我的最重要的一件事是:好的对话产品,功夫在"对话之外"——在流式管道的每一帧里,在提示词对 Schema 的一次次约束里,在降级路径的设计里,也在那些细小的"思考中…"的状态文案里。
「AI 环境角色闲谈系统」的完整源码已开源在 AtomGit:gcw_aBlYW1KZ/FreeRole(https://atomgit.com/gcw_aBlYW1KZ/FreeRole)。项目结构不过七个文件,一千行不到,但它完整地展现了"纯前端消费 LLM 流式接口"和"用提示词驱动结构化输出"这两条在现代 AI 应用开发中极其通用的技术路线。
如果你也正准备做某个"会说话"的界面——无论是虚拟陪聊、客服机器人还是数字人展厅——希望这份码道实践笔记能给你一点启发。动手写一个 JSON Schema,然后让模型听你的话。
欢迎大家一起交流、提交 Issue 或 PR,共同把这个小世界填得更热闹。
本文为「码道」系列实践笔记之一,记录真实开发过程中的设计决策与踩坑经验。
更多推荐


所有评论(0)