LiveKit Agents Expressive Agent 示例深度解析:用 expressive=True 让语音 Agent 拥有情绪化表达
2026/9/15 2:31:33 网站建设 项目流程

LiveKit Agents Expressive Agent 示例深度解析:用 expressive=True 让语音 Agent 拥有情绪化表达

【免费下载链接】agentsA framework for building realtime voice AI agents 🤖🎙️📹项目地址: https://gitcode.com/GitHub_Trending/agen/agents

导读

本篇文章围绕仓库中examples/expressive_agent示例展开,讲解如何在 LiveKit Agents 框架中通过AgentSession上的单个expressive=True标志,让语音 Agent 不再只是"朗读文字",而是具备情绪、节奏与拟声词等表达力:好消息会兴奋,坏消息会低落。文章会完整剖析该示例的架构分工(agent.py/prompt.md/protocol.py)、前后端 dispatch 契约、四种 TTS 语音的选择方式,并深入框架源码揭示 expressive mode 的 markup 注入与剥离机制。读完你将掌握 expressive mode 的开启方式、可配置项、适用前提,并能基于该示例快速搭建自己的"朋友式"情绪化语音 Agent。

Expressive Mode 是什么:一个标志开启的"表达层"

按示例文档的定义,Expressive mode 是AgentSession上的单一开关expressive=True(见 examples/expressive_agent/README.md)。启用后,框架会自动完成两件事:

  1. 把 TTS 提供商的 markup 指南注入 LLM 的 prompt——模型学会在回复里输出行内的"表达标签"(emotion 情绪、pacing 语速、non-verbal sounds 拟声词);
  2. 让 TTS 渲染这些标签、而对话转写文本中永不显示它们——用户听到的是情绪饱满的语音,看到的转写却是干净的文字。

这就是 expressive mode 与普通 TTS 的核心区别:"说什么"(内容)由 LLM 决定,"怎么说"(表达)由 markup 标签驱动,两者在 prompt 层面被刻意分开管理。示例的prompt.md里就明确写着:

Expressive Mode injects the delivery guide separately, so this prompt covers only who you are and what you say. Tone and pacing rules don't belong here, but word choice does.

(见 examples/expressive_agent/prompt.md 第 4-6 行)——persona 只管说什么,expressive 只管怎么发音,二者互不重复。

框架侧的关键证据

在框架源码中,这个"注入"由update_expressive_instructions完成,它会在 chat context 里插入或替换一条 ID 固定的系统消息lk.expressive.instructions(见 livekit-agents/livekit/agents/voice/generation.py):

EXPRESSIVE_INSTRUCTIONS_MESSAGE_ID = "lk.expressive.instructions" # value must not change def update_expressive_instructions(chat_ctx: ChatContext, *, text: str) -> None: """Insert or replace the expressive markup-guide system message.""" def remove_expressive_instructions(chat_ctx: ChatContext) -> None: """Remove the expressive markup-guide message added by update_expressive_instructions, if present."""

默认注入的模板在 livekit-agents/livekit/agents/voice/agent_session.py 中定义:

DEFAULT_EXPRESSIVE_OPTIONS: ExpressiveOptions = ExpressiveOptions( tts_instructions_template=Instructions( "You can control how you speak using the following formatting tags. " "Use them when appropriate to make your speech more expressive and natural:\n\n" "{tts.markup.llm_instructions}" ), speech_steering=DEFAULT_SPEECH_STEERING_OPTIONS, )

其中的{tts.markup.llm_instructions}占位符在渲染时由当前 TTS 的Markup.llm_instructions()填充——也就是每个 TTS 插件声明自己"会说哪种 markup 方言",框架负责把它教给 LLM。

示例架构:三个文件,各司其职

示例目录结构如下:

examples/expressive_agent/ ├── Dockerfile # 与 examples/ 下其他示例共用的容器镜像 ├── README.md # 本文依据的文档 ├── agent.py # 组合根:会话组装 + 服务入口 ├── prompt.md # 仅存放 persona(说什么) ├── protocol.py # 前后端完整契约(dispatch 元数据 + 回显属性 + 语音表) └── pyproject.toml # 依赖声明

README 明确给出了三文件分工(见 examples/expressive_agent/README.md):

  • agent.py是组合根(composition root):负责 AgentSession 的组装与服务器入口;
  • prompt.md只放 persona,只控制what(说什么),表达层how(怎么发音)交给 expressive mode,两者绝不互相重复;
  • protocol.py是整个前端契约:dispatch 元数据的形状、回显给前端的属性、以及这些元数据值所指向的语音表。

