第一单元 基础篇|第 1 讲:如何在前端调用文本大模型 API
实战环境:Trae‑cn(TraeCode AI IDE),掌握智能体切换、Auto Mode 模型自动调度,完成完整 Demo 项目落地。
🎯学习目标
- 理解大模型 HTTP API 基础,区分流式 SSE / 非流式两种调用模式。
- 掌握 DeepSeek、Coze 扣子平台鉴权、参数、数据协议,看懂 token 与 usage 计费。
- 理解浏览器直接调用的安全风险,掌握后端代理生产架构。
- 熟练掌握 Trae‑cn IDE 智能体切换、Auto Mode 开关;利用 SOLO Agent 实战生成可运行完整项目。
一、课程正文:前端调用大模型 API 基础
几乎所有商用大模型,都对外暴露标准 HTTP 协议的 API 接口 。
作为前端工程师,接入大模型最简单的方式就是调用厂商开放 API,不需要本地部署模型,只需要网络请求即可完成 AI 交互。
不同厂商 API 参数、字段会存在差异。本课时以国内主流 DeepSeek 、Coze(扣子) 作为案例,学习通用调用范式,后续可迁移适配其他大模型。
1.2.1 两种调用模式
-
非流式输出
stream:false模型全部生成完成,一次性返回完整 JSON 结果。适合后台任务、批量处理。缺点:长回答需要等待全部生成完成,用户体验差。
-
流式 SSE 输出
stream:trueServer‑Sent‑Events,分片逐段返回文本,实现聊天框打字机实时输出,面向 C 端聊天界面必备;前端需要处理流的分片拼接逻辑。
1.2.2 Token 基础概念
Token 是大模型处理文本的最小单元,同时作为计费、上下文窗口计量单位。
-
中文:1 汉字约 1.5‑2 个 token;英文约 0.75 单词 = 1token;标点、空格全部消耗 token。
-
每次 API 返回
usage对象,统计本次消耗:"usage": {
"prompt_tokens": 24, // 用户输入消耗token
"completion_tokens": 96, // AI输出消耗token
"total_tokens": 120 // 本次请求总消耗
}
计费规则:输入、输出分开计价,输出单价高于输入。
上下文窗口上限:超过最大 token,对话历史会被截断,回答异常。
二、DeepSeek 平台 API 实战(兼容 OpenAI 协议)(原 1.3)
平台地址:https://platform.deepseek.com/
优势:性价比高;R1 深度推理模型;接口完全兼容 OpenAI,Node 可直接复用 OpenAI SDK。
1.3.1 账号与鉴权凭证
- 手机号 / 微信登录平台。
- 两个关键信息:
- 账户余额:首页查看,API 调用扣费;充值之后才可以调用接口。
- API Key:左侧菜单【API Keys】创建密钥。
⚠️安全红线:密钥只完整显示一次;禁止硬编码写前端代码,禁止提交 Git。一旦泄露,他人可盗刷账户余额;泄露立刻删除重建密钥。
1.3.2 主流模型清单
| 模型名称 | 适用场景 |
|---|---|
deepseek‑v4‑flash |
高速低成本,日常问答、代码,开发测试首选 |
deepseek‑v4‑pro |
高性能,长文档、复杂逻辑推理,价格更高 |
deepseek‑reasoner(R1) |
深度思考推理模型,输出思考过程,数学、复杂问题求解 |
接口地址:POST https://api.deepseek.com/chat/completions
1.3.3 请求参数
| 参数 | 说明 |
|---|---|
model |
必填,模型名字符串 |
messages |
对话数组,system系统提示词;user用户消息;assistant模型历史回复 |
stream |
false非流式;true开启 SSE 流式输出 |
temperature |
0‑2,越大回答越发散;0 适合代码、事实;0.7 适合创作闲聊 |
max_tokens |
限制 AI 最大输出 token 数量,控制回复长度 |
请求头:
headers:{
"Content‑Type":"application/json",
"Authorization":"Bearer sk‑xxxx你的APIKey"
}
请求体示例
const payload = {
model:"deepseek-v4-flash",
messages:[
{"role":"system","content":"你是一名前端讲师,回答简洁易懂"},
{"role":"user","content":"请解释JS异步"}
],
stream:false,
temperature:0.7,
max_tokens:1024
}
1.3.4 非流式响应返回结构
{
"id":"xxxx",
"object":"chat.completion",
"choices":[
{
"index":0,
"message":{"role":"assistant","content":"AI返回回答文本"},
"finish_reason":"stop"
}
],
"usage":{"prompt_tokens":24,"completion_tokens":96,"total_tokens":120}
}
读取 AI 回答:res.choices[0].message.content
1.3.5 ❌浏览器直接调用(仅限学习测试,生产禁止)
// 仅本地学习演示,生产环境绝对不要把API Key写在浏览器前端!
async function deepseekBrowserTest(){
const res = await fetch("https://api.deepseek.com/chat/completions",{
method:"POST",
headers:{
"Content‑Type":"application/json",
"Authorization":"Bearer sk‑xxx"
},
body:JSON.stringify({
model:"deepseek-v4-flash",
messages:[{role:"user",content:"你好"}],
stream:false
})
})
const data = await res.json();
console.log(data.choices[0].message.content);
}
🚨两大致命问题
- CORS 跨域:DeepSeek 服务端没有浏览器跨域白名单,浏览器 fetch 直接请求会跨域报错。
- 密钥泄露风险 :前端源码任何人可见,密钥泄露直接被盗刷余额。
✅标准生产架构:前端页面 → 自有后端代理服务 → DeepSeek 大模型 API
密钥保存在后端环境变量,永远不下发给浏览器。
1.3.6 Node.js 后端调用(OpenAI 兼容 SDK)
npm install openai
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY, //环境变量存放密钥
baseURL:"https://api.deepseek.com"
})
async function chatCompletion(){
const result = await client.chat.completions.create({
model:"deepseek-v4-flash",
messages:[{role:"user",content:"hello world"}]
})
console.log(result.choices[0].message.content)
}
1.3.7 流式 SSE 调用说明
stream:true开启流式返回;服务端返回 SSE 数据流,每块分片以data:开头;[DONE]标记流结束。
前端通过ReadableStream读取分片,拼接文本实现打字机效果;流式请求同样需要后端代理,禁止浏览器直调。
三、Coze(扣子)平台 API 调用
Coze(扣子)字节智能体平台,可视化搭建 Bot,内置知识库、插件、工作流。适合直接调用已经编排好的智能体,不需要手写复杂 Prompt。
平台地址:https://www.coze.cn/
获取 PAT 令牌(就是 API 的 Key,个人访问令牌)直达链接:https://www.coze.cn/open/oauth/pats
- 鉴权凭证
bot_id:创建 Bot 后,浏览器地址栏获取 Bot 编号。PAT个人访问令牌:个人设置页面生成,等价 DeepSeek 的 API Key。
-
接口地址:
POST https://api.coze.cn/open_api/v2/chat
请求头Authorization: Bearer pat‑xxxx你的令牌
Content‑Type:application/json
请求体示例
{
"bot_id":"73xxxxxx",
"user":"user‑001",
"query":"你的提问",
"stream":false,
"conversation_id":"" //传入相同id维持对话上下文
}
同样禁止浏览器直接调用,必须后端代理,防止 PAT 令牌泄露。
四、开发最佳实践与避坑清单
- 密钥安全第一 :API Key、PAT 令牌,仅保存在后端环境变量;
.env加入.gitignore,不要提交版本库。 - 跨域:绝大多数大模型厂商不配置浏览器 CORS 白名单,浏览器直调会跨域;解决方案:后端请求代理转发。
- 监控 token 消耗:读取接口返回
usage,监控 token 消耗,避免 prompt 过长产生高额费用。 - 异常捕获:处理 HTTP 非 200 状态码,捕获密钥错误、余额不足、限流、网络异常,返回友好提示。
- 模型选型:测试优先使用低成本 Flash;复杂推理场景再切换 Pro/R1 推理模型。
五、课前:Trae‑cn IDE 智能体与模型模式操作指南
1.1.1 Auto Mode(自动模型模式,截图对应功能)
界面位置:输入框右下角,点击
Auto下拉,弹出Auto Mode开关Trae CN。
- ✅开启(绿色打开):Trae 根据任务复杂度、速度、成本自动选择最优内置模型 ;适合日常快速开发,不会使用自定义导入模型TRAE官...。
- ❌关闭开关:关闭自动调度,手动固定选择指定模型。
⚠️重要提示:开启 Auto Mode 会覆盖手动选定模型;如果你想要固定用某一个模型做开发调试,务必关闭 Auto Mode,再手动选择模型列表中指定模型TRAE官...。
1.1.2 切换智能体 Agent(@Ag... 下拉框)
输入框上方@Ag...下拉选择器,用于切换不同智能体:
- SOLO Agent(开发项目首选):具备调用工具能力,可以读写文件、跑终端命令、打开浏览器预览,自动完成整套项目开发、调试、重构。
- 普通对话 Agent:仅做问答,不会修改本地项目文件,不会执行终端命令,适合知识点查询。
✅做本章实战开发,务必选择:
@SOLO Agent。操作步骤:点击
@Ag...下拉 → 在列表选中SOLO Agent;确认 Agent 选中后,再写需求发送。
1.1.3 Trae SOLO Agent 模式核心工具回顾
| 工具 | 用途 |
|---|---|
| 编辑器 | 读写、修改项目源码 |
| 终端 | 执行 npm、git、启动服务等 shell 命令 |
| 浏览器 | 预览前端页面,读取控制台报错 |
| 代码变更 | ⭐最重要,AI 修改文件全部在此 diff 对比,人工审阅,接受 / 拒绝改动,不能无脑全部确认 |
| MCP | 对接外部 API、数据库扩展能力 |
六、Trae‑cn IDE 完整实战:c后端代理 Demo
实战前置准备
- 确认输入框
@选中 SOLO Agent。- 右下角
Auto下拉,关闭 Auto Mode 开关(固定模型调试项目)。- 导入本地空文件夹,或者直接让 Agent 从零生成项目。
1.6.1 直接复制下面提示词,发送给 SOLO Agent
新建Node.js + Express项目,完成DeepSeek大模型API后端代理Demo
需求清单:
1、实现POST接口 /api/deepseek/chat,后端代理转发请求到DeepSeek官方接口。
2、API密钥存放.env环境变量,禁止硬编码密钥;.gitignore忽略.env文件。
3、接口同时支持stream流式输出、普通非流式输出两种模式。
4、增加完整异常捕获:密钥错误、余额不足、网络异常,返回友好错误信息。
5、编写简单前端测试html页面,输入框提问,调用本地代理接口展示AI回答,支持流式打字机效果。
6、编写README.md,写清楚项目启动步骤、环境变量配置说明。
全部代码完成后,安装依赖,启动服务,打开浏览器预览测试,验证接口正常调用。
1.6.2 Trae 完整实操流程
- 发送提示词,SOLO Agent 自动拆解任务。
2.Agent 调用编辑器生成项目文件;调用终端执行npm install;启动服务,调用浏览器预览页面。 - 打开【代码变更】面板,逐条审阅 AI 生成的 diff 改动,确认合理后接受变更。
- 运行报错:复制终端、浏览器控制台报错信息,直接发送给 Agent 自动修复 bug。
- 测试:分别验证非流式、流式输出,查看接口返回
usagetoken 消耗。
1.6.3 Trae 进阶技巧:项目 Rules 规则
项目目录新建 .trae/rules/api‑rule.md,写项目编码约束,Agent 生成代码自动遵守,不用每次提示词重复写约束。
trigger: code_generate
所有大模型密钥、配置全部存放环境变量,禁止硬编码密钥。
API接口完成之后,自动生成markdown接口文档。
需求拆分:大需求拆分为小任务分步迭代;Agent 跑偏时直接指令纠正,例如
不要修改xxx文件,不要新增npm包。
七、完整创建代码
项目结构
bash
deepseek_lianxi/
├── server.js # Express 代理服务(POST /api/deepseek/chat)
├── public/index.html # 前端测试页面(流式打字机效果)
├── .env # 环境变量(含密钥,已 gitignore)
├── .env.example # 环境变量模板
├── .gitignore # 忽略 node_modules / .env
├── package.json
└── README.md
| 需求 | 实现 |
|---|---|
| POST /api/deepseek/chat 代理 | 白名单参数透传至 DeepSeek 官方接口,密钥仅存服务端,前端无法注入 |
| 密钥存 .env / 不硬编码 | .env + dotenv 读取,.gitignore 已忽略 .env |
| 流式 + 非流式双模式 | stream:true 走 SSE 逐块透传;否则完整 JSON 返回 |
| 完整异常捕获 | 401 密钥错误 / 402 余额不足 / 429 限流 / 网络异常 / 超时 / 占位密钥,均返回友好中文错误 |
| 前端测试页面 | 输入框 + 发送/停止按钮 + 流式切换 + 打字机效果 + 错误框 |
| README.md | 启动步骤、环境变量表、接口文档、curl 示例 |
EPSEEK_API_KEY 是占位符,真实调用 AI 前需替换为你的密钥并重启
html
// public\index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DeepSeek 代理测试</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 24px 16px;
color: #333;
}
.card {
max-width: 760px;
margin: 0 auto;
background: #fff;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
overflow: hidden;
}
.header {
background: #1a1a2e;
color: #fff;
padding: 20px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.header h1 { font-size: 20px; font-weight: 600; }
.header .badge {
background: rgba(255, 255, 255, 0.15);
border-radius: 20px;
padding: 4px 12px;
font-size: 12px;
color: #a5b4fc;
}
.body { padding: 24px; }
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.switch {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
cursor: pointer;
user-select: none;
}
.switch input { width: 16px; height: 16px; cursor: pointer; }
#statusText { font-size: 13px; color: #888; margin-left: auto; }
textarea {
width: 100%;
min-height: 96px;
border: 1px solid #e0e0e0;
border-radius: 10px;
padding: 12px 14px;
font-size: 14px;
line-height: 1.6;
resize: vertical;
outline: none;
transition: border-color 0.2s;
font-family: inherit;
}
textarea:focus { border-color: #667eea; }
.btn-row { display: flex; gap: 10px; margin-top: 12px; }
button {
border: none;
border-radius: 10px;
padding: 10px 24px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
}
button:active { transform: scale(0.97); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
#sendBtn { background: #667eea; color: #fff; }
#sendBtn:hover:not(:disabled) { opacity: 0.9; }
#stopBtn { background: #ef4444; color: #fff; display: none; }
.result {
margin-top: 20px;
border: 1px solid #e0e0e0;
border-radius: 10px;
min-height: 140px;
max-height: 420px;
overflow-y: auto;
padding: 14px 16px;
font-size: 15px;
line-height: 1.75;
white-space: pre-wrap;
word-break: break-word;
background: #fafafa;
}
.result .placeholder { color: #b0b0b0; }
.result .cursor {
display: inline-block;
width: 8px;
height: 18px;
background: #667eea;
vertical-align: -2px;
margin-left: 2px;
animation: blink 0.8s infinite;
border-radius: 1px;
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
.error-box {
margin-top: 12px;
background: #fef2f2;
border: 1px solid #fecaca;
color: #b91c1c;
border-radius: 10px;
padding: 12px 14px;
font-size: 14px;
display: none;
}
.footer { text-align: center; margin-top: 16px; font-size: 12px; color: rgba(255, 255, 255, 0.7); }
</style>
</head>
<body>
<div class="card">
<div class="header">
<h1>DeepSeek 代理测试</h1>
<span class="badge">/api/deepseek/chat</span>
</div>
<div class="body">
<div class="toolbar">
<label class="switch">
<input type="checkbox" id="streamToggle" checked />
流式输出(打字机效果)
</label>
<span id="statusText">就绪</span>
</div>
<textarea id="input" placeholder="输入你的问题,例如:请用三句话介绍 DeepSeek 大模型..."></textarea>
<div class="btn-row">
<button id="sendBtn">发送</button>
<button id="stopBtn">停止</button>
</div>
<div class="result" id="result"><span class="placeholder">AI 的回答将显示在这里...</span></div>
<div class="error-box" id="errorBox"></div>
</div>
</div>
<div class="footer">DeepSeek API 后端代理 Demo</div>
<script>
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('sendBtn');
const stopBtn = document.getElementById('stopBtn');
const streamToggle = document.getElementById('streamToggle');
const resultEl = document.getElementById('result');
const errorBox = document.getElementById('errorBox');
const statusText = document.getElementById('statusText');
let abortController = null;
// ---------- 打字机效果 ----------
const typeQueue = [];
let typeTimer = null;
function typewrite(text) {
for (const ch of text) typeQueue.push(ch);
if (typeTimer) return;
typeTimer = setInterval(() => {
if (typeQueue.length === 0) {
clearInterval(typeTimer);
typeTimer = null;
hideCursor();
return;
}
const cursor = resultEl.querySelector('.cursor');
const node = document.createTextNode(typeQueue.shift());
if (cursor) resultEl.insertBefore(node, cursor);
else resultEl.appendChild(node);
resultEl.scrollTop = resultEl.scrollHeight;
}, 12);
}
function showCursor() {
if (!resultEl.querySelector('.cursor')) {
const c = document.createElement('span');
c.className = 'cursor';
resultEl.appendChild(c);
}
}
function hideCursor() {
const c = resultEl.querySelector('.cursor');
if (c) c.remove();
}
function resetResult() {
resultEl.innerHTML = '';
showCursor();
typeQueue.length = 0;
if (typeTimer) { clearInterval(typeTimer); typeTimer = null; }
}
function showError(msg) {
hideCursor();
errorBox.textContent = msg;
errorBox.style.display = 'block';
}
function setStatus(text) { statusText.textContent = text; }
// ---------- SSE 流式解析 ----------
async function handleStream(res) {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // 保留可能被截断的最后一行
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const data = trimmed.slice(5).trim();
if (data === '[DONE]') continue;
try {
const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta?.content || '';
if (delta) typewrite(delta);
} catch { /* 忽略无法解析的分片 */ }
}
}
if (buffer.trim()) {
const data = buffer.trim().slice(5).trim();
if (data && data !== '[DONE]') {
try {
const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta?.content || '';
if (delta) typewrite(delta);
} catch { /* ignore */ }
}
}
}
// ---------- 发送请求 ----------
async function send() {
const question = inputEl.value.trim();
if (!question) { showError('请先输入问题'); return; }
const useStream = streamToggle.checked;
resetResult();
errorBox.style.display = 'none';
sendBtn.disabled = true;
stopBtn.style.display = 'inline-block';
setStatus(useStream ? '正在流式接收...' : '正在生成...');
abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), 120000); // 前端兜底超时
try {
const res = await fetch('/api/deepseek/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{ role: 'user', content: question }], stream: useStream }),
signal: abortController.signal,
});
if (!res.ok) {
let msg = `请求失败(HTTP ${res.status})`;
try {
const data = await res.json();
msg = data?.error?.message || msg;
} catch { /* ignore */ }
showError(msg);
setStatus('请求失败');
return;
}
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('text/event-stream')) {
await handleStream(res);
setStatus('完成');
} else {
const data = await res.json();
const content = data?.choices?.[0]?.message?.content || '';
if (content) typewrite(content);
else showError('未获取到回答内容');
setStatus('完成');
}
} catch (err) {
if (err.name === 'AbortError') {
showError('请求已中止或超时');
} else {
showError('网络异常:' + err.message);
}
} finally {
clearTimeout(timeoutId);
sendBtn.disabled = false;
stopBtn.style.display = 'none';
abortController = null;
}
}
// ---------- 事件绑定 ----------
sendBtn.addEventListener('click', send);
stopBtn.addEventListener('click', () => abortController?.abort());
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) send();
});
inputEl.focus();
</script>
</body>
</html>
js
// .env
// .env.example
# DeepSeek API 密钥(必填,请替换为你的真实密钥)
DEEPSEEK_API_KEY=sk-your-api-key-please-replace
# DeepSeek 官方接口地址(一般无需修改)
DEEPSEEK_BASE_URL=https://api.deepseek.com
# 默认使用的模型(一般无需修改)
DEEPSEEK_MODEL=deepseek-chat
# 本地服务端口
PORT=3000
json
// package.json
{
"name": "deepseek-proxy-demo",
"version": "1.0.0",
"description": "Node.js + Express 实现的 DeepSeek 大模型 API 后端代理 Demo,支持流式与非流式输出",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"keywords": [
"deepseek",
"express",
"proxy",
"llm",
"openai"
],
"license": "MIT",
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.19.2",
"node-fetch": "^2.7.0"
}
}
js
// server.js
require('dotenv').config();
const express = require('express');
const fetch = require('node-fetch');
const app = express();
const PORT = process.env.PORT || 3000;
// ==================== 配置读取 ====================
const DEEPSEEK_API_KEY = (process.env.DEEPSEEK_API_KEY || '').trim();
const DEEPSEEK_BASE_URL = (process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com').replace(/\/+$/, '');
const DEEPSEEK_MODEL = process.env.DEEPSEEK_MODEL || 'deepseek-chat';
const COMPLETIONS_URL = `${DEEPSEEK_BASE_URL}/chat/completions`;
const UPSTREAM_TIMEOUT_MS = 60_000; // 上游请求超时时间
app.use(express.json({ limit: '2mb' }));
app.use(express.static('public'));
// 启动时检查密钥配置(仅提示,不阻断启动)
if (!DEEPSEEK_API_KEY) {
console.error('[警告] 未配置 DEEPSEEK_API_KEY,请在 .env 文件中填写你的 DeepSeek API 密钥');
}
// ==================== 异常工具 ====================
/** 将上游 HTTP 状态码 + 原始错误信息,转换为友好中文提示 */
function friendlyErrorMessage(status, upstreamMessage) {
switch (status) {
case 401:
return 'API 密钥错误或已失效,请检查 .env 中的 DEEPSEEK_API_KEY 配置';
case 402:
return '账户余额不足,请前往 DeepSeek 开放平台充值后重试';
case 403:
return '无访问权限,请检查 API Key 是否具备该模型的调用权限';
case 404:
return '接口地址不存在,请检查 DEEPSEEK_BASE_URL 配置';
case 429:
return '请求过于频繁或触发速率限制,请稍后重试';
default:
if (status >= 500) {
return 'DeepSeek 服务暂时不可用,请稍后重试';
}
return upstreamMessage || '请求失败,请稍后重试';
}
}
/** 构造统一错误响应体 */
function buildErrorBody(message, code) {
return { error: { message, code: code || 'UPSTREAM_ERROR' } };
}
// ==================== 代理接口 ====================
app.post('/api/deepseek/chat', async (req, res) => {
// 1. 密钥检查(避免带着占位密钥空跑)
if (!DEEPSEEK_API_KEY) {
return res.status(500).json(buildErrorBody('服务端未配置 DEEPSEEK_API_KEY,请在 .env 文件中填写', 'CONFIG_MISSING'));
}
if (DEEPSEEK_API_KEY.includes('your-api-key') || /[\u4e00-\u9fa5]/.test(DEEPSEEK_API_KEY)) {
return res.status(500).json(buildErrorBody('服务端 DEEPSEEK_API_KEY 仍为占位符,请替换为真实密钥', 'CONFIG_PLACEHOLDER'));
}
// 2. 参数白名单透传,避免客户端传入敏感字段
const { messages, model, stream, temperature, max_tokens, top_p, presence_penalty, frequency_penalty } = req.body || {};
if (!Array.isArray(messages) || messages.length === 0) {
return res.status(400).json(buildErrorBody('请求体缺少 messages 参数,且不能为空数组', 'INVALID_PARAM'));
}
const useStream = stream === true;
const payload = {
model: model || DEEPSEEK_MODEL,
messages,
stream: useStream,
};
if (typeof temperature === 'number') payload.temperature = temperature;
if (typeof max_tokens === 'number') payload.max_tokens = max_tokens;
if (typeof top_p === 'number') payload.top_p = top_p;
if (typeof presence_penalty === 'number') payload.presence_penalty = presence_penalty;
if (typeof frequency_penalty === 'number') payload.frequency_penalty = frequency_penalty;
// 3. 转发请求到 DeepSeek 官方接口
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS);
let upstreamRes;
try {
upstreamRes = await fetch(COMPLETIONS_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${DEEPSEEK_API_KEY}`,
},
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timeoutId); // 收到响应头后不再受超时控制
} catch (err) {
clearTimeout(timeoutId);
// 网络异常 / 超时(请求未发出或连接阶段失败)
const isTimeout = err.name === 'AbortError' && controller.signal.aborted;
const message = isTimeout
? '网络连接超时,请检查网络或稍后重试'
: '网络异常,无法连接到 DeepSeek 服务,请检查网络连接';
console.error('[代理错误]', err.message);
return res.status(isTimeout ? 504 : 502).json(buildErrorBody(message, isTimeout ? 'NETWORK_TIMEOUT' : 'NETWORK_ERROR'));
}
// 4. 上游返回错误状态码:解析并返回友好提示
if (!upstreamRes.ok) {
let upstreamMessage = '';
try {
const errData = await upstreamRes.json();
upstreamMessage = errData?.error?.message || errData?.message || '';
} catch {
// 忽略无法解析的错误体
}
const friendly = friendlyErrorMessage(upstreamRes.status, upstreamMessage);
console.error(`[上游错误] ${upstreamRes.status}:`, upstreamMessage || friendly);
return res.status(upstreamRes.status).json(buildErrorBody(friendly, `UPSTREAM_${upstreamRes.status}`));
}
// 5a. 流式模式:逐块透传 SSE
if (useStream) {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
const body = upstreamRes.body;
body.on('data', (chunk) => res.write(chunk));
body.on('error', (err) => {
console.error('[流式转发中断]', err.message);
res.end();
});
body.on('end', () => res.end());
// 客户端断开连接时,中断上游请求
res.on('close', () => body.destroy());
return;
}
// 5b. 非流式模式:完整 JSON 返回
try {
const data = await upstreamRes.json();
res.json(data);
} catch (err) {
console.error('[响应解析失败]', err.message);
res.status(502).json(buildErrorBody('DeepSeek 返回了无法解析的数据,请稍后重试', 'BAD_UPSTREAM_RESPONSE'));
}
});
// 健康检查
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', model: DEEPSEEK_MODEL, keyConfigured: !DEEPSEEK_API_KEY.includes('your-api-key') });
});
// 404 兜底
app.use((req, res) => {
res.status(404).json(buildErrorBody('接口不存在', 'NOT_FOUND'));
});
app.listen(PORT, () => {
console.log(`DeepSeek 代理服务已启动: http://localhost:${PORT}`);
console.log(`接口地址: POST http://localhost:${PORT}/api/deepseek/chat`);
console.log(`默认模型: ${DEEPSEEK_MODEL}`);
});


如果开启stream:false时拿到数据结构:

OpenAI Chat Completion 标准协议,字段是协议强制规定。
choices:数组,支持多候选回答(index下标,多返回结果)usage:完整 token 消耗明细,包含缓存命中cached_tokens、cache hit/miss,用于计费统计system_fingerprint:模型快照指纹,用于流式、版本追踪finish_reason:判断结束原因 stop /length/tool_calls流式 SSE 模式的 usage 获取要点
- 开启
stream:true时,返回对象类型为chat.completion.chunk;绝大多数分片只携带增量delta.content,不会携带 usage。- DeepSeek 会在输出结束、
finish_reason:"stop"的数据包中返回完整usage对象;最后再发送data:[DONE]作为流结束标识。- 业务代码不能在每一条 onmessage 中读取 usage,必须判断
finish_reason === "stop"时,才读取当前 chunk 的 usage。- 部分厂商(旧版 OpenAI 兼容接口)流式不会返回 usage,此时只能用
stream:false非流式接口获取用量。
八、AI 接口封装:健壮 ES5、Java 使用、错误处理(原 1.9)
一、带空值防御健壮版 ES5(非流式 stream:false)
防止
choices[0]、usage、prompt_tokens_details为 null/undefined 报Cannot read property xxx of undefined
js
/**
* 非流式响应解析,带空值保护 ES5
* @param {Object} resp 三方原始完整响应对象
* @returns {Object} 业务结构化对象
*/
function parseAiResponse(resp) {
var aiResult = {
content: "",
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cachedTokens: 0,
finishReason: "",
model: ""
};
if (!resp) {
return aiResult;
}
// choices 数组防御
var choice0 = (resp.choices && resp.choices.length > 0) ? resp.choices[0] : null;
if (!choice0) {
return aiResult;
}
// 内容
aiResult.content = (choice0.message && choice0.message.content) ? choice0.message.content : "";
aiResult.finishReason = choice0.finish_reason ? choice0.finish_reason : "";
// model
aiResult.model = resp.model ? resp.model : "";
// usage 用量
if (resp.usage) {
aiResult.promptTokens = resp.usage.prompt_tokens || 0;
aiResult.completionTokens = resp.usage.completion_tokens || 0;
aiResult.totalTokens = resp.usage.total_tokens || 0;
// 缓存token
if(resp.usage.prompt_tokens_details){
aiResult.cachedTokens = resp.usage.prompt_tokens_details.cached_tokens || 0;
}
}
return aiResult;
}
// 使用
// var resp = http拿到的返回;
// var result = parseAiResponse(resp);
二、健壮版 ES5 流式 SSE(带空防御)
js
var fullContent = '';
var usageData = null;
function onMessageHandle(event) {
var data = event.data;
if (data === '[DONE]') {
// 流结束,业务使用 fullContent、usageData
console.log("完整回答:", fullContent);
console.log("token用量:", usageData);
return;
}
var chunk;
try {
chunk = JSON.parse(data);
} catch (e) {
// json解析失败直接跳过分片
return;
}
if (!chunk || !chunk.choices || chunk.choices.length === 0) {
return;
}
var item = chunk.choices[0];
// 拼接增量文本 delta.content
if(item.delta && item.delta.content){
fullContent += item.delta.content;
}
// stop时捕获usage,做判空
if(item.finish_reason === 'stop' && chunk.usage){
usageData = chunk.usage;
}
}
三、如何在 Java 中使用这套封装示例
1)定义 VO
java
public class AiChatVO {
private String content;
private Integer promptTokens = 0;
private Integer completionTokens = 0;
private Integer totalTokens = 0;
private Integer cachedTokens = 0;
private String finishReason;
private String model;
// getter setter
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public Integer getPromptTokens() { return promptTokens; }
public void setPromptTokens(Integer promptTokens) { this.promptTokens = promptTokens; }
public Integer getCompletionTokens() { return completionTokens; }
public void setCompletionTokens(Integer completionTokens) { this.completionTokens = completionTokens; }
public Integer getTotalTokens() { return totalTokens; }
public void setTotalTokens(Integer totalTokens) { this.totalTokens = totalTokens; }
public Integer getCachedTokens() { return cachedTokens; }
public void setCachedTokens(Integer cachedTokens) { this.cachedTokens = cachedTokens; }
public String getFinishReason() { return finishReason; }
public void setFinishReason(String finishReason) { this.finishReason = finishReason; }
public String getModel() { return model; }
public void setModel(String model) { this.model = model; }
}
2)三方原始响应实体(OpenAI 兼容格式)
使用 Jackson 反序列化 http 返回的 json;所有字段使用包装类型,允许 null
java
public class OpenAiResponse {
private String object;
private Long created;
private String model;
private List<Choice> choices;
private Usage usage;
private String systemFingerprint;
// getter setter
}
class Choice {
private Integer index;
private Message message;
private Message delta; //流式用delta
private String finishReason;
// getter setter
}
class Message {
private String role;
private String content;
}
class Usage {
private Integer promptTokens;
private Integer completionTokens;
private Integer totalTokens;
private PromptTokensDetails promptTokensDetails;
}
class PromptTokensDetails{
private Integer cachedTokens;
}
3)转换工具方法(带判空防御,防止 NPE)
java
/**
* 将三方原始OpenAiResponse 转为业务VO,全链路判空
* @param origin 三方返回原始对象,可以为null
* @return AiChatVO
*/
public AiChatVO convertToAiChatVO(OpenAiResponse origin){
AiChatVO vo = new AiChatVO();
if(origin == null){
return vo;
}
vo.setModel(origin.getModel());
List<Choice> choiceList = origin.getChoices();
if(choiceList != null && !choiceList.isEmpty()){
Choice first = choiceList.get(0);
vo.setFinishReason(first.getFinishReason());
if(first.getMessage() != null){
vo.setContent(first.getMessage().getContent());
}
}
Usage usage = origin.getUsage();
if(usage != null){
vo.setPromptTokens(usage.getPromptTokens() == null ? 0 : usage.getPromptTokens());
vo.setCompletionTokens(usage.getCompletionTokens() == null ? 0 : usage.getCompletionTokens());
vo.setTotalTokens(usage.getTotalTokens() == null ? 0 : usage.getTotalTokens());
if(usage.getPromptTokensDetails() != null){
vo.setCachedTokens(usage.getPromptTokensDetails().getCachedTokens() == null ? 0 : usage.getPromptTokensDetails().getCachedTokens());
}
}
return vo;
}
使用位置:http 拿到返回,Jackson 读为
OpenAiResponse,调用convertToAiChatVO()得到 VO,Controller 返回 VO 给前端。⚠️流式 Java 后端:不能直接用上面;流式需要单独解析每一条 chunk,缓存最后一块的 usage。
四、如何处理接口返回错误信息
OpenAI 兼容协议错误返回格式示例:
{
"error": {
"code": "invalid_api_key",
"message": "API key provided is invalid",
"type": "invalid_request_error"
}
}
1)识别错误的判断逻辑
- http 状态码 200:正常业务返回;
- http 状态码 400 /401 /429 /500 :错误;响应体里面包含
error对象。
JS 错误处理
js
// resp为http响应体
if(resp.error){
var err = {
code: resp.error.code || "",
msg: resp.error.message || "",
type: resp.error.type || ""
};
console.error("大模型接口异常", err);
// 抛出业务异常给上层
}
Java 错误处理
- http 请求先判断响应状态码;非 200 时读取响应 body;
- 判断 body 是否包含
error节点; - 定义错误实体
AiError解析错误信息; - 抛自定义业务异常(
AiApiException),不要直接把三方原始 error 返回前端。
java
public class AiError {
private String code;
private String message;
private String type;
//getter setter
}
常见错误码备忘
| code | 含义 |
|---|---|
| invalid_api_key | 密钥错误 |
| rate_limit_exceeded | 限流 429 |
| context_length_exceeded | 上下文超长 |
| model_not_found | 模型不存在 |
开发规范
- ❌禁止直接透传三方原始 error 对象返回前端;
- ✅后端捕获错误,记录日志(错误码、message、请求参数);对外输出友好业务提示;
- 流式场景:错误会以一条单独 chunk 下发,需要捕获 error chunk,终止流。
九、Trae AI 完整可运行 Demo 指令(Coze 扣子 V3 API,SpringBoot + vue2 前端)
直接复制全部粘贴到 Trae,全自动生成完整项目;适配 Trae Solo/Builder 模式,生成可直接启动运行,包含后端代理、SSE 流式聊天、异常处理、VO 封装
text
生成完整可运行Demo:SpringBoot2.7 + Java8 + vue2,对接Coze扣子V3 /v3/chat流式SSE。
后端代理转发Coze请求,PAT、bot_id放application.properties,禁止密钥暴露前端。
实现SseEmitter流式推送到前端,解析Coze的event事件:conversation.message.delta取增量文本;conversation.chat.completed捕获usage用量。
写完整VO/DTO,全链路空判,捕获Coze error错误(积分不足、鉴权失败),输出日志。
resources下生成index.html聊天页面,ES5实现SSE客户端,支持多轮conversation_id上下文。
输出pom.xml、全部Java类、配置、前端页面,不要片段。最后输出项目启动步骤,以及官网获取bot_id、PAT操作步骤。
Trae 生成后需要手动做的两件事
- 修改
application.properties,填入自己在coze.cn拿到的 bot_id 、PAT 令牌 (pat_xxxx) - 确认扣子平台 Bot 已经【部署】发布 API 渠道,否则 API 调用报错。
一、官网操作:拿到 2 个核心 ID/Key
1)获取 bot_id(智能体 ID)
- coze.cn登录,新建 Bot,编辑 Bot(写提示词、知识库、插件、工作流)
- 必须发布 API 服务(最重要!只保存草稿 API 调用报错)
- 顶部【部署】→【发布】→勾选【API】渠道,发布
- 看浏览器地址栏 URL:
https://www.coze.cn/space=xxxx/bot/748000111222333
bot/后面一串数字就是bot_id = 748000111222333扣子


