跳到主要内容

人格与声音:系统提示、声音库与声音克隆

30 秒导读: 前面几章(01-04)讲的是"音频怎么进、文字怎么出、什么时候该说话"的实时管线。这一章讲一件相对独立的事:这个语音助手的"人是谁"从哪来——它说什么语言、什么口吻、用哪把嗓子、遇到静音怎么办、怎么把你自己的声音克隆进去。答案全在三处:一个模板化的系统提示、一个 YAML 声音库、一条声音克隆/捐赠链路

本章几乎不碰实时循环(那是 01-orchestration-loop 的事),只回答一个问题:对话的"个性"是怎么被组装出来的。


1. 这是什么(零基础也能懂)

一句话定义: Unmute 给任意文本 LLM 装上"耳朵和嘴"。但同一个 LLM 可以是冷幽默的英国问答主持人,也可以是温柔的人生导师 Gertrude,还可以用你昨天录的 10 秒声音说话。决定这些的,就是本章讲的"人格与声音"层。

它由三块拼成:

拼块管什么住在哪
系统提示(system prompt)语言、口吻、静音规则、结束规则、"你是谁"unmute/llm/system_prompt.py
声音库(voice library)有哪些嗓子可选,每把嗓子配什么人格voices.yaml + unmute/tts/voices.py
声音克隆/捐赠把一段录音变成一把新嗓子unmute/tts/voice_cloning.pyvoice_donation.py

用起来什么样: 前端调 GET /v1/voices 拿到一列可选嗓子,每条都带一个 instructions(比如 type: quiz_show)。用户选中"Quiz show"这把嗓子,后端就用问答主持人的系统提示 + 那把嗓子的音色跑对话。想用自己的声音?上传一段录音到 POST /v1/voices,拿到一个 custom:<uuid> 名字,再选它就行。

一句话直觉: 把它想成给演员分剧本(系统提示)嗓音(voice)。剧本决定"怎么演",嗓音决定"听起来像谁"。两者在 voices.yaml 里被一条一条绑好——一把嗓子天生配一份剧本。


2. 顶层全景(个性怎么被拼出来)

从"用户选了一把嗓子"到"LLM 拿到系统提示、TTS 拿到音色",数据这样流:

voices.yaml (声音库,每条 = 音色 + instructions)

GET /v1/voices ───┤ 只返回 good:true 的

用户在前端选一把嗓子 ── SessionConfig{ voice, instructions } ──► update_session()

┌──────────────────────────────────────────┴───────────┐
▼ ▼
instructions ──► chatbot.set_instructions() voice ──► self.tts_voice
│ │
make_system_prompt() TTS start_up:
(拼 BASICS+STYLE+语言+静音+结束) ├─ 普通嗓 → ?voice=... URL 参数
│ └─ custom: → 发 embedding 消息
▼ │
LLM 的 system 消息 TTS 用这把音色发声

怎么读这张图: 左边一支是"剧本"(系统提示 → 喂给 LLM),右边一支是"嗓音"(voice → 喂给 TTS)。两支的分叉点是 update_session()(unmute/unmute_handler.py:640),它把一个 SessionConfig 拆成两半分别送走。

部件一句话职责:

部件干什么文件
_SYSTEM_PROMPT_TEMPLATE系统提示的骨架(BASICS/STYLE/静音/结束…)unmute/llm/system_prompt.py:33
Instructions(判别联合)6 种人格,每种自带 make_system_prompt()system_prompt.py:377
VoiceListvoices.yaml,得到一列 VoiceSampleunmute/tts/voices.py:176
voices() 路由GET /v1/voices,只吐 good 的嗓子unmute/main_websocket.py:200
clone_voice()录音 → 声音克隆服务 → embedding → 缓存unmute/tts/voice_cloning.py:15
submit_voice_donation()校验 + 落盘一份捐赠录音unmute/tts/voice_donation.py:80
check_for_bot_goodbye()检测助手是否以 Bye! 结尾,结束会话unmute/unmute_handler.py:609

3. 核心原理之一:系统提示怎么拼

