从 0 到 1 手搓 AI 调解训练系统:双 NPC 实时语音 + AI 自动评分(FastAPI + Qwen-Audio Realtime 实战)

项目简介:一个帮基层调解员练「消费纠纷调解」的移动端 PWA ------ 你和两个 AI 扮演的「消费者 / 经营者」实时语音对线,训练结束后 AI 自动从四个维度打分并给出改进建议。

一、项目背景

消费纠纷调解(预付费退费、商品质量、格式合同......)的能力主要靠实战积累,但传统「剧本演练 + 资深调解员带教」方式成本高、覆盖面窄。用大模型做角色扮演陪练是很自然的思路,难点在于:

  • 这不是普通聊天,而是谈判 ------ 双方要有各自的立场、让步阶梯、硬底线;
  • 要能语音实时对线,还要让两个 AI 角色「互不串台」;
  • 训练完要能像真人教练一样复盘打分。

于是就有了这个项目。目前已完成部署、小范围内测,下面完整分享技术实现和踩过的坑。这套「多 NPC 实时语音角色扮演」的骨架,也能复用到客服培训、销售演练等场景。

二、先看效果

功能速览:

  • 与 2 个 AI NPC 实时语音对话,点顶部标签切换「正在对谁说话」
  • 未读角标 + 标签消息预览:另一方在说话时你切走了,切回来不会漏消息
  • NPC 说话时自动暂停麦克风上传(自动对讲机,全程零按键)
  • 案例库:内置剧本 + 粘贴任意案情由 LLM 解析入库
  • 训练结束自动 AI 评分:4 维度分数 + 评审意见 + 改进建议
  • 历史记录:回看完整对话 + 评分
  • 移动端 PWA:可添加到主屏幕,离线首屏缓存

三、整体架构

技术选型:

层 选型
后端 Python 3.10+ / FastAPI
实时语音 阿里云 Qwen-Audio Realtime(qwen-audio-3.0-realtime-flash)
存储 SQLite(原生 sqlite3 + WAL,无 ORM)
AI 评分 / 剧本解析 任意 Anthropic 兼容 LLM 接口(/v1/messages)
前端 纯 HTML/CSS/JS(ES Module,零构建)+ PWA

数据流(浏览器不直连阿里云,全部经后端代理,API Key 不落前端):

复制代码
浏览器 (PWA)
  │  WebSocket:控制 JSON + 麦克风 PCM 音频(16kHz / 16bit / 单声道)
  ▼
FastAPI 后端
  ├─ SessionManager  :会话生命周期(一次训练 = 2 条上游连接)
  ├─ AudioRouter     :调解员音频 → 只路由给「当前激活」的 NPC
  ├─ NpcSession ×2   :各持一条独立的 Qwen-Audio WebSocket
  ├─ TranscriptLog   :对话记录实时双写(内存 + SQLite)
  └─ 会话结束 → LLM 自动评分 → 历史页展示
  │
  ▼
阿里云 Qwen-Audio Realtime(NPC 语音输出 24kHz PCM)

一个关键设计:一次训练 = 2 条独立的上游 WebSocket 连接(每个 NPC 一条,各自配置人设与音色),而不是在一个会话里来回切换角色。这样切换「对谁说话」是零等待的,音频隔离也是天然的。

四、难点 1:双 NPC 音频路由 ------「同一时刻只有一个人能听见你」

剧本里有条硬约束:「未经点名,另一方不得发言」。落到工程上,要保证同一时刻只有 1 个 NPC 收到调解员的声音,其余物理隔离:

python 复制代码
# AudioRouter:只把音频喂给当前激活的 NPC
async def feed_mediator_audio(self, pcm_bytes: bytes) -> None:
    if not self.active_npc:
        return
    await self._sessions[self.active_npc].feed_audio(pcm_bytes)

# NpcSession:未激活 → 直接 return,一个字节都不发
async def feed_audio(self, pcm_bytes: bytes) -> None:
    if not self.is_active:
        return
    await self.client.send_audio(pcm_bytes)

两个体验细节:

  1. 切换时不打断旧 NPC:让它把这句话说完(音频静音、文本落到自己的标签里计未读),于是切换零延迟,不会出现「话说到一半被掐断」。
  2. 上下文同步 :切换时把「刚才双方说了什么」的摘要注入新 NPC,它不会一脸懵。Qwen 侧通过 conversation.item.create + response.create 两条事件实现。

五、难点 2:半双工门控 ------ 不让 NPC「听见自己」

NPC 的语音从扬声器出来,会被麦克风拾取再传回上游 ------ 不处理的话,AI 会听见自己说话,进而自己接自己的话,变成复读机。

