在这里插入图片描述在这里插入图片描述在这里插入图片描述

关键词:AI 对话、SSE 流式解析、结构化输出、System Prompt 设计、原生前端

一、引言:为什么我想做一个"会聊天的场景"

过去一年里,以 ChatGPT 为代表的大语言模型(LLM)已经深度融入到我们的日常开发与创作中。打开任何一个成熟的产品,对话框中冰冷的"AI 助手"几乎长着同一张脸:左侧一个圆圆的头像,右侧一段又一段规整的 Markdown 排版答案。它们高效、准确,但总少了点什么——人味。

于是我开始思考一个问题:对话产品能不能做得更像"遇见了某个人",而不是"在使用一个工具"?

如果 AI 是一个深夜咖啡馆里爱碎碎念的咖啡师,是一个在旧图书馆里话很多的图书管理员,是一个漂浮在空间站观察舱里的机器人助手,甚至是一个住在森林木屋里养狐狸的年轻女巫——当用户敲下第一句话时,得到的不是一段结构化清单,而是一句带着表情和动作的闲谈,那种体验会不会完全不同?

这个想法催生了本文的主角——「AI 环境角色闲谈系统」(FreeRole)。它是一款纯前端、零后端、零依赖的 AI 角色扮演对话页面,用户可以选择环境、选择角色、开启一段有画面感的闲聊。本文会完整记录这个项目从想法到落地的全过程,包括:为什么选择纯静态方案、如何把 Python 里的 requests 流式请求完整翻译成浏览器的 fetch + SSE 解析、如何通过系统提示词让模型"只输出 JSON"来驱动前端富媒体渲染、以及过程中踩过的坑和验证方法。

如果你也想做一个"会说话的界面",这篇文章应该能给你不少可复用的思路。


二、项目概览:它长什么样,能做什么

在展开技术细节之前,先花一点篇幅介绍项目最终呈现出来的样子,方便读者理解后面每一段代码的动机。

「AI 环境角色闲谈系统」的核心交互很简单:选择环境 → 选择角色 → 对话。

当前内置了四个主题环境:

环境角色氛围关键词
☕ 街角咖啡馆咖啡师"阿黎"、小说作家"墨白"暖气熏香、爵士乐、咖啡机嗡鸣
📚 星尘图书馆图书管理员"小芸"、老学者"程老"旧纸香、落地窗、金色阳光
🚀 深空空间站女舰长"朱雀"、AI 助手"小飞"浩瀚星河、蓝色地球、失重
🌲 雾隐森林小屋女巫"露娜"、小狐仙"阿茶"炉火噼啪、萤火虫飞舞

用户选定角色后,发送任意话题(比如"今天心情不太好",或"给我讲讲你最喜欢的故事"),大模型会完全以该角色的身份与口吻回应,而不是以"作为一个人工智能助手"的口吻说教。

对话体验上,项目实现了三个"加分项":

  1. 流式打字机效果:AI 的回复逐字渲染,像真人正在输入,而不是等待漫长加载后一次性冒出来。
  2. 结构化富媒体气泡:模型每轮回复都按约定的 JSON Schema 输出,前端解析后渲染出"表情头像 + 情绪徽章 + 台词正文 + 动作描写 + 场景变化提示"五位一体的气泡,聊天气息浓郁。
  3. 思考态感知:模型进入深度思考(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 的逐项对照

把两段代码放在一起,不难整理出一份"翻译对照表":

PythonJavaScript说明
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"
}

这个提示词有四个刻意为之的设计点:

  1. 把"规则"放在"角色扮演"之后。模型处理长篇提示词时,靠后的内容比重更高,但"身份锚定"在前、则越早建立的角色人格在后文越不容易漂移——顺序是"先做人,再守规矩"。
  2. 对每个字段都给了示例(如"端起咖啡抿了一口"),而不是只给字段名。示例的作用是锚定风格,防止模型把 action 写成"进行了友好的肢体动作"这种废话。
  3. 明示"scene 可为 null",避免模型每一轮都强行编一个场景变化,制造出"每秒都在变天"的荒诞感。
  4. 结尾的注意强调"请确保输出的是纯 JSON,可被 JSON.parse 直接解析",这句话专门用来对抗模型偶尔输出的 Markdown 代码块包裹。

6.3 真实效果验证

在开发过程中,我用真实的 DeepSeek 模型跑过完整链路,模型确实忠实地按 Schema 返回。例如用户问"给我介绍一款特调咖啡吧",返回:

