码道:星语知答——我用纯前端造了一个 AI 校园问答系统

项目仓库:https://atomgit.com/gcw_3Bfddbff/hhht
技术栈:HTML5 + CSS3 + JavaScript,零框架、零构建、零依赖
模型能力:DeepSeek-V4-Flash,通过 GitCode AI API 调用
在这里插入图片描述
在这里插入图片描述

如果你问我,一个会写一点网页的人,最快能在多短的时间里拥有一个"属于自己的 AI 助手"?我的答案是:一个周末,甚至一个晚上。我最近做了一个叫「星语知答」的智能校园问答对话系统,它完全跑在浏览器里,没有后端,没有服务器程序,打开一个 HTML 文件就能跟 AI 聊天。这篇文章把我从灵感到成品的全过程写下来,包括技术选型的权衡、Python 到 JavaScript 的代码移植、提示词工程里那些容易被忽视的细节,以及我在开发中实实在在踩过的几个坑。写这篇文章,既是给项目留一份完整的记录,也希望能给同样想自己动手做 AI 对话应用的同学一条可以照着走的路。

先给还没有打开过项目的读者一个总览:你打开网页,会看到一个干净的校园风界面,左侧是预置的热门问题,中间是聊天气泡,底部是一个输入框。你问"怎么申请奖学金?“,几秒钟之后,屏幕上会出现一张结构清晰的答案卡片:顶部是"招生咨询"这样的分类徽章,下面正文分条给出申请条件和流程,再往下是学校教务系统的官方链接,最后还有两三个可以接着点下去的问题按钮,比如"奖学金的评审周期是多久”。从一个问题出发,你能像剥洋葱一样,顺着追问按钮把一件事问得明明白白。这套体验的背后,就是这篇文章要拆开讲清楚的东西。

一、项目缘起:同一个问题,每天要被问上一百遍

做这个项目的念头,来自一个再普通不过的场景。校园里每天都有大量重复的问题:图书馆几点闭馆?奖学金怎么申请?大一新生选课有什么注意事项?宿舍熄灯时间是多少?这些问题对熟门熟路的老生来说不值一提,可是对刚入学的新生、对孩子在校情况不放心的家长、还有每年报考季来咨询的高考生来说,每一个都实实在在。

学校想过很多办法:印宣传册、做 FAQ 页面、安排学生志愿者答疑。但宣传册会过时,FAQ 页面找不到人反馈,志愿者人力有限。我就在想,有没有一种方式,能让"回答校园问题"这件事 7×24 小时在线,而且回答质量稳定、覆盖范围足够广?

大模型出现以后,这个问题的答案变得很清晰:做一个 AI 问答对话系统。让语言模型扮演一个"熟悉校园一切事务的学长学姐",学生可以直接用自然语言提问,系统即时回复。想了解选课就聊选课,想咨询招生就聊招生,不必再一层一层去翻官网公告。

于是「星语知答」这个项目就立项了。它要解决的,也正是校园里最常见的信息咨询问题:招生政策、专业介绍、课程与选课、图书馆、宿舍生活、食堂、社团活动、奖学金助学金、考试安排、就业实习,以及各种"说不上属于哪个部门"的琐碎日常。

二、技术路线:为什么我坚持"纯前端"

技术选型是开头第一个需要拍板的事情。当时摆在面前的有好几条路:用后端框架搭一个完整的 Web 应用、用微信小程序做一版、或者用 MVP 思路做一版最快的原型。我最后选择的是"纯前端 + 云上模型 API"的组合,也就是整个项目只有 HTML、CSS、JavaScript 三种文件,没有任何服务端代码。

这个选择基于几个非常现实的理由。

第一,上手门槛极低,部署成本几乎为零。一个纯静态的文件夹,往任意一个静态托管平台上一扔,或者干脆双击 index.html,项目就能跑。没有数据库要初始化,没有端口要配置,没有依赖要安装。对学校这种"能上线就行、维护人力紧缺"的场景来说,这是最友好的形态。