主解决方案是前端门控(AEC 回声消除只是兜底,移动端浏览器 AEC 效果参差不齐):

  • NPC 开始说话(response_started)→ 暂停麦克风上传:直接丢弃工作线程送来的音频,并清空残余缓冲;
  • NPC 说完(response_done)→ 再等播放队列排空 → 再等 500ms「余音」→ 才恢复上传。
js 复制代码
function updateGate() {
  const shouldPause = npcSpeaking || player.isPlaying;
  if (shouldPause) {
    capture.setPaused(true);   // 连残余缓冲一起丢掉
    return;
  }
  // 播放刚排空,再等一小段避开余音,然后自动恢复上传
  resumeTimer = setTimeout(() => {
    if (npcSpeaking || player.isPlaying) return updateGate();
    capture.setPaused(false);
  }, 500);
}

六、难点 3:前端采集链路(这段坑最多)

链路:AudioWorklet(每 128 帧回调)→ 重采样到 16kHz → 聚合成 40ms/块 → WebSocket 上传

① 为什么要聚合成 40ms?

48kHz 下 AudioWorklet 的 process() 每 2.7ms 左右触发一次,即约 375 条消息/秒 ;逐块直接发会打爆 WebSocket。聚合到 40ms 一块后降到约 25 条/秒。

② 为什么重采样要「跨块」处理?

44.1kHz → 16kHz 的换算比是 2.75625,不是整数。如果每一块独立重采样,块边界会丢掉不足一个采样窗口的尾巴,几秒后累积成明显的时间漂移。做法是保留尾部样本(carry)+ 浮点读取位置,跨块连续消费。

③ AudioContext 必须在用户手势的同步段里创建!

如果先 await getUserMedia() 再去 resume(),手势上下文已经失效,resume() 会静默失败,AudioContext 停在 suspended ------ 表现就是「麦克风一个字节都传不出来」。iOS Safari / 微信内置浏览器上尤其严格。所以训练页开场专门有一个一次性的「开始谈话」蒙层,它存在的唯一理由就是满足这个手势要求(之后对话全程零操作)。

顺便:采集和播放用两个独立的 AudioContext ------ 采集用设备原生采样率,播放固定 24kHz(Qwen 输出格式);播放按 _nextPlayTime 串行调度,避免连续音频块互相叠音。

七、难点 4:Qwen-Audio Realtime 的两个深坑

坑 1:人设字段名是 instructions,不是 system_prompt。

写错不会报错,会被服务端静默忽略 ------ 现象是 NPC 完全没有性格设定,排查半天才发现是字段名问题。

坑 2:turn_detection 是硬依赖,配错 NPC 永远不回话。

项目早期有「按住说话」按钮,后来改全自动时删掉了整条 PTT 链路。此时剧本里如果还配 "manual"(关闭自动判停),就会既没有自动判停、也没有手动提交 ------ 说完话对方一直沉默:

python 复制代码
if td == "server_vad":
    td_obj = {"type": "server_vad", "threshold": 0.5, "silence_duration_ms": 800}
elif td in ("manual", "null", None, ""):
    td_obj = None   # ⚠️ 无 PTT 设计下,这个配置 = NPC 永远不会响应

还有个运维事实:上游对空闲会话有超时断开(观察到约 3 分钟),且没有自动重连机制。而且创建会话时就会立刻建立 2 条上游连接(不是懒加载)------ 所以产品引导上我们做了「建好就尽快开始,别放置」。

八、难点 5:让 AI「会谈判」------ 四段式人设 + 让步阶梯

要让 AI 演得像一个真实的谈判对手,光写「你要扮演一个生气的消费者」是不够的。我们给每个 NPC 的人设规定了统一的四段结构:

复制代码
【立场】      一句话说清立场与诉求
【让步阶梯】  至少 3 级,每级写明:解锁条件(调解员做到什么,才肯让)→ 让到什么程度
              逐级下移、禁止跳级、未满足条件不透露下一级
【硬底线】    无论如何不退让的红线
【不许做的事】未点名不得发言 / 不跳级让步 / 不主动暴露底线 ...

节选一段真实人设(消费者「王女士」,有删减):

复制代码
第1级:金额不变,但放弃利息和合理费用主张('不想拖着,就想尽快解决')。
       解锁条件:调解员释明消法53条(预付款未花完不仅退本金,还含利息和合理费用)。
第2级:退12,000元一次性、7日内到账、公司书面承担连带。
       解锁条件:调解员分析三点------①机构已停业,判决后可能'赢了官司拿不到钱';
       ②诉讼周期3到6个月;③连带举证责任在股东一方,但得走完诉讼。
【硬底线】12,000元,绝不再往下让。

这套结构带来两个好处:NPC 行为稳定可预期,像一个真实的谈判对手;并且它同时是 AI 评分的标尺(见下一节)。

九、AI 自动评分:把「让步阶梯」当评分标尺

