大模型 API 接通了,AI 聊天页面为什么还是不好用?

大模型 API 接通了,AI 聊天页面为什么还是不好用?

上一篇聊到,AI 降低了页面实现的成本,但"代码能运行"和"功能能交付"之间,还有不少具体问题。

这一次,换一个更容易动手验证的场景:做一个 AI 聊天页面。

第一版通常不难。一个输入框、一个发送按钮,调用接口,再把回复展示出来。如果接口支持流式返回,就边接收边追加文字。

但只要多操作几次,问题就会出现:中文偶尔乱码;点击停止后还在追加内容;切换会话后,旧回复跑进新窗口;连接断开了,界面却提示生成完成;想往上看历史消息,又被自动拉回底部。

这些问题很适合用来理解:前端接入大模型,具体需要处理哪些工程细节。

本文以浏览器中的 Vue 3 + fetch 为例,围绕流式解析、任务状态、会话隔离、Markdown 渲染和滚动体验展开。文中的接口采用应用自定义的 SSE 协议,配套示例使用本地模拟服务,不需要模型密钥。

先确定协议,再开始拼接文字

这里选择 fetch,是因为可以通过 response.body 读取响应流,也方便携带 POST 请求体、使用 AbortController 取消请求。它是演示这些问题的工具,已有项目也可以使用具备相应流式处理能力的请求方案。

先约定应用接口:

http 复制代码
POST /api/chat
Content-Type: application/json
Accept: text/event-stream

请求体包含会话标识和本次输入:

json 复制代码
{
  "conversationId": "a",
  "message": "解释一下 Vue 的响应式机制"
}

响应采用三类事件,每个事件由空行结束:

text 复制代码
event: delta
data: {"text":"Vue 的响应式"}

event: delta
data: {"text":"用于追踪数据变化。"}

event: done
data: {}

其中,delta 表示增量文本,done 表示业务上的生成完成。如果生成过程中出错,则发送:

text 复制代码
event: error
data: {"message":"生成失败,请稍后重试"}

这是一份应用层约定,不代表所有模型供应商都使用相同的事件名称或数据结构。实际接入时,可以由自己的服务端把上游格式转换成这份协议。模型密钥和上游调用放在服务端,前端调用应用自己的接口。

提前约定协议,还有一个好处:不必用"连接已经结束"去猜测"回复是否已经完成"。

第一个坑:一次 read,不是一条消息

很容易想到这样的写法:

js 复制代码
const { value } = await reader.read();
const text = new TextDecoder().decode(value);
const data = JSON.parse(text.replace('data:', ''));

问题在于,这里混淆了几种边界:

层级 它实际表示什么
网络读取 本次读到的一段字节,长度和分段位置不固定
UTF-8 解码 把字节转换成文字,一个中文字符可能跨越两次读取
SSE 事件 按行解析,以空行结束,可能包含多条 data: 行
应用数据 完整事件中的 JSON,例如 { "text": "你好" }
Markdown 多次增量累积后的内容,代码块等语法可能尚未闭合

因此,一次读取可能只有半个事件,也可能包含多个事件。网络读取的边界也不等于模型 token 的边界。

正确的处理顺序是:持续解码字节,缓存尚未完成的文本,解析完整 SSE 事件,再解析事件里的 JSON。

下面是配套示例使用的 sse.js:

js 复制代码
// 仅处理单次 fetch 响应中的 data / event,不实现自动重连、id 或 retry。
export async function* readSSE(body) {
  const reader = body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  let data = [];
  let event = 'message';

  function consume(line) {
    if (line === '') {
      const result = data.length ? { event: event || 'message', data: data.join('\n') } : null;
      data = [];
      event = 'message';
      return result;
    }
    if (line.startsWith(':')) return null;

    const colon = line.indexOf(':');
    const field = colon < 0 ? line : line.slice(0, colon);
    let value = colon < 0 ? '' : line.slice(colon + 1);
    if (value.startsWith(' ')) value = value.slice(1);
    if (field === 'data') data.push(value);
    if (field === 'event') event = value;
    return null;
  }

  function* drain(final = false) {
    while (true) {
      const index = buffer.search(/[\r\n]/);
      if (index < 0) return;
      // CR 可能与下一次读取中的 LF 组成一个换行,先保留它。
      if (!final && buffer[index] === '\r' && index === buffer.length - 1) return;
      const width = buffer[index] === '\r' && buffer[index + 1] === '\n' ? 2 : 1;
      const line = buffer.slice(0, index);
      buffer = buffer.slice(index + width);
      const message = consume(line);
      if (message) yield message;
    }
  }

  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
      yield* drain();
    }
    buffer += decoder.decode();
    yield* drain(true);
    // EOF 不补造空行:没有空行结束的事件不作为完整事件派发。
  } finally {
    try { await reader.cancel(); } catch { /* 已关闭或错误的流无需再次处理。 */ }
    reader.releaseLock();
  }
}

