跳到主要内容

在 Next.js 中接入 CloudBase AI(DeepSeek / 混元)

一句话定义:在 Next.js App Router 的 Route Handler 里用 @cloudbase/js-sdk 拿到 app.ai().createModel().streamText() 返回的 textStream(AsyncIterable),包成原生 ReadableStream 透给前端,Client Component 用 fetch + reader.read() 消费,前后端都不需要 OpenAI Key,也不需要 Vercel AI SDK。

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

适用场景

  • Next.js Web 应用想加个 AI 对话/摘要/搜索框,但不想买 OpenAI Key,也不想自建 LLM 网关
  • 国内业务,要求模型调用走腾讯云出口,不能让前端直连境外 API
  • 已经在小程序端用 add-ai-wechat-miniprogram 跑通了 CloudBase AI,现在要把同一套能力扩到 Web 端
  • 想要最朴素的流式 UI(逐字冒出来),不想引入 Vercel AI SDK 也不想配 SSE 协议

不适用

  • 海外业务,用户主要在境外,直接走 OpenAI / Anthropic 直连或者 Vercel AI SDK,本篇用不上
  • 只是要做用户登录/会话,不涉及 AI——走 add-auth-web-with-cloudbase-sdk 就够了
  • 想用 Vercel AI SDK 的 useChat Hook + 内置消息状态管理——本篇用裸 fetch,如果你坚持要 useChat,需要额外把流包装成 SSE data: 协议,本篇不展开
  • Next.js Pages Router(pages/api/*.ts)——本篇全程基于 App Router(app/*),Pages Router 写法不同,需要自行换成 res.write() 流式写法

环境要求

依赖版本
Next.js14+(App Router,稳定的 Route Handler)
@cloudbase/node-sdk3.16.0 及以上(AI 模块要求)
Node.js18.17+(Next.js 14 要求)
Route Handler runtime必须是 nodejs,不能用 edge(SDK 在 Edge Runtime 下跑不起来)
CloudBase 环境已开通,且控制台已开通「AI+」能力

服务端必须用 @cloudbase/node-sdk,不要用 @cloudbase/js-sdk + signInAnonymously() 这套 Web 端写法。Web SDK 的匿名登录默认会被严格限频(详见 Web SDK 安全策略),只适合 Demo;生产环境必须走 Node SDK + 环境级凭证的后端代理模式。

第一步:控制台开通 AI 能力 + 选模型

add-ai-wechat-miniprogram 第一步完全一样:

  1. CloudBase 控制台 → 选你的环境 → AI+快速接入
  2. 第一次进会有个「立即开通」按钮,点完会自动给环境注入 AI 调用权限。开通是免费的,调用按 token 计费
  3. 在「模型管理」里能看到当前环境可用的模型列表。CloudBase 通过 Token 资源包统一接入 DeepSeek、MiniMax、混元、Kimi、GLM 等主流模型,官方主推 deepseek-v4-flash(性价比 + 通用对话默认),完整列表见 接入大模型

下文示例统一用 deepseek-v4-flash。其他模型 ID 以控制台「模型管理」当前展示为准,换模型只要把代码里 model: 那行替换即可,其他不动。

第二步:Next.js 装 SDK + 写环境变量

npm install @cloudbase/node-sdk
# 或者 pnpm add / yarn add

.env.local:

CLOUDBASE_ENV=your-env-id
TENCENTCLOUD_SECRETID=your-secret-id
TENCENTCLOUD_SECRETKEY=your-secret-key

三个变量都没有 NEXT_PUBLIC_ 前缀——SDK 调用要在服务端 Route Handler 里跑,Env ID 和密钥都不能泄露到客户端 bundle。

SECRETID/SECRETKEY腾讯云控制台 → 访问密钥 里生成,生产环境建议用子账号密钥并通过 CAM 限制只能访问当前 CloudBase 环境。如果 Next.js 部署到 CloudBase 云托管/云函数,这两个变量会自动注入,可以省略。

如果你用 CodeBuddy / Cursor / VS Code 等接入了 CloudBase MCP 的 AI IDE,可以让 IDE 直接走内置的 coding plan skill 拆解需求并自动写代码,本篇是手写路线的对照实现,流程对得上即可。

第三步:写 Route Handler,把 AsyncIterable 转 ReadableStream

新建 app/api/chat/route.ts:

import tcb from '@cloudbase/node-sdk';

export const runtime = 'nodejs'; // 关键:不能用 edge,SDK 依赖 Node API

let app: ReturnType<typeof tcb.init> | null = null;

function getAi() {
if (!app) {
// node-sdk 会自动从 TENCENTCLOUD_SECRETID/SECRETKEY 读凭证,无需在代码里显式传
// timeout 60s:AI 生成耗时较长,默认 15s 会被 streamText 长输出击穿
app = tcb.init({ env: process.env.CLOUDBASE_ENV!, timeout: 60000 });
}
return app.ai();
}

export async function POST(req: Request) {
const { messages } = await req.json();
const ai = getAi();
const model = ai.createModel('cloudbase');

const result = await model.streamText({
model: 'deepseek-v4-flash',
messages,
});

// 把 AsyncIterable<string> 转成原生 ReadableStream<Uint8Array>
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) {
controller.error(err);
}
},
});

return new Response(stream, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}

几个关键点:

  • tcb.init() 在 Route Handler 里会被多次调用(每个请求都进 POST 函数),但 app 用模块级变量缓存,只第一次初始化,后续请求直接复用
  • 服务端 SDK 自带环境级身份,不需要 signInAnonymously()。把 Route Handler 当作后端代理层:Client Component 只跟 /api/chat 通,真正持有凭证的是这个服务端入口,不是浏览器
  • result.textStream 是只吐文字增量的异步迭代器,result.dataStream 才带 chunk 元数据,两者只能消费一个
  • controller.error(err) 不要漏,否则前端 reader 会一直挂着等
  • 返回的 Content-Type 用 text/plain 而不是 text/event-stream,因为本篇前端走裸 fetch,不需要 SSE 协议;如果你想换成 SSE 给 EventSource 用,这里要改成 text/event-stream 同时把每个 chunk 包成 data: xxx\n\n

第四步:Client Component 用 fetch + getReader 消费

新建 app/chat/page.tsx:

'use client';

import { useState } from 'react';

type Message = { role: 'user' | 'assistant'; content: string };

export default function Chat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);

async function send() {
const text = input.trim();
if (!text || loading) return;

const userMsg: Message = { role: 'user', content: text };
const aiMsg: Message = { role: 'assistant', content: '' };
const next = [...messages, userMsg, aiMsg];
setMessages(next);
setInput('');
setLoading(true);

try {
const res = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ messages: [...messages, userMsg] }),
headers: { 'Content-Type': 'application/json' },
});

if (!res.ok || !res.body) {
throw new Error(`HTTP ${res.status}`);
}

const reader = res.body.getReader();
const decoder = new TextDecoder();
let acc = '';

while (true) {
const { done, value } = await reader.read();
if (done) break;
// stream:true 必须加,否则跨 chunk 边界的中文会乱码
acc += decoder.decode(value, { stream: true });
setMessages((prev) => {
const copy = [...prev];
copy[copy.length - 1] = { role: 'assistant', content: acc };
return copy;
});
}
} catch (err) {
console.error('[chat] fetch failed', err);
setMessages((prev) => {
const copy = [...prev];
copy[copy.length - 1] = {
role: 'assistant',
content: `[出错] ${err instanceof Error ? err.message : String(err)}`,
};
return copy;
});
} finally {
setLoading(false);
}
}

return (
<div style={{ maxWidth: 720, margin: '40px auto', padding: 16 }}>
<div style={{ minHeight: 400, marginBottom: 16 }}>
{messages.map((m, i) => (
<div
key={i}
style={{
padding: 8,
margin: '8px 0',
background: m.role === 'user' ? '#eef' : '#f5f5f5',
whiteSpace: 'pre-wrap',
}}
>
<strong>{m.role}</strong>
{m.content}
</div>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<input
style={{ flex: 1, padding: 8 }}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="问点什么"
disabled={loading}
onKeyDown={(e) => e.key === 'Enter' && send()}
/>
<button onClick={send} disabled={loading || !input.trim()}>
{loading ? '生成中' : '发送'}
</button>
</div>
</div>
);
}

要注意的细节:

  • decoder.decode(value, { stream: true })stream: true 不能漏。一个中文字符通常占 3 字节,流式 chunk 的边界可能恰好把一个汉字劈成两半,不带 stream: true 那半个汉字会被解成 \uFFFD 替换字符
  • 给 assistant 消息预先 push 一条空 content,后续每次拿到 chunk 就替换最后一条,UI 看起来是「在原位增长」而不是「先空一会再整段冒出来」
  • 没有节流——浏览器的 setState + React 重渲染 比小程序 setData 桥快得多,几十毫秒一次没问题。如果模型吐字超快(比如 deepseek-v4-flash 这种 flash 系列一次几百字)可以加一层 80ms 节流,逻辑跟 add-ai-wechat-miniprogram 第三步一样

第五步:加 system prompt + 多轮对话

把上面 Route Handler 第 19 行那个 messages 改成在前面拼一条 system:

const result = await model.streamText({
model: 'deepseek-v4-flash',
messages: [
{
role: 'system',
content:
'你是 CloudBase 的产品助手,回答尽量简短,涉及代码用 TypeScript。',
},
...messages,
],
});

多轮对话不用做特别处理——前端 messages state 已经把历史完整保留,每次发送都是带着完整历史 POST 上去,模型按 OpenAI 风格 role: user / assistant / system 自己理解上下文。如果对话超长(几十轮)担心 token 爆,需要在 Route Handler 里做截断/摘要,本篇不展开。

想配合搜索增强(让 AI 先搜实时网页再回答),后端先调 connect-tavily-search-cloud-function 拿搜索结果,把命中片段拼进 system 或者用户消息再 streamText 即可。

运行验证

  1. 启动开发服务器:npm run dev
  2. 浏览器打开 http://localhost:3000/chat
  3. 输入「用一句话介绍 CloudBase」,点发送
  4. UI 上应该能看到回复一段段冒出来,而不是憋一阵后整段出现
  5. 打开浏览器 DevTools Network 面板,找到 /api/chat 请求,Response 那栏应该能看到一段流式累积的纯文本(不是一个 JSON)
  6. 服务器终端不应该报 cloudbase.init is not a function / model not found / permission denied
  7. CloudBase 控制台 → AI+ → 调用记录,应该能看到刚才那次调用的 token 计数

常见错误

错误信息 / 现象原因修复
部署到 Vercel/Cloudbase Run 后报 cannot find module '@cloudbase/node-sdk'XMLHttpRequest is not definedRoute Handler 用了 export const runtime = 'edge',Edge Runtime 没有完整 Node API改成 export const runtime = 'nodejs',SDK 必须跑在 Node Runtime
部署后报 secretId or secretKey not found / getCredential failed服务端鉴权凭证没注入。Vercel/自建机器上需要显式配 TENCENTCLOUD_SECRETID + TENCENTCLOUD_SECRETKEY(云托管/云函数会自动注入)在部署平台的环境变量里配上这两个值,值从 腾讯云控制台 → 访问密钥 拿;子账号密钥并用 CAM 策略锁定到当前 CloudBase 环境最安全
流式返回中途报 controller is closed 或者前端 reader 一直 hangfor awaitstreamText 抛异常没进 controller.error(err),controller 状态错乱Route Handler 的 try/catch 必须把异常转成 controller.error(err),不能 throw(此时 Response 已经发出去了 throw 也送不回客户端)
前端流式里中文显示 ��� 乱码TextDecoder.decode(value) 没加 { stream: true },跨 chunk 边界的多字节字符被劈成两半改成 decoder.decode(value, { stream: true }),流结束后再用 decoder.decode() 收尾(本例 reader 完整读完直接 break,可省略收尾 decode)
model not found / model xxx is not supported模型 ID 拼错了,或者你环境里这个模型没上架去控制台「模型管理」对照名字,不要照搬 OpenAI / Anthropic 的命名。CloudBase 当前主推 deepseek-v4-flash,完整列表见 接入大模型
timeout / 请求挂死在 60s 左右Node SDK 默认 timeout: 15000 太短,streamText 长输出场景一定会超tcb.init({ env, timeout: 60000 }) 显式拉到 60s 以上,见上面 Route Handler 代码

错误码完整定义参考 https://docs.cloudbase.net/error-code/

计费提示

  • 新开通环境首月赠送 100 万 token 试用额度(具体以 控制台计费页面 当前披露为准)
  • 计费按「输入 token + 输出 token」分别计算,不同模型单价不同;流式调用跟非流式 token 计费一致
  • Route Handler 自身就是后端代理层,凭证不会落到浏览器,但 /api/chat 这个端口仍然对公网开放。重要业务在 POST 入口先校验请求方:带上自家登录 session(参考 add-auth-web-with-cloudbase-sdk 拿真实用户身份)、按 IP/UID 限频、或者结合 CloudBase 安全管控 配置域名白名单

相关文档

下一步

跑通基础对话后,真正会用上的承接是把搜索结果拼进 prompt——走 connect-tavily-search-cloud-function,在 Route Handler 里先调 Tavily 拿到实时网页摘要,塞进 system 或者最后一条 user 消息再 streamText,就能做出「能回答昨天发生了什么」的 search-augmented chatbot;如果业务要让 AI 答你自己的产品文档/知识库,接 add-rag-with-pgvector-cloudbase 把检索片段拼进 messages 即可。