会话结束(freeze)后自动触发评分,不阻塞用户:

  • 从数据库取完整对话记录 + 案例人设(含双方的让步阶梯 / 硬底线),交给 LLM;
  • 固定 4 个维度打分:立场把握 / 让步推进 / 沟通与中立 / 结案推进 + 评审意见 + 改进建议,只输出 JSON;
  • 评分是 best-effort:LLM 未配置、没有调解员发言、输出非法 ------ 均不影响结束流程,只在详情页显示「未评分」或「评分失败」。

引导 LLM 的关键提示词就是一句话:「顺着各方的让步阶梯逐级推进了吗?触碰或破坏了某一方的硬底线吗?」 评出来的分数比泛泛而谈的「沟通能力打分」有区分度得多。

十、工程化细节

问题 做法
手机浏览器 100vh 把底部状态条挤出屏幕 dvh + 内部滚动(flex 列 + min-height: 0)
转写实时落库 TranscriptLog 实时双写(内存 + SQLite),WAL 模式
SQLite 不阻塞事件循环 关键写路径走 asyncio.to_thread
孤儿会话泄漏上游连接 60s 周期 idle 回收 + 断线即清理 + 创建失败回滚
上游断线 标记 dead → 推送 npc_error → 前端优雅收尾(不自动重连)
微信旧缓存 静态资源版本化 + SW 缓存版本升级 + 无缓存头兜底
鉴权零依赖 PBKDF2 + HMAC 签名 HttpOnly Cookie(纯 stdlib,无第三方库)
剧本解析防注入 用户文本用 <user_content> 分隔符包裹 + 指令隔离
会话成本控制 并发会话上限 + 登录 / 解析接口限流
播放叠音 按 _nextPlayTime 串行调度

还有一条值得展开说:训练页按「当前对谁说话」分两个标签。一开始按「说话人」分库,结果调解员的消息既不属于消费者也不属于经营者,永远渲染不出来 ------ 教训是数据归属要跟着「对话对象」走,样式才跟「说话人」走。

十一、踩坑速览表

# 坑 正解
1 Qwen 人设字段写成 system_prompt 被静默忽略 用 instructions
2 turn_detection=manual 且无 PTT → NPC 永不响应 上游配 server_vad
3 AudioContext 在 await 之后 resume 静默失败 用户手势同步段内创建 + resume
4 worklet 逐块上传打爆连接 聚合成 40ms/块
5 44.1k→16k 逐块重采样累积漂移 跨块 carry 尾样本
6 NPC 从扬声器听见自己 → 复读 半双工门控 + 500ms 余音
7 多段音频叠播 串行调度播放
8 切换标签后消息归属错乱 按对话对象分库 + 未读角标

十二、测试与现状

  • 后端:pytest 147 个用例(含 mock 阿里云连接的集成测试)
  • 前端:node 原生 test runner 76 个用例(零依赖,node --test 直接跑)
  • 内置 2 个案例(艺术培训机构闭馆退费 / 未成年人购机退费),也支持粘贴任意剧本、由 LLM 解析成结构化案例入库
  • 核心代码规模:后端约 2600 行 Python、前端约 2400 行 JS(不含测试)

规划中:多用户体系(注册 / 管理中心 / 数据隔离)、更丰富的案例和评分维度。

十三、体验与交流

目前项目在小范围内测。

如果这篇文章对你有帮助,点赞收藏支持一下;对「LLM 角色扮演 + 实时语音」方向感兴趣的朋友,欢迎评论区交流。

相关推荐
小猴子爱上树2 小时前
跨马翻译:批量图片翻译+视频字幕+智能抠图,跨境电商在线图片翻译工具
大数据·人工智能·python·音视频
W***25922 小时前
2026 企业 AI 办公工具怎么选:选型框架与平台全景分析
大数据·人工智能
薛定e的猫咪2 小时前
(AISTATS 2023)BaCaDI 逐章阅读
人工智能·深度学习·算法·机器学习
记得开心一点嘛2 小时前
Trellora:基于 Electron、React 和本地知识库的 AI 知识工作台
人工智能·react.js·electron
桃西西呀2 小时前
LangChain 之八:流式与透传
人工智能·langchain·llm
凡达Ai派2 小时前
AI画布里的文字总是错位或乱码?把生成、排版和校对拆成三段
图像处理·人工智能·深度学习·神经网络·自然语言处理·知识图谱
桃西西呀2 小时前
LangChain 之九:一个能检索又会调工具的流式问答助手
人工智能·langchain·llm
Martina_03212 小时前
AI生成的模块场景一烘焙就有黑边?用6步检查Lightmap UV、纹素密度与Padding
人工智能·游戏·3d·aigc·uv·游戏策划·关卡设计
熊猫钓鱼>_>2 小时前
从闲置平板到家里的“控制大脑“:鸿蒙智慧中控面板完整实战
运维·人工智能·华为·自动化·电脑·ai编程·harmonyos