AB
AiBoss站
教程

用双智能体与马尔可夫状态控制搭建全语音 AI 角色对话游戏

教程

用双智能体与马尔可夫状态控制搭建全语音 AI 角色对话游戏

把长篇小说式对话拆成可点击的小段落,用两个分工明确的模型分别负责写作与状态判定,再配合 44.1 kHz 参考音色库、表情差分立绘与异步语音队列,做出角色会点灯、会说话、长剧情也不失忆的 AI 视觉小说。本文给出目录结构、meta.json 与 bank_manifest.json 字段、分段与合成参数,以及常见破绽的排查方法。

让玩家和自己喜欢的角色实时对话、角色还会换表情并全程配音,听起来工程量很大,但真正需要自己准备的东西可以压缩成三件事:一套带表情差分的透明立绘、一组干净的短参考音频、以及把「写故事的模型」和「判定说话人并决定演出参数的轻量模型」拆开。难点不在单个环节,而在三者衔接时不破功:长篇剧情不能让记忆无限膨胀,语音不能发闷,立绘不能和文本描述打架。本文按这套分工给出可落地的目录结构、字段定义、分段规则与合成参数,适合已经能调用文本模型与语音合成接口、想把它组装成视觉小说式对话游戏的开发者。

准备工作

素材侧需要准备的东西

  • 立绘:每个角色至少一套透明 PNG 立绘,按形态阶段(Stage)划分,每个阶段配 9 种表情差分。差分之间必须像素级对齐,否则切换时画面会抖动。
  • 参考音频:每个角色 10 条干净短音频,原生 44,100 Hz、单声道、16-bit PCM 的 WAV。长度控制在 5.0 至 9.5 秒之间:短于 3 秒时整句的语调起伏不足,合成出来容易平;超过 12 秒会挤占语音模型的上下文长度。
  • 角色设定:每个角色的 id、显示名、职业或定位、配音来源标注、性格描述、一人称与口癖。这些会同时进入写作侧提示词和语音侧发音脚本。

工程侧需要准备的东西

  • 两个可调用的文本模型:一个承担长篇创作(Agent ①),一个承担结构化抽取(Agent ②)。Agent ② 只需要 Flash / Mini 级别的轻量模型即可,因为它做的是分类与抽取,不是创作。
  • 一个支持参考音频克隆的语音合成后端,能接受参考音频路径、发音文本、克隆模式与温度参数。
  • 一个能跑音频处理的 Python 环境,需要 librosa 做静音检测与重采样,需要 numpy 做归一化与滤波。
  • 一个前端渲染层,负责按段推进文本、切换立绘、挂载播放按钮。纯 HTML/JS 即可,不需要框架。

目录结构先定下来

引擎按角色 id 扫描资源目录,所以目录名与文件名就是接口约定,先固定再写代码:

resources/characters/char_001/
├── meta.json              # 角色设定、一人称、形态阶段定义
├── face.png               # 默认头像 256x256
├── figure_normal.png      # 默认透明立绘
├── figure_s1_normal.png   # Stage 1 通常
├── figure_s1_smile.png    # Stage 1 微笑(其余 7 种表情同理)
├── figure_s2_normal.png   # Stage 2 立绘 + 9 表情差分
└── figure_s3_normal.png   # Stage 3 立绘 + 9 表情差分

tts_engine/voice_banks/char_001/
├── calm_normal.wav        # 10 条参考音频
└── bank_manifest.json     # 参考音频清单与声学指纹

操作步骤

第一步:把写作与状态判定拆成两个智能体

常见做法是让一个大模型同时写小说、维护当前地点与时间、补说话人标签、再输出语音参数 JSON。剧情一长,问题就集中爆发:文风变硬,JSON 括号漏闭合、字段越界,而且每轮都要把全部历史塞进上下文,token 成本随轮数累积增长。

拆开之后,Agent ① 只负责写正文,Agent ② 只负责把「这一轮的正文」映射成结构化状态。关键约束是:Agent ② 不接收任何历史原始日志,也不接收世界观设定集,它的输入只有两样东西——本轮带编号的正文段落,以及一份约 100 至 200 token 的状态快照(当前时间、当前地点、在场角色 id 列表、当前衣装阶段)。

这在概率模型上等价于强制一阶马尔可夫性:

P(S_{t+1}, V_t | S_t, Y_t, H_{1:t-1}) = P(S_{t+1}, V_t | S_t, Y_t)