agent.py:会话组装细节

核心入口在 examples/expressive_agent/agent.py,值得逐段拆解:

class Friend(Agent): def __init__(self) -> None: super().__init__(instructions=INSTRUCTIONS) async def on_enter(self) -> None: await self.session.generate_reply(instructions=GREETING)

Friend是无任务、无工具的自由对话 Agent,on_enter时用一段"像给熟人接电话一样"的开场白主动发起第一句(GREETING常量见 examples/expressive_agent/agent.py)。

会话组装(examples/expressive_agent/agent.py):

session = AgentSession( stt=inference.STT("assemblyai/universal-3-5-pro", language="en"), llm=inference.LLM("google/gemma-4-31b-it"), tts=inference.TTS(config.voice.model, voice=config.voice.voice), turn_handling=TurnHandlingOptions( turn_detection=inference.TurnDetector(version="v1"), interruption={"mode": "adaptive"}, preemptive_generation={"enabled": True}, ), expressive=config.expressive, ) await session.start(agent=Friend(), room=ctx.room) await ctx.connect() await ctx.room.local_participant.set_attributes(config.attributes())

可以看出:

  • 整条管线全部走LiveKit Inference:STT 用 AssemblyAI Universal-3.5 Pro(英文)、LLM 用 Google Gemma 4 31B、TTS 由前端 dispatch 的语音名动态决定、轮转检测用 LiveKit turn detector(v1),与 README 描述一致(见 examples/expressive_agent/README.md);
  • turn_handling启用了 adaptive 打断模式与预生成(preemptive generation),让"朋友式"对话更自然;
  • expressive=config.expressive直接把解析后的 dispatch 配置传给会话;
  • 会话建立后通过set_attributes把配置回显为参与者属性,供前端展示。

本地运行:两步启动

按 README(examples/expressive_agent/README.md),先在仓库根目录(或环境变量)提供 LiveKit Cloud 凭证(放在../.env),然后:

uv sync --all-extras --dev # 在仓库根目录执行 uv run agent.py console
  • uv run agent.py console:启动本地 console 会话,适合快速试听效果;
  • uv run agent.py dev:把 Agent 连接到 LiveKit Cloud,供真实前端接入会话。

依赖声明在 examples/expressive_agent/pyproject.toml:

dependencies = [ "livekit-agents>=1.6", "python-dotenv>=1.0.0", ]

值得注意的细节:pyproject.toml中设置了package = false,并刻意不引用 workspace 的[tool.uv.sources],其注释说明——示例是"脚本集合而非可安装分发包",保持零 workspace 引用才能让该目录脱离仓库独立解析依赖

若需容器化部署,examples/expressive_agent/Dockerfile 与其他示例共用(构建上下文中 pyproject.toml 必须能独立解析),镜像基于 Python 3.13 slim + uv,并在构建期执行python -m livekit.agents download-files预下载模型权重(如 silero VAD、turn-detector 等),避免上线后冷启动卡顿,最终以python agent.py start启动。

前后端契约:dispatch 元数据与回显属性

README 强调:Agent 读取自身的 dispatch metadata,前端在连接时即可选择管线,无需重新部署(见 examples/expressive_agent/README.md)。契约形状由 examples/expressive_agent/protocol.py 顶部的 docstring 一锤定音:

{ "expressive": true, "tts": "fishaudio" }
  • expressive:布尔值,默认true,开关 expressive mode;
  • tts:从protocol.py的语音表选择一个声音:fishaudioinworldcartesiaxai

会话建立后,两个值会作为参与者属性回显给前端(expressivetts_providertts_label),前端据此展示当前激活的管线。

protocol.py 实现要点

SessionRequest是 pydantic 模型(examples/expressive_agent/protocol.py):

class SessionRequest(BaseModel, extra="ignore"): """The dispatch metadata, as sent. Unknown fields are ignored so an older agent still starts against a newer frontend.""" expressive: bool = True tts: str | None = None @classmethod def parse(cls, metadata: str | None) -> SessionRequest: """Read a dispatch metadata blob. Anything malformed falls back to defaults, because a demo that starts with the wrong voice beats one that fails to start."""