{
  "speaker": "阿黎",
  "emotion": "开心",
  "expression": "😊",
  "text": "我们店里最受欢迎的是“秋日桂花拿铁”哦!在香浓的拿铁里加入了手工桂花蜜……要试试看吗?",
  "action": "轻轻晃了晃手中的拉花缸",
  "scene": null
}

第一轮就能稳定命中 Schema,这给了"纯提示词约束"路线足够的信心。

6.4 JSON 解析的稳健性:三道防线

即便提示词写得再严谨,模型毕竟是概率模型,偶尔还是会"任性"一下。为此 parseAIJson() 里设了三道防线:

  1. 剥离代码块包裹:部分模型在少数请求下仍会输出 ```json ... ````,因此先用正则^```(?:json)?` 和尾部 ```````````剥掉包裹再解析。
  2. 滑动裁剪策略:如果整体解析失败,找到文本中第一个 { 和最后一个 },把中间的子串再试一次。这一步极其有效——很多"失败"其实是模型在 JSON 前后多说了几个字,比如用自然语言说了句"好的!"打头。
  3. 内容校验:解析成功后还要检查 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:深色星空下的玻璃拟态

聊完逻辑,说说长相。任何对话产品,视觉是体验的第一层皮肤。项目采用了一套统一的深色主题设计语言:

  1. 星空渐变背景:页面背景是两个径向渐变(一紫一蓝)叠在深蓝底上,分别从右上角和左下角打光,模拟"宇宙深处有星云"的质感。配合 background-attachment: fixed,让渐变在滚动时保持稳定。
  2. 玻璃拟态面板:聊天区、侧栏使用 background: rgba(255,255,255,0.06) 半透明底 + backdrop-filter: blur(12px) 背景模糊 + 1px 半透明描边,营造"毛玻璃"的层次感。
  3. 信息层级分明:用户气泡用紫蓝渐变实底(主色调),AI 气泡用半透明白底;AI 气泡内部再做次级分层——角色名(弱灰)、情绪徽章(亮蓝胶囊)、台词(白色正文)、动作描写(斜体弱灰)、场景变化(暖金提示条)。用户一眼扫过去就能看懂"谁在说、情绪如何、发生了什么"。
  4. 动效克制:气泡用极短的 pop 入场动画(透明度 + 上移 6px、0.25s),输入状态用三个呼吸小圆点表示"正在输入"。克制,是为了不抢戏。
  5. 响应式适配:桌面端是"侧栏 + 聊天区"双栏;820px 视口以下自动变为单栏纵向布局,侧栏压缩为横向的可换行按钮组,保证手机上也能舒服地聊。

这些细节单独看都很小,但组合起来构成了项目"让人愿意点进去聊两句"的第一印象。


十、测试与验证:没有框架如何保证质量

纯静态项目没有打包工具,也可以建立有效的验证手段。这个项目我做了两层验证:

10.1 真实接口链路验证

第一步是验证"网络侧"完全可用。我写了一个 Node 脚本,用与浏览器完全一致的解析逻辑(行缓冲 + SSE + DONE 处理)真实调用模型接口,确认三件事:

  1. 流式响应能完整接收,没有跨块断行丢失;
  2. choices[0].delta.content 能被正确累积成完整 JSON 文本;
  3. 最终 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 的安全提醒区,未来如果要上线,一定会补一个轻量代理把密钥下沉到服务端。


十二、复盘与思考:学到了什么

项目虽小,但最后一轮复盘还是提炼出了几点可以迁移到更大项目的经验:

  1. 结构化输出是 LLM 应用的隐藏杠杆。 提示词里多花 30 秒定义 Schema,能省掉 UI 层几个小时的脆弱解析代码。凡是"模型输出要驱动界面"的场景,都值得优先设计 JSON 协议而不是事后打补丁。
  2. 流式体验是聊天产品的隐形竞争力。 同样的模型、同样的答案,"逐字出现"和"等三秒一起出现"的用户体感完全不同。SSE 解析 + 行缓冲是一套随处可用的标准件,值得每个前端工程师掌握。
  3. 容错设计要排在"完美"前面。 格式降级、解析重试、静默忽略坏帧——这些"脏活"决定了产品在真实世界里的存活率,它们比那些炫技代码更重要。
  4. 数据与逻辑解耦让迭代成本骤降。 从头到尾我没有为"增加新角色"写过一行逻辑代码——这正是配置驱动设计的红利。

十三、未来展望

这个项目目前是一个可以演示、可以学习、可以二次开发的纯静态 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,共同把这个小世界填得更热闹。


本文为「码道」系列实践笔记之一,记录真实开发过程中的设计决策与踩坑经验。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