需要跨轮继承的上下文,全部已经写进结构化状态 S_t 和 Agent ① 刚写完的正文 Y_t 里,所以「从当前文本推断地点变化、补全说话人、生成事件摘要、给出每句台词的情绪参数」这件事,对历史日志条件独立。由此得到三个直接好处:

  • 记忆负载恒定:第 5 轮和第 500 轮,Agent ② 看到的输入长度都只有约 1.5k 至 3k token。调用时 messages 数组里只放一条 role: "user" 的消息。
  • 轻量模型够用:长期推理与文学创作已被 Agent ① 和状态快照吸收,Agent ② 解的是纯分类抽取问题,把 temperature 设为 0.1 就能拿到很高的 JSON 遵守率与约 1 秒的延迟。
  • 历史污染被物理隔离:两章前的激烈战斗日志 Agent ② 根本看不到,当前平静日常场景的说话人判定与情绪选择不会被旧情绪带偏。

第二步:按在场角色动态注入语音菜单

游戏里可能注册了 10 至 20 个角色、合计 100 至 200 条参考音频。如果每轮都把全部菜单塞给 Agent ②,既浪费 token,也容易让它输出别的角色的 ref_id。做法是后端先扫一遍本轮正文,确定实际出场并发话的 1 至 2 个角色,只把这几个角色各自的 10 条菜单注入提示词。这样既压住 token,也从源头上杜绝了跨角色串音。

第三步:写作侧做两层记忆控制

Agent ① 同样不能把设定集整本塞进上下文。用两层机制控制:

  • 常驻规则 + 关键词触发:世界的基本规则常驻提示词;配角、专有地名的详细设定只在最近几轮对话或玩家输入里出现对应关键词时才动态注入。
  • 退场即禁用:剧情中已经退场的角色条目要物理禁用,避免「早该离场的角色在下一幕突然开口」。

另外维护一张最多 30 行的滚动记忆表,只记录重大转折(遭遇、并肩、退场等),每行一句摘要,由 Agent ② 在检测到转折时追加。

第四步:把长文切成原子段落

Agent ① 为了临场感,常把叙述和台词写在同一段里,例如:

夜风拂过脸颊。〖角色A〗「……喂,你听见了吗?」她轻轻环视四周。

整段直接上屏,读者分不清哪句是谁说的,立绘切换也会错乱。所以在 Agent ① 输出之后立刻跑一次解析,按以下边界机械切分:

  • 换行处切分;
  • 说话人标签前后切分,标签形如 〖角色名〗 或 【角色名】:;
  • 引号 「…」 前后切分。

切完给每个片段编上 [0]、[1]、[2] 这样的连续序号,再整体交给 Agent ②。切分的目标是:叙述文字和某一个角色的发言,绝不出现在同一个点击画面里。一轮正文通常会被切成几个到几十个小段落。

第五步:让 Agent ② 一次完成三类判定

写作侧专注创作,对话一激烈就漏标签,或者把心声和台词混在一起。Agent ② 只读带编号的段落,一次性输出三类结果:

  1. 补全缺失的说话人(untagged_speakers):对没有 〖角色名〗 的段落,依据前后段的主语与句尾语气,按段落编号确定这是哪个角色、玩家,还是旁白。
  2. 只抽真正说出口的台词:挑出有立绘、有配音的角色实际说出的引号内容,排除叙述、心声和玩家台词。同时把「原文锚点 quote」和「发音专用脚本 ja_text」分开保存——前者用于屏幕显示,后者交给语音合成。这样既能保留角色特有的一人称与口癖写法,也方便以后做多语言字幕配单一语言配音。
  3. 更新状态与记忆:更新当前地点、时间带,并在发生重大转折的那一轮返回一行 event_summary,自动追加进滚动记忆表。

调用时把 temperature 压到 0.1,并要求输出严格 JSON。

第六步:按段推进并控制立绘点灯

前端渲染时先做两件事:把段落里的原始标签(如 〖角色名〗)剥掉,移到名牌位置;把作品特有的注音写法转成 HTML 的 ruby 标签,例如 <ruby>紅蓮の双剣<rt>クリムゾン・エッジ</rt></ruby>。

玩家点击画面或按空格,从 [0] 逐段推进。每推进一段,检查当前段落的说话人是否拥有立绘资源:

  • 有立绘的角色在说话(存在 portrait_url,且不是旁白也不是玩家):单人场景下,该角色当前衣装阶段 × 当前表情的立绘带淡入或滑入动画出现在舞台中央;多人同屏场景下,说话者亮度 100% 并轻微放大,其余角色降到 50% 亮度。
  • 旁白、玩家台词、无立绘角色:舞台上的立绘自动淡出或整体压暗,只留背景与文本框。

