跳到主要内容

用 Deepgram + CloudBase AI 做一个浏览器端语音对话 chatbot

一句话定义:浏览器 getUserMedia + MediaRecorder 录一段语音 → 上传云函数走 Deepgram nova-3 做 STT → 转写文本喂 CloudBase AI streamText({ model: 'deepseek-v4-flash' }) 流式回答 → 前端按句号切片送进 SpeechSynthesisUtterance 边读边播。一问一答的语音 chatbot,前后端凭证不落浏览器。

预计耗时:60 分钟 | 难度:进阶

适用场景

  • 做客服 / AI 助手类语音交互产品,用户按住说话、松开等回答,典型的 push-to-talk
  • 无障碍场景:视觉受限用户用语音问、用语音听结果,不用看屏幕
  • hands-free 场景:车载 / 厨房 / 健身,用户腾不出手敲字
  • 已经用 connect-deepgram-speech-to-text-cloud-function 跑通了 STT,用 add-ai-nextjs 跑通了 streamText,本篇把两端组合成产品级闭环

不适用:

  • 电话级实时双向语音(打断、抢话、亚 300ms 延迟)——那种走 WebRTC + 实时语音 API(Deepgram Live、ElevenLabs Conversational AI、OpenAI Realtime),本篇是一问一答,STT 走批量接口
  • 超长对话(连续 200+ 轮 TTS)——浏览器 speechSynthesis 队列存在 200 条左右的隐性上限,长时间不消费会被截断,产品上要么定期 cancel() 清队列要么换成第三方 TTS API
  • 中文 TTS 高音质需求——浏览器内置 SpeechSynthesisUtterance 中文音质一般(各系统自带的中文 voice 多为离线 TTS,韵律平),要好音质走第三方 TTS(Azure / Volcengine / MiniMax)的 HTTP API 在云函数里代理
  • 需要 punctuation 之外的字级时间戳来做卡拉 OK 式高亮——本篇 TTS 是浏览器播报,没有 audio 文件;要做字幕同步走 Deepgram utterances + 自己渲染

环境要求

依赖版本
浏览器Chrome / Edge / Safari 14+ (MediaRecorder + speechSynthesis 都支持);Safari macOS 14 之前 MediaRecorder 不支持 webm
@cloudbase/node-sdk3.16.0 及以上(AI 模块要求,timeout 选项要拉到 60s)
@deepgram/sdk^4.x(本文按 v4 API,listen.v1.media.transcribeFile)
Node.js云函数运行时 ≥ 18
CloudBase 环境已开通,且控制台已开通「AI+」能力
Deepgram 账号一个 API key(注册即送 200 美元额度)

需要准备:

  • 一个有麦克风的设备(笔记本自带就行)
  • HTTPS 域名或 localhost——getUserMedia 在非安全上下文下被浏览器拒绝
  • 一个 CloudBase 环境 envId + 一组腾讯云子账号密钥(SecretId / SecretKey),通过 CAM 锁到当前环境最安全

整体链路

[浏览器]
getUserMedia → MediaRecorder(webm/opus)
↓ Blob → base64 / FormData
↓ HTTP POST
[Web 云函数 transcribe-audio]
Buffer → Deepgram nova-3 transcribeFile
↓ transcript 文本
↓ HTTP 返回
[浏览器]
fetch('/api/chat')
↓ POST { messages: [...历史, { role: 'user', content: transcript }] }
[Web 云函数 chat]
CloudBase AI streamText(deepseek-v4-flash)
↓ textStream(AsyncIterable<string>)
↓ ReadableStream(text/plain)
[浏览器]
reader.read() 累积 chunk
↓ 按句号 / 问号 / 感叹号 / 换行切句
↓ 每整一句 enqueue 到 TTS 队列
SpeechSynthesisUtterance + speechSynthesis.speak()

关键设计:STT 走批量(短录音整体一次),AI 走流式,TTS 也走流式。STT 不流式是因为客户端短录音(常见 < 30 秒)上传后整体转一次比起 WebSocket 实时流麻烦更少;AI 必须流式,否则用户要等几秒才听到第一个字;TTS 必须按句切片,等模型整段输出完再 TTS 等于把流式收益吃掉。