第二,智能这一层,交给现成的模型 API。项目名叫智能问答,但"智能"不一定要自己造。GitCode 提供了兼容 OpenAI 接口格式的 AI 模型服务,只要用 HTTP 请求发一段对话消息过去,模型就会把回复流式地推回来。前端负责展示,模型负责思考,各司其职。

第三,快速迭代。前端技术栈在改 UI、调交互上天然快。今天改一行 CSS,刷新浏览器就能看到效果,不需要编译、不需要重启服务。这种"所见即所得"的开发节奏,对一个个人项目来说非常重要。

第四,安全边界清晰。没有后端,就没有数据库,没有用户数据,也不存在"服务器被打穿"的问题。学生和家长的对话内容只发生在浏览器和模型 API 之间,不经过任何第三方服务器,这本身就是一种朴素而有效的隐私保护。

当然,"纯前端"也有代价,最大的一个就是跨域问题。浏览器出于安全策略,默认不允许网页中的 JavaScript 直接向其他域名发请求,这就是常说的 CORS(跨域资源共享)。这我会在后面的"踩坑"章节详细展开,这里先留个悬念。这里先顺便说明一下我在技术栈上的另一个克制:我没有引入任何前端框架,也没用任何 UI 组件库,全程原生 HTML、CSS、JavaScript。动机很简单——项目规模本身不大,引入框架带来的构建、依赖、版本维护成本,会超过它带来的收益。等到功能复杂到手写 DOM 确实疼了,再引入框架也不迟,这符合"什么时候需要,什么时候再上"的朴素原则。

三、把 Python 参考代码翻译成 JavaScript

项目的基础能力是调用模型 API。官方给了一段 Python 参考代码,用的是 requests 库加流式读取。我需要在浏览器里实现等价的逻辑,也就是把这段 Python 翻译成 JavaScript。这本身是很有意思的一步,因为两种语言处理流式响应的方式完全不同。

先看参考代码的核心部分:

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:").rstrip("/n"))

这段代码做的事情很清晰:向模型接口发起 POST 请求,开启流模式;然后逐行读取响应体;只处理以 data: 开头的行;遇到底部标记 data:[DONE] 就结束;其余每一行都是一个 JSON 片段,解析出来交给调用方。

在浏览器里,同样的事情要靠 fetch 加 ReadableStream 来做。翻译过来的 JavaScript 是这样的:

async function streamChat(messages, onDelta) {
  const resp = await fetch(CONFIG.API_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: CONFIG.API_KEY,
    },
    body: JSON.stringify({
      model: CONFIG.MODEL,
      messages,
      stream: true,
      max_tokens: CONFIG.MAX_TOKENS,
      temperature: CONFIG.TEMPERATURE,
      top_p: CONFIG.TOP_P,
      frequency_penalty: CONFIG.FREQUENCY_PENALTY,
      thinking_budget: CONFIG.THINKING_BUDGET,
    }),
  });

  if (!resp.ok) throw new Error(`请求失败(HTTP ${resp.status})`);
  if (!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 });
    const 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 ??
          json.choices?.[0]?.message?.content;
        if (delta) onDelta(delta);
      } catch { /* 忽略无法解析的块 */ }
    }
  }
}

两段代码的对应关系一目了然,我做了一张对照表,方便后来者快速理解:

Python 参考实现JavaScript 对应实现
requests.post(..., stream=True)fetch(url, { method: "POST", body: ... })
response.iter_lines()response.body.getReader() + TextDecoder
line.startswith(b"data:")line.trim().startsWith("data:")
json.loads(...)JSON.parse(...)
yield chunk(生成器)onDelta(delta) 回调

翻译过程中有几个细节值得单独说。