两个设计很实用:

  • extra="ignore":未知字段被静默忽略,旧 Agent 也能在更新的前端下正常启动
  • parse容错:metadata 为空或 JSON 解析失败(ValidationError)时回退到默认值并打 warning——"用错声音启动的 demo 胜过起不来的 demo"。

语音表(examples/expressive_agent/protocol.py)定义了四种可选项:

providermodelvoice显示标签
fishaudiofishaudiofishaudio/s2.1-pro51b44863613e405a896f7f4294c6e6d0Fish Audio S2.1 Pro (Marley)
inworldinworldinworld/inworld-tts-2AshleyInworld TTS 2 (Ashley)
cartesiacartesiacartesia/sonic-39626c31c-bec5-4cca-baa8-f8ba9e84c8bcCartesia Sonic 3 (Jacqueline)
xaixaixai/tts-1evexAI TTS 1 (Eve)

resolve()把请求中的tts键解析为具体的Voice,未知键回落到默认fishaudio;随后SessionConfig.attributes()(examples/expressive_agent/protocol.py)生成回显属性,注意协议中属性必须是字符串,因此布尔值被序列化为"true"/"false"

def attributes(self) -> dict[str, str]: return { "expressive": "true" if self.expressive else "false", "tts_provider": self.voice.provider, "tts_label": self.voice.label, }

关于 xAI 的一个特殊之处

README 特别提醒(见 examples/expressive_agent/README.md):xAI 通过韵律(prosody)与声音标签引导表达,但它没有独立的 expression 标签,因此不会发布lk.expression。它的语音依然富有表现力,只是前端想展示"情绪指示器"时会没有数据可读。这个行为在protocol.py的语音表中得到印证——xAI 条目与其他三家的字段结构完全一致,差异只来自 provider 的 markup 方言能力。

深入框架:expressive mode 的底层实现

ExpressiveOptions:不止一个布尔值

框架层面expressive参数除了bool还接受ExpressiveOptions字典(见 livekit-agents/livekit/agents/voice/agent_session.py):

class ExpressiveOptions(TypedDict, total=False): """All keys are optional; common shapes: - {"speech_steering": {...}} — steer delivery and non-verbal sounds on top of the provider-agnostic default instructions. - {"tts_instructions_template": "..."} — a fully custom prompt. - {"tts_instructions_append": "..."} — your own rules appended to the template. """ speech_steering: SpeechSteeringOptions tts_instructions_template: Instructions | str tts_instructions_append: str

其中SpeechSteeringOptions提供三档表达微调(livekit-agents/livekit/agents/voice/agent_session.py):

  • disfluencies(默认True):是否允许填充词("um" / "uh"),设为False可退出;
  • nonverbal_sounds:允许 TTS 发出哪些非语言声音,True保留全套词汇,False全部禁用,也可传NonverbalOptions字典按类别开关;
  • pace"slow"|"normal"|"fast"

resolve_expressive_options(livekit-agents/livekit/agents/voice/agent_session.py)负责把用户配置解析成面向具体 provider 的最终指令:先基于默认模板,把speech_steering渲染成 provider 专属的 delivery 指南追加进去,再应用显式的tts_instructions_template覆盖,最后追加tts_instructions_append(用户自由规则永远最后生效、优先级最高)。未设置的 steering 字段回退到默认值。

值得注意:Agent上也存在expressive属性,且Agent 上的值会覆盖 Session 上的值(见 livekit-agents/livekit/agents/voice/agent.py 与 livekit-agents/livekit/agents/voice/agent_session.py 的注释)。

TTS.Markup:插件如何声明表达力

框架在 livekit-agents/livekit/agents/tts/tts.py 定义了TTS.Markup内部类,作为 expressive 管线的能力声明点:

  • _provider_key():返回""表示不支持 markup(不注入指令、不做归一化/转换);插件覆盖它即声明了自己"会说哪种方言",其他 markup 方法都会经由这个 key 委托到共享表;
  • info:暴露该语音的MarkupInfo(含nonverbals非语言声音矩阵);
  • llm_instructions():返回描述可用 markup 标签的 LLM 指令文本,框架在 expressive 模式下把它注入系统 prompt(也就是上面{tts.markup.llm_instructions}的取值来源)。

Markup 的完整数据流