第一步:CloudBase 上开通 AI + 拿 Deepgram key

add-ai-nextjs 第一步、connect-deepgram-speech-to-text-cloud-function 第一步完全一样:

  1. CloudBase 控制台 → 环境 → AI+快速接入,首次会有「立即开通」按钮,开通免费、调用按 token 计费
  2. 在「模型管理」里确认 deepseek-v4-flash 已上架(这是 CloudBase 当前主推的对话模型,完整列表见 接入大模型)
  3. 登录 Deepgram Console → API Keys → Create a New API Key,Scope 选 Member,复制 key 备用(只显示一次)

下面用两个云函数:

  • voice-stt — HTTP 触发的 Web 云函数,接前端上传的音频 buffer,调 Deepgram 返回 transcript
  • voice-chat — HTTP 触发的 Web 云函数,接前端 messages,调 CloudBase AI 流式返回

也可以塞进同一个函数走 path 分发,本篇分开写更清楚。

第二步:写 voice-stt(Web 云函数,Deepgram STT)

新建函数目录:

mkdir voice-stt && cd voice-stt
npm init -y
npm install --save @deepgram/sdk

index.js:

const { createClient } = require("@deepgram/sdk");

const deepgram = createClient(process.env.DEEPGRAM_API_KEY);

// Web 云函数入口:event 形如 { httpMethod, body, headers, isBase64Encoded, ... }
exports.main = async (event) => {
// 浏览器 fetch 传 base64 文本上来,云函数侧把它还原成 Buffer
// 不直接传 binary 是为了避开 Web 云函数对原始二进制 body 的尺寸 + Content-Type 限制
if (event.httpMethod !== "POST") {
return { statusCode: 405, body: "method not allowed" };
}

let audioBuffer;
let language = "zh-CN";
try {
const payload = JSON.parse(event.body || "{}");
if (!payload.audioBase64) {
return {
statusCode: 400,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ok: false, error: "missing_audioBase64" }),
};
}
audioBuffer = Buffer.from(payload.audioBase64, "base64");
if (payload.language) language = payload.language;
} catch (err) {
return {
statusCode: 400,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ok: false, error: "invalid_body", message: err.message }),
};
}

try {
const response = await deepgram.listen.v1.media.transcribeFile(audioBuffer, {
model: "nova-3",
smart_format: true, // 自动加标点 + 数字格式化
language, // "zh-CN" / "en" / "multi" 等
// 短问句不需要 diarize / utterances,省一点延迟
});

const transcript =
response?.result?.results?.channels?.[0]?.alternatives?.[0]?.transcript || "";

return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*", // 生产环境换成你的前端域名
},
body: JSON.stringify({ ok: true, transcript }),
};
} catch (err) {
console.error("deepgram failed", err);
return {
statusCode: 500,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ok: false,
error: "deepgram_failed",
statusCode: err.statusCode,
message: err.message,
}),
};
}
};

package.json:

{
"name": "voice-stt",
"main": "index.js",
"dependencies": {
"@deepgram/sdk": "^4.0.0"
}
}

几个要点:

  • 音频走 base64,不走 multipart:浏览器 MediaRecorder 拿到 Blob 后用 FileReader.readAsDataURL 转 base64 直接 POST JSON,云函数侧 Buffer.from(payload.audioBase64, 'base64') 还原。multipart 在 Web 云函数里要自己解析 form-data,不如 JSON 朴素
  • 音频别太长:Web 云函数请求体上限通常 ~6MB,base64 体积 +33%,留余量大概能装 30 秒 Opus 录音(Opus 32kbps 单声道 30 秒 ~120KB,远没到上限),真要传分钟级的录音改成「前端先上传云存储 → 云函数从 fileID 下载」,走 connect-deepgram-speech-to-text-cloud-function 那一套
  • language: "zh-CN" 强烈建议显式指定,nova-3 默认 en,中文不指定会音译成英文乱码;中英文混说传 "multi"
  • 没开 diarize / utterances——单人短问句不需要,删掉省 200-500ms

第三步:写 voice-chat(Web 云函数,CloudBase AI 流式)

