跳到主要内容

在 CloudBase AI 中用 DeepSeek V4-Pro 做图片理解(多模态)

一句话定义:Next.js Route Handler 拿到用户上传图片,转成 base64 Data URL,调 @cloudbase/node-sdkapp.ai().createModel('cloudbase').generateText,model: 'deepseek-v4-pro',messages[].content 走 OpenAI 兼容的多模态数组结构(type: 'image_url' + type: 'text'),一次拿回 AI 对图片的描述 / OCR / 内容分析。

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

适用场景

  • 用户上传产品图,让 AI 帮忙写电商详情页文案 / 自动生成 alt 文案
  • 上传发票 / 名片 / 表单照片,AI 抽字段直接回填到结构化表单
  • 上传截图 / 图表,AI 抽取数据点或解读图表趋势
  • 简单内容审核(图里有没有违规元素),作为前置粗筛挂在专业内容安全服务之前
  • 给小程序 / Web 端加一个"拍一张图问点啥"的入口,不想引入第三方多模态 SDK

不适用:

  • 实时视频流理解(每秒几十帧),走专业视频 API 或自部署 VLM,本篇 generateText 是单次调用,延迟和成本都不合适
  • 海量批处理(几百万张图一次性跑完),走 add-cos-upload-from-cloudbase-app 先入 COS,再用云函数批跑 + 定时任务,本篇是用户上传即时返回的同步链路
  • 多轮图片对话(用户先发图、再发追问、AI 要记得上一张图),需要前端持续累积 messages 数组并把历史图片一起带上,本篇只覆盖单次调用,多轮按 add-ai-nextjs 第五步的多轮模板扩展即可
  • 通用文本对话(没图,只问问题),换成 deepseek-v4-flash 性价比更高,详见 add-ai-nextjs

环境要求

依赖版本
Next.js14+(App Router,稳定的 Route Handler)
@cloudbase/node-sdk3.16.0 及以上(AI 模块要求,多模态走同一接口)
Node.js18.17+(Next.js 14 要求)
Route Handler runtime必须是 nodejs,不能用 edge(SDK 依赖 Node API)
CloudBase 环境已开通,且控制台 AI+ 已开通,模型管理里能看到 deepseek-v4-pro
单图大小建议单张 ≤ 4MB,base64 后约 5.3MB,大图先在前端压缩

服务端必须走 @cloudbase/node-sdk,不要用 @cloudbase/js-sdk + signInAnonymously() 这套 Web 端写法 — 匿名登录会被严格限频,生产环境只能走 Node SDK + 环境级凭证的后端代理模式,详见 add-ai-nextjs

第一步:控制台确认 deepseek-v4-pro 可用

  1. CloudBase 控制台 → 选环境 → AI+模型管理
  2. 在模型列表里确认 deepseek-v4-pro 在线、状态可用
  3. 如果只看到 deepseek-v4-flash 没看到 deepseek-v4-pro,说明当前环境未开通该模型 — 点 快速接入 → 勾选 DeepSeek V4-Pro,几秒后生效

为什么用 pro 不用 flash:

  • deepseek-v4-pro — 支持图片输入(多模态),pro 系列是唯一在 messages.content 数组里能塞 type: 'image_url' 的型号
  • deepseek-v4-flash — 只接受纯文本 messages,塞图进去会被忽略或直接报错

完整模型矩阵以 接入大模型 当前文档为准,本篇示例统一用 deepseek-v4-pro

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

npm install @cloudbase/node-sdk

.env.local:

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

三个变量都不带 NEXT_PUBLIC_ 前缀 — SDK 调用在服务端,env id 和密钥不能进客户端 bundle。

SECRETID/SECRETKEY腾讯云控制台 → 访问密钥 生成,生产环境用子账号密钥 + CAM 策略锁到当前 CloudBase 环境。如果 Next.js 跑在 CloudBase 云托管 / 云函数里,这两个变量会自动注入,可省略。

第三步:写 Route Handler,接 FormData → base64 → generateText

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

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

export const runtime = 'nodejs'; // 关键:不能用 edge

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

function getAi() {
if (!app) {
// timeout 60s:多模态推理比纯文本慢,默认 15s 容易超
app = tcb.init({ env: process.env.CLOUDBASE_ENV!, timeout: 60000 });
}
return app.ai();
}

// File → data:image/xxx;base64,xxxxxx
async function fileToDataUrl(file: File): Promise<string> {
const buf = Buffer.from(await file.arrayBuffer());
const mime = file.type || 'image/jpeg';
return `data:${mime};base64,${buf.toString('base64')}`;
}

export async function POST(req: Request) {
const form = await req.formData();
const prompt = (form.get('prompt') as string) || '描述这张图片的内容';
const files = form.getAll('images').filter((v): v is File => v instanceof File);

if (files.length === 0) {
return Response.json({ error: 'no image uploaded' }, { status: 400 });
}

// 把所有图片都转成 data URL,顺序保持跟用户上传一致
const imageContents = await Promise.all(
files.map(async (file) => ({
type: 'image_url' as const,
image_url: { url: await fileToDataUrl(file) },
})),
);

const ai = getAi();
const model = ai.createModel('cloudbase');

const result = await model.generateText({
model: 'deepseek-v4-pro',
messages: [
{
role: 'user',
content: [
...imageContents,
{ type: 'text', text: prompt },
],
},
],
});

return Response.json({ text: result.text });
}