第一个是编码问题。Python 直接按字节迭代,而浏览器拿到的是字节流,必须用一个 TextDecoder 把字节翻译成字符串。这里我踩过一个很隐蔽的坑:流式响应的数据可能在任何位置被"拦腰截断",也就是说一个完整的 JSON 行可能被拆成两半,分别落在两次 reader.read() 的结果里。如果不处理,就会出现 JSON 解析失败。解决办法是维护一个 buffer 变量,每次收完数据先拼进 buffer,再按换行符切分,只处理完整的行,最后把可能残缺的那一小段留到下一轮继续拼。这段逻辑在代码里就是 buffer.split("\n") 之后把 lines.pop() 剩下的部分还给 buffer。

第二个是兼容不同返回格式。GitCode 的接口在流式模式下,增量内容放在 choices[0].delta.content 里;但有的兼容接口在非流式模式下,内容放在 choices[0].message.content 里。我用了可选链 ?. 把两种情况都覆盖到了,这样即使某次请求没有真正走流式,前端也能正常收到内容。

第三个是请求体参数。参考代码里除了消息本身,还带了 temperature(回答的随机程度)、top_p(采样范围)、max_tokens(最大生成长度)、frequency_penalty(重复惩罚)等控制参数。这些参数我都原样保留,并且支持单独调节,方便针对不同场景微调回答风格。thinking_budget 这个参数是让模型在回答前有"思考预算",适合需要严谨作答的校园问答场景。

四、提示词工程:让模型输出正规的 JSON

如果说流式传输解决了"怎么把答案传回来"的问题,那么提示词工程解决的是"怎么让答案能直接被前端用起来"的问题。

一开始我也想过最省事的方案:模型回复什么,前端就直接把文本渲染在聊天窗口里,像所有普通聊天机器人那样。这个方案当然能用,但太"普通"了。我想要的不是一段干巴巴的文字,而是一份"结构化"的答案:既有正文,又有分类标签、关键词、相关资源和推荐追问。只有这样,前端才能把回答渲染成漂亮的卡片,用户一眼就能扫出重点。

要让模型输出结构化数据,最直接的方法,就是在系统提示词里把想要的 JSON 结构描述清楚,并严格要求模型"只输出 JSON、不要输出任何其他内容"。这是当前大模型应用中最常用也最有效的做法。我给「星语知答」写的系统提示词大概是这样的逻辑:

  1. 定义角色:你叫"星语知答",是一所高校的智能校园问答助手,服务对象是在校学生、家长与报考考生。
  2. 限定范围:覆盖招生政策、专业介绍、课程选课、图书馆、宿舍食堂、社团活动、奖助学金、考试就业等校园话题。
  3. 约束行为:回答要准确、具体、有条理;涉及不确定的信息要如实说明,并提示用户通过学校官方渠道核实;正文保持在 300 字以内;语气亲和但不过度用表情符号。
  4. 固定格式:必须且只能输出一个合法 JSON 对象,禁止用 markdown 代码块包裹,禁止输出解释性文字。
  5. 定义字段:category(问题分类,枚举值)、answer(回答正文)、keywords(关键词数组)、resources(相关资源数组,每项含 title/desc/link)、related_questions(推荐追问数组)。

模型按照提示词约定返回的 JSON 长这样:

{
  "category": "校园生活",
  "answer": "图书馆工作日开放时间为 8:00 - 22:00,周末为 9:00 - 21:00。\n\n寒暑假期间开放时间会调整,建议出行前关注图书馆官方公众号的公告。",
  "keywords": ["图书馆", "开放时间", "校园生活"],
  "resources": [
    {
      "title": "图书馆官网",
      "desc": "查询实时开放公告与预约入馆",
      "link": "https://lib.example.edu.cn"
    }
  ],
  "related_questions": [
    "图书馆可以预约自习座位吗?",
    "图书借阅期限是多久?"
  ]
}

有了这份数据,前端就把显示逻辑彻底解放了:它不需要去"猜"模型的话里哪句是重点,JSON 里已经说得明明白白。