这样玩家仅凭立绘的亮灭就能判断谁在说话,叙述段也不会被立绘干扰。

第七步:文本与语音异步,逐句追着播

一轮里可能有 5 至 8 句台词。如果等全部合成完再出文本,玩家要干等几十秒。正确做法是把「文本与立绘立即显示」和「语音合成」彻底异步:

  • Agent ② 返回轻量 JSON(约 1 秒)后,立刻把全部段落的文本与立绘点亮信息推给前端,玩家可以从 [0](通常是开场叙述)开始点击阅读。
  • 后台语音工作线程按队列顺序逐句合成 voice_lines,合成完一句,对应段落的播放按钮就实时点亮,形成追着播的效果。

第八步:立绘的抠图、去边与表情对齐

把任意一张角色插画转成引擎可驱动的标准资源,分三步:

  1. 抠图:用通用分割模型或动漫专用分割模型(如 birefnet-anime、isnet-anime)提取前景,输出 RGBA PNG。
  2. 去边:暗色夜景背景上叠立绘时,发梢和轮廓常残留 1 至 2 像素的原背景色,看起来发白。对半透明边缘区域做向内颜色插值(Defringe),把颜色污染清掉。
  3. 对齐:同一形态阶段内的 9 张表情差分,画布分辨率与颈、肩、衣装坐标必须 100% 一致。若从单张原画改表情,只在头部 512×512 区域内编辑,再用羽化蒙版合成回全身图。

9 个标准表情键固定为:normal(通常)、smile(微笑)、angry(愤怒)、blush(脸红)、sad(悲伤)、shout(喊叫/战斗)、surprised(惊讶)、serious(认真)、calm(闭眼/静谧)。头像则从眉间与视线为中心做正方形裁切,缩放到 256×256。

第九步:写 meta.json 注册角色

最小定义示例(字段名照抄即可):

{
  "id": "char_001",
  "name": "原创角色A",
  "class_name": "Blade",
  "cv": "开放语音数据集",
  "personality": "说话礼貌,偶尔露出俏皮一面的剑士",
  "stages": {
    "1": {
      "stage": 1,
      "figure_normal": "/resources/characters/char_001/figure_s1_normal.png",
      "face": "/resources/characters/char_001/face_s1.png",
      "expressions": {
        "normal": "/resources/characters/char_001/figure_s1_normal.png",
        "smile": "/resources/characters/char_001/figure_s1_smile.png",
        "angry": "/resources/characters/char_001/figure_s1_angry.png",
        "blush": "/resources/characters/char_001/figure_s1_blush.png",
        "sad": "/resources/characters/char_001/figure_s1_sad.png",
        "shout": "/resources/characters/char_001/figure_s1_shout.png",
        "surprised": "/resources/characters/char_001/figure_s1_surprised.png",
        "serious": "/resources/characters/char_001/figure_s1_serious.png",
        "calm": "/resources/characters/char_001/figure_s1_calm.png"
      }
    }
  }
}

衣装阶段切换时,除了换立绘,还要把该衣装的外貌描述强制锁进 Agent ① 的系统指令,否则会出现「画面是便服、正文还写着铠甲」的矛盾。

第十步:从参考音频里挑出 10 条黄金片段

从角色的干净干声(无伴奏的对话、独白)里,按情绪与说话场景各异的思路切出 10 条 WAV,遵循四条规则:

  • 时长:5.0 至 9.5 秒。不足 3 秒语调轮廓不够,超过 12 秒挤占上下文。
  • 排除音效:战斗喊声里混着爆炸、打击或强 BGM 的轨道一律不用,只切干净台词区间。长独白只取其中 6 至 9 秒的那一句。
  • 收紧死气:表演性停顿超过 1.0 秒的,用 librosa.effects.split(y, top_db=35) 检测静音段,把句中超过 0.45 秒的空白压到 0.22 至 0.25 秒的自然换气间隔;拼接处加 12 ms 余弦淡入淡出防爆音。
  • 格式固定:原生 44,100 Hz、单声道、16-bit PCM。处理只做 65 Hz 二阶巴特沃斯零相位高通(去直流偏移)和峰值 0.85 归一化,不要做频谱门限类降噪,也不要加低通滤波。

第十一步:测量声学指纹并写 bank_manifest.json