3.1 一个模板,槽位化填充

Unmute 不给每种人格写一整份系统提示,而是共用一个大模板,只往里填 4 个槽位。模板见 system_prompt.py:33_SYSTEM_PROMPT_TEMPLATE,它固定包含这几段:

段落作用
# BASICS这是语音对话、转写来的、别用 emoji/星号、"像人说话那样"
# STYLE"Be brief" + 语言限制 + 用户的额外指令
# TRANSCRIPTION ERRORS转写可能有错;像是猜得出就直接猜,别反问
# SWITCHING BETWEEN ENGLISH AND FRENCHTTS 只支持英/法,拒绝输出别的语言;法语用 « » 书名号
# WHO ARE YOU / WHO MADE YOU自我介绍(三段式模块、Kyutai 是谁)
# SILENCE AND CONVERSATION END... 静音规则 + 三次没人应就说再见并以 Bye! 收尾

四个被 .format() 填进去的槽位:

槽位来源
{_SYSTEM_PROMPT_BASICS}常量 _SYSTEM_PROMPT_BASICS(system_prompt.py:12)
{language_instructions}由语言码查表得到(见 3.2)
{additional_instructions}每种人格自己生成的那段(见 3.3)
{llm_name}get_readable_llm_name()——把 creator/model-name 裁成人类可读名

这段模板紧接着写道 You cannot speak other languages because they're not supported by the TTS.(system_prompt.py:39-40)——语言限制不是礼貌,是硬约束:TTS 只会英语和法语。

3.2 语言限制:只有 en / fr 两种(外加两种混说)

语言码是个窄类型 LanguageCode = Literal["en", "fr", "en/fr", "fr/en"](system_prompt.py:80)。每个码映射到一句给 LLM 的指令,查表在 LANGUAGE_CODE_TO_INSTRUCTIONS(system_prompt.py:81):

语言码效果(简述)
None / "en"说英语,也会点法语,但被要求说时提一句"可能有口音"
"fr"说法语,不主动说英语
"en/fr"英法都说
"fr/en"法英都说

源码里有句诚实的注释:# Hacky, but it works since we only have two languages(system_prompt.py:85)——整套语言处理是为"只有两种语言"这个事实量身定做的,不是通用国际化。

3.3 六种人格 = 判别联合(discriminated union)

"额外指令"这一槽由人格类提供。所有人格收在一个 Pydantic 判别联合 Instructions 里(system_prompt.py:377),用 type 字段区分:

type人格额外指令怎么来
constant固定文本(默认;也用于自定义人设)直接用 text 字段
smalltalk闲聊,开场抛一个话题注入当前时间 + 随机挑一句 conversation starter
guess_animal你想一个动物、用户猜随机挑一个 easy + 一个 hard 动物
quiz_show冷幽默问答主持人从题库随机抽 5 题拼进提示
news聊科技新闻现拉 newsapi 的头条,拼成 JSON 塞进去
unmute_explanation讲解 Unmute 自己固定的一大段官方说明

每个类都实现 make_system_prompt(),套路一样:先生成自己那段 additional_instructions,再喂给 _SYSTEM_PROMPT_TEMPLATE.format(...)。以 quiz_show 为例:

# 示意,非源码 —— quiz_show 的 make_system_prompt 骨架
questions = random.sample(QUIZ_SHOW_QUESTIONS, k=5) # 随机抽 5 题
extra = QUIZ_SHOW_INSTRUCTIONS.format(questions=...) # 拼进主持人剧本
return TEMPLATE.format(additional_instructions=extra, language_instructions=..., ...)

真实实现见 QuizShowInstructions.make_system_prompt(system_prompt.py:277)。注意 quiz_show 的剧本里明确要求:答对 3 题以上就恭喜、但告知"抱歉出错了没有奖品",然后Bye! 结尾(system_prompt.py:266-269)——这直接对应下一节的"结束对话"机制。

一个巧妙的容错: NewsInstructions.make_system_prompt(system_prompt.py:315)拉不到新闻时不报错,而是回退成 SmalltalkInstructions,并在额外指令里说明"本想聊新闻但拉取失败,换个话题吧"(system_prompt.py:318-324)。人格降级,而非崩掉。