新建函数目录:

mkdir voice-chat && cd voice-chat
npm init -y
npm install --save @cloudbase/node-sdk

index.js:

const tcb = require("@cloudbase/node-sdk");

// 模块级缓存,Web 云函数实例 hot 状态下复用
let app = null;
function getApp() {
if (!app) {
// timeout 60s:streamText 长输出会击穿默认 15s
app = tcb.init({ env: process.env.TCB_ENV, timeout: 60000 });
}
return app;
}

// Web 云函数入口
exports.main = async (event) => {
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Methods": "POST,OPTIONS",
},
};
}
if (event.httpMethod !== "POST") {
return { statusCode: 405, body: "method not allowed" };
}

let messages;
try {
const payload = JSON.parse(event.body || "{}");
messages = payload.messages;
if (!Array.isArray(messages) || messages.length === 0) {
return {
statusCode: 400,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ok: false, error: "missing_messages" }),
};
}
} catch (err) {
return {
statusCode: 400,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ok: false, error: "invalid_body", message: err.message }),
};
}

const ai = getApp().ai();
const model = ai.createModel("cloudbase");

// 给一段语音对话场景下专用的 system,提示模型短句、口语化
const systemMsg = {
role: "system",
content:
"你是一个语音助手,用户通过语音和你对话,回答要短、口语化、用完整句子(每句以句号/问号/感叹号结尾),不要用 markdown / bullet / 代码块,纯文本即可。",
};

const result = await model.streamText({
model: "deepseek-v4-flash",
messages: [systemMsg, ...messages],
});

// Web 云函数支持 streamingBody;不同版本写法略有差异,这里用最通用的 ReadableStream
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
for await (const chunk of result.textStream) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
} catch (err) {
console.error("streamText failed", err);
controller.error(err);
}
},
});

return {
statusCode: 200,
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache",
},
body: stream,
};
};

package.json:

{
"name": "voice-chat",
"main": "index.js",
"dependencies": {
"@cloudbase/node-sdk": "^3.16.0"
}
}

要点:

  • 服务端 SDK + 环境凭证:不要用 @cloudbase/js-sdk + signInAnonymously(),后者匿名身份在 Web 云函数会被严格限频,详见 Web SDK 安全策略
  • provider: 'cloudbase':createModel('cloudbase') 这一行,模型才会按 CloudBase 统一接入路径计费 / 鉴权
  • timeout: 60000:模型流式输出 30 秒以上很常见,SDK 默认 15s 会断
  • CORS:OPTIONS 预检和正式响应都要带 Access-Control-Allow-Origin,前端在 https://your-app.com 上调跨域必须设置;生产环境把 * 换成具体域名
  • Web 云函数的 body 字段在新版运行时支持直接返回 ReadableStream;如果你的运行时版本不支持,见本节末尾的「Web 云函数流式输出兼容写法」

第四步:前端三件套(录音 → STT → 流式聊天 + TTS)

完整 HTML(单文件 demo,生产环境拆成 React/Vue 组件即可):

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>语音对话 chatbot</title>
</head>
<body>
<button id="btn">按住说话</button>
<div id="status">就绪</div>
<div id="dialog"></div>

<script type="module">
// 部署后替换成你的 Web 云函数 HTTPS URL
const STT_URL = "https://your-env.service.tcloudbase.com/voice-stt";
const CHAT_URL = "https://your-env.service.tcloudbase.com/voice-chat";

const btn = document.getElementById("btn");
const statusEl = document.getElementById("status");
const dialogEl = document.getElementById("dialog");

let mediaRecorder = null;
let chunks = [];
const messages = []; // 多轮对话历史

// ========== 录音 ==========
btn.addEventListener("mousedown", startRecord);
btn.addEventListener("mouseup", stopRecord);
btn.addEventListener("touchstart", (e) => {
e.preventDefault();
startRecord();
});
btn.addEventListener("touchend", (e) => {
e.preventDefault();
stopRecord();
});

