手把手教你在浏览器和RUST中处理流式传输

169 阅读4分钟

最近在研究ai,通过ollama部署了deepseek之后,对ollama调用方式感到很有兴趣,所有研究了一下,发现了一些以前没见过的技术,下面来说一下吧。

Ollama流式响应

一开始我是使用了Page Assist这个浏览器插件对ollama进行调用,这里展示一下ollama的接口是怎样响应的:

image.png

image.png

image.png

很可惜edge并不支持直接预览这个响应,只能看十六进制值以及对应的字符,而且和标准的event-source也不一样,不过好在我在apifox里面调试发现可以正常显示json

image.png

ndjson

从响应可以看到,响应的类型是 application/x-ndjson ,这是ds对他的介绍:

application/x-ndjson 是一种 MIME 类型,表示数据格式为 Newline Delimited JSON(简称 NDJSON)。它是一种基于文本的数据格式,用于存储或传输多个独立的 JSON 对象,每个对象占一行,并通过换行符(\n)分隔。NDJSON 在需要高效处理大规模或流式 JSON 数据的场景中非常实用。

这个内容说明了,这是一个二进制(application代表这是二进制数据)的多个json,而且每个json之间用换行符隔开,可以看apifox的响应预览,切到raw:

image.png

可以看到确实是每一行都是一个json字符串。

Transfer-Encoding: chunked

除了内容类型,还有个传输编码方式值得注意,就是响应头的:Transfer-Encoding: chunked。这是ds对这个头的说明:

Transfer-Encoding: chunked 是 HTTP 协议中的一种传输编码方式,允许服务器将响应数据分成多个“块”(chunk)逐步发送给客户端,无需预先知道数据的总长度。适用于动态生成内容或大文件传输的场景。

核心特点

无需预知总大小
不需要提前计算并设置 Content-Length 头,适合实时生成内容(如流媒体、动态 API 响应)。

分块传输
数据被拆分为多个独立块,每块包含:

-   **块大小**(十六进制数值,如 `1A` 表示 26 字节)。
-   **块内容**(实际数据)。
-   换行符(`\r\n`)。

结束标志
最后发送一个 0 长度的块(0\r\n\r\n),表示传输完成。

可以看到对比于普通的请求,这样的传输方式是一块一块的,不用提前知道整个请求的最终文本长度,比较咱们也没办法预算ai的回答有多长对吧,所有这种传输方法就非常合适。

在浏览器请求

讲解完这两个核心知识点之后,我们就已经大致掌握了流式请求的要义了,剩下的就是怎么发起和处理这些数据了,咱们先来看看JS是怎么请求的,咱们可以参考Page Assist,通过对源码的预览,我找到发起请求的源码:

github.com/n4ze3m/page…

async function* createOllamaStream(
  url: string,
  params: OllamaRequestParams,
  options: OllamaCallOptions
) {
  const response = await fetch(formattedUrl, {
    method: "POST",
    body: JSON.stringify(params),
  })
  if (!response.ok) {
    // ....
  }

  const stream = IterableReadableStream.fromReadableStream(response.body)

  const decoder = new TextDecoder()
  let extra = ""
  for await (const chunk of stream) {
    const decoded = extra + decoder.decode(chunk)
    const lines = decoded.split("\n")
    extra = lines.pop() || ""
    for (const line of lines) {
      try {
        yield JSON.parse(line)
      } catch (e) {
        console.warn(`Received a non-JSON parseable chunk: ${line}`)
      }
    }
  }
}

其他那些有的没的代码就先删掉了,之间看核心部分代码。首先是使用fetch这个api发起请求,对于fetch这个api就不多说了,详情看MDN文档

为了方便迭代处理,这里将普通的可读流转成可迭代的可读的流,方便下面for await处理,这部分语法详情可以看mdn文档

因为前面说了,我们拿到的是二进制(也是十六进制)码,我们需要解码成文本,才能进行json反序列化,所有这里创建了一个文本解码类:TextDecoder,剩下就是调用decode方法对二进制进行解码。

解码出每一行json之后,就是大家都懂的json反序列化了。

最后我们修改一下代码,在控制台看看数据:

image.png

image.png

最终我们这样就能从流式请求接口拿到咱们的数据啦!

rust请求

因为最近还在学习rust,然后我也突发奇想使用rust调用试试,一开始问ai也写不出rust的代码,经过对各种rust群进行发问,有个群友发了个ollama的rust版客户端

详情看发起请求的代码:

github.com/pepperoni21…

然后是调用这个方法的example:

github.com/pepperoni21…

然后我们抄袭借鉴一下代码:

use ollama_rs::generation::chat::ChatMessageResponse;
use reqwest::Client;
use serde::Serialize;
use std::io::{stdout, Write};

#[derive(Debug, Clone, Serialize)]
pub struct ChatMessageRequest {
    model: String,
    messages: Vec<ollama_rs::generation::chat::ChatMessage>,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    use tokio_stream::StreamExt;
    let mut stdout = stdout();
    let client = Client::new();
    let builder = client.post("http://127.0.0.1:11434/api/chat");
    let serialized = serde_json::to_string(&ChatMessageRequest {
        model: "deepseek-r1:1.5b".to_string(),
        messages: vec![ollama_rs::generation::chat::ChatMessage {
            role: ollama_rs::generation::chat::MessageRole::User,
            content: "你是谁".to_string(),
            tool_calls: vec![],
            images: None,
        }],
    })
    .map_err(|e| e.to_string())?;
    let res = builder.body(serialized).send().await?;
    if !res.status().is_success() {
        println!("请求不成功:{}", res.status());
        return Ok(());
    }
    let mut stream = Box::new(res.bytes_stream().map(|res| match res {
        Ok(bytes) => {
            let res = serde_json::from_slice::<ChatMessageResponse>(&bytes);
            match res {
                Ok(res) => Ok(res),
                Err(e) => {
                    eprintln!("Failed to deserialize response: {}", e);
                    Err(())
                }
            }
        }
        Err(e) => {
            eprintln!("Failed to read response: {}", e);
            Err(())
        }
    }));
    while let Some(Ok(res)) = stream.next().await {
        stdout.write_all(res.message.content.as_bytes())?;
        stdout.flush()?;
    }
    Ok(())
}

代码和js类似,看代码是少了一步转字符串的,可以直接将字符串字节反序列化。

a7ddz-njehu.gif