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

28 阅读14分钟

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

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

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

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

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

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

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

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

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

先约定应用接口:

POST /api/chat
Content-Type: application/json
Accept: text/event-stream

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

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

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

event: delta
data: {"text":"Vue 的响应式"}

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

event: done
data: {}

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

event: error
data: {"message":"生成失败,请稍后重试"}

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

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

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

很容易想到这样的写法:

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:

// 仅处理单次 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用户主动停止保留已生成内容,本次任务结束
errorHTTP 错误、协议错误或异常断流保留已有内容,显示错误

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

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:

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 里把所有情况统一改成成功。

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

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

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

activeSession.messages.at(-1).content += delta;

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

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

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 的状态。

示例的停止操作如下:

function stop(sessionId) {
  const task = tasks.get(sessionId);
  if (!task) return;

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

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

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

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

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

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

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

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

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

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

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 渲染则放在单条消息组件里,通过计算属性根据这条消息的内容更新:

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

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

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

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

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

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

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

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

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

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。在示例目录安装依赖并启动:

npm install
npm start

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

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

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

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

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

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

回到“API 接通了”这件事

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

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

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

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

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