这里有几个细节容易漏掉:

  • TextDecoder 要复用,并使用 { stream: true },保留跨分块字符的解码状态;流结束时再调用一次 decode() 收尾。
  • 行结束可能是 LF、CRLF 或 CR,CRLF 本身也可能跨越两次读取。
  • 多条 data: 行要以换行连接;以冒号开头的注释行可以忽略。
  • 遇到 EOF 时,不把尚未由空行结束的内容补造成一条完整事件。
  • 退出读取循环时,需要取消读取并释放 reader 的锁。

这个解析器只处理单次 fetch 响应需要的 data 和 event。它没有实现 EventSource 的自动重连、Last-Event-ID 或 retry 语义。生产项目也可以选用成熟的 SSE 解析库,但仍需要理解这些边界,才能判断问题出在什么位置。

第二个坑:一个 loading,表达不了整次生成

把发送前设为 loading = true、结束后设为 false,很难解释这些情况:还没收到首段内容、正在输出、用户主动停止、异常中断、正常完成。

示例为每条助手消息保存状态:

状态 界面含义 常见出口
connecting 等待响应或首段内容 开始输出、停止、失败,或直接收到完成事件
streaming 正在接收增量内容 完成、停止或失败
done 收到业务完成标记 本次任务结束
stopped 用户主动停止 保留已生成内容,本次任务结束
error HTTP 错误、协议错误或异常断流 保留已有内容,显示错误

检查状态之前,还要检查响应:

js 复制代码
if (!response.ok) {
  throw new Error(`请求失败:HTTP ${response.status}`);
}

const type = response.headers.get('content-type') || '';
if (type.split(';')[0].trim().toLowerCase() !== 'text/event-stream') {
  throw new Error('接口未返回 text/event-stream');
}

if (!response.body) {
  throw new Error('当前响应没有可读取的数据流');
}

fetch 不会仅因为 HTTP 4xx、5xx 就自动抛出异常,需要检查 response.ok。而 SSE 响应头已经发送之后,后续业务错误也可能需要通过 error 事件表达。

流读取结束后,同样需要判断是否收到过 done:

js 复制代码
let completed = false;

for await (const event of readSSE(response.body)) {
  if (event.event === 'done') {
    completed = true;
    finish(task, 'done');
    break;
  }
  // delta、error 等分支见后文。
}

if (isCurrent(task) && !completed) {
  throw new Error('连接提前结束,未收到完成标记,内容可能不完整');
}

这段是状态处理的节选,task、finish 和 isCurrent 用于管理当前生成任务。

这里的完成规则来自前面约定的协议。如果接入的接口使用其他完成标记,应按它的契约处理。关键是明确"什么叫完成",而不是在 finally 里把所有情况统一改成成功。

断流时保留已经生成的文字,同时提示内容可能不完整,比直接清空或显示"已完成"更符合实际情况。是否重试、是否重新生成,也应该有明确的业务语义,不能把一次新的模型请求当成对旧连接的无成本恢复。

第三个坑:停止旧请求,不等于旧回调已经消失

另一个常见写法是:收到增量后,直接追加到当前会话的最后一条消息。

js 复制代码
activeSession.messages.at(-1).content += delta;

只要用户在生成过程中切换会话,"当前会话"就变了。

更可靠的办法是:发送时创建任务对象,让它持有当时的会话 ID、目标消息和取消控制器。后续异步回调始终操作这个对象,而不是重新查找当前界面正在显示哪条消息。

js 复制代码
const tasks = new Map();
const isCurrent = (task) => tasks.get(task.sessionId) === task;

// message 是此次请求对应的助手消息。
const task = {
  sessionId,
  message,
  controller: new AbortController(),
  pending: '',
  frame: null,
};