需要强调的是,提示词的措辞直接决定输出质量。我第一次写的提示词里只说了"请返回 JSON 格式",结果模型时不时会在 JSON 外面包一层 markdown 的 ```json 代码块,或者顺手补一句"好的,以下是回答:“,每当这个时候,前端的 JSON.parse 就会直接抛异常。后来我把提示词改成了"必须严格只输出一个合法的 JSON 对象,不要输出任何其他说明文字,不要用 markdown 代码块包裹”,并且补充了对字符串转义的提醒,输出稳定性才有了质的提升。这说明在提示词工程里,"把话说死"往往比"说得客气礼貌"更有效。

提示词也不是一版定稿的。我建立了一个很小的"烟囱测试集",也就是几十条覆盖各分类的代表性问题,每次改提示词,就把这一套问题重跑一遍,看输出格式是否稳定、内容是否跑偏。这个方法说起来朴素,但非常管用:同样是"图书馆几点关门",有的版本会答得很好,有的版本会突然开始介绍图书馆历史。只有用固定的测试集反复对比,才可能在"格式稳定"和"内容靠谱"之间找到平衡点。我甚至把 category 字段做成了明确的枚举值(招生咨询、课程学习、校园生活、考试就业、其他),逼着模型做分类选择题而不是自由发挥,配合枚举值在提示词里的显式声明,前端就不用再处理临时的怪分类了。

五、前端渲染:把 JSON 变成一张看得懂的卡片

拿到结构化 JSON 之后,剩下的就是把它变成好看的界面。我把渲染逻辑集中在一个函数里,它负责把模型返回的对象翻译成 DOM 卡片。

整张答案卡片分为五个区域,从上到下依次是:

  • 头部徽章区:显示 category 分类徽章和若干 keywords 关键词标签。用户一眼就能看出这条回答属于"招生咨询"还是"校园生活"。
  • 正文区:渲染 answer 字段。前端用 white-space: pre-wrap 保留换行,这样模型用 \n\n 做的分段能原样呈现,段落之间自然留白,读起来不累。
  • 资源区:渲染 resources 数组。每一条资源都是一个小条目,标题可点击跳转官方链接,下面配一行描述。这部分相当于"可靠信息源"的快捷入口。
  • 追问区:渲染 related_questions。这些不是死文字,而是可以点击的按钮。点击任何一个,问题文本会直接进入输入框并触发新一轮对话。这让对话不是"一问一答"的终结,而是可以不断延伸下去。
  • 整体动效:卡片出现时带一个轻微的淡入上移动画,让界面观感更柔和。

这里我只截取渲染函数的关键片段,完整代码在项目仓库里:

function renderAnswerCard(data) {
  const card = document.createElement("div");
  card.className = "answer-card";

  let html = "";
  if (data.category || data.keywords?.length) {
    html += `<div class="ac-head">`;
    if (data.category) html += `<span class="ac-category">${escapeHtml(data.category)}</span>`;
    (data.keywords || []).slice(0, 4).forEach(k => {
      html += `<span class="ac-tag"># ${escapeHtml(k)}</span>`;
    });
    html += `</div>`;
  }

  html += `<div class="ac-body">${escapeHtml(data.answer || "")}</div>`;

  // ... resources 与 related_questions 区域渲染省略

  card.innerHTML = html;
  dom.chatList.appendChild(card);

  card.querySelectorAll(".related-q").forEach(btn => {
    btn.addEventListener("click", () => sendQuestion(btn.dataset.q));
  });
}

有一个细节可能看起来多余,但实际很重要:所有模型返回的文本,在插入 DOM 之前都要过一遍 escapeHtml。原因是模型输出的内容是不可信的,如果它返回的内容里带着 <script> 之类的片段,直接插进 innerHTML 就会造成 XSS 注入风险。作为开发者,绝不能因为"模型很智能"就放松对输入输出的安全边界。