确定后跳转新页面 拿到地址后面的 bot

2)获取 PAT 令牌(就是 API 的 Key,个人访问令牌)
-
左侧菜单【Coze API】→【授权】→【个人访问令牌 (PAT)】
-
点击【添加令牌】
- 名称:随便写,例如
demo测试 - 有效期:选 1 天 / 30 天 / 自定义
- 权限必须勾选 chat(对话权限);


- 名称:随便写,例如
-
确认后,只弹出一次完整 pat 字符串,复制保存,关闭弹窗再也看不到!
请求头格式:
Authorization: Bearer pat_xxxxxxxxxxxxCoze⚠️安全警告:PAT 密钥绝对不能放在前端 JS 代码,全部请求走后端代理,防止密钥泄露被盗刷积分。
3)user_id(不需要官网拿,业务自定义字符串)
业务侧自己定义,标识用户,测试写test_user_001。
十、附:Coze 扣子 V3 流式 SSE 聊天 Demo --- 笔记
本文档用于记录该项目(Spring Boot 2.7 + Java 8 + Vue2)的完整设计、关键代码逻辑与对接 Coze 扣子 V3
/v3/chat的要点,方便日后复盘与复用。
1. 项目概览
| 项 | 内容 |
|---|---|
| 后端框架 | Spring Boot 2.7.18 |
| JDK | Java 1.8 |
| 前端 | Vue2(CDN 运行时,无需 node 构建)+ 原生 EventSource |
| 对接服务 | Coze(扣子)V3 Chat API |
| 通讯方式 | 前端 GET /api/chat → 后端代理转发 Coze POST /v3/chat(stream=true)→ 后端经 SseEmitter 把 SSE 事件实时推给浏览器 |
| 地址 | 国内 api.coze.cn / 国际 api.coze.com |
安全设计 :个人访问令牌 PAT、bot_id 只放后端 application.properties,由后端请求扣子时携带,绝不暴露到前端。前端只与自己的后端打交道。
2. 目录结构
coze_lianxi/
├── pom.xml # Spring Boot 2.7 依赖
├── run.bat # Windows 一键启动脚本
├── mvnw / mvnw.cmd + .mvn/wrapper/ # Maven Wrapper(免安装全局 Maven)
└── src/main/
├── java/com/example/coze/
│ ├── CozeDemoApplication.java # 启动类
│ ├── config/CozeProperties.java # 绑定 coze.* 配置 + 启动校验
│ ├── controller/ChatController.java # GET /api/chat 返回 SseEmitter
│ ├── service/CozeChatService.java # 【核心】代理转发 + SSE 解析 + 推送
│ ├── dto/ChatRequestDTO.java # 前端入参
│ ├── dto/CozeChatRequest.java # 请求扣子的请求体
│ ├── dto/EnterMessage.java # 单条消息结构
│ ├── vo/SseEventVO.java # 后端→前端的事件统一封装
│ ├── vo/ChatCompletedVO.java # 对话完成(含 usage)
│ ├── vo/TokenUsageVO.java # 兼容两种 usage 结构
│ ├── vo/CozeApiErrorVO.java # 错误体 code/msg/request_id
│ └── exception/CozeApiException.java# 积分不足 / 鉴权失败判定
└── resources/
├── application.properties # PAT、bot_id 等配置
└── static/index.html # Vue2 聊天页面
3. Coze V3 Chat API 要点(踩坑记录)
参考官网《发起对话》《错误码》等文档,关键点如下:
3.1 请求
http
POST https://api.coze.cn/v3/chat?conversation_id=xxx ← conversation_id 是【query 参数】
Authorization: Bearer $PAT
Content-Type: application/json
请求体(CozeChatRequest + EnterMessage):
json
{
"bot_id": "73428668*****",
"user_id": "123456789",
"stream": true, // 必须 true 才能拿到增量
"auto_save_history": true, // stream=true 时建议 true
"additional_messages": [
{ "role": "user", "content": "你好", "content_type": "text" }
]
}
要点:
conversation_id是 query 参数,不是 body 字段(容易踩坑)。stream=true时必须 配auto_save_history=true,否则报错4007。additional_messages的最后一条作为本次用户输入。- 新会话不传
conversation_id,由扣子自动生成并随事件返回。 - 新起的每轮对话,都要把用户最新问题放进
additional_messages。
3.2 SSE 返回的事件(下行事件)
响应是 text/event-stream,每条事件由 event: 行 + data: 行(JSON)组成,空行分隔:
| event | 用途 |
|---|---|
conversation.chat.created |
对话已创建,含 conversation_id |
conversation.message.delta |
增量文本 ,data.type == "answer" 且 data.content 为增量片段 |
conversation.message.completed |
一条回复完整结束,data.content 为完整文本 |
conversation.chat.completed |
本次对话结束,data.usage 携带用量 |
conversation.chat.failed |
对话失败,data.last_error{code,msg} |
error |
流式错误事件 |
done |
最后一个结束标记 |
3.3 usage 的两种结构(重要)
实测/文档中 data.usage 有两种形态,必须兼容:
jsonc
// 形态 A(对象)
{ "token_count": { "prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30 } }
// 形态 B(整型,token_count 即总量)
{ "token_count": 100, "input_count": 50, "output_count": 50 }
3.4 常见错误码 (code)
| code | 含义 |
|---|---|
4000 |
请求参数错误 |
4006 / 4015 |
bot_id 无效 / 智能体未发布为 API |
4008 4009 |
限流 / 系统繁忙 |
4011 4019 4022 4028 702232007 |
积分不足 / Token 余额不足 / 欠费(需充值/续费) |
4012 |
无效模型 |
4016 |
同一会话有对话仍在运行 |
4100 / 700012006 |
鉴权失败(PAT 无效) |
4. 核心逻辑:后端代理转发(CozeChatService)
chatStream(...) 总体流程:
校验配置 → 组装请求体 + URL(query带conversation_id)
POST 扣子 /v3/chat → HttpURLConnection,Header 带 Bearer PAT
├── 2xx → readStream() 逐行解析 SSE 帧
│ └── 命中事件 → handleEvent() 抽取增量/用量 → SseEmitter 推送
└── 非2xx → getErrorStream() 读错误 JSON → CozeApiErrorVO → 推 error 事件
最后统一 done 事件 + emitter.complete()
异常兜底 → 捕获 IOException/Exception → 推 error + done
4.1 解析 SSE 帧
用 BufferedReader.readLine() 逐行:
java
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) {
// 空行 = 一个事件结束,交 handleEvent 处理
handleEvent(emitter, currentEvent, dataBuilder.toString(), conversationId);
currentEvent = ""; dataBuilder.setLength(0); continue;
}
if (line.charAt(0) == ':') continue; // 注释/心跳行
if (line.startsWith("event:")) currentEvent = line.substring(6).trim();
else if (line.startsWith("data:")) dataBuilder.append(line.substring(5));
}
4.2 增量文本(conversation.message.delta)
java
String type = data.get("type").asText();
String content = data.get("content").asText(); // 增量片段
if ("answer".equals(type) && content != null) {
// 组装 SseEventVO,event 名打上 "delta",推给前端
}
只取
type == "answer",避免把function_call等中间消息拼进去。
4.3 用量(conversation.chat.completed)
ChatCompletedVO.from(data) → TokenUsageVO.from(usage) 兼容两种形态:
java
JsonNode tc = usage.get("token_count");
if (tc.isObject()) {
vo.promptTokens = tc.path("prompt_tokens").asLong();
vo.completionTokens = tc.path("completion_tokens").asLong();
vo.totalTokens = tc.path("total_tokens").asLong();
} else if (tc.isNumber()) {
vo.totalTokens = tc.asLong();
vo.promptTokens = usage.path("input_count").asLong();
vo.completionTokens = usage.path("output_count").asLong();
}
4.4 错误处理
- 非 2xx:读
conn.getErrorStream()→CozeApiErrorVO.parse()。 isCreditError()区分积分不足,CozeApiException.isAuthFailed()/isInsufficientCredit()可复用判定。- 所有分支最后都保证推送
done+emitter.complete(),避免前端挂起。
5. 后端事件协议 → 前端
后端把扣子事件统一装进 SseEventVO,用 SseEmitter.event().name(...).data(vo) 命名事件下发。事件名与载荷:
| SSE event 名 | data 载荷 | 前端用途 |
|---|---|---|
chat_started |
{ chatId } |
提示"对话已创建" |
delta |
{ text } |
追加到当前 assistant 气泡(打字机) |
message_completed |
{ content } |
日志提示 |
chat_completed |
ChatCompletedVO(含 usage) |
展示 token 用量、成本 |
error |
CozeApiErrorVO { code, msg } |
展示错误 |
done |
{} |
收尾:关闭 EventSource,恢复可发送 |
前端用原生 EventSource(url) 监听这些命名事件;conversation_id 从各事件 data.conversationId 取出,存入 localStorage,实现多轮上下文。
6. 前端要点(Vue2)
- Vue2 CDN 引入,
new Vue({ el:'#app' }),messages[]渲染消息、items[]渲染日志。 EventSource只支持 GET ,故后端接口是GET /api/chat(message、conversation_id、user_id走 query)。- 输入框
v-model="input",@keydown回车发送。 - 收到
done时主动es.close(),避免 EventSource 自动重连导致重复请求。 - 多轮上下文:
localStorage.setItem('coze_conversation_id', id),刷新自动恢复;"新会话"按钮清除。
7. 配置(application.properties)
properties
# 国内/国际站点二选一
coze.base-url=https://api.coze.cn
# 个人访问令牌(只存在于后端,勿泄露)
coze.pat=pat_xxxxxxxxxxxxxxxxxxxxxx
# 智能体 bot_id(URL 中 bot= 后的数字串)
coze.bot-id=73428668xxxxx
# 用户标识,隔离上下文
coze.user-id=user_from_spring_demo
coze.auto-save-history=true
coze.debug-log=true
CozeProperties 会在启动时校验 base-url / pat / bot-id 是否填好(不打印明文 PAT)。
8. 启动步骤
- 填写
application.properties的coze.pat与coze.bot-id。 - 构建 + 运行(任选其一):
bash
# 方式一:直接用打好的 jar(无需 JAVA_HOME)
java -jar target\coze-chat-demo.jar
# 方式二:一键脚本(dev 模式用 run.bat dev)
run.bat
# 方式三:Maven Wrapper(等价于 mvn,可用于开发重编译)
mvnw.cmd clean package -DskipTests
java -jar target\coze-chat-demo.jar
# 或 mvnw.cmd spring-boot:run
- 浏览器打开
http://localhost:8080/。 - 后端控制台会打印
[coze]事件日志与 usage。
9. 官网获取 bot_id 与 PAT
获取 bot_id
-
进入智能体的开发页面 ,地址形如
https://www.coze.cn/space/341****/bot/73428668*****。 -
bot=后面的数字串73428668*****即为 bot_id。
-
必须发布为 API :右上角「发布」→「API」→ 发布;否则报错
4015。
获取 PAT
- 左下角个人头像 →「API 授权 / 安全设置」→「个人访问令牌」。
- 点击「添加新令牌」,勾选
chat权限,设名称与有效期。 - 令牌只显示一次,复制
pat_开头的串填入配置。
注意:PAT 与智能体须在同一团队空间 ;报
4016表示同一会话旧对话未结束,等待后重试。
10. 已知问题 & 后续可改进
HttpURLConnection读超时设为0(长连接),若扣子异常挂起会占用线程;可改用spring-boot-starter-webflux/响应式WebClient更佳。- 目前事件通过
SseEmitter+ 线程池转发,属异步多线程方案,生产可考虑引入请求日志与重试。 - 密钥未做加密,仅落地于配置文件,可进一步引入外部配置中心 / KMS。
- 前端
conversation_id存localStorage,多端共用会串上下文,可按用户维度隔离。
11. 完整实战