Agent ② 要判断「哪句台词配哪条参考音频」,需要可比较的声学特征。为每条 WAV 计算频谱重心(Hz)、基频 F0(均值 ± 标准差,Hz)、3k 至 12k Hz 高频清晰度比(%),写进清单:

{
  "char_id": "char_001",
  "char_name": "原创角色A",
  "default_ref_id": "calm_normal",
  "sample_rate": 44100,
  "total_references": 10,
  "references": [
    {
      "ref_id": "calm_normal",
      "label": "沉稳・日常对话",
      "when_to_use": "用于日常安静交谈、自我介绍、冷静说明状况的场景",
      "wav_path": "tts_engine/voice_banks/char_001/calm_normal.wav",
      "prompt_text": "此处填入与 WAV 音频逐字完全一致的转写文本",
      "duration_s": 7.45,
      "sample_rate": 44100,
      "acoustic_notes": "44.1kHz 原生 | 频谱重心=4120.5Hz | F0=225.0±35.0Hz"
    }
  ]
}

prompt_text 必须与音频逐字一致,这是克隆质量的硬前提;when_to_use 是给 Agent ② 看的选型说明,写得越具体,情绪匹配越准。

第十二步:处理短感叹词导致的音质塌陷

常见现象是:长句自然,但遇到「诶?」「咳、咳咳」这类短感叹词或口吃时,紧接着的声音突然变虚、变成不自然的假声或带沙砾感。原因与对策有三条:

  • 不要在说话人编码器前做双重提亮:说话人嵌入由 CAMPPlus 从 16 kHz Fbank 特征中提取。若对参考音频先做中低频削减再加过度高频强调,女声 200 至 250 Hz 附近的基频能量会被削掉 8 dB 以上,短促音之后的有声帧率会掉到 0%,音质直接崩。铁律是把未加工的原生 44.1 kHz 原音直接送进合成接口。
  • 不要做后处理变调:用相位声码器事后升降半音会扭曲共振峰包络,产生机械抖动(Warble)。
  • 极短感叹词强制合并:按句号、感叹号切分小节时,实际字数在 4 字以下的孤立感叹词不要单独合成,必须并入紧随其后的小节,并强制 clone_mode="full_vae"、temperature=0.0,用 latent_back 把声带相位锚定住。

第十三步:一句台词内的情绪弧线用多小节拼接

一句长台词里如果前半是惊讶动摇、后半是害羞道谢,用一条参考音频硬压必然平。做法是让 Agent ② 用 clauses 数组把台词切成 2 至 3 个小节,每节分配不同的 ref_id 与混合权重分别合成,再把各段波形用 0.26 秒自然停顿加端部 12 ms 余弦淡入淡出拼成一个 WAV。

送进合成前还要过一道发音保护:把易读错的汉字与注音写法规范化,例如把注音表记 【汉字(ルビ)】 只取假名读音部分,避免专有名词和难读汉字被读错。

第十四步:说话人向量混合后必须重新归一化

想表达「七分冷静加三分害羞」的中间语气时,取同一角色两条参考的说话人向量做线性混合,但混合后要按主说话人向量的 L2 范数重新归一化,否则音量与音色会漂:

v_mix = (1 - w) * v_primary + w * v_secondary v_hat = v_mix * (||v_primary||_2 / ||v_mix||_2),其中 w ∈ [0, 0.45]

权重上限压在 0.45,是为了不让次要音色盖过角色本音。

第十五步:缓存语音潜变量,避免每次启动重编码

语音编码器的内部实现是确定性的后验均值采样,说话人编码器同样完全确定。一条 8 秒音频编码出的 Voice 对象(说话人向量 [1, 192] 加前后潜变量 [1, T, 64])在内存里只占约 51 KB。把每条 WAV 的 (wav_size, wav_mtime_ns, prompt_text) 作为校验锁键,持久化到 voice_latents_cache.pt,启动时直接加载,省掉重复编码。只要文件大小、修改时间或转写文本任一变化,缓存自动失效重算。

第十六步:加两道安全网,防止漏句与串音

轻量模型再稳也有极小概率出问题:JSON 括号漏闭合、漏掉一句台词、输出未注册的 ref_id。后端做两层兜底:

  • JSON 修复 + 真值比对:先对不完整 JSON 做容错解析,再与从原始段落里抽出的「有声角色引号台词清单(真值)」逐条比对。即使 Agent ② 漏了一句或返回了不存在的 ref_id,也用默认情绪自动补齐,保证不会出现某句台词没声音。
  • 确定性哈希缓存 + 单句重掷:每个音频文件以 tts_{session}_t{turn}_{idx}_{md5}.wav 这样的确定性 ID 落盘。刷新页面或读档时无需重新合成即可播放;玩家若对某一句的演绎不满意,可以只针对那一句改情绪参数重新合成。