3.4 ... 静音规则 与 以 Bye! 结束对话

模板末尾的 # SILENCE AND CONVERSATION END(system_prompt.py:70-77)定义了两条约定,它们和实时循环里的两个方法遥相呼应:

静音 → ...: 当用户长时间不说话,后端不是干等,而是往对话里插一条内容为静音标记的用户消息detect_long_silence()(unmute/unmute_handler.py:626)在会话处于 waiting_for_user 且超过 USER_SILENCE_TIMEOUT 时,调 add_chat_message_delta(USER_SILENCE_MARKER, "user")。系统提示告诉 LLM:看到 ... 就是"人走神了",可以问一句"还在吗"。

三次静音 → Bye! → 关流: 提示要求 LLM 在多次无应答后说再见并Bye! 收尾。后端每轮说完话都会检查这个信号:

# 真实源码 unmute/unmute_handler.py:609 check_for_bot_goodbye
if last_assistant_message.lower().endswith("bye!"):
await self.output_queue.put(
CloseStream("The assistant ended the conversation. Bye!")
)

这里有个刻意的设计选择——源码注释写着 Using function calling would be a more robust solution, but it would make it harder to swap LLMs.(unmute/unmute_handler.py:619-620)。为了能随意换 LLM,它宁可用"末尾字符串匹配"这种朴素信号,也不用 function calling。 Bye! 因此是提示和代码之间的一份契约:提示端负责说出它,代码端负责识别它。


4. 核心原理之二:声音库(voices.yaml)

4.1 每条声音 = 音色 + 人格 + 来源

声音库是一份 YAML,由 VoiceList 读入(unmute/tts/voices.py:176),每条解析成一个 VoiceSample(voices.py:166)。字段:

字段含义
name显示名(如 "Quiz show")
comment内部备注(如 man, UK, skeptical),不对外暴露
goodbool | None 三态:True 上线 / None 待定 / False 淘汰
instructions这把嗓子天生配的人格(就是第 3 节那个 Instructions 联合)
source音频从哪来:filefreesound(判别联合)

voices.yaml 里一条真实条目长这样(节选自库中 "Quiz show"):

- name: Quiz show
comment: man, UK, skeptical
good: true
instructions:
type: quiz_show # ← 绑定第 3 节的问答主持人人格
source:
source_type: freesound # ← 来源:freesound
path_on_server: unmute-prod-website/freesound/519189_...mp3

关键点:人格是绑在声音上的。 选了 "Quiz show" 这把嗓子,就顺带拿到了 quiz_show 人格——音色和剧本在库里被打包在一起。

4.2 source 的两种来源:file 与 freesound

source 是个判别联合,靠 source_type 区分:

source_type含义
fileFileVoiceSource(voices.py:159)已在服务器上的音频文件,带 description / description_link
freesoundFreesoundVoiceSource(见 freesound_download.py)来自 freesound.org,带原始 URL、license、上传者等元数据,需要下载

VoiceList.upload_to_server()(voices.py:182)会遍历所有声音,只处理 good(if not voice.good: ... return,voices.py:184),freesound 的现下载、file 的查本地是否存在,再 rsync 上服务器。还有一处细节:如果发现某音频有 Adobe 增强过的"清洁版"(find_enhanced_version,voices.py:149),就优先用清洁版。

4.3 /v1/voices 只吐 good

对外接口 GET /v1/voices(unmute/main_websocket.py:200)有两处过滤:

# 真实源码 unmute/main_websocket.py:205 voices()
good_voices = [
voice.model_dump(exclude={"comment"}) # ← 抹掉内部备注
for voice in voice_list.voices
if voice.good # ← 只要 good:true
]

两层"只给该给的":if voice.good 把待定/淘汰的挡掉;exclude={"comment"} 把内部备注(如"skeptical")从响应里抹掉。这个路由还带 @cache 装饰器(main_websocket.py:201),声音库不常变,直接缓存。