十一、本章小结
- 前端接入大模型最基础方式:调用厂商 HTTP API;鉴权凭证为 API Key/PAT 令牌。
- Token 是计费、上下文计量单位;接口返回
usage查看消耗;区分流式 SSE、非流式两种调用模式。 - DeepSeek 兼容 OpenAI 协议;浏览器禁止直接调用大模型 API,存在跨域、密钥泄露风险;生产环境必须增加后端代理层。
- Coze 扣子面向已经编排完成 Bot 智能体,适合快速接入带知识库、插件的 AI 应用。
- Trae‑cn 中,SOLO Agent 用于完整项目开发;Auto Mode 自动调度模型,调试项目建议关闭 Auto Mode 固定模型。开发务必审阅代码变更面板,保障代码安全。
第一单元 基础篇|第 2 讲:通过流式(streaming)传输方式使用大模型 API
接下来呢,我们来了解一下对于初级前端工程师来说稍微复杂一点的内容,那就是通过流式(streaming)的传输方式来使用大模型 API。
📌 笔记整理说明(整理时新增,非原文内容):正文内容未做任何增删改。本讲原文本身即按「为什么要流式 → Streams API 流式实现 → SSE 实现 → 要点总结」的逻辑顺序展开,因此本笔记仅补充标题层级、保持原顺序输出,便于阅读与复习。文末另附「五、扩展:Vue3 + TypeScript 工程实践」章节(整理时新增,非课程原文,偏前端工程化落地)。
一、为什么要使用流式传输
在具体实践之前,先来说说为什么要使用流式传输。由于大模型通常是需要实时推理的,Web 应用调用大模型时,它的标准模式是浏览器提交数据,服务端完成推理,然后将结果以 JSON 数据格式通过标准的 HTTP 协议返回给前端,这个我们在上一小节里已经通过例子体会过。但是这么做有一个问题,主要是推理所花费的时间和问题复杂度、以及生成的 token 数量有关。比如像第一节课里那样,只是简单问候一句,可能 Deepseek 推理所花费的时间很少,但是如果我们提出稍微复杂一点的要求,比如编写一本小说的章节目录,或者撰写一篇千字的作文,那么 AI 推理的时间会大大增加,这在具体应用中就带来一个显而易见的问题,那就是用户等待的时间很长。
而你应该已经发现,我们在使用线上大模型服务时,不管是哪一家大模型,通常前端的响应速度并没有太慢,这正是因为它们默认采用了流式(streaming)传输,不必等到整个推理完成再将内容返回,而是可以将逐个 token 实时返回给前端,这样就大大减少了响应时间。如果你是熟悉比较传统的 Web 业务的前端工程师,可能会比较疑惑这种模式具体怎么实现,不要着急,我们接下来通过一个稍微复杂一点的例子,来学习和体会这项技术。
二、使用流式(streaming)传输减少等待时间
大多数文本模型,都支持使用流式传输来返回内容。在流式传输下,在模型推理过程中,生成的 token 会及时返回,而不用等待推理过程完全结束。在这一小节,我们先看一下 Deepseek Platform 下如何使用流式传输。
2.1 创建 Vue+Vite+TypeScript 项目
首先我们从 Trae 创建一个新项目,这次我们选择创建 Vue+Vite+TypeScript 项目,在后续的课程中,我们基本上以 Vue+Vite+TypeScript 为标配。创建的项目目录结构如下:
2.2 配置 .env.local 环境变量
别忘了配置我们的.env.local 文件:
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxx
2.3 修改 App.vue
接着我们修改一下 App.vue。
vue
<script setup lang="ts">
import { ref } from 'vue';
const question = ref('讲一个关于中国龙的故事');
const content = ref('');
const stream = ref(true);
const update = async () => {
if(!question) return;
content.value = "思考中...";
const endpoint = 'https://api.deepseek.com/chat/completions';
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
};
const response = await fetch(endpoint, {
method: 'POST',
headers: headers,
body: JSON.stringify({
model: 'deepseek-chat',
messages: [{ role: 'user', content: question.value }],
stream: stream.value,
})
});
if(stream.value) {
content.value = '';
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let done = false;
let buffer = '';
while (!done) {
const { value, done: doneReading } = await (reader?.read() as Promise<{ value: any; done: boolean }>);
done = doneReading;
const chunkValue = buffer + decoder.decode(value);
buffer = '';
const lines = chunkValue.split('\n').filter((line) => line.startsWith('data: '));
for (const line of lines) {
const incoming = line.slice(6);
if(incoming === '[DONE]') {
done = true;
break;
}
try {
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if(delta) content.value += delta;
} catch(ex) {
buffer += `data: ${incoming}`;
}
}
}
} else {
const data = await response.json();
content.value = data.choices[0].message.content;
}
}
</script>
<template>
<div class="container">
<div>
<label>输入:</label><input class="input" v-model="question" />
<button @click="update">提交</button>
</div>
<div class="output">
<div><label>Streaming</label><input type="checkbox" v-model="stream"/></div>
<div>{{ content }}</div>
</div>
</div>
</template>
<style scoped>
.container {
display: flex;
flex-direction: column;
align-items: start;
justify-content: start;
height: 100vh;
font-size: .85rem;
}
.input {
width: 200px;
}
.output {
margin-top: 10px;
min-height: 300px;
width: 100%;
text-align: left;
}
button {
padding: 0 10px;
margin-left: 6px;
}
</style>
2.4 运行与效果
运行项目,点击提交按钮,你会看到 AI 正以流式传输的方式输出内容,这样就能减少用户的等待时间。
2.5 代码关键部分解析
好,我们来一起看一下代码的关键部分。
2.5.1 流式请求:stream 参数置为 true
首先,流式输出的 API 调用机制,和普通的 HTTPS 输出没有什么区别,都是通过 POST 请求,只不过提交的数据中,将 stream 参数设置为 true。
js
const endpoint = 'https://api.deepseek.com/chat/completions';
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
};
const response = await fetch(endpoint, {
method: 'POST',
headers: headers,
body: JSON.stringify({
model: 'deepseek-chat',
messages: [{ role: 'user', content: question.value }],
stream: stream.value, // 这里 stream.value 值如果是 true,采用流式传输
})
});
2.5.2 通过 ReadableStream 处理流数据
在浏览器处理请求的时候,会通过 HTML5 标准的 Streams API 来处理数据,具体处理逻辑如下:
js
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let done = false;
let buffer = '';
while (!done) {
const { value, done: doneReading } = await (reader?.read() as Promise<{ value: any; done: boolean }>);
done = doneReading;
const chunkValue = buffer + decoder.decode(value);
buffer = '';
const lines = chunkValue.split('\n').filter((line) => line.startsWith('data: '));
for (const line of lines) {
const incoming = line.slice(6);
if(incoming === '[DONE]') {
done = true;
break;
}
try {
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if(delta) content.value += delta;
} catch(ex) {
buffer += `data: ${incoming}`;
}
}
}
首先,我们利用 ReadableStream API 通过 getReader() 获取一个读取器,并创建 TextDecoder 准备对二进制数据进行解码。然后我们设置控制流标志 done,以及一个 buffer 变量来缓存数据,因为某些情况下,Stream 数据返回给前端时,不一定传输完整。接着我们开始循环读取数据,通过 TextDecoder 解析数据,将数据转换成文本并按行拆分。因为 API 返回流式数据的协议是每一条数据以 "data:" 开头,后续是一个有效的 JSON 或者DONE表示传输结束,所以我们要对每一行以"data:"开头的数据进行处理。
js
for (const line of lines) {
const incoming = line.slice(6);
if(incoming === '[DONE]') {
done = true;
break;
}
try {
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if(delta) content.value += delta;
} catch(ex) {
buffer += `data: ${incoming}`;
}
}
2.5.3 增量内容与缓存兜底
如果数据传输完整,且不是DONE,那么它就是合法 JSON,我们从中读取 data.choices0.delta.content,就是需要增量更新的内容,否则说明数据不完整,将它存入缓存,以便后续继续处理。这样我们就实现了数据的流式传输和浏览器的动态接收。
三、使用 Server-Sent Events
刚才的做法虽然可以直接使用流式数据,但是处理起来还是略为繁琐。实际上 Deepseek API 和其他大部分兼容 OpenAI 的平台,AI 返回的流式输出数据都是符合标准的Server-Sent Events(SSE)规范的,现代浏览器几乎都支持更简单的 SSE API,只不过我们目前暂时无法在前端直接使用它。
3.1 为什么前端不能直接使用 SSE
主要原因是,根据标准,SSE 的底层只支持 HTTP GET,并且不能发送自定义的 Header,而我们的授权却需要将 API Key 通过 Authorization Header 发送,而且必须使用 POST 请求。尽管如此,并不意味着我们前端就不能使用 SSE 来处理流式输出,而是我们需要创建一个 BFF 层,通过 Node Server 来做中转。
3.2 创建项目并安装依赖
首先我们还是用 Trae 创建一个新的 Vue 项目 Deepseek API SSE。接着在 IDE 终端安装依赖包 dotenv 和 express。
3.3 添加 server.js(SSE 端点)
然后在项目根目录下添加如下 server.js 文件:
js
import * as dotenv from 'dotenv'
import express from 'express';
dotenv.config({
path: ['.env.local', '.env']
})
const openaiApiKey = process.env.VITE_DEEPSEEK_API_KEY;
const app = express();
const port = 3000;
const endpoint = 'https://api.deepseek.com/v1/chat/completions';
// SSE 端点
app.get('/stream', async (req, res) => {
// 设置响应头部
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders(); // 发送初始响应头
try {
// 发送 OpenAI 请求
const response = await fetch(
endpoint,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${openaiApiKey}`,
},
body: JSON.stringify({
model:'deepseek-chat', // 选择你使用的模型
messages: [{ role: 'user', content: req.query.question }],
stream: true, // 开启流式响应
})
}
);
if (!response.ok) {
throw new Error('Failed to fetch from OpenAI');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let done = false;
let buffer = '';
// 读取流数据并转发到客户端
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
const chunkValue = buffer + decoder.decode(value, { stream: true });
buffer = '';
// 按行分割数据,每行以 "data: " 开头,并传递给客户端
const lines = chunkValue.split('\n').filter(line => line.trim() && line.startsWith('data: '));
for (const line of lines) {
const incoming = line.slice(6);
if(incoming === '[DONE]') {
done = true;
break;
}
try {
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if(delta) res.write(`data: ${delta}\n\n`); // 发送数据到客户端
} catch(ex) {
buffer += `data: ${incoming}`;
}
}
}
res.write('event: end\n'); // 发送结束事件
res.write('data: [DONE]\n\n'); // 通知客户端数据流结束
res.end(); // 关闭连接
} catch (error) {
console.error('Error fetching from OpenAI:', error);
res.write('data: Error fetching from OpenAI\n\n');
res.end();
}
});
// 启动服务器
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
});
3.4 启动服务与直接测试
完成后,我们在终端启动服务:node server.js
这个 server.js 的主要作用是在 server 端处理大模型 API 的流式响应,并将数据仍以兼容 SSE(以"data: "开头)的形式逐步发送给浏览器端。现在我们在 IDE 中可以访问 http://localhost:3000/stream?question=hello 进行测试。
3.5 配置 Vite 代理转发
为了在前端页面上访问,我们可以通过配置 vite 的 server 来进行请求转发。此时需要修改项目中的 vite.config.js 文件:
js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vueDevTools from 'vite-plugin-vue-devtools';
// https://vitejs.dev/config/
export default defineConfig({
server: {
allowedHosts: true,
port: 4399,
proxy: {
'/api': {
target: 'http://localhost:3000',
secure: false,
rewrite: path => path.replace(/^\/api/, ''),
},
},
},
plugins: [
vue(),
vueDevTools(),
],
});
这样 server 请求就被转发到了 /api/stream。
3.6 前端 App.vue(EventSource 版)
最后我们这样实现 App.vue:
vue
<script setup lang="ts">
import { ref } from 'vue';
const question = ref('讲一个关于中国龙的故事');
const content = ref('');
const stream = ref(true);
const update = async () => {
if(!question) return;
content.value = "思考中...";
const endpoint = '/api/stream';
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_MOONSHOT_API_KEY}`
};
if(stream.value) {
content.value = '';
const eventSource = new EventSource(`${endpoint}?question=${question.value}`);
eventSource.addEventListener("message", function(e: any) {
content.value += e.data;
});
} else {
const response = await fetch(endpoint, {
method: 'POST',
headers: headers,
body: JSON.stringify({
model: 'moonshot-v1-8k',
messages: [{ role: 'user', content: question.value }],
stream: stream.value,
})
});
const data = await response.json();
content.value = data.choices[0].message.content;
}
}
</script>
<template>
<div class="container">
<div>
<label>输入:</label><input class="input" v-model="question" />
<button @click="update">提交</button>
</div>
<div class="output">
<div><label>Streaming</label><input type="checkbox" v-model="stream"/></div>
<div>{{ content }}</div>
</div>
</div>
</template>
<style scoped>
.container {
display: flex;
flex-direction: column;
align-items: start;
justify-content: start;
height: 100vh;
font-size: .85rem;
}
.input {
width: 200px;
}
.output {
margin-top: 10px;
min-height: 300px;
width: 100%;
text-align: left;
}
button {
padding: 0 10px;
margin-left: 6px;
}
</style>
3.7 SSE 简化代码与优势
注意和前面直接通过 Streams API 处理数据相比,有了 server 端处理转发后,浏览器只需使用 SSE,代码如下:
js
const eventSource = new EventSource(`${endpoint}?question=${question.value}`);
eventSource.addEventListener("message", function(e: any) {
content.value += e.data;
});
eventSource.addEventListener('end', () => {
eventSource.close();
});
这不仅仅让前端代码实现变得简洁很多,而且 SSE 在浏览器内置了自动重连机制。这意味着当网络、服务器或者客户端连接出现问题,恢复后将自动完成重新连接,不需要用户主动刷新页面,这让 SSE 特别适合长时间保持连接的应用场景。此外,SSE 还支持通过 lastEventId 来支持数据的续传,这样在错误恢复时,能大大节省数据传输的带宽和接收数据的响应时间。关于 SSE 的问题,在后续课程中,我们还会有机会继续深入探讨。
四、要点总结
在大模型的 API 调用方式上,除了传统的 HTTP 调用方式外,还支持流式传输,由于这么做不用等待推理完成就可以实时响应内容,因此能够大大减少用户等待时间,是非常有意义的。这节课,我们以 Deepseek Platform 为例,探讨了文本大模型使用 Streams API 的流式传输和 Server-Sent Events 的方法。这两种方式,提高了响应实效性,从而能够大大减少用户的等待时间,带来较好的用户体验。这也是我在实际的 AI 应用产品中推崇并最常使用的两种调用方式。我也希望同为前端的你,能够掌握这些调用方式并将它们运用到实际产品项目中去,从而改进用户的体验。
五、扩展:Vue3 + TypeScript 工程实践(整理时新增)
📌 本节为整理笔记时新增的扩展内容(非课程原文),偏 Vue3 + TypeScript 工程化落地,分别对应正文中的两种流式方式:Streams API 直连(学习/原型)与 SSE + BFF 中转(生产)。
5.1 工程化项目目录结构(补全正文缺失的目录树)
正文中「创建的项目目录结构如下:」未给出实际目录树,这里补一个按 Vue3 + Vite + TS 组织、适合 AI 对话功能的工程化结构:
text
deepseek_stream_vue/
├── .env.local # VITE_DEEPSEEK_API_KEY=sk-xxx(禁止提交 Git)
├── .gitignore
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
└── src/
├── main.ts # 入口
├── App.vue # 聊天页面
├── types/
│ └── chat.ts # 流式响应 / 消息 / usage 类型
├── composables/
│ ├── useChatStream.ts # Streams API 流式封装(学习测试)
│ └── useChatSSE.ts # EventSource 封装(配合 BFF 代理)
└── api/
└── deepseek.ts # 请求地址 / 请求头集中管理
5.2 TypeScript 类型定义(types/chat.ts)
ts
// 对话消息
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
// 流式响应分片(OpenAI 兼容协议)
export interface ChatCompletionChunk {
id: string;
object: 'chat.completion.chunk';
created: number;
model: string;
choices: Array<{
index: number;
delta: { role?: string; content?: string };
finish_reason: string | null; // 流结束时为 "stop"
}>;
usage?: TokenUsage; // 绝大多数分片没有,仅在 stop 块携带
}
// token 用量
export interface TokenUsage {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
}
5.3 Streams API 流式封装:useChatStream(composable)
把正文 2.5 的解析逻辑封装成组合式函数,补上 TS 类型、中止(AbortController)、超时兜底与状态管理:
ts
import { ref } from 'vue';
import type { ChatMessage, ChatCompletionChunk, TokenUsage } from '@/types/chat';
export function useChatStream() {
const content = ref('');
const status = ref<'idle' | 'loading' | 'done' | 'error'>('idle');
const errorMsg = ref('');
const usage = ref<TokenUsage | null>(null);
let controller: AbortController | null = null;
async function send(messages: ChatMessage[], apiKey: string) {
content.value = '';
errorMsg.value = '';
usage.value = null;
status.value = 'loading';
controller = new AbortController();
const timeoutId = window.setTimeout(() => controller?.abort(), 120_000);
try {
const res = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'deepseek-chat',
messages,
stream: true,
}),
signal: controller.signal,
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.error?.message || `HTTP ${res.status}`);
}
if (!res.body) {
throw new Error('当前浏览器不支持流式读取(ReadableStream)');
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? ''; // 保留可能被截断的最后一行
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const payload = trimmed.slice(5).trim();
if (payload === '[DONE]') {
status.value = 'done';
return;
}
try {
const chunk = JSON.parse(payload) as ChatCompletionChunk;
const delta = chunk.choices?.[0]?.delta?.content ?? '';
if (delta) content.value += delta;
// 关键:只在 finish_reason === 'stop' 时读取 usage
if (chunk.choices?.[0]?.finish_reason === 'stop' && chunk.usage) {
usage.value = chunk.usage;
}
} catch {
buffer += `data: ${payload}`; // 数据不完整,缓存待下次拼接
}
}
}
status.value = 'done';
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
errorMsg.value = '请求已中止';
} else {
errorMsg.value = err instanceof Error ? err.message : '网络异常';
}
status.value = 'error';
} finally {
window.clearTimeout(timeoutId);
controller = null;
}
}
// 停止生成
function stop() {
controller?.abort();
}
return { content, status, errorMsg, usage, send, stop };
}
5.4 组件层用法(App.vue 工程化版)
vue
<script setup lang="ts">
import { ref, onUnmounted } from 'vue';
import { useChatStream } from '@/composables/useChatStream';
import type { ChatMessage } from '@/types/chat';
const question = ref('');
const history = ref<ChatMessage[]>([]);
const { content, status, errorMsg, usage, send, stop } = useChatStream();
async function handleSend() {
const text = question.value.trim();
if (!text || status.value === 'loading') return;
history.value.push({ role: 'user', content: text });
question.value = '';
await send(history.value, import.meta.env.VITE_DEEPSEEK_API_KEY);
// 把完整回答写回历史,支持多轮上下文
if (content.value) history.value.push({ role: 'assistant', content: content.value });
}
// 组件卸载时中止进行中的请求,避免内存泄漏
onUnmounted(() => stop());
</script>
<template>
<div class="container">
<div>
<label>输入:</label>
<input class="input" v-model="question" @keydown.enter="handleSend" />
<button :disabled="status === 'loading'" @click="handleSend">
{{ status === 'loading' ? '生成中...' : '提交' }}
</button>
<button v-if="status === 'loading'" @click="stop">停止</button>
</div>
<div class="output">
<p v-if="errorMsg" class="error">{{ errorMsg }}</p>
<div>{{ content }}</div>
<p v-if="usage" class="usage">
本次消耗:prompt {{ usage.prompt_tokens }} + completion {{ usage.completion_tokens }} = {{ usage.total_tokens }} tokens
</p>
</div>
</div>
</template>
<style scoped>
.container { display: flex; flex-direction: column; align-items: start; height: 100vh; font-size: .85rem; }
.input { width: 220px; }
.output { margin-top: 10px; min-height: 300px; width: 100%; text-align: left; }
.error { color: #b91c1c; }
.usage { color: #888; font-size: 12px; }
</style>
5.5 SSE 封装:useChatSSE(配合 BFF 代理)
正文 3.6 使用原生 EventSource,这里封装成 composable,并处理 end 事件主动关闭(避免自动重连导致重复请求):
ts
import { ref } from 'vue';
export function useChatSSE() {
const content = ref('');
const status = ref<'idle' | 'loading' | 'done' | 'error'>('idle');
let es: EventSource | null = null;
function start(question: string) {
content.value = '';
status.value = 'loading';
es?.close();
es = new EventSource(`/api/stream?question=${encodeURIComponent(question)}`);
// 默认 message 事件:服务端 res.write(`data: xxx\n\n`) 触发
es.onmessage = (e: MessageEvent<string>) => {
content.value += e.data;
};
// 自定义 end 事件:服务端 res.write('event: end\n') 触发
es.addEventListener('end', () => {
status.value = 'done';
es?.close();
});
// 网络异常:EventSource 内置自动重连,无需手动处理
es.onerror = () => {
status.value = 'error';
};
}
function stop() {
es?.close();
status.value = 'done';
}
return { content, status, start, stop };
}
5.6 两种流式方式的工程选型对比
| 维度 | Streams API 直连 | SSE + BFF 中转 |
|---|---|---|
| 前端代码复杂度 | 高(手动解析分片、buffer 拼接) | 低(EventSource 一行监听) |
| 密钥安全 | Key 在 .env.local 仍会被打包进前端,仅限学习 | 密钥只存后端 BFF,生产安全 |
| 自定义 Header / POST | 支持(fetch 随意) | 不支持,必须后端中转 |
| 自动重连 | 无,需自行实现 | 浏览器内置,自动重连 |
| 断点续传 | 无 | lastEventId 支持续传 |
| 适用场景 | 本地学习、原型验证 | 生产环境 C 端聊天应用 |
| 建议 | 学习原理用 | 生产项目首选 |
5.7 Vue3 + TS 工程实践避坑清单
- 密钥管理 :
VITE_前缀的变量会打进前端包,生产环境一律走 BFF,前端不要出现任何 Key。 - 生命周期 :流式请求是异步长任务,组件卸载(路由切换)必须
AbortController.abort()/es.close(),否则会内存泄漏、状态写入已卸载组件。 - 分片不完整 :
chunkValue.split('\n')后最后一行可能被截断,必须存入 buffer 等下次拼接(正文 2.5.3 与 5.3 均有体现)。 - usage 读取时机 :流式绝大多数分片没有 usage,只在
finish_reason === 'stop'的分片读取(与第 1 讲 1.8 的要点一致)。 - EventSource 主动关闭 :收到
end/[DONE]后必须close(),否则浏览器会自动重连触发重复请求。 - 多轮上下文 :完整回答生成后 push 回
messages数组(含历史),而不是只传当前问题;系统提示词建议放在messages首位。 - 类型安全 :为 chunk、usage 定义 TS 类型,避免到处
as any;res.body可能为 null,读取前先判空。 - 超时兜底 :长回答也可能卡死,前端 120s 超时 + 后端 BFF 也要设置上游超时(参考第 1 讲 server.js 的
UPSTREAM_TIMEOUT_MS)。 - 错误提示 :非 2xx 时优先解析
error.message返回给用户;AbortError(主动停止)与真实网络错误要区分对待。

好的,我已仔细阅读您提供的课程讲稿。现在我将把豆包图像生成API的内容融合进去,形成一份完整、逻辑连贯且包含三方对比的文档。
第一单元 基础篇|第 3 讲:图像大模型 API 实战 FLUX + 可灵 AI(Kling)+ 豆包
实战环境:Trae‑cn(TraeCode AI IDE),SOLO Agent 生成完整可运行 Vue3+TS Demo
🎯学习目标
- 理解图像大模型通用异步任务范式:提交任务 → 获取任务 ID → 轮询查询任务状态
- 掌握海外 FLUX (BFL) 图像 API 鉴权、参数、轮询逻辑;区分 Demo 直调与生产环境风险
- 掌握国内可灵 AI (Kling) 鉴权:AccessKeyId / AccessKeySecret 生成 JWT Token,Node 中转服务
- 掌握豆包图像生成 API:国内可用,后端中转鉴权,异步任务轮询模式
- 对比 FLUX、可灵 AI、豆包三者接口差异,掌握跨域处理、临时图片 URL 业务风险
- 会写 Trae‑cn 可直接复制的项目生成指令,一键生成可运行 Demo
一、课程概述
前面使用文本大模型 API ,实现对话、文本生成。
AI 能力不止文本,还包含:文生图、图生图、AI 视频、语音、图像识别。
图像生成属于高耗时任务,几乎所有图像大模型 API 统一采用「异步读写分离」架构:
- POST 提交绘图任务,接口立刻返回任务 ID,不会同步返回图片二进制 / 图片 URL
- 业务侧循环轮询查询接口,携带任务 ID 查询状态
- 判断状态:运行中继续轮询;成功拿到图片地址;任务失败捕获异常
核心范式 :
提交任务 → taskId → 循环轮询 → 判断状态 → 获取资源
本节课实战三套线上图像生成 API:
- FLUX(Black Forest Labs,海外):鉴权简单,返回生成进度 progress,Demo 可前端直调
- 可灵 AI (快手 Kling,国内):JWT 鉴权,禁止前端直接调用,需要 Node 中转服务,无进度返回
- 豆包图像生成 (字节跳动,国内):API‑Key 鉴权,必须后端中转,异步任务轮询,无进度返回
⚠️通用安全提醒:图像模型密钥,禁止裸暴露前端;生产环境全部后端代理转发。
二、FLUX (BFL) 图像大模型实战
2.1 FLUX 简介
- 出品方:Black Forest Labs
- 对比 Stable Diffusion:上手简单,出图质量优秀,海外 API 服务
- 官网地址:https://api.us1.bfl.ai/
- 鉴权:请求头自定义字段
x-key: API_KEY - 计费:消耗 Credits,不同模型价格不同,查阅官方 API 文档
- 特点:轮询接口返回
progress进度字段,可以做前端进度条
2.2 账号与密钥获取
- 打开官网,邮箱注册账号,登录后台
- 点击
Add Key,生成API_KEY,复制保存,不要泄露密钥
2.3 Trae‑cn 创建项目指令(直接复制给 SOLO Agent)
创建Vue3 + TypeScript Vite项目,项目名 flux-ai-demo-1
不引入UI组件库;生成.env.local;
实现FLUX文生图Demo;页面包含提示词输入框、生成按钮、进度条、图片展示区域;
实现提交任务、轮询状态、展示loading图、失败兜底图片;
增加基础异常捕获;输出README.md说明启动步骤。
2.4 环境变量 .env.local
# FLUX BFL API密钥
VITE_API_KEY=你的BFL_API_KEY
Vite 中,只有带
VITE_前缀环境变量才可以在前端import.meta.env读取。
2.5 App.vue 完整代码
vue
<script setup lang="ts">
import { ref } from 'vue';
const prompt = ref('A lovely rabbit');
const imgUrl = ref('');
const progress = ref('0%');
const generateImage = async () => {
const endpoint = `https://api.bfl.ml/v1`;
const modelName = 'flux-dev';
const payload = {
prompt: prompt.value,
width: 1024,
height: 1024,
steps: 40,
prompt_upsampling: true,
seed: 42,
guidance: 3,
sampler: 'dpmpp_2m',
safety_tolerance: 2,
};
const headers = {
'Content-Type': 'application/json',
'x-key': import.meta.env.VITE_API_KEY,
};
// 1、提交绘图任务,拿到任务id
const res = await fetch(`${endpoint}/${modelName}`, {
headers,
method: 'POST',
body: JSON.stringify(payload),
});
const { id } = await res.json();
const resultUrl = `${endpoint}/get_result?id=${id}`;
// loading占位图
imgUrl.value = 'https://res.bearbobo.com/resource/upload/a3IZyOsZ/loading-giaz5ycpd7j.gif';
// 2、循环轮询任务状态
do {
await new Promise((resolve) => setTimeout(resolve, 100));
const result = await fetch(resultUrl);
const resultJson = await result.json();
if (resultJson.status === 'Pending') {
// 任务处理中,更新进度
const progressValue = resultJson.progress;
if(progressValue) {
progress.value = `${Math.round(progressValue * 100)}%`;
}
continue;
}
// 任务结束,读取图片
const sample = resultJson.result?.sample;
if (sample) {
imgUrl.value = sample;
} else {
imgUrl.value = 'https://res.bearbobo.com/resource/upload/vNg4ALJv/6659895-ox36cbkajrr.png';
}
break;
} while (1);
};
</script>
<template>
<div class="container">
<div>
<label>Prompt </label>
<button @click="generateImage">Generate</button>
<textarea class="input" v-model="prompt" />
</div>
<div class="progress">
<div :style="{width: progress}"></div>
</div>
<div class="output">
<img :src="imgUrl" />
</div>
</div>
</template>
<style scoped>
.input {
width: 100%;
height: 2rem;
font-size: 1rem;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 0.5rem;
}
.progress {
width: 100%;
height: 0.1rem;
margin: .4rem 0;
background: #ccc;
}
.progress > div {
background: #c00;
height: 100%;
}
.container {
display: flex;
flex-direction: column;
align-items: start;
justify-content: start;
height: 100vh;
gap:12px;
}
.output {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
border: 1px solid #ccc;
width:100%;
}
.output > img {
width: 100%;
max-width:600px;
}
</style>
2.6 FLUX 请求参数详解(前端易懂)
| 参数 | 说明 |
|---|---|
| prompt | 正向提示词,描述希望生成什么图片 |
| width / height | 图像宽高,单位像素 |
| steps | 去噪迭代步数;步数越高画质越好,生成耗时更长 |
| prompt_upsampling | 提示词增强,提升画面细节 |
| seed | 随机种子;相同 seed + 全部参数,生成完全一致图片;修改 seed 得到不同结果 |
| guidance | 提示词遵循强度;数值越大 AI 严格遵守 prompt;数值越低 AI 创意自由度越高 |
| sampler | 采样算法;dpmpp_2m为扩散模型常用采样器 |
| safety_tolerance | 安全内容过滤等级,拦截违规内容 |
鉴权区别:普通文本大模型使用
Authorization: Bearer xxx;FLUX 使用自定义请求头x-key。
🚨重要风险提示本 Demo 将 API_KEY 放在前端环境变量,浏览器网络面板可以抓取密钥。仅限本地学习演示!生产环境必须后端中转,禁止前端直调 FLUX 接口,防止密钥泄露盗刷 Credits。
三、可灵 AI(快手 Kling)图像大模型实战
3.1 可灵 AI 简介
国内商用图像大模型,同时支持文生图、AI 视频生成。
- 鉴权:
AccessKeyId+AccessKeySecret,算法生成 JWT Token 鉴权;密钥不能下发前端 - 跨域限制:浏览器禁止直接请求官方 API,必须使用 Vite 代理 / Node 服务中转
- 任务模式:异步任务,返回
task_id;轮询接口不返回进度 progress,无法实现进度条 - 控制台地址:https://klingai.com/dev/api-key
3.2 Trae‑cn 创建完整项目指令(复制给 SOLO Agent)
创建Vue3+TS Vite项目,项目名 kling-ai-demo
安装依赖 express jsonwebtoken dotenv;
生成.env.local、后端入口server.js;修改vite.config.ts配置代理;
实现可灵AI文生图Demo;页面包含提示词输入框、生成按钮、图片预览;
内置一套负面提示词过滤畸形模糊图片;增加异常捕获;
编写README,写明密钥获取、双终端启动步骤。
3.3 获取可灵 AI 密钥
- 访问 https://klingai.kuaishou.com 注册登录
- 左下角点击「开发者平台」进入控制台
- 左侧菜单「密钥管理」,创建密钥,拿到两组凭证
ACCESS_KEY_IDACCESS_KEY_SECRET
3.4 .env.local
ACCESS_KEY_ID=你的AccessKeyId
ACCESS_KEY_SECRET=你的AccessKeySecret
3.5 Node 鉴权服务 server.js
作用:后端根据 AK/SK 生成 JWT 鉴权 Token;前端永远拿不到 AccessKeySecret
javascript
import * as dotenv from 'dotenv'
import express from 'express';
import jwt from 'jsonwebtoken';
dotenv.config({
path: ['.env.local', '.env']
});
const accessKeyId = process.env.ACCESS_KEY_ID;
const accessKeySecret = process.env.ACCESS_KEY_SECRET;
const app = express();
const port = 3000;
async function authKlingai() {
const headers = {
algorithm: 'HS256',
};
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: accessKeyId,
exp: now + 1800, // token有效期30分钟
nbf: now - 5, // 提前5秒生效
};
const token = jwt.sign(payload, accessKeySecret, headers);
return token;
}
// 前端请求该接口获取JWT token
app.get('/jwt-auth', async (req, res) => {
const token = await authKlingai();
res.send(token);
});
app.listen(port, () => {
console.log(`可灵鉴权服务启动:localhost:${port}`);
});
3.6 vite.config.ts 代理配置
typescript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
allowedHosts: true,
port: 4399,
proxy: {
// 转发到本地node鉴权服务,拿jwt token
'/api': {
target: 'http://localhost:3000',
secure: false,
rewrite: path => path.replace(/^\/api/, ''),
},
// 转发可灵官方API,解决浏览器跨域报错
'/klingai': {
target: 'https://api.klingai.com',
changeOrigin: true,
rewrite: path => path.replace(/^\/klingai/, ''),
}
}
}
})
代理说明
/api/*→ 转发本地 3000 端口 Node 服务,获取 JWT Token/klingai/*→ 转发可灵官方 API,绕过浏览器 CORS 跨域限制
3.7 App.vue 前端完整代码
vue
<script setup lang="ts">
import { ref } from 'vue';
const prompt = ref('A lovely rabbit');
const imgUrl = ref('');
const generateImage = async () => {
// 负面提示词,过滤畸形、模糊、崩坏画面
const negativeWords = 'Blurry, Bad, Bad anatomy, Bad proportions, Deformed, Disconnected limbs, Disfigured, Extra arms, Extra limbs, Extra hands, Fused fingers, Gross proportions, Long neck, Malformed limbs, Mutated, Mutated hands, Mutated limbs, Missing arms, Missing fingers, Poorly drawn hands, Poorly drawn face.';
const endpoint = `/klingai/v1/images/generations`;
// 请求本地后端拿到JWT Token
const token = await (await fetch('/api/jwt-auth')).text();
const payload = {
prompt: prompt.value,
negative_prompt: negativeWords,
aspect_ratio: '1:1',
};
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
};
// 提交绘图任务
const res = await fetch(endpoint, {
headers,
method: 'POST',
body: JSON.stringify(payload),
});
if (res.status >= 400) {
throw new Error(`请求异常: ${await res.text()}`);
}
const ret: any = await res.json();
const task_id = ret.data.task_id;
const resultUrl = `${endpoint}/${task_id}`;
imgUrl.value = 'https://res.bearbobo.com/resource/upload/a3IZyOsZ/loading-giaz5ycpd7j.gif';
// 轮询查询任务状态
do {
await new Promise((resolve) => setTimeout(resolve, 100));
const result = await fetch(resultUrl, { headers });
const resultJson = await result.json();
const taskStatus = resultJson.data.task_status;
if (taskStatus === 'processing' || taskStatus === 'submitted') {
continue;
}
if (taskStatus === 'failed') {
throw new Error(`任务失败 ${JSON.stringify(resultJson)}`);
}
const sample = resultJson.data?.task_result;
if (sample) {
imgUrl.value = sample.images[0].url;
} else {
imgUrl.value = 'https://res.bearbobo.com/resource/upload/vNg4ALJv/6659895-ox36cbkajrr.png';
}
break;
} while (1);
};
</script>
<template>
<div class="container">
<div>
<label>Prompt </label>
<button @click="generateImage">Generate</button>
<textarea class="input" v-model="prompt" />
</div>
<div class="output">
<img :src="imgUrl" />
</div>
</div>
</template>
<style scoped>
.input {
width: 100%;
height: 2rem;
font-size: 1rem;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 0.5rem;
}
.container {
display: flex;
flex-direction: column;
align-items: start;
justify-content: start;
height: 100vh;
gap:12px;
}
.output {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
border: 1px solid #ccc;
width:100%;
}
.output > img {
width: 100%;
max-width: 600px;
}
</style>
3.8 项目启动步骤(必须两个终端)
- 安装全部依赖
bash
pnpm i
pnpm i dotenv express jsonwebtoken
- 终端 1:启动 Node 鉴权后端服务
bash
node server.js
- 终端 2:启动 Vite 前端项目
bash
pnpm dev
访问 Vite 输出地址即可测试。
四、豆包图像生成大模型实战
4.1 豆包图像生成简介
字节跳动旗下火山引擎提供的图像生成服务,国内可用,支持文生图、图生图。
- 官网:https://www.volcengine.com/product/ark
- 鉴权方式:
Authorization: Bearer {API_KEY},与文本大模型鉴权一致 - 计费:按调用次数 / 图片张数计费,具体查阅火山引擎定价
- 安全要求:密钥严禁暴露在前端,必须后端中转
- 异步任务模式:提交任务 → 轮询查询状态 → 获取图片结果
- 不支持进度回调:只能查询任务状态(排队中、生成中、成功、失败)
- 返回的图片 URL 为临时 CDN 链接,有有效期
4.2 获取豆包 API 密钥
- 访问火山引擎控制台 https://console.volcengine.com/
- 开通豆包图像生成服务
- 在「密钥管理」中创建 API Key,获取
API_KEY - 记录该密钥,后续配置到环境变量中
4.3 Trae‑cn 创建项目指令(复制给 SOLO Agent)
创建Vue3+TS Vite项目,项目名 doubao-image-demo
安装依赖 express dotenv cors;
生成.env.local、后端入口server.js;修改vite.config.ts配置代理;
实现豆包文生图API调用Demo;页面包含提示词输入框、生成按钮、图片预览;
封装异步轮询任务逻辑;增加完整try catch错误处理;
编写README.md,写明密钥获取、双终端启动步骤。
4.4 .env.local
# 豆包(火山方舟 Ark)API 密钥
# 获取:火山引擎方舟控制台 -> API Key 管理 -> 创建 API Key
DOUBAO_API_KEY=ark-*******************
# 图像模型 ID 或接入点 Endpoint ID
# 去方舟控制台「开通管理」开通模型后,复制准确的 Model ID
# Seedream 5.0 Lite 官方 Model ID:doubao-seedream-5-0-260128(别名 doubao-seedream-5-0-lite-260128)
DOUBAO_IMAGE_MODEL=doubao-seedream-5-0-lite-260128
4.5 Node 中转服务 server.js
豆包图像生成 API 不支持浏览器直接调用,需要 Node 后端转发请求,保护 API Key 不被前端获取。
javascript
import * as dotenv from 'dotenv';
import express from 'express';
import cors from 'cors';
// Node < 18 无原生 fetch,故使用 node-fetch;Node 18+ 亦兼容
import fetch from 'node-fetch';
dotenv.config({ path: ['.env.local', '.env'] });
const app = express();
const port = 3000;
const API_KEY = process.env.DOUBAO_API_KEY; // 方舟(Ark)API Key,作为 Bearer Token
const MODEL = process.env.DOUBAO_IMAGE_MODEL; // 图像模型 ID 或接入点 Endpoint ID,如 doubao-seedream-4-0-250828 或 ep-xxxx
const DOUBAO_API_BASE = process.env.DOUBAO_API_BASE || 'https://ark.cn-beijing.volces.com/api/v3';
app.use(cors());
app.use(express.json());
// 1. 文生图接口(豆包方舟 API 为同步返回,data[].url 直接可用)
app.post('/api/image/generate', async (req, res) => {
try {
const { prompt, size = '1024x1024', n = 1, response_format = 'url' } = req.body;
if (!API_KEY) {
return res.status(500).json({ error: '服务端未配置 DOUBAO_API_KEY' });
}
if (!MODEL) {
return res.status(500).json({
error: '服务端未配置 DOUBAO_IMAGE_MODEL,请在 .env.local 中填写你的模型 ID(如 doubao-seedream-4-0-250828 或 ep-xxx)',
});
}
if (!prompt) {
return res.status(400).json({ error: '缺少 prompt 参数' });
}
const upstreamRes = await fetch(`${DOUBAO_API_BASE}/images/generations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: MODEL,
prompt,
size,
n,
response_format,
}),
});
const data = await upstreamRes.json();
// 透传上游 HTTP 状态码与错误信息,方便前端定位
res.status(upstreamRes.ok ? 200 : upstreamRes.status).json(
upstreamRes.ok ? data : { error: data.error?.message || data.error || `${upstreamRes.status} ${upstreamRes.statusText}` }
);
} catch (error) {
// 网络/TLS/超时等异常:带上具体原因,便于排查
const detail = error instanceof Error ? error.message : String(error);
console.error('文生图调用失败:', error);
res.status(500).json({ error: '调用豆包图像接口失败', detail });
}
});
app.listen(port, () => {
console.log(`豆包鉴权中转服务启动:localhost:${port}`);
});
4.6 vite.config.ts 代理配置
typescript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
}
}
}
})
4.7 App.vue 前端完整代码
vue
<script setup lang="ts">
import { ref } from 'vue';
const prompt = ref('');
const imgUrl = ref('');
const isLoading = ref(false);
const errorMsg = ref('');
// 兜底图片
const FALLBACK_IMG = 'https://res.bearbobo.com/resource/upload/vNg4ALJv/6659895-ox36cbkajrr.png';
const LOADING_GIF = 'https://res.bearbobo.com/resource/upload/a3IZyOsZ/loading-giaz5ycpd7j.gif';
const generateImage = async () => {
if (!prompt.value.trim()) {
errorMsg.value = '请输入提示词';
return;
}
isLoading.value = true;
errorMsg.value = '';
imgUrl.value = LOADING_GIF;
try {
// 调用后端中转服务,由服务端转发到豆包方舟 API(同步返回 data[].url)
const submitRes = await fetch('/api/image/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: prompt.value,
size: '2048x2048',
n: 1,
}),
});
const data = await submitRes.json();
if (!submitRes.ok) {
throw new Error(data?.error || `请求失败: ${submitRes.status}`);
}
// 方舟图像 API 返回格式:{ data: [ { url, ... } ] }
const images = data?.data || [];
if (images.length > 0 && images[0]?.url) {
imgUrl.value = images[0].url;
} else if (images[0]?.b64_json) {
imgUrl.value = `data:image/jpeg;base64,${images[0].b64_json}`;
} else {
throw new Error('未返回图片');
}
} catch (err: any) {
console.error('生成失败:', err);
errorMsg.value = err.message || '生成失败,请稍后重试';
imgUrl.value = FALLBACK_IMG;
} finally {
isLoading.value = false;
}
};
</script>
<template>
<div class="container">
<h1>🎨 豆包图像生成</h1>
<div class="input-area">
<label for="prompt">提示词</label>
<textarea
id="prompt"
class="input"
v-model="prompt"
placeholder="描述你想要生成的图片..."
rows="3"
/>
<button
class="btn"
@click="generateImage"
:disabled="isLoading"
>
{{ isLoading ? '生成中...' : '✨ 生成图片' }}
</button>
</div>
<div v-if="errorMsg" class="error">
⚠️ {{ errorMsg }}
</div>
<div class="output">
<img :src="imgUrl" alt="生成图片" />
</div>
</div>
</template>
<style scoped>
.container {
max-width: 720px;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
h1 {
font-size: 1.8rem;
margin-bottom: 1.5rem;
}
.input-area {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.input {
width: 100%;
padding: 0.8rem;
font-size: 1rem;
border: 1px solid #ddd;
border-radius: 0.5rem;
resize: vertical;
font-family: inherit;
box-sizing: border-box;
}
.btn {
padding: 0.8rem 2rem;
font-size: 1rem;
background: #1a73e8;
color: #fff;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: background 0.2s;
align-self: flex-start;
}
.btn:hover:not(:disabled) {
background: #1557b0;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
margin-top: 1rem;
padding: 0.8rem;
background: #fee;
color: #c00;
border-radius: 0.5rem;
}
.output {
margin-top: 1.5rem;
min-height: 300px;
border: 1px solid #eee;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
overflow: hidden;
}
.output img {
max-width: 100%;
max-height: 600px;
object-fit: contain;
}
</style>
4.8 豆包图像生成参数说明
| 参数 | 类型 | 说明 |
|---|---|---|
| prompt | string | 必填,正向提示词 |
| size | string | 图片尺寸,如 1024x1024、1024x1792 等 |
| style | string | 风格预设,如 photography、illustration、anime 等 |
| n | integer | 生成图片数量,默认 1 |
| response_format | string | url 或 b64_json,建议 url |
💡 与可灵 AI 对比:豆包和可灵都不支持进度回调;豆包鉴权方式与文本大模型一致,比可灵的 JWT 更简单。
4.9 # 火山引擎 Seedream(豆包图像生成)完整接入教程
适用:写 Demo 调试,新账号有 API 专属免费测试额度,免费额度用于接口调用,不是网页端积分;免费额度耗尽后按量计费,单价低,支持文生图、图生图、多图融合。
模型 ID
doubao‑seedream‑5‑0‑260128👉5.0‑lite(demo 首选,性价比高)doubao‑seedream‑4‑5‑251128👉4.5 高质量版
一、前置准备(控制台操作步骤)
1. 注册 & 实名认证
打开火山引擎官网 https://www.volcengine.com/ 注册账号
完成个人实名认证(必须),不认证无法调用 API。
2. 进入火山方舟,开通图像模型
- 控制台顶部搜索:火山方舟,进入方舟控制台
- 左侧菜单:模型广场,搜索
seedream - 找到
Seedream‑5.0‑lite,点击开通服务,同意协议。开通后自动下发新用户免费测试额度(API 调用消耗)。





3. 获取 API‑Key(非常重要,不要硬编码到代码)
- 方舟控制台左下角:API Key 管理
- 点击「创建 API Key」,复制保存
ARK_API_KEY

4. 查看免费额度消耗
方舟控制台‑费用,可查看免费额度剩余张数,调用接口自动扣免费额度,用完才扣账户余额。
二、环境变量配置(本地开发)
所有示例优先读取环境变量,避免密钥泄露。
Windows PowerShell
$env:ARK_API_KEY="你的APIKEY复制粘贴这里"
Windows CMD
setx ARK_API_KEY "你的APIKEY复制粘贴这里"
Mac / Linux bash
export ARK_API_KEY="你的APIKEY复制粘贴这里"
三、Python 完整 Demo(可直接复制运行)
1. 安装 SDK
pip install arkruntime
2. 文生图完整代码 seedream_text2img.py
import os
from arkruntime import Ark
# 初始化客户端,自动读取环境变量 ARK_API_KEY
client = Ark(
base_url="https://ark.cn-beijing.volces.com/api/v3",
api_key=os.getenv("ARK_API_KEY")
)
def text2image():
resp = client.images.generate(
model="doubao-seedream-5-0-260128",
prompt="一只橘猫坐在海边看日落,写实摄影,8k高清",
size="2K",
response_format="url", # 返回图片url;可选b64返回base64
watermark=False # 关闭水印,免费额度也无水印
)
# 打印生成图片访问链接
for item in resp.data:
print("生成图片地址:", item.url)
if __name__ == "__main__":
text2image()
3. 图生图示例(参考一张图再生成)
resp = client.images.generate(
model="doubao-seedream-5-0-260128",
prompt="把图片改成赛博朋克夜景风格",
image="https://xxx你的参考图片公网url",
size="2K",
response_format="url",
watermark=False
)
运行命令:
python seedream_text2img.py
控制台输出图片 url,浏览器直接打开查看结果。
四、Java 完整 Demo(Maven 项目)
1. pom.xml 依赖
<dependency>
<groupId>com.volcengine</groupId>
<artifactId>ark-runtime</artifactId>
<version>0.1.30</version>
</dependency>
2. 主类代码 ArkImageDemo.java
import com.volcengine.ark.runtime.model.images.generation.GenerateImagesRequest;
import com.volcengine.ark.runtime.model.images.generation.GenerateImagesResponse;
import com.volcengine.ark.runtime.model.images.generation.ResponseFormat;
import com.volcengine.ark.runtime.service.ArkService;
import okhttp3.ConnectionPool;
import okhttp3.Dispatcher;
import java.util.concurrent.TimeUnit;
public class ArkImageDemo {
public static void main(String[] args) {
// 读取环境变量 ARK_API_KEY
String apiKey = System.getenv("ARK_API_KEY");
ConnectionPool connectionPool = new ConnectionPool(5,1, TimeUnit.SECONDS);
Dispatcher dispatcher = new Dispatcher();
ArkService arkService = ArkService.builder()
.apiKey(apiKey)
.baseUrl("https://ark.cn-beijing.volces.com/api/v3")
.connectionPool(connectionPool)
.dispatcher(dispatcher)
.build();
GenerateImagesRequest req = GenerateImagesRequest.builder()
.model("doubao-seedream-5-0-260128")
.prompt("雪山湖泊,自然风光,胶片摄影,高清")
.size("2K")
.responseFormat(ResponseFormat.Url)
.watermark(false)
.build();
GenerateImagesResponse resp = arkService.images().generate(req);
// 打印图片URL
resp.getData().forEach(img->{
System.out.println("图片URL:" + img.getUrl());
});
arkService.shutdown();
}
}
IDEA 运行配置:配置运行环境变量
ARK_API_KEY=你的key。
五、curl 原生 http 请求(不依赖 SDK,快速测试)
把下面 $ARK_API_KEY 替换成你的 key,直接终端执行
curl --location "https://ark.cn-beijing.volces.com/api/v3/images/generations" \
--header "Content-Type: application/json" \
--header "Authorization: Bearer $ARK_API_KEY" \
--data '{
"model":"doubao-seedream-5-0-260128",
"prompt":"可爱的小狗,卡通插画风格",
"size":"2K",
"response_format":"url",
"watermark":false
}'
六、关键参数说明
表格
| 参数 | 说明 |
|---|---|
| model | 模型 ID,5‑0‑lite 适合 demo 调试 |
| prompt | 提示词,中文友好,建议 300 字以内 |
| size | 分辨率:1K / 2K / 4K;越大消耗额度越高 |
| response_format | url 返回临时图片链接;b64_json 返回 base64 图片二进制 |
| watermark | false 关闭水印,免费额度也不带水印 |
| image | 图生图使用,传入公网可访问图片 URL |
七、常见踩坑排查
- 报错 401 鉴权失败 :
ARK_API_KEY复制错误,检查前后空格;确认是方舟的 API Key,不是账号密码。 - 报错 403 无权限:确认模型已经在模型广场点击开通服务;账号已完成实名认证。
- 免费额度没扣,直接扣余额 :确认调用的是方舟 Seedream 模型;网页端即梦 AI 的积分不能用于 API 调用。
- 生成图片 url 有有效期:返回的 url 是临时资源,业务代码需要下载保存到自己对象存储。
- 速率限制:demo 调试足够,正式业务需要参考官方文档调整限流策略。
八、架构安全提醒(重要)
❌ 禁止前端浏览器直接调用该接口 ,ARK_API_KEY 会泄露,产生盗刷账单。
✅ 标准链路:前端提交提示词 → Java/Python/Node 后端服务携带密钥调用 Seedream API → 获取图片链接返回前端渲染。
4.10 demo


五、三方对比:FLUX vs 可灵 AI vs 豆包(前端开发视角)
| 对比项 | FLUX (BFL) | 可灵 AI (Kling) | 豆包图像生成 |
|---|---|---|---|
| 服务地区 | 海外 API | 国内 API | 国内 API |
| 鉴权方式 | 请求头 x-key: api-key |
AK/SK 生成 JWT Token | Authorization: Bearer API_KEY |
| 前端直接调用 | ✅ Demo 可直调(生产禁止) | ❌ 完全禁止 | ❌ 完全禁止 |
| 跨域问题 | 存在跨域 | 存在跨域,必须代理 | 存在跨域,必须代理 |
| 轮询返回进度 | ✅ 返回 progress,可做进度条 | ❌ 无进度数值 | ❌ 无进度数值 |
| 任务唯一标识 | id |
task_id |
task_id |
| 图片返回字段 | result.sample |
task_result.images[0].url |
result.images[0].url |
| 负面提示词 | 无独立字段 | 支持 negative_prompt |
支持 negative_prompt(部分模型) |
| 推荐轮询间隔 | 100-300ms | 100-500ms | 1-2 秒(官方建议) |
| 后端复杂度 | 低(仅需代理) | 中(需生成 JWT) | 低(仅需转发) |
✨通用图像大模型 API 范式(几乎所有 AI 绘图、AI 视频接口通用)
- POST 提交生成任务,立刻返回任务 ID,不会同步返回图片资源
- sleep 延时,循环轮询状态查询接口
- 判断状态:运行中继续轮询;成功拿到图片 URL;失败抛出业务异常
六、生产环境避坑清单
-
密钥安全第一
FLUX 示例中前端写 API_KEY 只用于学习;可灵、豆包直接禁止前端调用。线上业务全部后端中转,密钥存放后端环境变量,
.env加入.gitignore,禁止提交代码仓库。 -
图片 URL 生命周期风险
AI 接口返回图片 URL 大多是临时 CDN 地址,一段时间之后会失效。真实业务拿到图片地址后,后端下载图片保存到自有对象存储(OSS),不要直接使用返回的临时链接。
-
轮询优化
- FLUX:进度敏感,可 100-300ms 轮询
- 可灵:500ms 左右
- 豆包:官方建议 1-2 秒,避免频繁请求
- 通用建议:增加最大轮询次数(如 120 次),防止死循环;可配置超时时间(如 5 分钟)
-
异常捕获
增加 try-catch,处理网络异常、任务失败、额度耗尽、参数错误等场景,给用户友好提示。
-
JWT / Token 缓存复用
- 可灵 AI 的 JWT Token 有效期 30 分钟,不需要每次请求都重新生成
- 豆包 API Key 长期有效,直接复用
-
负面提示词策略
- 可灵 AI 原生支持
negative_prompt - 豆包部分模型支持,可优先使用
- FLUX 不支持,需通过 prompt 工程规避(如 "high quality, detailed")
- 可灵 AI 原生支持
七、拓展:其他图像大模型快速接入指令
7.1 豆包图像大模型 Trae 生成指令
豆包图像大模型鉴权方式和文本大模型一致,
Authorization: Bearer API_KEY,同样异步任务模式。直接复制下面指令给到 Trae‑cn SOLO Agent:
Vue3 TS Vite项目:doubao-image-demo
实现豆包文生图API调用Demo;vite代理解决跨域;封装异步轮询任务逻辑;UI参考上面图像Demo;.env.local存放DOUBAO_API_KEY;增加完整try catch错误处理,编写README.md。
7.2 通用指令模板(可替换为任意图像 API)
Vue3 TS Vite项目:[项目名]
实现[平台名]文生图API调用Demo;vite代理解决跨域;
采用「提交任务 → 轮询状态 → 获取结果」异步范式;
.env.local存放密钥;增加错误处理和兜底图片;
编写README.md。
八、本章小结
- 图像生成属于耗时任务,行业通用设计:异步任务 + 轮询查询结果,提交任务拿到任务 ID,循环查询状态。
- FLUX 海外图像模型,鉴权简单,接口返回进度;Demo 可前端直调,生产禁止暴露密钥。
- 可灵 AI 使用 AK+SK 生成 JWT Token,密钥不能给到前端,需要 Node 做鉴权中转;无进度字段,支持负面提示词。
- 豆包图像生成 使用
Bearer API_KEY鉴权,与文本大模型一致;必须后端中转,异步轮询,无进度回调;建议 1-2 秒轮询间隔。 - 浏览器存在跨域限制,生产环境一律后端代理,不要把密钥暴露在前端包中。
- AI 返回图片 URL 大多为临时链接,业务系统必须下载保存到自有存储;轮询需要做超时、最大次数保护。
拓展方向:图生图、AI 视频生成,接口架构依然是这套异步 + 轮询模式,可以直接复用这套业务逻辑。
第一单元 基础篇|第 4 讲:语音合成 (TTS) 与视觉理解 (Vision) 实战【可实战完整版】
学习目标
- 熟练完成火山 TTS、Kimi‑Vision 平台开通、参数调优、前端调试
- 掌握 Base64 音频 / 图片处理、浏览器播放限制、文件大小控制
- 分清「本地 Demo 模式」和「生产 BFF 代理模式」,掌握两套代码
- 掌握异常捕获、参数校验、内存释放、错误提示、边界问题
- 学会多模态服务组合模式:文本 ↔ TTS ↔ 视觉
核心思想:很多大模型本身只懂文本,语音、图片能力来自外部独立服务;业务通过编排多个 API,拼成完整多模态产品。
模块一 火山引擎大模型语音合成 TTS
1.1 平台开通完整流程(避坑版)
第一步:开通语音合成模型权限
- 控制台搜索「语音技术」找到 豆包语音 进入产品页
- 左侧菜单点 开通管理
- 在模型列表找到:
Doubao‑Seed‑TTS‑2.0(豆包语音合成2.0) - 点右侧【开通】,勾选协议完成开通。
刚开通后等待 2‑5 分钟服务生效,立刻调用会返回权限错误。
第二步:获取 API Key(鉴权密钥)
- 左侧菜单点 API Key
- 点击【创建 API Key】,输入名称,确定
新版鉴权只需要一个:
API‑Key,请求头只用X‑Api‑Key。
第三步:拿到音色 ID
- 左侧菜单 → 音色库
- 挑选一个免费可用音色,点音色卡片右上角
...,复制音色 ID
示例音色 ID(仅做示范,以控制台复制为准):
ICL_uranus_zh_male_jilingxiaohuo_tob
1.2 本地开发环境(Demo 模式:Vite 代理)
.env.local
# 新版豆包语音2.0,只填API‑Key
VITE_DOUBAO_API_KEY=你的API‑Key
vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
allowedHosts: true,
proxy: {
'/doubao‑tts': {
target: 'https://openspeech.bytedance.com',
changeOrigin: true,
rewrite: path => path.replace(/^\/doubao‑tts/, '')
}
}
}
})
⚠️注意:
vite proxy仅开发环境生效,npm run build打包之后代理消失,打包后前端直调会跨域 + 密钥泄露,绝对不能上线。
1.3 完整可实战 App.vue(增加校验、内存释放、错误处理、参数切换)
<script setup lang="ts">
import { ref } from 'vue'
const prompt = ref('您好,欢迎使用新版豆包语音合成演示。')
type TtsStatus = 'ready' | 'generating' | 'done' | 'error'
const status = ref<TtsStatus>('ready')
const audioEl = ref<HTMLAudioElement>()
let currentObjectUrl = '' // 保存blob临时url用于内存释放
/**
* base64音频 → Blob播放地址
*/
function createBlobURL(base64AudioData: string, mimeType: string): string {
const byteArrays: number[] = []
const byteCharacters = atob(base64AudioData)
for (let i = 0; i < byteCharacters.length; i++) {
byteArrays.push(byteCharacters.charCodeAt(i))
}
const blob = new Blob([new Uint8Array(byteArrays)], { type: mimeType })
return URL.createObjectURL(blob)
}
/**
* 释放blob内存,防止内存泄漏
*/
function freeBlobUrl() {
if (currentObjectUrl) {
URL.revokeObjectURL(currentObjectUrl)
currentObjectUrl = ''
}
}
const generateAudio = async () => {
if (!prompt.value.trim()) {
alert('请输入待合成文本')
return
}
freeBlobUrl()
const apiKey = import.meta.env.VITE_DOUBAO_API_KEY
if (!apiKey) {
alert('请检查.env.local配置VITE_DOUBAO_API_KEY')
status.value = 'error'
return
}
// ⚠️替换为音色库复制出来的音色ID
const speakerId = "在这里粘贴控制台复制的音色ID"
// 上线优先 mp3,兼容性更强;ogg_opus体积更小部分浏览器不支持
const encoding = 'mp3'
const mime = encoding === 'mp3' ? 'audio/mpeg' : 'audio/ogg'
// =====新版请求体结构,和旧版完全不同=====
const payload = {
req_params: {
text: prompt.value,
speaker: speakerId
},
audio_params: {
format: encoding,
sample_rate: 24000,
speed: 1.0, // 0.2‑3.0
volume: 1.0, // 0.1‑3.0
pitch: 1.0 // 0.1‑3.0
}
}
try {
status.value = 'generating'
const res = await fetch('/doubao‑tts/api/v3/tts/unidirectional', {
method: 'POST',
headers: {
'Content‑Type': 'application/json',
'X‑Api‑Key': apiKey,
'X‑Api‑Resource‑Id': 'seed‑tts‑2.0'
},
body: JSON.stringify(payload)
})
const json = await res.json()
if (!res.ok || json.code !== 0 || !json.data) {
throw new Error(json?.message || '接口返回异常')
}
currentObjectUrl = createBlobURL(json.data, mime)
if (audioEl.value) {
audioEl.value.src = currentObjectUrl
// 浏览器策略:必须由用户手势触发play;代码自动播放会抛出异常捕获
await audioEl.value.play()
}
status.value = 'done'
} catch (e: any) {
console.error('TTS异常', e)
status.value = 'error'
if (e.name === 'NotAllowedError') {
alert('浏览器拦截自动播放,请点击按钮手动触发播放')
} else {
alert(`语音生成失败:${e.message}`)
}
}
}
</script>
<template>
<div style="padding:24px;max-width:650px">
<h3>豆包语音2.0(新版控制台)TTS演示</h3>
<div style="display:flex;flex-direction:column;gap:12px;margin:16px 0">
<textarea
v-model="prompt"
placeholder="输入要转语音的文字"
style="min-height:90px;padding:10px;border:1px solid #ccc;border-radius:6px"
/>
<button @click="generateAudio" :disabled="status==='generating'" style="width:fit-content;padding:8px 16px">
{{ status==='generating'?'生成中...':'生成并播放语音' }}
</button>
<div>状态:{{ status }}</div>
<audio ref="audioEl" controls/>
</div>
</div>
</template>
1.4 参数调优手册(实战必看)
⚠️新版不再支持 emotion 情感字段,情感效果由音色本身决定
| 参数 | 取值范围 | 实战建议 |
|---|---|---|
speaker |
音色库复制音色 ID | 中文选中文音色;中英文混合选双语类型音色 |
format |
mp3 / ogg_opus |
生产优先 mp3;ogg 体积小,微信 / 部分移动端浏览器无法播放 |
sample_rate |
16000 /24000 | 24000 音质更好 |
speed |
0.2‑3.0 | 1 正常;客服场景 1.1 略微加快 |
volume |
0.1‑3.0 | 建议≤1.2,避免爆音 |
pitch |
0.1‑3.0 | 不建议大幅度修改,容易音色失真 |
1.5 高频报错 & 解决方案
- 401 Unauthorized 鉴权失败
- API‑Key 复制包含多余空格;缺少请求头
X‑Api‑Resource‑Id:seed‑tts‑2.0;模型未开通,开通后等待 2‑5 分钟生效
- API‑Key 复制包含多余空格;缺少请求头
- 业务 code≠0,提示 speaker 非法
- 使用旧版音色 ID,必须从新版音色库复制 speakerId
- play () 抛出
NotAllowedError
浏览器安全策略:不能 JS 自动播放音频,必须是用户点击手势触发调用 play。
- ogg_opus 在微信 / 部分手机无声
→ 修改format:"mp3" - 内存持续上涨
→ 每次生成新音频前调用URL.revokeObjectURL()释放 Blob 临时地址
1.6 业务接入模式对比
| 模式 | 做法 | 适用场景 |
|---|---|---|
| 本地 Demo 模式 | Vite 代理,前端携带 API‑Key | 仅本地课程学习,禁止上线 |
| 生产 BFF 后端模式 | 前端调用自有后端接口;后端存放 API‑Key,请求豆包语音接口,返回 base64 音频 | 正式项目,密钥安全,无跨域问题 |
【后端简单思路】前端传文本给后端,后端组装 payload 调用火山接口,把音频 base64 直接返回前端;前端只处理 Blob 播放逻辑。
新旧版本核心差异摘要(笔记备注)
表格
| 项目 | 旧版火山 TTS | 新版豆包语音 2.0 |
|---|---|---|
| 鉴权凭证 | APP‑ID + AccessToken | 仅 API‑Key |
| 请求头 | Authorization: Bearer;${token} |
X‑Api‑Key + X‑Api‑Resource‑Id:seed‑tts‑2.0 |
| 接口地址 | /tts/api/v1/tts |
/api/v3/tts/unidirectional |
| 请求体根字段 | app/audio/request |
req_params / audio_params |
| 音色字段 | voice_type |
speaker |
| 情感参数 | emotion支持 happy/calm/sad |
无 emotion 字段,情感内置音色 |
模块二 月之暗面 Kimi Vision 视觉理解
2.1 平台开通
- https://platform.moonshot.cn/ 注册、实名认证
- API‑Key 管理 → 创建密钥
sk‑xxxx,仅显示一次,保存好 - 模型选型
moonshot‑v1‑8k‑vision‑preview:小图、简短描述;图片不大优先选,成本低moonshot‑v1‑32k‑vision‑preview:文档、截图、细节丰富图片moonshot‑v1‑128k‑vision‑preview:超长图文、多图、长文档
⚠️视觉模型消耗 token 更高,图片越大 token 消耗越高。
2.2 .env.local 配置
VITE_KIMI_API_KEY=sk-xxx
2.3 完整可实战 App.vue(增加图片大小限制、校验、异常、清空功能)
<script setup lang="ts">
import { ref, computed } from 'vue'
const resultText = ref('')
const imgBase64 = ref('')
const isValid = computed(() => !!imgBase64.value)
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 限制5MB,避免base64过大请求爆炸
/**
* 文件选择处理,限制图片大小,转base64
*/
function handleFileChange(e: Event) {
resultText.value = ''
imgBase64.value = ''
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
if (file.size > MAX_FILE_SIZE) {
alert(`图片不能超过${MAX_FILE_SIZE/1024/1024}MB,请压缩后上传`)
return
}
const allowType = ['image/jpeg','image/png','image/gif','image/webp']
if(!allowType.includes(file.type)){
alert('仅支持jpg/png/gif/webp')
return
}
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = ()=>{
imgBase64.value = reader.result as string
}
}
/**
* 调用Kimi视觉接口
*/
async function runVisionAnalyze(){
if(!imgBase64.value) return
resultText.value = '模型思考中...'
const apiKey = import.meta.env.VITE_KIMI_API_KEY
if(!apiKey){
resultText.value = '请配置.env.local的VITE_KIMI_API_KEY'
return
}
try{
const res = await fetch('https://api.moonshot.cn/v1/chat/completions',{
method:'POST',
headers:{
'Content‑Type':'application/json',
'Authorization':`Bearer ${apiKey}`
},
body:JSON.stringify({
model:'moonshot‑v1‑8k‑vision‑preview',
messages:[
{
role:'user',
content:[
{ type:'image_url', image_url:{ url: imgBase64.value }},
{ type:'text', text:'请详细描述图片内容,如果包含文字把文字提取出来。' }
]
}
],
stream:false
})
})
const json = await res.json()
if(!res.ok){
throw new Error(json.error?.message || '接口调用失败')
}
resultText.value = json.choices?.[0]?.message?.content || '无返回结果'
}catch(err:any){
console.error('vision error',err)
resultText.value = `调用失败:${err.message}\n检查:密钥、额度、图片大小、网络跨域`
}
}
function resetAll(){
imgBase64.value=''
resultText.value=''
}
</script>
<template>
<div style="padding:24px;max‑width:700px">
<h3>Kimi Vision 图片视觉理解演示</h3>
<div style="margin:12px 0;display:flex;gap:10px;align‑items:center">
<input type="file" accept=".jpg,.jpeg,.png,.gif,.webp" @change="handleFileChange"/>
<button @click="runVisionAnalyze" :disabled="!isValid">分析图片</button>
<button @click="resetAll">清空</button>
</div>
<div v‑if="imgBase64" style="margin‑bottom:12px">
<h4>图片预览</h4>
<img :src="imgBase64" style="max‑width:100%;border:1px solid #eee;border‑radius:6px"/>
</div>
<div>
<h4>模型输出结果</h4>
<pre style="white‑space:pre‑wrap;background:#f7f7f7;padding:12px;border‑radius:6px;min‑height:120px">{{ resultText }}</pre>
</div>
</div>
</template>
2.4 多模态消息体核心格式(必背)
普通文本对话 content 是字符串;视觉模型 content 是数组,支持多张图片 + 多条文本指令
{
"role":"user",
"content":[
{"type":"image_url","image_url":{"url":"base64字符串 / http公网图片地址"}},
{"type":"text","text":"你的问题指令"}
]
}
两种图片传入方式对比
- Base64(
data:image/xxx;base64,xxxx)
✅ 用户本地上传图片不需要后端存储图片;❌请求体很大,图片不能过大 - 公网 HTTP URL
✅ 请求体很小;❌图片必须公网可访问,内网图片不行
2.5 实战踩坑大全
- 图片太大 → 请求体过大报错 413
- 前端限制文件大小 5MB 以内;上传前做图片压缩
- 浏览器直接请求跨域
- 本地调试部分网络环境会跨域;生产必须后端 BFF 转发
- 密钥写前端:F12 直接看到 sk‑xxx,别人盗用扣费
- 返回 token 超限
- 切换更高版本模型
moonshot‑v1‑32k‑vision‑preview
- 切换更高版本模型
- base64 不要手动去掉
data:image/jpeg;base64,前缀,Kimi 可直接识别
2.6 扩展指令示例(直接替换 text 字段即可)
1. OCR提取:提取图片中全部文字
2. 图表分析:分析这张图表数据,总结结论
3. 错题识别:识别题目并给出解答
4. 商品识别:描述图中物品,列出特征
模块三 多模态组合实战模式(业务真正用法)
不要孤立使用 TTS、Vision;真实业务是链路串联。
链路 A:文本对话 + TTS 语音播报
用户输入文字 → 文本大模型 → 返回回答文本 → TTS接口生成音频base64 → 前端展示文字 +播放语音
链路 B:图片问答(视觉 + 文本大模型串联)
用户上传图片 → FileReader转base64 → Vision模型提取图片信息 → 将图片描述交给文本大模型 → 输出深度分析结果
链路 C:图片理解 + 语音朗读
上传图片 → Vision识别图片内容 → TTS把识别结果转语音播放
模块四:安全规范(面试高频考点)
4.1 Demo 模式 vs 生产模式对照表
表格
| 项目 | 本地 Demo(学习) | 生产业务 |
|---|---|---|
| 密钥存放 | .env.local前端环境变量 |
后端环境变量,绝不传到前端 |
| 请求路径 | 前端直调厂商接口,Vite 代理中转 | 前端只调用自己后端接口;后端代理 AI 厂商 |
| 跨域处理 | vite proxy | 后端服务转发,无浏览器跨域问题 |
| 音频 Blob | 前端处理 Base64 转播放地址 | 仍然前端做 Blob 转换;二进制 /base64 由后端返回 |
| 图片 Base64 | 浏览器读取直接传给厂商 | 浏览器读 Base64 传给自己后端,后端再调用视觉接口 |
核心红线:任何 AI 服务商的 API Key / AccessToken,绝对不能暴露到浏览器前端。F12 开发者工具可以直接拿到密钥,造成盗刷扣费。
4.2 BFF 后端极简伪代码思路(Node.js 示例,看懂逻辑)
前端 →
/api/tts(自己后端) →后端携带 token 请求火山 →返回音频 base64 给前端
//node后端伪代码
app.post('/api/tts',async (req,res)=>{
const {text}=req.body
//密钥只写后端环境变量
const token=process.env.VOLC_ACCESS_TOKEN
//组装payload请求火山TTS
//拿到返回base64音频字符串直接返回前端
res.json({audioBase64:xxx})
})
4.3 浏览器内存泄漏注意点
URL.createObjectURL()每次生成临时对象,必须调用 revokeObjectURL 释放- 音频、图片大量切换不释放,页面会越来越卡
4.4 异常统一处理规范
业务代码务必捕获这几类异常:
- 用户输入校验(空文本、空图片、文件超限)
- 配置缺失(凭证为空)
- 网络异常
- AI 业务错误:额度不足、鉴权失败、参数错误、token 超限
- 浏览器特殊限制(音频自动播放拦截)