async function startRecord() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
chunks = [];
mediaRecorder = new MediaRecorder(stream, {
mimeType: "audio/webm;codecs=opus",
});
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
mediaRecorder.onstop = onRecordStop;
mediaRecorder.start();
statusEl.textContent = "录音中...";
} catch (err) {
console.error("getUserMedia failed", err);
statusEl.textContent = "麦克风获取失败:" + err.message;
}
}

function stopRecord() {
if (mediaRecorder && mediaRecorder.state !== "inactive") {
mediaRecorder.stop();
mediaRecorder.stream.getTracks().forEach((t) => t.stop());
}
}

// ========== STT ==========
async function onRecordStop() {
const blob = new Blob(chunks, { type: "audio/webm" });
if (blob.size < 1000) {
statusEl.textContent = "录音太短";
return;
}
statusEl.textContent = "识别中...";
const audioBase64 = await blobToBase64(blob);

let transcript = "";
try {
const res = await fetch(STT_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ audioBase64, language: "zh-CN" }),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error || data.message);
transcript = data.transcript;
} catch (err) {
statusEl.textContent = "STT 失败:" + err.message;
return;
}

if (!transcript.trim()) {
statusEl.textContent = "没听清,再试一次";
return;
}

appendBubble("user", transcript);
messages.push({ role: "user", content: transcript });
statusEl.textContent = "AI 生成中...";
await streamChatAndSpeak();
}

function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
// data:audio/webm;base64,xxxxx → 去掉前缀
const result = reader.result;
const idx = result.indexOf(",");
resolve(result.slice(idx + 1));
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}

// ========== AI 流式 + TTS 按句切片 ==========
async function streamChatAndSpeak() {
// 先把这一轮先取消之前队列里没念完的(用户开新一轮通常想打断)
window.speechSynthesis.cancel();

const res = await fetch(CHAT_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
});
if (!res.ok || !res.body) {
statusEl.textContent = "AI 请求失败 " + res.status;
return;
}

const reader = res.body.getReader();
const decoder = new TextDecoder();
let acc = ""; // 整段 transcript,渲染用
let sentenceBuf = ""; // 还没遇到标点的句子 buffer
const aiBubble = appendBubble("assistant", "");

// 中文标点 + 英文标点 + 换行 都算句末
const sentenceEnd = /[。!?!?\n]/;

while (true) {
const { done, value } = await reader.read();
if (done) break;
// stream: true 不能漏,跨 chunk 边界的中文会乱码
const text = decoder.decode(value, { stream: true });
acc += text;
aiBubble.textContent = acc;
sentenceBuf += text;

// 反复切句:每次遇到标点就立刻拆出一段塞 TTS
let m;
while ((m = sentenceBuf.match(sentenceEnd))) {
const cut = m.index + 1;
const sentence = sentenceBuf.slice(0, cut).trim();
sentenceBuf = sentenceBuf.slice(cut);
if (sentence) speak(sentence);
}
}
// 流结束,buffer 里可能还剩没有标点结尾的尾句,也念掉
if (sentenceBuf.trim()) speak(sentenceBuf.trim());

messages.push({ role: "assistant", content: acc });
statusEl.textContent = "就绪";
}

function speak(text) {
const u = new SpeechSynthesisUtterance(text);
u.lang = "zh-CN";
u.rate = 1.05; // 略快一点,语音对话场景更自然
u.pitch = 1;
window.speechSynthesis.speak(u);
}

// ========== UI 辅助 ==========
function appendBubble(role, text) {
const div = document.createElement("div");
div.style.padding = "8px";
div.style.margin = "8px 0";
div.style.background = role === "user" ? "#eef" : "#f5f5f5";
div.style.whiteSpace = "pre-wrap";
div.textContent = (role === "user" ? "我:" : "AI:") + text;
dialogEl.appendChild(div);
return {
set textContent(t) {
div.textContent = (role === "user" ? "我:" : "AI:") + t;
},
get textContent() {
return div.textContent;
},
};
}
</script>
</body>
</html>

要点拆开讲:

切句正则:/[。!?!?\n]/ 同时匹配中英文句末标点,英文逗号 , 不算句末——按逗号切会让 TTS 念出来卡顿。如果对话以英文为主,把 ! 后面加 ;(分号)也能让停顿更自然。

