摘要
本文详细介绍了一个面向开发者的讯飞 AI 能力一站式调试平台的完整技术实现。该平台集成了讯飞 13 种核心 AI 能力------涵盖语音识别、语音合成、语音评测、声音复刻、音频转写以及星火大模型对话等场景,为开发者提供了统一的 Web 调试控制台、参数可视化调节界面、实时交互测试以及可运行 Python Demo 导出功能。
平台采用 FastAPI + SQLAlchemy 2.0 + MySQL 构建后端服务,React 18 + TypeScript + Vite 搭建前端调试台,通过多用户隔离、按用户凭证管理、调用日志记录与统计分析,实现了企业级的安全性与可观测性。架构上针对讯飞三类不同协议族(WebSocket v2 实时流式、HTTP v1 异步任务轮询、MaaS SSE 流式推理)分别设计了统一的服务层封装和鉴权处理,屏蔽了底层协议差异,为前端提供一致的调试体验。
本文从业务需求、架构设计、核心模块实现、多协议适配、安全与可观测性设计等维度,系统性地阐述了平台的技术选型逻辑、关键实现细节和工程最佳实践,为有相似需求的开发者提供可复用的架构参考和实现范式。
一、业务背景与需求分析
1.1 业务场景
讯飞开放平台提供了丰富的 AI 能力,包括语音识别(IAT)、语音合成(TTS)、语音评测(ISE)、声音复刻(Voice Clone)、转写服务(Fast Trans / LFASR / RTASR)、语音大模型(SLM)以及星火大模型(Spark MaaS)等。这些能力广泛应用于智能客服、教育测评、有声读物、会议记录、多语种翻译等场景。
然而,开发者在对接这些能力时普遍面临以下痛点:
-
协议复杂多样:不同能力使用不同协议------IAT/TTS 是 WebSocket v2 实时流式协议,LTS(长文本合成)是 HTTP v1 异步任务协议,Spark MaaS 是 HTTP SSE 流式协议,每种都需要独立实现鉴权、帧分片、结果聚合等逻辑。
-
参数繁多难调:每个能力有十几到几十个可调参数(如 IAT 的方言 accent、动态修正 dwa、后端点静默 vad_eos,TTS 的发音人 vcn、语速 speed、音高 pitch),文档分散,缺乏一个统一的可视化调试界面。
-
测试成本高:官方 SDK 需要下载、配置环境、编写代码,快速验证效果门槛高;实时能力(如麦克风识别、流式合成)需要手动处理音频流和 WebSocket 连接。
-
多环境凭证管理混乱:不同项目、不同团队成员使用不同的 APPID/API Key,本地调试时凭证明文散落在代码或配置文件中,存在泄露风险。
-
缺乏可观测性:调用历史无统计、成功率不可见、耗时分布不明确,排查问题只能靠日志搜索。
1.2 核心需求
基于上述痛点,我们提出了「讯飞 AI 调试平台」的建设需求。
功能需求:
-
统一调试控制台:一个 Web 页面集成所有 13 种能力的测试入口。
-
参数可视化:将每个能力的业务参数(domain、accent、vcn、temperature 等)动态渲染为表单,支持下拉框、开关、数字输入等控件,无需查阅文档。
-
实时交互测试:
-
语音识别:支持文件上传 + 实时麦克风流式识别。
-
语音合成:支持文字输入实时合成音频并播放。
-
语音评测:支持朗读文本对照评分并展示音素级诊断。
-
大模型对话:支持多轮对话、流式输出、思考模式。
-
-
Demo 导出:根据当前调试参数一键生成可运行的 Python 代码,开发者拷贝即用。
-
多用户隔离:通过邀请码注册,每个用户独立管理自己的凭证、查看自己的调用日志。
-
凭证加密存储:用户配置的 API Secret 和 MaaS API Key 加密存储,页面仅显示脱敏值。
-
调用日志与统计:自动记录每次调用的参数、结果、耗时、错误信息,聚合展示成功率、平均耗时、大模型 token 用量等指标。
非功能需求:
-
高性能:WebSocket 流式识别延迟 < 100ms,文件转写响应 < 3s。
-
高可用:后端服务 99.9% 可用性,数据库主从复制。
-
安全性:JWT 认证、API Secret 加密、HTTPS 传输、凭证隔离。
-
可扩展:新增能力只需添加 service + API router,无需修改核心框架。
二、技术选型与架构设计
2.1 技术栈选型
后端:
-
FastAPI:Python 异步 Web 框架,原生支持 WebSocket、SSE、类型提示、自动文档生成,开发效率高。
-
SQLAlchemy 2.0 :ORM 框架,支持
Mapped类型注解、异步 IO,与 FastAPI 深度集成。 -
MySQL 8.0:关系型数据库,存储用户、凭证、调用日志、配置等结构化数据。
-
Pydantic:数据校验与序列化,与 FastAPI 无缝集成。
-
Cryptography:Fernet 对称加密,保护用户凭证。
-
Passlib + python-jose:密码哈希(bcrypt)与 JWT 签发。
-
websockets / httpx:与讯飞 API 通信的底层客户端。
-
pydub / ffmpeg:音频格式转换(WAV/MP3 → PCM 16k/16bit)。
前端:
-
React 18:组件化 UI 框架,支持 Hooks、并发模式。
-
TypeScript:静态类型检查,提升代码可维护性。
-
Vite:快速的前端构建工具,HMR 体验优于 Webpack。
-
TailwindCSS:原子化 CSS 框架,快速实现响应式布局。
-
React Router:前端路由管理。
-
Axios:HTTP 请求客户端,拦截器统一处理 JWT。
-
Zustand:轻量状态管理库,管理登录态与用户信息。
-
ECharts:数据可视化图表(统计页)。
部署:
-
Nginx:反向代理 HTTPS(8443)到后端(8010)。
-
systemd:守护后端 FastAPI 进程。
-
CentOS 7:生产服务器系统。
-
远程 MySQL:独立数据库服务器(主从复制)。
2.2 整体架构
text
┌─────────────────────────────────────────────────────────────┐
│ 浏览器 (React 18) │
│ ┌─────────┬─────────┬─────────┬─────────┬─────────┐ │
│ │ 登录注册 │ 能力列表 │ 调试台 │ 控制台 │ 设置页 │ │
│ └─────────┴─────────┴─────────┴─────────┴─────────┘ │
│ ↓ HTTP/WS (JWT) ↓ HTTP (JSON) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ FastAPI 后端 (Python 3.9+) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ API Layer (Routers) │ │
│ │ /auth /abilities /iat /tts /ise /spark etc. │ │
│ └──────────────────────────────────────────────────────┘ │
│ ↓ Depends(get_current_user) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Service Layer (业务逻辑) │ │
│ │ xfyun_iat xfyun_tts xfyun_maas credentials ... │ │
│ └──────────────────────────────────────────────────────┘ │
│ ↓ resolve_voice() / resolve_maas() │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Models & Database (SQLAlchemy 2.0) │ │
│ │ User UserCredential CallLog InviteCode ... │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ MySQL 8.0 │
│ users user_credentials call_logs invite_codes ... │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 讯飞开放平台 APIs │
│ WebSocket v2 (IAT/TTS/ISE) HTTP v1 (LTS) SSE (MaaS) │
└─────────────────────────────────────────────────────────────┘
分层职责:
-
API Layer:接收 HTTP/WebSocket 请求,校验 JWT,解析参数,调用 Service,记录日志,返回结果。
-
Service Layer:封装与讯飞 API 的交互逻辑(鉴权 URL 构造、WebSocket 握手、帧分片、结果聚合),屏蔽协议差异。
-
Models & Database:定义数据模型(User、CallLog、UserCredential),持久化业务数据。
-
Core:config(配置管理)、security(JWT/密码哈希)、crypto(凭证加密)、db(数据库连接池)。
三、核心模块实现详解
3.1 多用户认证与凭证隔离
3.1.1 用户注册与邀请码机制
平台采用邀请码注册制,控制用户准入:
python
# models/user.py
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(32), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(128))
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = ...
# models/invite_code.py
class InviteCode(Base):
__tablename__ = "invite_codes"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
code: Mapped[str] = mapped_column(String(32), unique=True, index=True)
used: Mapped[bool] = mapped_column(Boolean, default=False)
used_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
created_by: Mapped[int] = mapped_column(Integer) # 管理员 user_id
注册流程:
-
用户提交
username、password、invite_code。 -
后端校验邀请码是否存在且未使用(
used=False)。 -
哈希密码(bcrypt),创建 User 记录。
-
标记邀请码为已使用(
used=True,used_by=user.id)。
管理员可在控制台批量生成邀请码,限制平台用户规模。
3.1.2 JWT 认证与依赖注入
登录成功后,后端签发 JWT(有效期 1440 分钟):
python
# core/security.py
def create_access_token(subject: str, expires_minutes: Optional[int] = None) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=expires_minutes or settings.access_token_expire_minutes
)
payload = {"sub": subject, "exp": expire}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
# api/auth.py
@router.post("/login")
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == form.username).first()
if not user or not verify_password(form.password, user.hashed_password):
raise HTTPException(status_code=401, detail="用户名或密码错误")
token = create_access_token(user.username)
return {"access_token": token, "token_type": "bearer"}
前端在 Axios 拦截器中自动附加 Authorization: Bearer <token>:
typescript
// api/client.ts
api.interceptors.request.use((config) => {
const token = getToken();
if (token) {
config.headers = config.headers ?? {};
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
后端通过 FastAPI Depends 机制解析当前用户:
python
# deps.py
def get_current_user(
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
) -> User:
username = decode_token(token)
if not username:
raise HTTPException(status_code=401, detail="无效或过期的凭证")
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=401, detail="用户不存在")
return user
所有需要登录的接口只需声明 current: User = Depends(get_current_user),即可拿到当前用户对象。
3.1.3 按用户凭证管理与加密存储
平台支持每个用户独立配置自己的讯飞凭证,分为两类:
-
语音类凭证 (kind=voice):包含
appid、api_key、api_secret,用于 IAT/TTS/ISE/SLM 等语音能力。 -
大模型凭证 (kind=maas):包含
maas_base、maas_api_key,用于 Spark MaaS 推理服务。
每个用户每类可配置多个凭证,标记 is_active=True 的为当前使用。敏感字段(api_secret、maas_api_key)使用 Fernet 对称加密存储:
python
# models/user_credential.py
class UserCredential(Base):
__tablename__ = "user_credentials"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, index=True)
kind: Mapped[str] = mapped_column(String(16), index=True) # voice / maas
name: Mapped[str] = mapped_column(String(64)) # 用户起的名字
is_active: Mapped[bool] = mapped_column(Boolean, default=False)
# 语音类字段
appid: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
api_key: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
api_secret_enc: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # 加密
# MaaS 字段
maas_base: Mapped[Optional[str]] = mapped_column(String(256), nullable=True)
maas_api_key_enc: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # 加密
加密实现 (基于 settings.secret_key 派生 Fernet 密钥):
python
# core/crypto.py
def _fernet() -> Fernet:
digest = hashlib.sha256(settings.secret_key.encode("utf-8")).digest()
key = base64.urlsafe_b64encode(digest)
return Fernet(key)
def encrypt(plaintext: str) -> str:
if plaintext is None:
return ""
return _fernet().encrypt(plaintext.encode("utf-8")).decode("utf-8")
def decrypt(ciphertext: str) -> str:
if not ciphertext:
return ""
try:
return _fernet().decrypt(ciphertext.encode("utf-8")).decode("utf-8")
except (InvalidToken, ValueError):
return ""
凭证解析服务(按用户查询当前启用的凭证):
python
# services/credentials.py
@dataclass
class VoiceCreds:
appid: str
api_key: str
api_secret: str
@property
def configured(self) -> bool:
return bool(self.appid and self.api_key and self.api_secret)
def resolve_voice(db: Session, user: Union[User, int, None] = None) -> VoiceCreds:
cred = get_active(db, user, "voice")
if cred and cred.appid and cred.api_key and cred.api_secret_enc:
return VoiceCreds(
appid=cred.appid,
api_key=cred.api_key,
api_secret=decrypt(cred.api_secret_enc),
)
return VoiceCreds(appid="", api_key="", api_secret="")
每次调用讯飞 API 前,先 resolve_voice(db, current) 拿到当前用户的解密凭证,未配置时拒绝调用并提示去控制台设置。
3.2 讯飞三类协议适配与服务层封装
讯飞开放平台的能力分为三类协议:
-
WebSocket v2 实时流式协议:IAT、TTS、ISE、Suntone 等,双向帧交互,需实时收发。
-
HTTP v1 异步任务协议:LTS(长文本合成)、Fast Trans、LFASR LLM、RTASR LLM,先创建任务,后轮询结果。
-
HTTP SSE 流式推理 :Spark MaaS,OpenAI-compatible 接口,
data: {...}流式响应。
3.2.1 WebSocket v2 协议:IAT 语音识别实现
鉴权 URL 构造(HMAC-SHA256 签名):
python
# services/xfyun_iat.py
def build_auth_url(api_key: str, api_secret: str, host: str, path: str) -> str:
now = datetime.now()
date = format_date_time(mktime(now.timetuple())) # RFC1123 格式
signature_origin = f"host: {host}\ndate: {date}\nGET {path} HTTP/1.1"
signature_sha = hmac.new(
api_secret.encode("utf-8"),
signature_origin.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
signature = base64.b64encode(signature_sha).decode("utf-8")
authorization_origin = (
f'api_key="{api_key}", algorithm="hmac-sha256", '
f'headers="host date request-line", signature="{signature}"'
)
authorization = base64.b64encode(authorization_origin.encode("utf-8")).decode("utf-8")
params = {"authorization": authorization, "date": date, "host": host}
return f"wss://{host}{path}?{urlencode(params)}"
文件转写实现(一次性发送所有帧):
python
async def transcribe_pcm(
audio: bytes,
cfg: IATConfig,
appid: str, api_key: str, api_secret: str,
frame_ms: int = 40,
) -> Dict[str, Any]:
url = build_auth_url(api_key, api_secret, settings.xfyun_iat_host, settings.xfyun_iat_path)
# 音频分帧:16k PCM,每帧 40ms = 16000 * 2 * 0.04 = 1280 字节
frame_bytes = int(16000 * 2 * frame_ms / 1000)
frames: List[bytes] = [
audio[i : i + frame_bytes] for i in range(0, len(audio), frame_bytes)
] or [b""]
sid: Optional[str] = None
pgs_map: Dict[int, str] = {} # 动态修正(wpgs)时按 sn 定位
segments: List[str] = []
async with websockets.connect(url, max_size=None) as ws:
# 1. 发送音频帧
for idx, frame in enumerate(frames):
status = STATUS_FIRST_FRAME if idx == 0 else (
STATUS_LAST_FRAME if idx == len(frames) - 1 else STATUS_CONTINUE_FRAME
)
payload = {
"common": {"app_id": appid} if idx == 0 else {},
"business": cfg.business_args() if idx == 0 else {},
"data": {
"status": status,
"format": cfg.format,
"encoding": cfg.encoding,
"audio": base64.b64encode(frame).decode("utf-8"),
},
}
await ws.send(json.dumps(payload))
# 2. 接收结果
async for message in ws:
resp = json.loads(message)
sid = resp.get("sid", sid)
code = resp.get("code")
if code != 0:
return {"sid": sid, "text": "", "error": resp.get("message"), "code": code}
result = resp.get("data", {}).get("result", {})
piece = _extract_text(result)
# 处理动态修正 wpgs(replace / append)
pgs = result.get("pgs")
sn = result.get("sn")
if pgs == "rpl":
rg = result.get("rg", [])
if rg:
for k in list(pgs_map.keys()):
if rg[0] <= k <= rg[-1]:
pgs_map.pop(k, None)
pgs_map[sn] = piece
elif pgs == "apd":
pgs_map[sn] = piece
else:
if piece:
segments.append(piece)
if resp.get("data", {}).get("status") == 2:
break
final_text = "".join(pgs_map[k] for k in sorted(pgs_map.keys())) if pgs_map else "".join(segments)
return {"sid": sid, "text": final_text, "segments": segments, "code": 0}
实时麦克风流式识别实现(边收边发):
python
async def transcribe_stream(
frame_source: AsyncIterator[bytes],
cfg: IATConfig,
appid: str, api_key: str, api_secret: str,
on_partial: Callable[[Dict[str, Any]], Any],
frame_bytes: int = 1280,
) -> Dict[str, Any]:
url = build_auth_url(api_key, api_secret, settings.xfyun_iat_host, settings.xfyun_iat_path)
async with websockets.connect(url, max_size=None) as ws:
async def sender():
buf = bytearray()
async for chunk in frame_source:
buf.extend(chunk)
while len(buf) >= frame_bytes:
frame = bytes(buf[:frame_bytes])
del buf[:frame_bytes]
payload = {...} # 首帧带 business,后续帧仅 data
await ws.send(json.dumps(payload))
await asyncio.sleep(0.04) # 40ms 间隔
# 音频结束:发送残余 + 末帧标识
tail = bytes(buf)
payload = {"data": {"status": STATUS_LAST_FRAME, "audio": base64.b64encode(tail).decode()}}
await ws.send(json.dumps(payload))
async def receiver():
async for message in ws:
resp = json.loads(message)
# 解析 wpgs / 普通识别结果,实时回调 on_partial({text, is_final, sid})
piece = _extract_text(resp.get("data", {}).get("result", {}))
is_final = resp.get("data", {}).get("status") == 2
await on_partial({"text": current_text(), "is_final": is_final, "sid": sid})
if is_final:
return
send_task = asyncio.create_task(sender())
await receiver()
send_task.cancel()
return {"sid": sid, "text": current_text(), "code": 0}
前端麦克风 → 后端 WS 代理 → 讯飞 WS 的流式链路:
python
# api/iat.py
@router.websocket("/stream")
async def iat_stream(websocket: WebSocket):
await websocket.accept()
token = websocket.query_params.get("token", "")
username = decode_token(token)
if not username:
await websocket.send_json({"error": "未认证"})
await websocket.close()
return
# 用队列桥接浏览器音频帧 → 讯飞 WS
frame_q: asyncio.Queue = asyncio.Queue()
async def browser_reader():
while True:
msg = await websocket.receive()
if msg.get("bytes") is not None:
await frame_q.put(msg["bytes"])
elif msg.get("text") == "__END__":
await frame_q.put(SENTINEL)
break
async def frame_source():
while True:
item = await frame_q.get()
if item is SENTINEL:
return
yield item
async def on_partial(p: dict):
await websocket.send_json({
"type": "partial",
"text": p.get("text"),
"is_final": p.get("is_final"),
})
reader_task = asyncio.create_task(browser_reader())
res = await transcribe_stream(frame_source(), cfg, appid, api_key, api_secret, on_partial)
await websocket.send_json({"type": "final", "text": res["text"], "sid": res["sid"]})
reader_task.cancel()
前端通过 navigator.mediaDevices.getUserMedia() 获取麦克风流,用 AudioContext 采样为 16k PCM,通过 WebSocket 实时推送给后端;后端实时推送识别结果回浏览器,实现了端到端延迟 < 100ms 的流式识别体验。
3.2.2 HTTP v1 异步任务协议:LTS 长文本合成实现
LTS 不支持实时流式,采用异步任务模式 :先创建任务得到 task_id,再轮询查询直到状态变为 5(完成),拿到音频下载链接。
鉴权 URL 构造(与 WebSocket 类似,但用 POST):
python
# services/xfyun_lts.py
def build_auth_url(api_key: str, api_secret: str, host: str, path: str) -> str:
date = format_date_time(mktime(datetime.now().timetuple()))
signature_origin = f"host: {host}\ndate: {date}\nPOST {path} HTTP/1.1"
signature_sha = hmac.new(api_secret.encode(), signature_origin.encode(), hashlib.sha256).digest()
signature = base64.b64encode(signature_sha).decode()
authorization = base64.b64encode(
f'api_key="{api_key}", algorithm="hmac-sha256", '
f'headers="host date request-line", signature="{signature}"'
.encode()
).decode()
params = {"host": host, "date": date, "authorization": authorization}
return f"http://{host}{path}?{urlencode(params)}"
创建任务:
python
async def create_task(
text: str, cfg: LTSConfig,
appid: str, api_key: str, api_secret: str,
) -> Dict[str, Any]:
url = build_auth_url(api_key, api_secret, settings.xfyun_lts_host, settings.xfyun_lts_create_path)
payload = {
"header": {"app_id": appid},
"parameter": {"dts": cfg.parameter_args()},
"payload": {
"text": {
"encoding": "utf8",
"compress": "raw",
"format": "plain",
"text": base64.b64encode(text.encode()).decode(),
}
},
}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(url, json=payload)
result = resp.json()
header = result.get("header", {})
return {
"code": header.get("code", -1),
"message": header.get("message", ""),
"task_id": header.get("task_id"),
}
查询任务(轮询直到完成):
python
async def query_task(
task_id: str,
appid: str, api_key: str, api_secret: str,
) -> Dict[str, Any]:
url = build_auth_url(api_key, api_secret, settings.xfyun_lts_host, settings.xfyun_lts_query_path)
payload = {"header": {"app_id": appid, "task_id": task_id}}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(url, json=payload)
result = resp.json()
header = result.get("header", {})
payload_data = result.get("payload", {})
# 音频下载链接(base64 编码)
audio_url = None
if payload_data.get("audio"):
audio_b64 = payload_data["audio"].get("audio")
if audio_b64:
audio_url = base64.b64decode(audio_b64).decode()
return {
"code": header.get("code", -1),
"task_status": header.get("task_status"), # 1创建成功 3处理中 5完成
"audio_url": audio_url,
}
API 接口(轮询 + 超时控制):
python
# api/lts.py
@router.post("/synthesize")
async def synthesize(payload: dict, current: User = Depends(get_current_user), db: Session = Depends(get_db)):
creds = resolve_voice(db, current)
if not creds.configured:
raise HTTPException(status_code=503, detail="讯飞凭证未配置")
text = payload.get("text", "")
cfg = _cfg_from_dict(payload)
# 1. 创建任务
res = await create_task(text, cfg, creds.appid, creds.api_key, creds.api_secret)
if res["code"] != 0:
raise HTTPException(status_code=502, detail=res["message"])
task_id = res["task_id"]
# 2. 轮询查询(最多 60s,每 2s 一次)
for _ in range(30):
await asyncio.sleep(2)
res = await query_task(task_id, creds.appid, creds.api_key, creds.api_secret)
if res["code"] != 0:
raise HTTPException(status_code=502, detail=res["message"])
status = res["task_status"]
if status == 5: # 完成
return {"task_id": task_id, "audio_url": res["audio_url"]}
elif status in [2, 4]: # 失败
raise HTTPException(status_code=502, detail="任务失败")
raise HTTPException(status_code=504, detail="任务超时")
3.2.3 HTTP SSE 流式推理:Spark MaaS 实现
Spark MaaS 采用 OpenAI-compatible 接口 ,支持非流式 /chat/completions 和流式 SSE。
非流式对话补全:
python
# services/xfyun_maas.py
async def chat_completion(
messages: List[Dict[str, str]],
cfg: MaaSConfig,
api_key: str,
base: str = "",
) -> Dict[str, Any]:
base = base or settings.xfyun_maas_base
body = cfg.request_body(messages, stream=False)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(f"{base}/chat/completions", headers=headers, json=body)
resp.raise_for_status()
return resp.json()
流式对话补全(SSE 解析):
python
async def chat_completion_stream(
messages: List[Dict[str, str]],
cfg: MaaSConfig,
api_key: str,
base: str = "",
) -> AsyncIterator[Dict[str, Any]]:
base = base or settings.xfyun_maas_base
body = cfg.request_body(messages, stream=True)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=180.0) as client:
async with client.stream("POST", f"{base}/chat/completions", headers=headers, json=body) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
line = line.strip()
if not line or line == "data: [DONE]":
continue
if line.startswith("data: "):
try:
chunk = json.loads(line[6:])
yield chunk
except json.JSONDecodeError:
pass
API 接口(流式 SSE 回传浏览器):
python
# api/maas.py
@router.post("/chat")
async def chat(payload: dict, current: User = Depends(get_current_user), db: Session = Depends(get_db)):
creds = resolve_maas(db, current)
if not creds.configured:
raise HTTPException(status_code=503, detail="MaaS 凭证未配置")
messages = payload.get("messages", [])
stream = payload.get("stream", False)
cfg = _cfg_from_dict(payload)
if not stream:
res = await chat_completion(messages, cfg, creds.api_key, creds.base)
return res
else:
# 返回 SSE 流式响应
async def generate():
try:
async for chunk in chat_completion_stream(messages, cfg, creds.api_key, creds.base):
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
except Exception as e:
yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
前端通过 EventSource 或 fetch 监听 SSE 流式响应,逐 chunk 渲染对话内容。
3.3 调用日志与统计分析
每次调用能力(无论成功或失败),后端都记录一条 CallLog:
python
# models/call_log.py
class CallLog(Base):
__tablename__ = "call_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), index=True)
ability: Mapped[str] = mapped_column(String(32), index=True) # iat/tts/spark
sid: Mapped[str] = mapped_column(String(128), index=True, nullable=True)
status: Mapped[str] = mapped_column(String(16)) # success/error
duration_ms: Mapped[float] = mapped_column(Float, default=0.0)
params: Mapped[str] = mapped_column(Text, nullable=True) # 请求参数快照 JSON
result: Mapped[str] = mapped_column(Text, nullable=True) # 结果摘要
error: Mapped[str] = mapped_column(Text, nullable=True)
# 大模型 token 用量(仅 spark 有值)
model: Mapped[str] = mapped_column(String(64), nullable=True)
prompt_tokens: Mapped[int] = mapped_column(Integer, nullable=True)
completion_tokens: Mapped[int] = mapped_column(Integer, nullable=True)
total_tokens: Mapped[int] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_cst, index=True)
记录示例(IAT 文件转写):
python
# api/iat.py
def _log_call(db: Session, user_id: int, status_: str, sid, params, result, error, dur):
log = CallLog(
user_id=user_id,
ability="iat",
sid=sid,
status=status_,
duration_ms=dur,
params=json.dumps(params, ensure_ascii=False)[:2000],
result=(result or "")[:2000],
error=(error or "")[:2000] if error else None,
)
db.add(log)
db.commit()
@router.post("/transcribe")
async def transcribe_file(...):
t0 = time.time()
res = await transcribe_pcm(...)
dur = (time.time() - t0) * 1000
if res.get("error"):
_log_call(db, current.id, "error", res.get("sid"), param_dict, None, res["error"], dur)
raise HTTPException(status_code=502, detail=res["error"])
_log_call(db, current.id, "success", res.get("sid"), param_dict, res.get("text"), None, dur)
return {...}
统计接口(按用户聚合):
python
# api/console.py
@router.get("/stats", response_model=ConsoleStats)
def stats(db: Session = Depends(get_db), current: User = Depends(get_current_user)):
base = db.query(CallLog).filter(CallLog.user_id == current.id)
total = base.count()
success = base.filter(CallLog.status == "success").count()
error = base.filter(CallLog.status == "error").count()
# 按能力分组
by_ability_rows = (
db.query(CallLog.ability, func.count(CallLog.id))
.filter(CallLog.user_id == current.id)
.group_by(CallLog.ability)
.all()
)
by_ability = {a: c for a, c in by_ability_rows}
# 平均耗时
avg_dur = (
db.query(func.avg(CallLog.duration_ms))
.filter(CallLog.user_id == current.id, CallLog.status == "success")
.scalar() or 0.0
)
# 大模型 token 用量
llm_tokens = (
db.query(
func.coalesce(func.sum(CallLog.prompt_tokens), 0),
func.coalesce(func.sum(CallLog.completion_tokens), 0),
func.coalesce(func.sum(CallLog.total_tokens), 0),
)
.filter(CallLog.user_id == current.id, CallLog.ability == "spark")
.first()
)
llm_prompt, llm_completion, llm_total = (int(llm_tokens[0]), int(llm_tokens[1]), int(llm_tokens[2]))
return ConsoleStats(
total_calls=total,
success_calls=success,
error_calls=error,
by_ability=by_ability,
avg_duration_ms=round(float(avg_dur), 1),
llm_prompt_tokens=llm_prompt,
llm_completion_tokens=llm_completion,
llm_total_tokens=llm_total,
)
前端在控制台页通过 ECharts 渲染成功率饼图、按能力分布柱状图、token 用量趋势图等,帮助用户直观了解使用情况。
3.4 参数可视化与 Demo 导出
3.4.1 参数元数据动态渲染
每个能力的参数通过 /params 接口返回元数据,前端根据 type 动态渲染表单控件:
python
# api/iat.py
@router.get("/params")
def iat_params(current: User = Depends(get_optional_user), db: Session = Depends(get_db)):
return {
"configured": resolve_voice(db, current).configured,
"fields": [
{
"key": "language",
"label": "语种 language",
"type": "select",
"default": "zh_cn",
"hint": "识别语种",
"allow_custom": True,
"options": [
{"value": "zh_cn", "label": "中文(含简单英文)"},
{"value": "en_us", "label": "英文"},
],
},
{
"key": "vad_eos",
"label": "后端点静默 eos(ms)",
"type": "number",
"default": 3000,
"min": 0,
"max": 10000,
"hint": "静默多久判定结束",
},
{
"key": "ptt",
"label": "标点符号 ptt",
"type": "switch",
"default": 1,
"hint": "1=开启(默认) 0=关闭",
},
# ... 更多参数
],
}
前端根据 type 映射到组件:
-
select→<select>下拉框,allow_custom=true时支持输入自定义选项。 -
number→<input type="number">数字输入框,带min/max约束。 -
switch→ Toggle 开关。 -
text→<input type="text">文本框。
支持用户自定义透传参数 :用户可在表单底部添加 key=value 键值对(如 lfasr_type=0),后端通过 __extra__ 字段收集并透传给讯飞 API:
python
def _collect_extra(d: dict) -> dict:
extra: dict = {}
raw_extra = d.get("__extra__")
if isinstance(raw_extra, dict):
for k, v in raw_extra.items():
if k and k not in _KNOWN_KEYS:
extra[k] = v
for k, v in d.items():
if k not in _KNOWN_KEYS and k not in extra:
extra[k] = v
return extra
def _cfg_from_dict(d: dict) -> IATConfig:
return IATConfig(
language=d.get("language", "zh_cn"),
# ... 已知字段
extra=_collect_extra(d), # 自定义参数打包进 extra
)
3.4.2 Python Demo 导出
用户调参后,点击「导出 Demo」按钮,后端根据当前参数生成可运行的 Python 代码:
python
# api/iat.py
@router.post("/export-demo", response_class=PlainTextResponse)
def export_demo(payload: dict, current: User = Depends(get_current_user)):
cfg = _cfg_from_dict(payload or {})
code = generate_iat_demo(
business=cfg.business_args(),
host=settings.xfyun_iat_host,
path=settings.xfyun_iat_path,
)
return code
# services/demo_export.py
def generate_iat_demo(business: Dict[str, Any], host: str, path: str) -> str:
return f'''#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""讯飞语音识别(IAT) Python Demo
使用前安装依赖: pip install websockets
"""
import asyncio
import base64
import hashlib
import hmac
import json
from datetime import datetime
from time import mktime
from wsgiref.handlers import format_date_time
import websockets
# ========== 配置项(替换为你的凭证) ==========
APPID = "your_appid"
API_KEY = "your_api_key"
API_SECRET = "your_api_secret"
HOST = "{host}"
PATH = "{path}"
# ========== 业务参数(根据调试台当前配置生成) ==========
BUSINESS_ARGS = {json.dumps(business, ensure_ascii=False, indent=4)}
# ========== 鉴权 URL 构造 ==========
def build_auth_url():
now = datetime.now()
date = format_date_time(mktime(now.timetuple()))
signature_origin = f"host: {{HOST}}\\ndate: {{date}}\\nGET {{PATH}} HTTP/1.1"
signature_sha = hmac.new(
API_SECRET.encode("utf-8"),
signature_origin.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
signature = base64.b64encode(signature_sha).decode("utf-8")
authorization_origin = (
f'api_key="{{API_KEY}}", algorithm="hmac-sha256", '
f'headers="host date request-line", signature="{{signature}}"'
)
authorization = base64.b64encode(authorization_origin.encode("utf-8")).decode("utf-8")
from urllib.parse import urlencode
params = {{"authorization": authorization, "date": date, "host": HOST}}
return f"wss://{{HOST}}{{PATH}}?{{urlencode(params)}}"
# ========== 识别函数 ==========
async def transcribe_pcm(audio_bytes: bytes):
url = build_auth_url()
frame_size = 1280 # 40ms 一帧
frames = [audio_bytes[i:i+frame_size] for i in range(0, len(audio_bytes), frame_size)] or [b""]
async with websockets.connect(url, max_size=None) as ws:
for idx, frame in enumerate(frames):
status = 0 if idx == 0 else (2 if idx == len(frames) - 1 else 1)
payload = {{
"common": {{"app_id": APPID}} if idx == 0 else {{}},
"business": BUSINESS_ARGS if idx == 0 else {{}},
"data": {{
"status": status,
"format": "audio/L16;rate=16000",
"encoding": "raw",
"audio": base64.b64encode(frame).decode("utf-8"),
}},
}}
await ws.send(json.dumps(payload))
text = ""
async for message in ws:
resp = json.loads(message)
if resp.get("code") != 0:
print(f"错误: {{resp.get('message')}}")
break
result = resp.get("data", {{}}).get("result", {{}})
for item in result.get("ws", []):
for w in item.get("cw", []):
text += w.get("w", "")
if resp.get("data", {{}}).get("status") == 2:
break
return text
# ========== 主函数 ==========
if __name__ == "__main__":
# 读取音频文件(16k PCM)
with open("test.pcm", "rb") as f:
audio = f.read()
result = asyncio.run(transcribe_pcm(audio))
print(f"识别结果: {{result}}")
'''
用户拷贝代码、替换凭证、准备音频文件,即可本地运行验证。
四、前端调试台架构
4.1 路由与页面结构
前端采用 React Router v6 管理路由:
typescript
// App.tsx
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/abilities" element={<Abilities />} />
{/* 调试台:左侧栏布局 */}
<Route path="/debug" element={<DebugLayout />}>
<Route index element={<Navigate to="/debug/iat" replace />} />
<Route path="iat" element={<IatConsole />} />
<Route path="slm-zh" element={<SlmConsole kind="zh" />} />
<Route path="tts" element={<TTSConsole />} />
<Route path="spark" element={<SparkConsole />} />
{/* ... 其他能力 */}
</Route>
<Route path="/console" element={<ProtectedRoute><ConsolePage /></ProtectedRoute>} />
<Route path="/console/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
</Routes>
DebugLayout :左侧能力导航栏 + 右侧 <Outlet />,每个能力独立页面。
4.2 调试台核心组件
以 IatConsole(语音识别调试台) 为例:
typescript
// pages/debug/IatConsole.tsx
export default function IatConsole() {
const [params, setParams] = useState<Record<string, any>>({});
const [metadata, setMetadata] = useState<ParamMetadata | null>(null);
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
api.get("/iat/params").then((r) => {
setMetadata(r.data);
// 用默认值填充 params
const defaults: Record<string, any> = {};
r.data.fields.forEach((f: any) => { defaults[f.key] = f.default; });
setParams(defaults);
});
}, []);
// 文件上传转写
async function transcribeFile(file: File) {
setLoading(true);
const formData = new FormData();
formData.append("file", file);
formData.append("params", JSON.stringify(params));
try {
const res = await api.post("/iat/transcribe", formData);
setResult(res.data);
} catch (e) {
alert(apiErr(e));
} finally {
setLoading(false);
}
}
// 实时麦克风识别
async function startMic() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const audioContext = new AudioContext({ sampleRate: 16000 });
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);
const ws = new WebSocket(`wss://.../api/iat/stream?token=${token}¶ms=${JSON.stringify(params)}`);
processor.onaudioprocess = (e) => {
const buffer = e.inputBuffer.getChannelData(0);
const pcm = new Int16Array(buffer.length);
for (let i = 0; i < buffer.length; i++) {
pcm[i] = Math.max(-32768, Math.min(32767, buffer[i] * 32768));
}
ws.send(pcm.buffer);
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "partial") {
setResult((prev) => ({ ...prev, text: msg.text }));
}
};
source.connect(processor);
processor.connect(audioContext.destination);
}
return (
<div>
<h1>语音识别(IAT)</h1>
{/* 参数表单 */}
<ParamForm fields={metadata?.fields || []} values={params} onChange={setParams} />
{/* 文件上传 */}
<FileUpload onUpload={transcribeFile} loading={loading} />
{/* 麦克风 */}
<button onClick={startMic}>🎤 实时识别</button>
{/* 结果展示 */}
{result && <ResultDisplay result={result} />}
{/* Demo 导出 */}
<button onClick={() => exportDemo(params)}>导出 Python Demo</button>
</div>
);
}
ParamForm :根据 fields 元数据动态渲染表单:
typescript
function ParamForm({ fields, values, onChange }: Props) {
return (
<div className="grid grid-cols-2 gap-4">
{fields.map((field) => (
<div key={field.key}>
<label>{field.label}</label>
{field.type === "select" && (
<select value={values[field.key]} onChange={(e) => onChange({ ...values, [field.key]: e.target.value })}>
{field.options?.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
)}
{field.type === "number" && (
<input type="number" min={field.min} max={field.max} value={values[field.key]}
onChange={(e) => onChange({ ...values, [field.key]: Number(e.target.value) })} />
)}
{field.type === "switch" && (
<label>
<input type="checkbox" checked={!!values[field.key]}
onChange={(e) => onChange({ ...values, [field.key]: e.target.checked ? 1 : 0 })} />
开启
</label>
)}
{field.hint && <span className="hint">{field.hint}</span>}
</div>
))}
</div>
);
}
音频格式自动转换:前端支持上传 WAV/MP3/M4A,后端通过 pydub + ffmpeg 统一转为 PCM 16k:
python
# services/audio.py
import io
from pydub import AudioSegment
def to_pcm16k(raw: bytes, filename: str) -> bytes:
"""将任意音频格式转为 PCM 16k/16bit/mono。"""
try:
fmt = filename.split(".")[-1].lower()
audio = AudioSegment.from_file(io.BytesIO(raw), format=fmt)
except Exception:
audio = AudioSegment.from_file(io.BytesIO(raw)) # 自动检测
audio = audio.set_frame_rate(16000).set_channels(1).set_sample_width(2)
return audio.raw_data
4.3 实时交互优化
WebSocket 断线重连:
typescript
function createReconnectableWS(url: string, onMessage: (data: any) => void) {
let ws: WebSocket | null = null;
let reconnectTimer: NodeJS.Timeout | null = null;
function connect() {
ws = new WebSocket(url);
ws.onmessage = (e) => onMessage(JSON.parse(e.data));
ws.onerror = () => {
ws?.close();
};
ws.onclose = () => {
reconnectTimer = setTimeout(connect, 3000); // 3s 后重连
};
}
connect();
return {
send: (data: any) => ws?.send(data),
close: () => {
if (reconnectTimer) clearTimeout(reconnectTimer);
ws?.close();
},
};
}
SSE 流式渲染(大模型对话):
typescript
async function streamChat(messages: Message[]) {
const res = await fetch("/api/maas/chat", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ messages, stream: true, model: "xopglm53" }),
});
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) {
if (line.startsWith("data: ")) {
const chunk = JSON.parse(line.slice(6));
const delta = chunk.choices?.[0]?.delta?.content || "";
setMessages((prev) => {
const last = prev[prev.length - 1];
return [...prev.slice(0, -1), { ...last, content: last.content + delta }];
});
}
}
}
}
五、安全与可观测性设计
5.1 安全措施
-
JWT 认证 + HTTPS 传输:所有 API 需携带 JWT,生产环境强制 HTTPS。
-
凭证加密存储 :
api_secret和maas_api_key用 Fernet 加密,密钥派生自SECRET_KEY。 -
凭证脱敏展示 :前端仅展示
ak-3A****X8IR,不显示完整密钥。 -
SQL 注入防护:使用 SQLAlchemy ORM,参数化查询。
-
XSS 防护:React 自动转义 HTML,后端返回的 JSON 不含可执行脚本。
-
CORS 白名单 :
cors_origins配置仅允许前端域名跨域。 -
邀请码准入:限制注册,防止恶意用户滥用。
5.2 可观测性
-
调用日志 :每次调用记录
CallLog(参数、结果、耗时、错误)。 -
统计看板:控制台展示总调用、成功率、平均耗时、按能力分布、大模型 token 用量。
-
错误监控 :失败调用记录
error字段,前端可按状态筛选日志。 -
实时监控 (未来扩展):接入 Prometheus + Grafana,暴露
/metrics端点。
六、部署与运维
6.1 部署架构
text
┌─────────────────────────────────────────────────────────────┐
│ Internet │
└─────────────────────────────────────────────────────────────┘
↓ HTTPS (8443)
┌─────────────────────────────────────────────────────────────┐
│ Nginx (反向代理) │
│ location /api { proxy_pass http://127.0.0.1:8010; } │
│ location / { root /opt/xfyun-debug/frontend-dist; } │
└─────────────────────────────────────────────────────────────┘
↓ HTTP (8010)
┌─────────────────────────────────────────────────────────────┐
│ FastAPI Backend (systemd 守护) │
│ uvicorn app.main:app --host 127.0.0.1 --port 8010 │
└─────────────────────────────────────────────────────────────┘
↓ TCP 3306
┌─────────────────────────────────────────────────────────────┐
│ MySQL 8.0 (远程) │
│ 主从复制 + 定时备份 │
└─────────────────────────────────────────────────────────────┘
6.2 Systemd 服务配置
ini
[Unit]
Description=讯飞AI调试平台后端
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/xfyun-debug/backend
ExecStart=/opt/xfyun-debug/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8010
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
6.3 数据库初始化
启动时自动建表:
python
# core/db.py
def init_db():
from app.models import Base
Base.metadata.create_all(bind=engine)
# main.py
@app.on_event("startup")
def _startup():
try:
init_db()
except Exception as e:
print(f"[startup] 数据库初始化失败: {e}")
SQLAlchemy 2.0 的 Mapped + mapped_column 自动推导类型,无需手动写 DDL。
七、总结与展望
本文详细阐述了讯飞 AI 调试平台的完整技术实现,从业务需求、架构设计、核心模块实现、多协议适配、安全与可观测性设计,到部署运维,系统性地展示了如何构建一个企业级的 AI 能力调试平台。
核心亮点:
-
统一服务层封装:屏蔽 WebSocket v2、HTTP v1、MaaS SSE 三类协议差异,提供一致的调试体验。
-
按用户凭证隔离:支持多用户独立管理凭证,凭证加密存储,安全合规。
-
参数可视化 + Demo 导出:降低开发者上手门槛,调参即用、导出即跑。
-
调用日志与统计:全链路可观测,成功率、耗时、token 用量一目了然。
-
实时交互优化:WebSocket 流式识别、SSE 流式对话,延迟 < 100ms。
未来扩展方向:
-
能力扩展:接入更多讯飞能力(如图像识别、OCR、机器翻译)。
-
协作功能:支持团队内共享调试结果、参数模板。
-
性能监控:接入 APM(Application Performance Monitoring),实时监控接口延迟、QPS。
-
自动化测试:支持批量音频回归测试,对比不同参数组合的识别效果。
-
API Gateway 集成:将调试台作为 API 网关的管理界面,统一管理所有 AI 服务调用。
通过本文的实践经验,我们证明了 FastAPI + React 技术栈在构建复杂 AI 调试平台中的适用性与高效性。对于有类似需求的开发者,本文提供的架构设计、协议适配、安全隔离等实践可直接复用,加速平台建设进程。
关键词:讯飞开放平台、FastAPI、React 18、WebSocket v2、语音识别、语音合成、语音评测、大模型、MaaS、凭证加密、调用日志、参数可视化、Demo 导出、多用户隔离、实时流式交互