一个完整示例

下面走一遍最小可跑通的链路,从一轮正文到玩家听到第一句语音。

1. 写作侧产出正文

Agent ① 收到状态快照 S_t(地点:旧宅走廊;时间:夜;在场:char_001)与玩家输入,返回:

夜风从廊下穿过。〖char_001〗「……喂,你听见了吗?」
她停下脚步,指尖按在刀柄上。
「……算了,大概是我多心。」

2. 切分为原子段落

[0] 夜风从廊下穿过。
[1] 〖char_001〗「……喂,你听见了吗?」
[2] 她停下脚步,指尖按在刀柄上。
[3] 「……算了,大概是我多心。」

注意 [3] 没有标签,需要 Agent ② 补全。

3. Agent ② 返回结构化结果

{
  "untagged_speakers": { "3": "char_001" },
  "voice_lines": [
    {
      "idx": 1,
      "speaker": "char_001",
      "quote": "……喂,你听见了吗?",
      "ja_text": "……ねえ、聞こえる?",
      "ref_id": "serious_tense",
      "clauses": [
        { "text": "……ねえ、", "ref_id": "serious_tense", "weight": 0.0 },
        { "text": "聞こえる?", "ref_id": "surprised_soft", "weight": 0.3 }
      ]
    },
    {
      "idx": 3,
      "speaker": "char_001",
      "quote": "……算了,大概是我多心。",
      "ja_text": "……まあ、気のせいか。",
      "ref_id": "calm_normal",
      "clauses": [
        { "text": "……まあ、気のせいか。", "ref_id": "calm_normal", "weight": 0.0 }
      ]
    }
  ],
  "state_delta": { "location": "旧宅走廊", "time": "夜" },
  "event_summary": null
}

[0] 与 [2] 是叙述,不进 voice_lines,因此不发声、不点灯。

4. 前端立即渲染

收到 JSON 后马上把四段文本推给前端。玩家点击推进到 [0]:旁白,立绘不显示。再点一次到 [1]:char_001 有立绘且不是旁白也不是玩家,于是按当前衣装阶段与 serious 表情点亮中央立绘,同时该段出现播放按钮(此时可能还在合成,按钮置灰)。

5. 后台逐句合成

语音线程先处理 idx=1:按 clauses 分两节合成,第一节用 serious_tense,第二节用 surprised_soft 并以 0.3 权重与主向量混合后重新归一化;两段波形以 0.26 秒停顿加 12 ms 余弦淡入淡出拼接,落盘为 tts_{session}_t{turn}_1_{md5}.wav。合成完成即通知前端点亮 [1] 的播放按钮。随后处理 idx=3,同理。

6. 玩家推进到 [2] 与 [3]

到 [2] 时是叙述,立绘淡出。到 [3] 时说话人已由 untagged_speakers 补全为 char_001,立绘重新点亮,表情按 calm_normal 切换为 calm。整轮结束,state_delta 写回状态快照,供下一轮使用。

注意事项

  • Agent ② 的输入里绝不能出现历史日志。一旦为了「让它更懂上下文」而塞入前几轮原文,记忆负载就从常数变回线性增长,历史情绪污染也会重新出现。需要跨轮的信息请写进状态快照。
  • 参考音频不要做降噪与低通。频谱门限类降噪会削掉短促音赖以成立的高频细节,低通会让声音发闷。只做高通去直流与峰值归一化。
  • 表情差分必须像素级对齐。同一阶段内 9 张图只要有一张偏移 1 像素,切换时画面就会跳。
  • 衣装阶段与文本描述要同步锁定。只换立绘不锁提示词,正文会继续按旧衣装描写。
  • 退场角色要禁用条目,否则会在后续场景里意外开口。
  • 极短感叹词不要单独合成,4 字以下必须并入后一小节并强制 full_vae 与 temperature=0.0。
  • 说话人向量混合权重不超过 0.45,且混合后必须按主向量 L2 范数重新归一化。
  • 缓存键要包含转写文本。只校验文件大小与修改时间,改了 prompt_text 却复用旧潜变量,会导致音色与文本不匹配。
  • 不要等全部语音合成完再出文本,一轮 5 至 8 句会让玩家干等几十秒;文本与立绘先出,语音追着播。
  • 模型版本、可用地区、调用配额与计费方式都可能变化,具体以各服务官网当前信息为准。