TTS 队列就是 speechSynthesis 自带的队列:连续 speak() 多次会自动排队播放,不需要自己 await 上一句念完speechSynthesis.cancel() 一调用清空整个队列,所以新一轮开始要先 cancel——否则上一轮没念完的会和新一轮抢话。

stream: true 是中文流式的硬底线:一个汉字 UTF-8 占 3 字节,流式 chunk 边界经常把汉字劈成两半,不带 stream: true 会出现 \uFFFD(��� 替换字符);带上之后 TextDecoder 会缓存边界字节,下次 decode 再拼回去。

多轮对话:messages 数组完整保留历史,每次发都带过去,模型按 OpenAI 风格 role: user / assistant 自己理解上下文。语音 chatbot 因为模型 system 写了「短句口语化」,token 消耗一般比纯文字 chat 还低。

Push-to-talk vs 长按:本例用 mousedown / touchstart 开始 mouseup / touchend 结束,移动端用户体验最稳。如果想用 VAD(Voice Activity Detection)做「自动检测说完」,要么前端引一个 vad-web 之类的包,要么直接走 Deepgram WebSocket 流式 + 内置 endpointing,本篇不展开。

第五步:部署

部署两个 Web 云函数:

cd voice-stt
tcb fn deploy voice-stt -e your-env-id --type http

cd ../voice-chat
tcb fn deploy voice-chat -e your-env-id --type http

到控制台:

  • voice-stt 环境变量:DEEPGRAM_API_KEY,超时改 30s,内存 256MB 够
  • voice-chat 环境变量:TCB_ENV=你的环境 ID;Vercel/自建机器还要 TENCENTCLOUD_SECRETID + TENCENTCLOUD_SECRETKEY(部署在 CloudBase 自家会自动注入);超时改 60s 起步,内存 512MB
  • 两个函数都要在「函数配置 → HTTP 触发」里看到生成的 HTTPS URL,形如 https://your-env.service.tcloudbaseapp.com/voice-stt,前端 STT_URL / CHAT_URL 用这个

前端可以挂到 CloudBase 静态托管(把 HTML 扔进去),也可以本地 python3 -m http.server + localhost(注意 getUserMedia 必须 HTTPS 或 localhost)。

运行验证

  1. 浏览器打开你部署的 HTML 页面,首次会弹「允许使用麦克风」,选允许
  2. 按住「按住说话」按钮说一句话,例如「介绍一下 CloudBase」,松开
  3. 状态栏依次显示:录音中...识别中...AI 生成中...就绪
  4. 对话框看到「我:介绍一下 CloudBase」「AI:CloudBase 是腾讯云推出的...」逐字增长
  5. 同时浏览器会朗读第一句完整句,然后继续朗读后续句子,边生成边读,延迟应当在 1.5-3 秒内出第一个字音
  6. 再问第二句话(例如「它能做什么?」),AI 应当能基于上一轮上下文回答(说明多轮对话正常)
  7. 浏览器 DevTools Network 看 /voice-stt(JSON 返回,带 transcript)和 /voice-chat(Response 流式累积纯文本,不是单个 JSON)
  8. CloudBase 控制台 → AI+ → 调用记录,看到 token 计数;Deepgram Console → Usage,看到本次调用记 0.x 分钟

常见错误