再来说说容错。无论提示词写得多严密,模型毕竟不是程序,偶尔还是会"不听话":有时在 JSON 前后夹带几句闲话,有时干脆输出损坏的 JSON。为此我写了一个提取函数,逻辑很简单粗暴但非常有效:找到整个回复里第一个 { 和最后一个 },把中间的部分抠出来尝试 JSON.parse。闲话都在括号外面,自然就被丢掉了;如果连括号内的内容都不是合法 JSON,就退回到最原始的方案——把整段文本当普通聊天消息渲染。这个兜底逻辑保证了:模型偶尔抽风,页面永远不会崩。

除了解析层面的容错,我在渲染层还加了一道"字段体检"。模型返回的 JSON 虽然结构固定,但字段可能缺、可能是空数组、甚至可能是类型不对的脏数据。所以渲染函数里大量使用可选链与兜底写法:data.category || ""、(data.keywords || []).slice(0, 4)、Array.isArray(data.resources) && data.resources.length 才渲染。这些判断单独看琐碎,合在一起的效果是:无论模型给的是"标准答案"还是"残缺答案",页面都能给出得体的呈现,比如某条回答没有资源时,资源区自然消失,不会留一个空白的小标题干杵在那里。

六、界面设计:校园风不是玄学

一个面向校园的对话系统,界面风格应该是什么样?我定的基调是:干净、明快、有校园的青春感,同时保留科技产品的专业感。

配色上,主色选了大学蓝,从深蓝到亮蓝的渐变用在顶部导航上,传达稳重和理性;辅助色用了一个温暖的橙色,用在用户消息气泡上,形成冷暖对比,界面立刻活泼起来。背景是极浅的蓝灰色,配了淡淡的径向渐变光晕,避免大面积纯色带来的单调。

布局上,桌面端采用"左侧边栏 + 右侧对话"的两栏结构。左侧边栏放了两块内容:一块是"热门问题",预置了六个出现频率最高的校园问题,点一下就直接提问,省去打字;另一块是"问题分类",用五个小胶囊把问题分成招生咨询、课程学习、校园生活、考试就业和其他,暗示用户这个系统能回答什么。右侧是主对话区,顶部是欢迎卡片,底部是输入区,输入区上方还排了一排可点击的分类快捷标签。

交互细节上,我特别在意"等待"这个状态。AI 回答需要几秒时间,如果界面毫无反应,用户会以为卡死了。所以我做了三层反馈:第一层,输入框和发送按钮在请求期间禁用,从源头避免重复提交;第二层,聊天区出现一个三点跳动的"正在输入"动画,告诉用户 AI 正在思考;第三层,顶部状态指示器从绿色的"就绪"变成琥珀色的"思考中",并且持续闪烁。三层反馈加在一起,整个等待过程几乎不会让用户产生焦虑。

响应式也没落下。在手机或窄屏幕上,左侧边栏不再竖着占一整列,而是横过来变成可以左右滑动的一排卡片,聊天区高度相应调整,保证小屏上输入框依然好用。毕竟校园场景里,用手机访问的概率和用电脑差不多一样高。

界面细节上还有两个容易被忽略但很值得说的地方。一个是空状态与占位符。刚打开页面时聊天区是空的,我放了一张欢迎卡片,不仅写"你好,我是星语知答",还把四个示例问题做成按钮摆在上面,把"用户不知道问什么"这个最常见的冷启动问题化解在了界面里;输入框的 placeholder 也随对话状态变化,始终给用户一个可预期的指令。另一个是无障碍与可读性。消息列表设置了 aria-live="polite",屏幕阅读器能感知新消息;正文用 pre-wrap 保留换行、正文与背景保持足够的对比度、字号最小不小于 12 像素,这些小细节平时看不见,但对读屏用户和视疲劳的学生来说,是实打实的体验改善。做界面这件事,我越来越相信一句话:好的设计不是炫技,而是把意外情况都提前想好。

