大模型 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 应用里一项很具体的价值:把一段模型输出,组织成用户能够理解、控制和恢复的交互过程。