tasks.set(sessionId, task);

为什么还要判断 isCurrent?因为任务 A 停止后,同一个会话可能已经启动任务 B。A 的迟到响应、异常处理或者清理逻辑,都不应该再修改 B 的状态。

示例的停止操作如下:

js 复制代码
function stop(sessionId) {
  const task = tasks.get(sessionId);
  if (!task) return;

  // finish 会同步提交缓冲文字,设置状态并移除任务。
  finish(task, 'stopped');
  task.controller.abort();
}

顺序有意义:先让任务失效,再取消请求。旧任务进入 catch 后,先检查身份:

js 复制代码
catch (error) {
  if (!isCurrent(task)) return;
  finish(task, 'error', error instanceof Error ? error.message : '生成失败');
  task.controller.abort();
}

本文示例选择"切换会话时停止旧生成,并保留已有内容"的策略。如果产品要求后台继续生成,也可以保留旧任务,但它仍然必须写入自己所属的会话。

还要区分取消的范围:浏览器里的 abort() 会取消本地 fetch 请求或响应体读取,并不保证上游模型已经停止生成。服务端需要配合感知断连并取消下游调用,供应商是否支持取消及如何计费,也取决于对应服务的契约。

在 Vue 中还有一个容易忽略的细节:把普通对象放入响应式数组后,如果异步任务一直修改原始对象,就可能绕过代理上的响应式追踪。示例在插入后,从响应式数组中重新取得目标消息,再交给任务持有。

第四个坑:每收到一小段,就重渲染整段 Markdown

正确拿到增量,还不代表应该立即为每次网络读取更新界面。

网络读取频率并不固定。每次读取都追加响应式数据、解析 Markdown、重新高亮代码,长回复下可能产生不必要的工作。

配套示例把增量先放进任务缓冲区,再合并到下一次界面更新:

js 复制代码
task.pending += payload.text;

if (task.frame === null) {
  task.frame = schedule(() => {
    task.frame = null;
    flush(task);
  });
}

浏览器侧把 schedule 和 cancelSchedule 分别传为 requestAnimationFrame 和 cancelAnimationFrame。flush 会将缓冲文字追加到目标消息,并清空缓冲区。

这里有一个必须配套的动作:正常完成、停止和异常收尾时,都要同步 flush 一次。 否则最后一小段文字可能仍然停在缓冲区里。

requestAnimationFrame 也不保证固定的 16ms 间隔,后台标签页中还可能暂停,所以不能依赖下一帧来完成任务的最终收尾。

Markdown 渲染则放在单条消息组件里,通过计算属性根据这条消息的内容更新:

js 复制代码
const html = computed(() => DOMPurify.sanitize(
  marked.parse(props.message.content),
  { USE_PROFILES: { html: true } },
));
vue 复制代码
<div class="markdown" v-html="html"></div>

这既避免每次更新都主动遍历并重解析所有历史消息,也明确了 HTML 的处理边界:模型输出可能包含 HTML,Markdown 解析之后还需要清洗,不能直接把未经处理的结果传给 v-html。用户消息在示例中使用文本插值展示。

至于尚未闭合的代码块、列表和表格,解析结果在后续内容到达时仍可能变化。降低更新频率不能保证布局完全稳定。更长的回复可以考虑按稳定内容块缓存渲染、降低解析频率,并把昂贵的代码高亮推迟到内容稳定后执行。

这些优化需要根据实际内容长度和设备表现决定。示例只做帧级合并与按消息缓存,没有声称解决任意长文本的全部性能问题。

第五个坑:自动滚动,把用户的阅读位置抢走了

每次输出都执行一次 scrollToBottom(),演示时很顺畅,真正使用时却可能让人烦躁。

当用户往上查看历史内容时,应该暂时停止跟随;用户回到底部或点击"回到最新消息"后,再恢复跟随。

不过,不能把所有滚动事件都解释成用户操作。内容增长、滚动锚定和程序主动设置滚动位置,也可能影响滚动事件。

配套示例把这两种判断分开处理:向上滚动、触摸、在记录区域按下指针或使用向上的导航键,表示用户可能正在查看历史内容,暂停跟随;滚动位置接近底部时恢复跟随。

js 复制代码
function onWheel(event) {
  if (event.deltaY < 0) following.value = false;
}

