在本地部署大模型后,很多开发者会遇到同一个体验问题:接口明明已经开始生成内容,页面却长时间没有变化,直到请求结束才一次性显示全部答案。要改善这种“卡住”的感觉,关键是同时处理好 Ollama REST API 的流式响应、浏览器端的增量解析,以及前端打字机动画。本文将从请求方式到异常处理,完整梳理一套可直接落地的实现思路。🚀
一、认识 Ollama 的流式响应
Ollama 的 /api/generate 与 /api/chat 接口支持流式输出,并且默认启用。流式响应不是普通的完整 JSON,而是 NDJSON,也就是以换行符分隔的一系列 JSON 对象,响应类型通常为 application/x-ndjson。每个对象携带一小段生成内容,最后一个对象的 done 字段为 true。具体约定可参考 Ollama 流式响应文档。
以 /api/chat 为例,请求体包含模型名称、消息数组和 stream 参数。接口会持续返回类似以下内容:
{"message":{"role":"assistant","content":"你好"},"done":false}
{"message":{"role":"assistant","content":",很高兴"},"done":false}
{"message":{"role":"assistant","content":"帮助你"},"done":true}
如果调用的是 /api/generate,文本片段一般位于 response 字段;调用 /api/chat 时,则通常读取 message.content。两种接口的字段结构不同,封装公共解析器时应通过参数指定取值方式。聊天接口的请求与响应字段可查看 官方 Chat API 说明。
二、使用 Fetch 读取响应流
浏览器的 Fetch API 可以通过 response.body 获取 ReadableStream。基本步骤是发送 POST 请求、取得 reader、循环调用 read(),再使用 TextDecoder 将二进制数据解码为字符串。
const response = await fetch("来源链接 {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "你的模型名称",
messages: [{ role: "user", content: "你好" }],
stream: true
})
});
if (!response.ok || !response.body) {
throw new Error("请求失败:" + response.status);
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
这里不要直接调用 response.json(),因为流式响应中包含多个独立 JSON 对象,并不是一个完整 JSON 文档。正确方式是边读取、边解码、边拆分。
三、正确处理半包与粘包
网络数据块的边界与换行边界并不一致。一次 read() 可能拿到多行数据,也可能只拿到半行 JSON。如果对每个数据块立即执行 JSON.parse,很容易出现“Unexpected end of JSON input”等错误。稳妥做法是维护一个缓冲区 buffer。
let buffer = "";
while (true) {
const { value, done } = 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 text = line.trim();
if (!text) continue;
const data = JSON.parse(text);
const chunk = data.message?.content || "";
if (chunk) enqueueText(chunk);
}
}
这段逻辑会把最后一条可能不完整的数据保留在 buffer 中,等下一批内容到达后再拼接。读取结束后,还应使用 decoder.decode() 获取解码器中可能残留的字符,并尝试处理最终 buffer,从而避免遗漏最后一段内容。
四、实现自然的打字机效果
如果每收到一个流片段就立即修改页面,显示速度会受到模型生成节奏影响,可能忽快忽慢。更自然的方案是将接口流与界面动画分离:解析器负责把文本加入队列,定时器负责按字符取出并渲染。⌨️
let textQueue = [];
let typing = false;
function enqueueText(text) {
textQueue.push(...Array.from(text));
if (!typing) startTyping();
}
function startTyping() {
typing = true;
function tick() {
const char = textQueue.shift();
if (char !== undefined) {
output.textContent += char;
setTimeout(tick, 25);
} else {
typing = false;
}
}
tick();
}
Array.from(text) 比直接使用 split("") 更适合处理部分 Unicode 字符,但复杂 Emoji 可能由多个码点组成。如果需要严格按用户看到的字符切分,可以使用 Intl.Segmenter。动画间隔无需固定,也可以根据队列长度动态调整:积压较多时加快输出,队列较短时恢复正常速度,在流畅度与实时性之间取得平衡。
五、避免页面卡顿与内容错乱
- 减少频繁重排:长文本场景不要每个字符都执行复杂 DOM 操作,可先修改文本节点,或每帧批量追加多个字符。
- 区分消息容器:每次提问创建独立的回答区域,不要让并发请求共同写入同一个元素。
- 支持主动停止:使用 AbortController 保存当前请求控制器,用户点击“停止生成”时调用 abort()。
- 防止重复提交:请求进行期间禁用发送按钮,或为每次请求分配唯一标识,只处理当前会话的数据。
- 安全渲染内容:普通文本优先写入 textContent。需要渲染 Markdown 时,应先进行安全转换与过滤,避免直接把模型输出写入 innerHTML。
六、完善状态与异常处理
一个可用的聊天界面不应只处理成功路径。请求开始时可显示“正在连接模型”;收到首个文本片段后切换为“正在生成”;流读取结束且队列清空后显示完成状态。网络断开、模型不存在、服务未启动或用户主动取消时,也应给出不同提示。🛠️
JSON.parse 建议放入 try 和 catch 中,但不要简单吞掉所有异常。如果当前行已经完整却无法解析,应记录原始片段以便排查。Fetch 抛出 AbortError 时通常表示用户主动停止,可显示“生成已停止”;其他异常则应提供重试入口。对于 done 为 true 的最终对象,还可以读取生成耗时、token 数量等统计字段,但页面显示应以实际接口返回为准,不要依赖固定字段一定存在。
七、开发调试建议
- 先用命令行确认 Ollama 服务、模型和接口均可正常响应。
- 在浏览器开发者工具中检查响应头与流数据,确认 stream 没有被设为 false。
- 故意模拟半行 JSON、空行和连续多行,验证缓冲区逻辑。
- 测试中文、英文、换行、代码片段及 Emoji,观察 UTF-8 解码是否完整。
- 测试停止生成、快速重复发送和服务中断,确认界面状态能够恢复。
总结
Ollama 前端流式交互的核心并不只是“循环读取数据”,而是建立一条稳定的数据链路:Fetch 获取 ReadableStream,TextDecoder 增量解码,buffer 按换行拆分 NDJSON,JSON.parse 提取文本片段,字符队列再以可控节奏更新界面。只要同时处理好半包、停止请求、并发状态、安全渲染和异常提示,就能实现响应及时、动画自然且易于维护的本地大模型聊天体验。✅