几个关键点:

  • app 用模块级变量缓存,每个请求都进 POST 函数,但 tcb.init() 只第一次执行
  • 服务端 SDK 自带环境级身份,不需要 signInAnonymously()
  • content 是数组,顺序很重要 — 先 image 再 text 是官方推荐写法,模型把图片当上下文,text 当对图片的提问;反过来塞也能跑但偶尔会让模型把 text 当主任务、图当附件忽略
  • 多图就在 imageContents 里堆多个 { type: 'image_url', ... },最多 4 张(再多 pro 系列的上下文窗口会爆)
  • 图片传的是 base64 Data URL 字符串,不是 走 COS / OSS 公网 URL — 走公网 URL 在某些区域出口会被截,Data URL 内联最稳;副作用是 request body 会变大,生产环境单图建议前端压到 1MB 以下
  • generateText 不用 streamText:图片理解通常一次性输出 50-300 字,流式收益不大,直接拿完整 result.text 更省事;真要流式就把 generateText 换成 streamTextresult.textStream,参考 add-ai-nextjs 第三步的流式转换写法

第四步:前端 Client Component 上传图 + 显示结果

新建 app/vision/page.tsx:

'use client';

import { useState } from 'react';

export default function Vision() {
const [files, setFiles] = useState<File[]>([]);
const [prompt, setPrompt] = useState('描述这张图片');
const [result, setResult] = useState('');
const [loading, setLoading] = useState(false);

async function send() {
if (files.length === 0 || loading) return;

setLoading(true);
setResult('');

try {
const form = new FormData();
form.append('prompt', prompt);
// 注意 key 都叫 'images',Route Handler 用 form.getAll('images') 拿到数组
files.forEach((f) => form.append('images', f));

const res = await fetch('/api/vision', {
method: 'POST',
body: form,
});

if (!res.ok) {
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
throw new Error(err.error || `HTTP ${res.status}`);
}

const data = await res.json();
setResult(data.text);
} catch (err) {
setResult(`[出错] ${err instanceof Error ? err.message : String(err)}`);
} finally {
setLoading(false);
}
}

return (
<div style={{ maxWidth: 720, margin: '40px auto', padding: 16 }}>
<input
type="file"
accept="image/*"
multiple
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
disabled={loading}
/>

<div style={{ margin: '12px 0' }}>
{files.map((f) => (
<img
key={f.name}
src={URL.createObjectURL(f)}
alt={f.name}
style={{ width: 120, height: 120, objectFit: 'cover', marginRight: 8 }}
/>
))}
</div>

<textarea
rows={2}
style={{ width: '100%', padding: 8 }}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="问点关于这张图的问题"
disabled={loading}
/>

<button
onClick={send}
disabled={loading || files.length === 0}
style={{ marginTop: 8 }}
>
{loading ? '分析中' : '发送'}
</button>

{result && (
<div
style={{
marginTop: 16,
padding: 12,
background: '#f5f5f5',
whiteSpace: 'pre-wrap',
}}
>
{result}
</div>
)}
</div>
);
}

要注意的细节:

  • multiple 属性允许一次选多张,Route Handler 拿到的是 File 数组
  • 多个 file 用同一个 form key 'images' 反复 append,后端 form.getAll('images') 自动收成数组;不要改成 images[0] images[1] 这种带下标的命名,FormData 没那语义
  • URL.createObjectURL(f) 出来的 blob URL 用完最好在 unmount 时 revokeObjectURL 释放,本例简化省略 — 长时间上传很多图会慢慢吃内存
  • 上传前要不要在前端压缩:浏览器原生可以 <canvas> + toBlob({ type: 'image/jpeg', quality: 0.8 }) 把大图压到 1MB 内,大幅降低 Data URL 体积和 token 消耗;不压缩也能跑,只是 4MB 原图转 base64 后 5.3MB,API request body 偏胖

第五步:三种典型场景的 prompt 写法

调同一套 Route Handler,只改 prompt 就能切换场景:

场景 A — 单图描述 / 写文案

你是电商详情页文案撰稿人。请观察这张产品图,用一段 50 字以内的中文描述,
突出产品类别、颜色、核心卖点。直接输出文案,不要加任何前缀说明。

场景 B — 单图 OCR + 结构化抽取

这是一张发票。请抽取以下字段并用 JSON 返回:
`{ "vendor": "", "amount": 0, "date": "YYYY-MM-DD", "items": [] }`
找不到的字段返回空字符串或 0。不要返回 JSON 以外的内容,不要加 markdown 代码块。

记得在 Route Handler 拿到 result.text 后再 JSON.parse(result.text)。模型偶尔会偷偷加 ```json ... ``` 包裹,生产代码里先 strip 一遍代码块标记再 parse 稳一些。

