我做 Soul-TTY 的起因很偶然,最开始只是有一天看到一篇语音模型的文章,想试试现在的本地语音识别和语音合成究竟到了什么程度,便部署了 ASR 和 TTS,又接上 LLM,做了一个语音对话 Demo。

当时没有产品计划,也没有想到“长期陪伴”这些词,目标只是让电脑能在本地听懂我说话,再开口回应。

Demo 跑起来后的效果比我预想的好,于是我又看了一些同类项目,它们大多在网页、Live2D、表情和声音上投入很多。这个方向没有问题,只是如果我也继续做一层视觉外壳,最后很可能还是一个带角色形象的聊天窗口。

我开始关注一个角色在声音和外表之外,能否记得过去,有自己的状态,并且不必每次都用同一种方式回答。Soul-TTY 后来的功能,基本都是沿着这个问题一点点加出来的。

为什么放在终端里

我每天本来就在终端里工作,所以最先做的是 TUI,没有先写网页。这里没有什么特别的设计哲学,主要是顺手,也适合快速验证。

做出来以后,我反而觉得终端和这个项目很合适。界面上能用的东西很少,头像也只是字符画,注意力自然会落到对话本身。声音是否及时,上一件事有没有记住,情绪变化会不会影响回应,这些问题很难再被动画遮过去。

第一版的结构非常普通:

  1. 用户输入
  2. LLM
  3. 回复

换成语音以后,它依然只是一个会说话的聊天客户端。为了让默认角色 Serena 不至于每次启动都像刚认识我,我先从 System Prompt 入手,写她的名字、性格和说话方式。实际聊了一段时间后,Prompt 很容易被长对话冲淡,角色也会慢慢回到通用助手的语气,于是我把原本写在 Prompt 里的东西陆续拆了出来。

把状态从 Prompt 里拆出来

Emotion 记录当前的愉悦、平静、好奇、压力和活力,这些数值会随互动改变,也会慢慢衰减。它们不直接决定一句固定台词,只影响这一轮说话的长度、语气和主动程度。

Bond 记录关系变化,跨启动保留,从初识慢慢变成熟悉和信任,但不做成好感度进度条,也不用来解锁内容。它只影响称呼、熟悉程度和表达边界。

Memory 则保存用户画像、偏好和共同经历。当前对话仍由 Conversation History 负责,情绪归 Emotion,关系归 Bond。我不想让同一件事在几套状态里各存一份,最后谁也说不清哪一份有效。

后来加入 Agency 时,我想处理的是回答之前的选择。普通聊天程序的默认动作是“用户问一句,模型答一句”,但真实交流并不总是这样,有时只需要简单附和,有时适合追问,也有不想继续说话的时候。Soul-TTY 会先由本地策略判断这一轮是否回应、回应到什么程度,再让 LLM 组织具体内容。

代码里还分开保存了 desire_to_talkdesire_for_company,Serena 可以暂时不想说话,但仍然希望有人在场。这个细节未必最终会保留,不过我也因此发现,如果所有状态最后都只落到“多说几句”或“少说几句”,那前面设计得再复杂也没有多少意义。

在记忆上绕过的路

Memory 是我改动较多的一部分,最初准备接入通用的 Agent Memory 框架,自动抽取、向量检索和长期存储都有现成方案,看起来可以省下很多工作。

实际接入以后,麻烦变成了“到底应该记住什么”。每轮都抽取,系统会保存很多没有长期价值的信息,检索结果一多,Serena 又会反复提起用户事实,听起来像在背档案。

我最后还是用了 SQLite 和自己写的抽取、召回规则。稳定偏好可以长期保留,具体经历只在相关话题出现时召回,Secret Mode 里的内容则明确不进入普通记忆。这套方案没有通用框架完整,但目前更容易控制,也方便我继续调整记忆的边界。

最费时间的其实是声音

如果严格按照“说完一句,再等系统回答”的顺序,语音接入并不算难,开始做全双工以后,问题一下子多了起来。

实时语音
  1. Mic
  2. VAD / AEC
  3. Streaming ASR
  4. LLM
  5. 语义段缓冲
  6. TTS
  7. Playback

Serena 播放声音时,麦克风还在收音,系统要判断传回来的声音是扬声器回声,还是我真的在打断她。一旦确认打断,正在播放的音频和已经排队的内容都要取消,ASR 也要接住新一轮输入。VAD、AEC、识别断句、TTS 分段和播放状态只要有一个环节不稳,表现出来就是抢话、误打断或者迟迟没有反应。

为了缩短等待,LLM 不会等整段内容生成完再交给 TTS。程序先积累一个能够独立朗读的短句,当前句播放时,后面的文字继续生成和合成。这样比逐字朗读自然一些,但也引入了新的收尾和取消问题。

我原先以为最需要调的是模型,实际花时间最多的却是这些时序,做到后面,它已经更像一个小型实时通信程序,而不是把几项语音 API 接在一起。

主对话不能被状态拖慢

情绪、关系和记忆都需要分析完整对话,但这些工作不能卡在回答之前。否则状态越多,每轮等待越长,陪伴感反而先被延迟消耗掉了。

现在的处理分成两条路径:

正在发生的对话
  1. 听见
  2. 理解
  3. 回应
稍后处理
  1. 完整对话
  2. Reflection
  3. Emotion、Bond、Memory

主链路只处理正在发生的交流,旁路等空闲时再做 Reflection、情绪评估和记忆抽取。新对话到来就让出资源,一次分析失败也不影响这一轮回答。这种拆法没有增加可见功能,却解决了本地模型争抢资源时最直接的问题。

现在做到了哪一步

Soul-TTY 目前的结构已经不再是最初那个 Demo:

  1. 感知语音、文字、声学线索
  2. 状态Emotion、Bond、Memory、Need
  3. 决策Response Policy、Agency
  4. 表达LLM、TTS、TUI、Avatar

它在 Apple Silicon macOS 上的体验最完整,ASR 使用 sherpa-onnx 和 Streaming Paraformer,TTS 使用 MLX-Audio 与 Qwen3-TTS,LLM 可以连接 llama.cpp 或其他 OpenAI-compatible endpoint。角色状态和长期记忆默认保存在本地。

Serena 也有默认、工作和深夜三套外观,头像会眨眼,口型由实际播放音量驱动。Secret Mode 使用独立会话,不写入长期记忆,也不改变普通关系状态。这些功能有些是为了体验,有些只是我用来验证边界的实验,后面还可能继续调整。

外放效果仍会受麦克风位置、扬声器和房间混响影响,旁路模型偶尔也会与实时对话争夺资源。主动回忆和未完成话题目前只是初步实现,它已经可以使用,但还没有自然到让我忘记背后是一套程序。

回头看,Soul-TTY 没有经历“先确定目标,再按设计实现”的过程。它从一次本地语音实验开始,每做完一步,我再决定下一步是否值得继续。现在我比较确定,模型只解决了角色能说什么,而角色能否持续存在,还要看状态、记忆和行为怎样一起工作。

这个判断仍在代码里验证,项目也还会继续改。

项目代码放在 GitHub