在 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 的
useChatHook + 内置消息状态管理——本篇用裸 fetch,如果你坚持要useChat,需要额外把流包装成 SSEdata:协议,本篇不展开 - Next.js Pages Router(
pages/api/*.ts)——本篇全程基于 App Router(app/*),Pages Router 写法不同,需要自行换成res.write()流式写法
环境要求
| 依赖 | 版本 |
|---|---|
| Next.js | 14+(App Router,稳定的 Route Handler) |
@cloudbase/node-sdk | 3.16.0 及以上(AI 模块要求) |
| Node.js | 18.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 第一步完全一样:
- 进 CloudBase 控制台 → 选你的环境 → AI+ → 快速接入
- 第一次进会有个「立即开通」按钮,点完会自动给环境注入 AI 调用权限。开通是免费的,调用按 token 计费
- 在「模型管理」里能看到当前环境可用的模型列表。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 即可。
运行验证
- 启动开发服务器:
npm run dev - 浏览器打开
http://localhost:3000/chat - 输入「用一句话介绍 CloudBase」,点发送
- UI 上应该能看到回复一段段冒出来,而不是憋一阵后整段出现
- 打开浏览器 DevTools Network 面板,找到
/api/chat请求,Response 那栏应该能看到一段流式累积的纯文本(不是一个 JSON) - 服务器终端不应该报
cloudbase.init is not a function/model not found/permission denied - CloudBase 控制台 → AI+ → 调用记录,应该能看到刚才那次调用的 token 计数
常见错误
| 错误信息 / 现象 | 原因 | 修复 |
|---|---|---|
部署到 Vercel/Cloudbase Run 后报 cannot find module '@cloudbase/node-sdk' 或 XMLHttpRequest is not defined | Route 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 一直 hang | for await 里 streamText 抛异常没进 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 安全管控 配置域名白名单
相关文档
- add-ai-wechat-miniprogram — 同一套 CloudBase AI 能力在小程序端的对照实现(
wx.cloud.extend.AI,小程序自带身份) - add-vercel-ai-sdk-streaming-chatbot — 如果你想用 Vercel AI SDK 的
useChatHook 而不是裸 fetch,走这篇 - connect-tavily-search-cloud-function — 给 AI 加联网搜索能力,做 search-augmented chatbot
- connect-openai-api-cloud-function — 海外业务用 OpenAI API 走云函数代理 的对照方案
- add-auth-web-with-cloudbase-sdk — Next.js Web 端接入真实用户身份,搭配 Route Handler 做精确限流
- CloudBase AI Toolkit — 在 AI IDE 里直接用内置 coding plan skill 拆需求/写代码/部署
- SDK 初始化与调用 —
app.ai()官方初始化指引(包含 Node.js 服务端写法) - SDK API 参考 —
createModel / generateText / streamText完整签名
下一步
跑通基础对话后,真正会用上的承接是把搜索结果拼进 prompt——走 connect-tavily-search-cloud-function,在 Route Handler 里先调 Tavily 拿到实时网页摘要,塞进 system 或者最后一条 user 消息再 streamText,就能做出「能回答昨天发生了什么」的 search-augmented chatbot;如果业务要让 AI 答你自己的产品文档/知识库,接 add-rag-with-pgvector-cloudbase 把检索片段拼进 messages 即可。