在 livekit-agents/livekit/agents/inference/tts.py 可以看到合成的关键链路:会话开始时框架快照 expressive 状态,然后分句器(sentence_tokenizer(provider, expressive=...))以 provider 的 markup 方言切句,文本先经markup.normalize归一化,token 再经markup.convert转换后送交 TTS。相关的文本处理函数集中在 livekit-agents/livekit/agents/tts/_provider_format.py:

  • normalize_markup(provider, text):把 LLM 产出的标签归一化为 provider 的原生语法;
  • convert_markup(provider, text):归一化后进一步转换为该 provider 可消费的标记;
  • split_all_markup/strip_all_markup/strip_expr_markup:把标签从文本中剥离——转写 sink 是 provider 无关地统一剥离 markup 的(见 livekit-agents/livekit/agents/tts/tts.py 注释),这保证了用户看到的转写始终干净;
  • steering_instructions(provider, steering):把SpeechSteeringOptions渲染成 delivery 指南;只有真正改变默认值的字段才产生输出,被禁用的声音不会出现在广告词表中;
  • sentence_tokenizer(provider, *, expressive):返回 provider 专属的分句器;此外_MAX_INPUT_LEN表(livekit-agents/livekit/agents/tts/_provider_format.py)按 provider 设定了每次合成的字符上限(如 inworld 900、cartesia 400),expressive 模式下同时充当句子批量分组的上限。

关闭 expressive 时的历史清理

框架还处理了一个隐蔽的边界:当某一轮以 expressive off 运行时(显式关闭,或当前 TTS 无 markup 方言),_strip_assistant_markup会把历史 assistant 消息中的 markup 全部剥离(见 livekit-agents/livekit/agents/voice/generation.py)——否则历史里残留的标签会 few-shot 诱导 LLM 继续输出无人转换、无人剥离的 markup。而且一旦某一轮以 off 运行,之前轮次的 markup 就永久清除了(即使之后重新开启 expressive,也因为指令已重新注入而保持一致性)。

适用前提:哪些 TTS 支持 expressive

README 明确(examples/expressive_agent/README.md):expressive mode 要求livekit.agents.inference.TTS模型声明了自己的 markup 方言。Fish Audio、Inworld TTS 2、Cartesia Sonic 3、xAI 均满足条件;而没有方言的 provider 会正常合成、该标志保持惰性(inert)——不会报错,只是表达标签不生效。这与框架侧TTS.Markup._provider_key()默认返回""(不支持 markup)的实现完全对应。

对比实验:开与关,感受"表达层"的价值

README 把对比本身称为这个 demo 的意义所在(见 examples/expressive_agent/README.md):分别以expressive=Trueexpressive=False各跑一次,对两边说同样的话——文字内容几乎一样,但听感天差地别

  • 开启时:模型按 markup 指南输出情绪标签、语速标记与拟声词,TTS 渲染出兴奋、低落、停顿等听感;
  • 关闭时:同一段文字以平铺直叙的方式合成,表达标签不生成也不渲染。

具体操作上,你可以在连接时通过 dispatch metadata 传入{"expressive": false, "tts": "fishaudio"}(对应protocol.pyexpressive字段默认true、可显式置false),也可以直接改agent.py中传给AgentSessionexpressive=config.expressive的值。由于回显属性expressive会实时反映配置,前端可以在界面上直接看到当前会话处于哪种模式。

小结

examples/expressive_agent演示了 LiveKit Agents 中 expressive mode 的完整落地路径:一个布尔标志 + 一个前端可选的语音表 + 一个只管"说什么"的 persona prompt,就能得到一个情绪随对话起伏、转写却始终干净的自由语音 Agent。理解这套机制的钥匙在于三层契约:

  1. 会话层AgentSession(expressive=...)是唯一入口,可扩展为ExpressiveOptions精细控制表达;
  2. 插件层TTS.Markup._provider_key()决定 provider 是否具备 markup 方言,llm_instructions把方言教给 LLM;
  3. 管线层update/remove_expressive_instructions维护 prompt 注入,normalize/convert/strip系列函数保证"标签进音频、不进转写"。

如果你要在此基础上继续深入,可以依次阅读 examples/expressive_agent/agent.py、examples/expressive_agent/protocol.py、livekit-agents/livekit/agents/voice/agent_session.py、livekit-agents/livekit/agents/tts/_provider_format.py 这几处核心实现,即可从"会用"进阶到"能改"。

【免费下载链接】agentsA framework for building realtime voice AI agents 🤖🎙️📹项目地址: https://gitcode.com/GitHub_Trending/agen/agents

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询