七、开发过程中踩过的坑

这部分我专门写一写真正的"事故现场"。很多坑不实际做一遍是记不住的。

坑一:模型把 JSON 包进了 markdown 代码块。 表现是页面弹解析异常,控制台里 JSON.parse 报错。前几轮的修复是加容错提取逻辑;根治的办法是回头改提示词,把"禁止 markdown 包裹"写进约束条款。两者缺一不可:提示词负责降低概率,容错负责兜住剩下的概率。

坑二:先流式后解析的显示时机。 我最初做了"流式打字机"效果,让模型的内容一个字一个字蹦出来。效果很酷,但后来发现一个问题:既然最终要按 JSON 渲染卡片,那么流式过程中用户看到一大段"原始 JSON 文本"是没有任何意义的,甚至可以说是噪音。我最后的方案是折中:请求期间依然有等待动画和正在输入提示,收到完整回复后先整体解析,再直接渲染成卡片。也就是说,这个项目把"流式传输"当成了协议层手段,而不是展示层手段。展示层展示的永远是最终的结构化结果。

坑三:浏览器跨域。 这是纯前端调用 API 的"宿命之敌"。浏览器默认不允许页面跨域请求。我的解决思路是分层:本地开发时用任意静态服务器(比如 python -m http.server)起本地服务,必要时配一个本地代理;如果未来要正式上线,把 API 调用收敛到一个极薄的后端代理后面。README 里我把这条写得很明确,避免后来者卡在第一步动弹不得。

坑四:上下文一长,记忆就"窜位"。 多轮对话时,我得把整个对话历史拼接起来发给模型,让模型"记得"之前聊过什么。这个需求有个隐患:历史消息越攒越长,带宽和费用都会上去。我在前端保存了一个完整消息数组,每次请求都把 system 提示词加在历史前面整体发送。对校园问答这种短对话为主的场景,这个方案的性能完全够用;真要支撑很长的会话语境,后续应该把历史压缩或摘要化。

坑五:模型"编造"资源链接。 resources 字段里的链接是模型生成的,如果允许它随便填,很可能出现一个指向不存在域名的链接。我在提示词里要求它"不确定就留空字符串",前端又把 link 为空字符串的条目渲染成纯文本而不是超链接,双保险之后,这个坑基本被填平了。

坑六:"正在输入"的动画和最终卡片打架。 起初我让"正在输入"的动画一直滚到 JSON 解析完成才消失,结果在弱网环境下,动画区和高亮的按钮样式挤在一起,视觉上很乱。后来我把状态拆得更细:请求开始 → 显示打字动画 → 收到完整回复 → 先移除动画节点 → 再插入答案卡片,中间用显式的 DOM 操作顺序保证"一个时刻界面上只有一种等待表达"。这个坑的教训是:异步 UI 里的状态切换,要让动画和内容"交接"得干净,而不是靠 CSS 慢慢过渡糊弄过去。

坑七:忘了防止重复提交。 现实的用户手速往往比想象中快,连按两次回车,两个同样的请求就发出去了,页面会出现两条用户消息、两条 AI 回复,非常尴尬。修法是加一个 busy 状态锁:请求未结束时,输入框和发送按钮全部 disabled,同时在业务层用一个布尔值做二次拦截。这个改动只有几行,但属于"不加就会丢脸"的体验底线。类似的底线还有输入为空时直接忽略提交,以及点击快捷问题时防止与手输内容叠加。

坑八:消息历史里混入了残缺回合。 有一次我发现多轮对话里,模型突然"失忆"。排查后真相是:某一次请求失败时,我把用户消息推入了历史数组,但 assistant 的回复没进来,导致后续请求里 model 面对一个"只有问没有答"的坏样本。修法也简单:请求失败时执行 messages.pop() 回滚,保证进入历史的永远是一对完整的问答。这种"数据一致性"问题在纯前端内存状态里也会出现,别因为是前端就放松警惕。