保存回 YAML 时(VoiceList.save,voices.py:220)还做了个体贴排序:good 在前、待定居中、淘汰垫底(sorted(..., key=lambda x: {True:0, None:1, False:2}[x.good]),voices.py:227),且是稳定排序,组内原顺序不动。


5. 核心原理之三:声音克隆链路

5.1 从录音到 custom: 名字

想用一段自己的录音当嗓子,走 POST /v1/voices(unmute/main_websocket.py:240)。它就一行活:把上传文件读成 bytes 交给 clone_voice(),返回一个名字。核心逻辑很短:

# 真实源码 unmute/tts/voice_cloning.py:15 clone_voice
voice_name = "custom:" + str(uuid.uuid4()) # ① 生成带前缀的唯一名
response = requests.post(f"{VOICE_CLONING_SERVER}/api/voice",
data=audio_data, headers={...octet-stream...}) # ② POST 给克隆服务
msgpack_data = response.content # ③ 拿回 msgpack 格式的 embedding
voice_embeddings_cache.set(voice_name, msgpack_data) # ④ 存进 TTL 缓存
return voice_name

四步:① 名字以 custom: 前缀 + UUID;② 把音频 POST 给独立的声音克隆服务(VOICE_CLONING_SERVER,克隆模型未开源,是个单独的服务);③ 服务返回的是 msgpack 序列化的 embedding(不是音频);④ 把 embedding 存进一个 TTL 1 小时的缓存(voice_embeddings_cache = get_cache(prefix="voice", ttl_seconds=60*60*1),voice_cloning.py:12)。

为什么用 custom: 前缀? 因为普通嗓子和克隆嗓子在喂给 TTS 的方式上完全不同——前缀是这个分叉的开关。

5.2 克隆嗓子怎么进 TTS:发 embedding 消息,而非 URL 参数

普通嗓子作为 URL 查询参数 ?voice=... 传给 TTS;但克隆嗓子的 embedding 是二进制、且敏感,不能塞进 URL。所以 TTS 客户端在构造查询参数时就把 custom 嗓子排除:

# 真实源码 unmute/tts/text_to_speech.py:154 TtsStreamingQuery 构造
self.query = TtsStreamingQuery(
voice=self.voice
# Don't pass in custom voices as a query parameter, we set it later using a message
if (self.voice and not self.voice.startswith("custom:"))
else None,
...
)

真正发送发生在 TTS websocket 刚连上时(start_up,text_to_speech.py:206):

# 真实源码 unmute/tts/text_to_speech.py:216 start_up
if self.voice is not None and self.voice.startswith("custom:"):
voice_embedding = voice_embeddings_cache.get(self.voice) # 从缓存取回 embedding
if voice_embedding is not None:
await self.websocket.send(voice_embedding) # 作为消息发给 TTS
else:
logger.warning(f"Custom voice {self.voice} not found, ...") # 缓存过期就降级

两条路径对照:

普通嗓子克隆嗓子(custom:)
名字voices.yaml 里的 path_on_servercustom:<uuid>
怎么传给 TTSURL 参数 ?voice=...连上后发一条 embedding 消息
数据在哪服务器上的音频文件TTL 缓存里的 msgpack embedding
过期风险1 小时后 embedding 失效,start_up 打 warning 并不发

TTS 启动、逐词喂文本、按真实时间放音的完整机制见 03-tts-realtime-queue;本章只关心"这把嗓子的身份是怎么传进去的"。

5.3 voice 是怎么到 handler 手上的

绕回起点:前端在 SessionConfig 里带上 voice 字段,update_session() 把它存进 self.tts_voice:

# 真实源码 unmute/unmute_handler.py:640 update_session
if session.instructions:
self.chatbot.set_instructions(session.instructions) # ← 剧本这一支
if session.voice:
self.tts_voice = session.voice # ← 嗓音这一支

之后每次起 TTS,start_up_tts 都把 self.tts_voice 传进 TextToSpeech(...)(unmute/unmute_handler.py:476)。注释说明 tts_voice 单独存,是因为 TTS 会被重启(打断时销毁重建,见 04-quest-lifecycle-interruption),声音选择必须在重启间存活。