场景 C — 多图对比

上传 2-4 张产品图,prompt:

这是同一品类的 N 张产品图。请按"颜色 / 风格 / 适用场景"三个维度做一段简短对比,
最后给出"哪张更适合做主图"的判断。

generateText 自动按 messages 顺序处理多张图,模型回答里通常会用「图 1」「图 2」指代各张图。如果想强制对齐编号,在 prompt 里写明「第一张图是 A 款,第二张图是 B 款」之类的标号。

第六步:运行验证

  1. 启动开发服务器:npm run dev
  2. 浏览器开 http://localhost:3000/vision
  3. 选一张本地图,prompt 留默认,点发送
  4. 几秒后应该看到 AI 的描述出现在下方灰色方块里
  5. 浏览器 DevTools Network 面板,/api/vision 请求的 Request payload 应该是 multipart/form-data,Response 是 { "text": "..." }
  6. 服务器终端不应该cloudbase.init is not a function / model not found / image format not supported
  7. CloudBase 控制台 → AI+ → 调用记录,刚才那次调用应该出现,token 计数明显比纯文本调用大(图占很多 input token)

如果第 4 步要等 30 秒以上才出结果,大概率是图太大 — F12 看 Network 里 request body 大小,超过 6MB 就压一下再传。

常见错误

错误信息 / 现象原因修复
model not found / model 'deepseek-v4-pro' is not supported当前环境没开通 deepseek-v4-pro;或者拼错(比如写成 deepseek-vl-v4-pro / deepseek-v4)控制台 → AI+ → 模型管理对照名字,完全照搬控制台展示的 ID;没开通就点「快速接入」勾上
上传后报 image format not supported / invalid image_urlimage_url 传的不是 Data URL,而是 blob: URL 或者前端 File 对象的 path必须传 data:image/jpeg;base64,xxx 这种格式,见 Route Handler 里 fileToDataUrl 的实现;blob URL 只在浏览器内有效,服务端拿到也访问不到
多张图传上去,模型只回答了第一张content 数组顺序写反了 — text 写在 image 前面,模型把 text 当主指令、图当附件忽略;或者图超过模型支持的张数上限调换 content 数组顺序,所有 type: 'image_url' 排前面、type: 'text' 排最后;并把图数控制在 4 张以内
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 策略锁到当前环境最安全
请求挂在 60s 才报错Node SDK 默认 timeout: 15000 太短,加上多模态图片大、推理慢经常击穿;或者图太大上传慢tcb.init({ env, timeout: 60000 }) 显式拉到 60s 以上(上面已经这么写);图过大就在前端 canvas 压到 1MB 以内
OCR 场景下 AI 输出带 ```json ... ``` 代码块包裹,JSON.parse 报错模型自作主张加了 markdown 标记parse 前先 `text.replace(/^```json\s*
单张图清楚但放大查细节就错(比如 OCR 把 8 看成 0)多模态图片理解的细节识别精度有限,做不到专业 OCR / 文档 AI 的级别关键文档(发票 / 票据 / 身份证)走 腾讯云 OCR 这种专业 API,本篇这条路只适合做一些粗筛 / 描述类任务

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

计费提示

  • 多模态图片输入会消耗大量 input token — 一张 512×512 的图大约消耗 800-1500 token 不等(以模型当前披露的图像分片规则为准),比同等 prompt 的纯文本贵不少;批量场景算成本时按"图片单价 + 输出文本单价"分别估
  • deepseek-v4-pro 单价比 deepseek-v4-flash 高 — 仅图片场景用 pro,纯文本对话切回 flash,详见 接入大模型
  • Route Handler 是后端代理层,凭证不会落浏览器,但 /api/vision 仍对公网开放 — 上线前在 POST 入口先校验请求方:自家登录 session(参考 add-auth-web-with-cloudbase-sdk)、按 UID / IP 限频、或挂 CloudBase 安全管控 配域名白名单,避免被人刷接口刷 token

相关文档

  • add-ai-nextjs — 纯文本对话版本(streamText + deepseek-v4-flash),本篇是它的多模态扩展版
  • add-ai-wechat-miniprogram — 同一套 CloudBase AI 能力在小程序端的实现(wx.cloud.extend.AI),小程序端图片上传走 wx.chooseMedia + wx.getFileSystemManager().readFile 转 base64,后续 messages 结构跟本篇一致
  • add-cos-upload-from-cloudbase-app — 海量图片入 COS 后再用云函数批量调多模态的对照方案,适合"先存后分析"的异步链路
  • connect-tavily-search-cloud-function — 图片理解 + 联网搜索组合(让 AI 看完图再去查实时网页),参考它的搜索调用拼到本篇的 messages 里
  • CloudBase AI Toolkit — Cursor / Windsurf / CodeBuddy 等 AI IDE 接入路径
  • 接入大模型 — 完整模型矩阵,包含每个模型支持的能力(多模态 / Function Calling / JSON mode)
  • SDK API 参考createModel / generateText / streamText 完整签名,messages.content 的多模态 schema