错误信息 / 现象原因修复
getUserMedia is not a function / permission denied页面不是 HTTPS 也不是 localhost,浏览器拒绝麦克风访问用 HTTPS 域名(静态托管自带 HTTPS),或本地用 localhost 调试;企业内网自签证书也行,但要先信任根证书
录音 Blob size === 0 或 1KB 以下MediaRecorder.start() 立刻 stop(),数据还没来得及 flush;或者 mimeType 浏览器不支持至少录 500ms 再 stop;不支持 audio/webm;codecs=opus 时去掉 mimeType 让浏览器自己选,Safari 会给 audio/mp4(Deepgram 也支持)
Deepgram 返回 Audio decode failedwebm 头损坏,或者 MediaRecorder 在某些浏览器上 stop 时数据没 flush 完整录完 mediaRecorder.onstop 里组装 Blob,不要在 ondataavailable 单条 chunk 直接传;实在不行 ffmpeg 转 mp3 再传
中文转出来全是英文音译(类似 "Da jia hao")STT 请求没传 language: "zh-CN",nova-3 默认 en 走音译在 STT 函数 / 前端请求里显式传 language: "zh-CN";中英文混说传 "multi"
AI 流式返回里中文显示 ��� 乱码TextDecoder.decode(value) 没加 { stream: true },跨 chunk 边界多字节字符被劈两半改成 decoder.decode(value, { stream: true }),本文示例已加
TTS 完全不响 / 第一句念了后面不念了speechSynthesis 在长时间无操作或频繁 cancel() 后被浏览器挂起;或者队列被打断用户每次开新一轮先 speechSynthesis.cancel() 重置;Chrome 上 speechSynthesis 闲置 ~14s 后会暂停,长间隔对话场景在每次 speak() 前手动 speechSynthesis.resume()
TTS 念了一半被截断,中文断在词中间切句正则切得太碎(比如把英文逗号也算句末),或者模型一段没有标点直接吐 500 字切句正则只用 [。!?!?\n],逗号不算;模型 system 强制要求「每句以标点结尾」,见本文 voice-chat 函数里那条 system
部署后 fetch 报 CORS errorWeb 云函数响应没带 Access-Control-Allow-Origin,或 OPTIONS 预检没单独处理OPTIONS 直接返回 204 + CORS 头;POST 响应也加 Access-Control-Allow-Origin: *(生产换成具体域名),本文示例已加
streamText 60s 超时 / 中途断流Node SDK 默认 timeout: 15000 太短;或者 Web 云函数运行时本身有响应时长上限tcb.init({ env, timeout: 60000 }) 拉到 60s 以上;Web 云函数控制台「超时时间」改到 60s 起步;超长输出场景考虑分段返回
第二轮对话 AI 不记得第一轮内容前端 messages 没把上一轮的 assistant 回复 push 进去,只 push 了 user流读完后 messages.push({ role: "assistant", content: acc }),本文示例已加
浏览器报 speechSynthesis queue exceeded 或者多轮后 TTS 不响speechSynthesis 队列有上限(实测 200 条左右),长时间对话不清队列会撑满每轮 user 说话开始时 speechSynthesis.cancel() 清旧队列;真要做超长持续对话考虑换第三方 TTS API

错误码完整定义参考 https://docs.cloudbase.net/error-code/。Deepgram 自己的错误码在它官方文档里。

计费提示

  • Deepgram STT:nova-3 按音频时长计费(不是 token),Pay As You Go 单价约 0.0043 美元 / 分钟;一句话 5 秒 ≈ 0.0004 美元,1000 次问答约 0.4 美元
  • CloudBase AI:deepseek-v4-flash 按输入 + 输出 token 计;语音 chatbot 因为 system 强制短句、口语化,单轮 token 比纯文字 chat 还低;新开通环境首月赠送 100 万 token 试用额度(具体以 控制台计费页面 当前披露为准)
  • Web 云函数:按调用次数 + 资源消耗(GBs)计;一次问答涉及 2 个函数(stt + chat),按 CloudBase 标准计费
  • TTS 完全免费:用浏览器内置 SpeechSynthesisUtterance,本地引擎,不发网络请求,所有计费都不涉及 TTS

Web 云函数流式输出兼容写法

如果你的 Web 云函数运行时版本不支持直接返回 ReadableStream,改用 Node.js 原生 http.IncomingMessage 或者 SSE 模式:

// 用 res.write() 模式(部分运行时支持 streaming response)
const { Readable } = require("stream");

const stream = new Readable({
read() {},
});

(async () => {
try {
for await (const chunk of result.textStream) {
stream.push(chunk);
}
stream.push(null);
} catch (err) {
stream.destroy(err);
}
})();

return {
statusCode: 200,
headers: { "Content-Type": "text/plain; charset=utf-8" },
body: stream,
};

或者干脆走 SSE(text/event-stream):每个 chunk 包成 data: xxx\n\n,前端用 EventSource 消费。SSE 比裸 fetch 多了协议开销但浏览器支持自动重连,长时间对话更稳。

相关文档