八、项目结构一览

做完之后回看,整个工程的代码量非常克制,全部代码加在一起不到一千行,组织也足够清晰:

├── index.html          # 页面结构:顶栏、侧边栏、对话区、输入区
├── css/
│   └── style.css       # 校园风样式:配色变量、布局、动效、响应式
├── js/
│   └── app.js          # 核心逻辑:配置、流式请求、JSON解析、卡片渲染
└── README.md           # 使用说明与配置文档

每个文件各司其职,没有多余的抽象。app.js 内部按职责分成清晰的区块:顶部是 CONFIG 配置区,所有需要调的东西都集中在这里;接着是 SYSTEM_PROMPT 系统提示词;然后是预设问题数据、会话状态、流式请求、JSON 弹错处理、渲染函数、发送主流程和事件绑定。一条从上到下读完,基本就是整个应用的电路图。

说到状态管理,纯前端应用里最容易被写乱的就是"当前在干嘛"。我用一个极简的 state 对象承担了这个职责:messages 保存完整对话历史,busy 标记请求是否进行中,answerBuffer 暂存流式累积的原始文本。所有读写这个状态的地方就只有发送主流程那一个函数,配合 setBusy 统一开关按钮与输入框的禁用状态。这种"状态收敛到一处"的做法,让我在排查"怎么会同时出现两个回复"这类问题时,几乎不用翻代码就能定位到 state.busy 这一行。越小的项目,越应该把全局状态管清楚,因为复杂度不会因为项目小就消失,只会换一个地方藏起来。

从按下回车到答案上屏:一次请求的完整旅程

讲了这么多模块,不妨把一次"提问—回答"完整串一遍,看看这些零件是怎么协同工作的。

用户按下回车的那一刻,sendQuestion 被触发。函数先做三件小事:校验输入不为空、检查 busy 锁没有被占用、把输入框清空。接着,用户的文字被渲染成右侧的蓝色气泡,同时推入 state.messages 历史数组,页面进入"忙碌"状态:按钮禁用、状态灯变琥珀色、聊天区底部出现"正在输入"的三点动画。

随后 streamChat 发起真正的 HTTP 请求。请求体里除了 system 角色那一整段提示词,还包括之前所有的对话历史——这是多轮追问能"记得前文"的原因。模型开始流式返回,ReadableStream 一点一点把字节吐出来,TextDecoder 把它们翻译回字符串,缓冲拼接、按行切分、过滤出以 data: 开头的行、跳过 [DONE] 结束标记,每一行 JSON 里的内容片段被逐段累积进 answerBuffer。这个过程可能持续几秒,界面上那三点动画就这样跳着,给用户一个"它真的在思考"的信号。

收到 [DONE],一个完整的回复文本已经在 answerBuffer 里了。这时前端做的不是立刻展示,而是先把整段文本交给 extractJSON:抠出第一个 { 到最后一个 } 之间的内容、尝试 JSON.parse。解析成功,就把这个对象交给 renderAnswerCard,生成那张头戴分类徽章、身披关键词标签、脚踩资源与追问按钮的毛边卡片;解析失败,则退而求其次,把原始文本当作普通气泡展示。无论哪条路,最后一步都是把 assistant 的完整回答推进历史数组、关闭忙碌状态、恢复输入焦点,等待下一个问题。

整条链路里,流式、缓冲、解析、渲染、状态回滚,每一环都围绕同一个目标:让用户感知到的东西,永远是最终的、整洁的、可信的结果,而那些字节切分的琐碎,交给我们这些写代码的人就行。

九、运行与部署

运行这件事简单到可以只用一句话说清楚:打开 index.html。为了照顾没有静态服务器习惯的同学,我在 README 里又补了一条路:在本目录执行 python -m http.server 8000,然后访问 http://localhost:8000,效果一样。