6. 声音捐赠(voice donation):两步验证式上传

克隆是"临时给你一把嗓子";捐赠是"你把声音贡献进库"。它比克隆多了同意验证尺寸校验,走两个接口。

6.1 第一步:领一段验证词

GET /v1/voice-donation(unmute/main_websocket.py:250)调 generate_verification()(voice_donation.py:40),返回一段要求你朗读的文字:固定前缀 "I consent to my voice being used for voice cloning." 后面接随机抽的两句(voice_donation.py:42-43)。这段验证词连同一个 UUID、创建时间,一起存进一个 TTL 1 小时的缓存(voice_donation_verification_cache,voice_donation.py:19)。随机句子的意义:证明这是当场录的、本人同意,不是拿别人的旧录音顶替。

6.2 第二步:上传录音 + 元数据

POST /v1/voice-donation(unmute/main_websocket.py:264)收 file + metadata(JSON)。元数据解析成 VoiceDonationSubmission(voice_donation.py:60),含 email(仅用于日后撤回,不公开)、nickname、verification_id、license(目前只允许 CC0)。校验落在 submit_voice_donation()(voice_donation.py:80),按顺序卡这几关:

校验规则源码
文件太小< 0.1 MB 拒绝("说不完验证句")voice_donation.py:86
文件太大> MAX_VOICE_FILE_SIZE_MB 拒绝voice_donation.py:90
昵称太长> 30 字符拒绝voice_donation.py:95
验证过期/不存在缓存里找不到、或超过 5 分钟拒绝voice_donation.py:98-115

过关后把录音写成 <uuid>.wav、元数据写成 <uuid>.json 落盘到 VOICE_DONATION_DIR(voice_donation.py:117-129),然后删掉验证缓存(一次性)、计数 metric +1。

6.3 大小限制:中间件先卡,函数再卡一遍

尺寸限制上了两道。第一道是 ASGI 中间件 LimitUploadSizeForPath(unmute/main_websocket.py:213),它对指定 path 的 POST 在读 body 前就看 Content-Length:缺头 → 411,超限 → 413。这道中间件对 /v1/voices/v1/voice-donation 各挂了一次(main_websocket.py:233257),上限都是 MAX_VOICE_FILE_SIZE_MB * 1024 * 1024(MAX_VOICE_FILE_SIZE_MB = 4,unmute/kyutai_constants.py:33)。

第二道就是 6.2 里 submit_voice_donation 里的 if file_size_mb > MAX_VOICE_FILE_SIZE_MB。源码注释直说 Should be checked by middleware already, but let's ensure it here too(voice_donation.py:89)——中间件挡早、函数兜底,防的是有人绕过中间件直接调函数。


7. 巧妙之处(可借鉴)

  • 提示与代码之间用"契约字符串"而非 function calling。 Bye! 结尾既是系统提示要 LLM 说的、又是 check_for_bot_goodbye(unmute/unmute_handler.py:621)识别的信号。为的是能随意换 LLM(注释 unmute_handler.py:619),牺牲鲁棒性换可移植性,是个清醒的取舍。

  • 人格降级而非报错。 新闻拉不到就回退到 smalltalk 并解释原因(system_prompt.py:318),克隆 embedding 过期就打 warning 不发消息(text_to_speech.py:221)——出错路径上仍是一段能说话的对话。

  • custom: 前缀当路由开关。 一个字符串前缀,同时决定"名字怎么生成""音色走 URL 还是走消息""embedding 从哪取",把两条截然不同的数据通路收敛成一个可读的约定(voice_cloning.py:17text_to_speech.py:158/216)。

  • 对外抹掉内部字段。 /v1/voicesexclude={"comment"} 把"skeptical"这种内部标注从响应里去掉(main_websocket.py:206),库内自用和对外暴露分离。

  • 同意验证靠随机句子 + 短 TTL。 捐赠要求当场朗读随机抽的两句、且 5 分钟内交(voice_donation.py:42/111),用"内容不可预测 + 时间窗窄"堵住拿旧录音冒名。