function onScroll() {
  const el = scroller.value;
  const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
  if (distance < 64) following.value = true;
}

这里的 64px 是示例使用的交互容差,可以根据产品调整。触摸、指针和键盘操作的绑定放在配套页面中;点击记录区暂停跟随,也方便用户选择文字。

真正更新滚动位置前,要等待 Vue 完成 DOM 更新,并再次确认仍在原会话、用户也没有暂停跟随。否则,异步滚动回调也可能在切换会话后操作新的消息列表。

发送新消息时,示例会主动恢复跟随;查看历史时,则显示"回到最新消息"按钮,让用户决定何时返回。

这个功能没有增加新的模型能力,但会直接影响聊天页面是否好用。

怎样验证这些问题确实解决了?

配套示例使用 Node.js 20 或更新版本运行本地服务,浏览器加载 Vue 3、Marked 和 DOMPurify。在示例目录安装依赖并启动:

bash 复制代码
npm install
npm start

然后访问终端显示的本地地址。运行自动检查使用 npm test。

仅用一条正常回复检查,很难发现异步交互的问题。可以按下面这些操作验收:

操作 预期结果
把中文字符拆到不同的字节分块中 中文和 emoji 正确还原
一次读取包含多个事件,或一个事件跨越多次读取 每条事件都能正确解析
服务端发送部分内容后结束连接,不发送 done 保留部分内容,提示回复可能不完整
生成中点击停止 保留已有文字,停止状态不被迟到异常覆盖
停止后立即在同会话发送新消息 旧请求不能修改新任务
生成中切换会话 旧内容不进入新会话
输出 HTML 事件属性等内容 用户文本不按 HTML 执行,助手 HTML 经清洗
输出时向上查看历史消息 保持阅读位置,可主动回到最新消息

配套示例的本地服务支持输入"模拟断流""模拟报错""HTTP错误",分别触发提前结束、业务错误事件和 HTTP 错误。

自动检查覆盖了解码边界、SSE 分帧、完成标记、停止收尾、任务隔离等情况;Markdown 渲染、按钮状态和滚动行为还需要在浏览器里操作验证。

如果前端逻辑正确,文字仍然最后一次性出现,还需要检查服务端、代理和网关是否缓冲了响应。前端分块读取的前提,是整条链路确实在持续交付数据。

回到"API 接通了"这件事

接口返回内容,只说明这条调用链在当前条件下能够工作。

要把它变成一个聊天功能,还需要定义消息边界、完成条件和异常状态,处理异步任务的归属,并让渲染和滚动尊重用户正在做的事情。

本文的本地示例验证的是这些交互机制,不包含真实模型调用、持久化和鉴权。实际项目还需要按业务补充超时策略、数据量限制和大规模消息列表处理。

这些工作都可以让 AI 参与实现,但验收时仍要回到具体行为:停止是否生效,断流是否被识别,切换会话是否串数据,用户能否安心地翻看前面的消息。

这也是前端在 AI 应用里一项很具体的价值:把一段模型输出,组织成用户能够理解、控制和恢复的交互过程。

相关推荐
悟天特斯1 小时前
楼宇自控的进阶:从「自动控制」到「自学习优化」
人工智能·物联网
见闻小天地1 小时前
新手入门:印刷包装设备的伺服和PLC运动控制方案各管什么?七步选型讲清楚
运维·网络·数据库·人工智能
爱勇宝1 小时前
你可能成不了技术大佬,然后呢?
前端·后端·程序员
JuiceFS1 小时前
HeyGen:如何让全球 10+ GPU 集群训练不再等待数据?
人工智能·后端
鬼手点金1 小时前
KV Cache 未必是一次性的:可编辑、可组合的“笔记”
java·人工智能·笔记·学习·自动化·1024程序员节
天天代码码天天1 小时前
魔方转不回去了?我用 C# 做了个还原助手,跟着动画一步步来
人工智能
xsd202411181 小时前
智慧园区算法平台搭建详解:从模型仓到统一推理,多模型怎么管起来
人工智能·深度学习
Kyops1 小时前
别让智能体 Agent 卡在登录页!接管“已登录 Chrome浏览器”的 6 种实战方案
人工智能
百度Geek说2 小时前
好看视频鸿蒙端AI研发交付流程的建设思考与实践
人工智能