需要改配置的话,只需要动 app.js 里 CONFIG 对象的几个值:接口地址、调用凭证、模型名,以及 temperature 等生成参数。其中调用凭证请务必替换成你自己的有效 key,不要把我放在示例里的 key 原样用到生产环境——这是常识,也是我反复提醒自己的事。

这个项目最后托管在 AtomGit 上,整个发布流程也简单得令人愉快。我用 git 在本地把项目初始化成仓库,提交所有文件,然后关联远程地址并推送。项目本身是纯静态文件,所以任何支持静态托管的平台——无论是 AtomGit 的 Pages 能力,还是你喜欢的其他对象存储加 CDN——都能一键点亮这个网站。我把关闭浏览器、直接访问线上地址的那一刻,当作项目真正"活过来"的时刻:它不再需要我开一个本地服务器,任何人拿到这个链接就能跟它对话。

顺着这条,我给打算自己"复刻"一遍这套流程的同学附一份精简的验证清单,都是我在开发时实际用过的检查项:

  1. 本地打开页面,确认标题、欢迎卡片、热门问题都正常渲染,控制台无红色报错。
  2. 发一条测试问题,确认"正在输入"动画出现、回答以结构化卡片呈现,而非裸 JSON 文本。
  3. 连续追问两轮,确认模型"记得"前文,且新卡片内容与当前话题相关。
  4. 输入一条明显不在校园范围的问题,确认模型能得体地引导回校园话题,而不是硬编一段答案。
  5. 断网或填错密钥各试一次,确认页面优雅报错且不会白屏。
  6. 手机浏览器打开,确认布局切换正常,输入框可以正常唤起键盘。
  7. 换一个全新的 API 密钥,确认系统仍能正常工作,证明配置确实是从 CONFIG 读的。

这份清单既是我自测时的 checklist,也是我理解"AI 应用也要讲工程质量"这件事的起点:模型输出的不确定性,恰恰要求我们在这类应用上比传统网页更较真。

十、总结与下一步

做完「星语知答」这个项目,我最大的体会是:现阶段的 AI 应用开发,拼的不再是"能不能调通模型",而是"谁能把模型能力和产品体验融合得更好"。调通模型接口只需要几十行代码,但把提示词设计、结构化输出、容错处理、交互等待、安全转义这些细节都打磨到位,才真正决定一个 AI 应用好不好用。

这个项目也让我对"码道"这两个字有了更实在的理解:写代码的路,是一步一个脚印踩出来的。每一步都会遇到具体的问题——跨域、编码、JSON 容错、XSS——发现问题、查资料、动手解决、写下记录,这条路就走通了。

关于成本与效率,也多说两句。有人会担心"AI 应用是不是很烧钱"。以校园问答这种短问答为主的场景,平均每个问题消耗的 token 量很小,配合 max_tokens 上限和精简的系统提示词,单次对话的成本在可忽略的范围内。更值得投入的反而是"人肉调优"的时间:把提示词打磨到稳定、把空状态和异常兜住,这些一次性的工程投入,换来的是长期稳定的使用体验。

最后说说下一步想做的事。第一,给对话加上"来源可查":模型回答里提到的政策,尽量附上学校官方页面的直链,让每一条信息都有据可依。第二,加入用户反馈机制:回答下方放"有用/没用"两个按钮,把负反馈样本收回来做后续的提示词迭代。第三,把常见问题沉淀成语料库:模型回答错得比较多的问题,用固定答案覆盖,做成"人工兜底"。第四,如果将来有需要,把纯前端这个形态升级成"前端 + 极薄后端"的架构,既保住现在的轻量,又获得更好的安全性和扩展性。

代码已经全部开源在 AtomGit 上,欢迎取用、修改、提出你的想法。愿每一位同学都能被真诚地回答每个问题——这是「星语知答」的初心,也是我把它做出来的全部理由。

Logo

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

更多推荐