8. 边界与局限

  • 只有英语和法语。 语言限制是硬约束(TTS 不支持其它),源码自嘲这套逻辑 Hacky, but it works since we only have two languages(system_prompt.py:85)。别指望加第三种语言是改配置就行的事。

  • Bye! 匹配是脆的。endswith("bye!") 识别结束(unmute_handler.py:621),若 LLM 没照提示说、或标点不同(如 Bye.),会话就不会自动关。这是"可移植性优先"的已知代价。

  • 克隆嗓子活不过 1 小时。 embedding 存在 TTL=1h 的缓存(voice_cloning.py:12);过期后再起 TTS,start_up 只会 warning 并回退成"不带自定义音色",不会报错提示用户。

  • 克隆模型未开源。 clone_voice 依赖一个独立的 VOICE_CLONING_SERVER(voice_cloning.py:21);系统提示里也写明"voice cloning model is not open-sourced directly"(system_prompt.py:361)。本仓库只是它的客户端。

  • 捐赠只落盘,不自动进库。 submit_voice_donation 把 wav+json 写到 VOICE_DONATION_DIR(voice_donation.py:117)就结束了——进不进 voices.yaml、标不标 good,是后续人工/离线流程,代码里看不到自动化。


9. 横向对比

本章讲的是 Unmute 相对独立的一支:它不参与 01-orchestration-loop 的实时音频循环,而是给那条循环提供两样"配置":给 LLM 的系统提示给 TTS 的声音身份

同 shelf 的 voice-realtime 兄弟项目(livekit-agents、pipecat、vocode-core 等)也各有"人格/声音"层,但 Unmute 的特色是:人格与音色在 YAML 里被绑成一条,且系统提示是一个大模板 + 6 种判别联合人格,而非每种场景独立提示文件。


10. 代码地图(导航索引)

主题文件路径符号名
系统提示模板骨架unmute/llm/system_prompt.py_SYSTEM_PROMPT_TEMPLATE
BASICS 常量段unmute/llm/system_prompt.py_SYSTEM_PROMPT_BASICS
语言码 → 指令查表unmute/llm/system_prompt.pyLanguageCode / LANGUAGE_CODE_TO_INSTRUCTIONS
6 种人格判别联合unmute/llm/system_prompt.pyInstructions
问答主持人人格unmute/llm/system_prompt.pyQuizShowInstructions.make_system_prompt
新闻人格失败回退unmute/llm/system_prompt.pyNewsInstructions.make_system_prompt
LLM 名字美化unmute/llm/system_prompt.pyget_readable_llm_name
声音库单条模型unmute/tts/voices.pyVoiceSample
声音来源(file/freesound)unmute/tts/voices.pyFileVoiceSource
读 YAML 声音库unmute/tts/voices.pyVoiceList
只上传 good 声音unmute/tts/voices.pyVoiceList.upload_to_server
good 优先排序保存unmute/tts/voices.pyVoiceList.save
/v1/voices 只吐 goodunmute/main_websocket.pyvoices
克隆上传路由unmute/main_websocket.pypost_voices
上传大小中间件unmute/main_websocket.pyLimitUploadSizeForPath
声音克隆核心unmute/tts/voice_cloning.pyclone_voice
embedding TTL 缓存unmute/tts/voice_cloning.pyvoice_embeddings_cache
custom 嗓子发 embeddingunmute/tts/text_to_speech.pyTextToSpeech.start_up
捐赠取验证词unmute/tts/voice_donation.pygenerate_verification
捐赠提交+校验unmute/tts/voice_donation.pysubmit_voice_donation
捐赠提交数据模型unmute/tts/voice_donation.pyVoiceDonationSubmission
检测 Bye! 结束会话unmute/unmute_handler.pycheck_for_bot_goodbye
长静音注入 ...unmute/unmute_handler.pydetect_long_silence
会话配置分发 voice/instructionsunmute/unmute_handler.pyupdate_session
大小上限常量(4MB)unmute/kyutai_constants.pyMAX_VOICE_FILE_SIZE_MB