从 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 角色扮演 + 实时语音」方向感兴趣的朋友,欢迎评论区交流。

相关推荐
Jsundoku2 小时前
第四章 深度学习 -- 训练技巧
人工智能·深度学习
HDU-Dade3 小时前
【校招笔试真题】简要介绍 Self-Attention 的前沿算法
人工智能·算法
在所不辞兄3 小时前
【零基础学智能仿真-63】ABACUS作业工具与MCP接口
人工智能·深度学习·神经网络·机器学习·智能仿真
楚山之石3 小时前
【Medical Image Analysis】深度学习医学图像合成综述:伪CT、合成MRI与合成PET的架构、性能与临床边界
图像处理·人工智能·学习·mr·mri
blackappleD3 小时前
百度 Unlimited OCR 2 拆解:SR-SWA 如何让百页文档的 KV 缓存不再涨
人工智能·llm
数字新视界3 小时前
国产动环数据库在智能机房环境监控中的应用与优势分析
大数据·人工智能·数据中心·机房管理·模块化机房
holly就是我4 小时前
AI 编程助手为什么跑偏?用开源 ccglass 看清 Prompt、工具调用和 Token 消耗
人工智能·后端
雨中飘荡的记忆4 小时前
额度又见底了?这个开源项目,给 AI 编程助手接上 61 家"加油站"
人工智能
天远Date Lab4 小时前
零信任架构实战:基于天远企业年报信息核验构建自动化商业保理财务与社保穿透评估网关
人工智能·架构·自动化
EnCi Zheng4 小时前
后端怎么把AI接口稳定安全低成本接进业务:超时重试、密钥隔离与成本熔断清单
人工智能·后端·安全·架构·api