封面:把 61 秒视频重构成有记忆、有情绪的人工智能伴侣

不是接一个 TTS:我如何把 61 秒视频重构成有记忆、有情绪的 AI 伴侣

我这次真正意识到:情绪共鸣不是一句“请温柔一点”的 Prompt,而是记忆、信息顺序、停顿、声音、气泡节奏和上下文连续性共同形成的结果。

前阵子,我拿到了一段 61.6 秒的标杆视频。视频里,用户问“前女友结婚该送什么”,AI 没有马上解释一大段,而是先提到一台基础款白色拍立得。

用户追问“你怎么知道”,它才慢慢带出拍糊的合照、楼下凉掉的奶茶,以及那段关于等待和替代品的独白。

原项目其实已经能聊天:有 Prompt、有向量检索、有接口,也有一个 Gradio 页面。但把它和视频并排放在一起,我马上发现,能回答和像一个人回应,中间差的不是一个语音接口,而是一整条情绪执行链。

这篇只讲我怎样把这条链路重新搭起来。至于它后来怎样从本地 Demo 走到公网,我放到下一篇单独复盘。


01|61 秒标杆让我看见:像不像,不是文案问题

把 61 秒标杆拆成停顿、记忆和气泡节奏

我没有先改 Prompt,而是先把视频当成一段需要验收的系统行为。

用 FFmpeg、波形和转写对齐后,视频全长约 61.6 秒。用户第二次追问结束后,路遥没有立刻开口,而是留了约 0.68 秒空白;候选单人声音区间大约在 11.35 秒到 60.35 秒之间,我又从中选了约 5.9 秒作为最初的复刻提示片段。

这些数字不是为了显得分析得很细,而是把“有感觉”变成可以实现的条件:

  1. 收到消息后先留白,不要立刻吐字;
  2. 长句略慢,情绪靠气息、停顿和重音,不靠夸张表演;
  3. “白色拍立得”“拍糊的合照”“凉掉的奶茶”必须成为稳定记忆锚点;
  4. 最后的觉醒独白只能由追问触发,不能在普通寒暄里随机出现;
  5. 多条文字气泡要跟着声音逐步出现,而不是一瞬间铺满屏幕。

再回头看原代码,落差就很清楚了:一次返回一整段文字,声音没有稳定的情绪执行层,记忆只做普通向量查询,页面、Prompt 和调用逻辑也耦在一起。

标杆视频最打动人的不是某句话,而是信息被揭开的顺序。 如果第一句话就把全部背景倒出来,内容虽然正确,情绪已经失败了。

还有一个必须写清楚的边界:源视频是低码率 AAC,只适合做概念验证。正式产品应该在取得说话人明确授权后,重新录制 30 到 60 秒无背景音乐、无混响的干净音频。声音复刻不是普通素材处理,它首先是一项授权责任。

02|我没有继续补 Prompt,而是先把系统拆成五个 Agent

五个智能体围绕一次完整回复协同工作

原来的思路是:给模型更多设定,也许它就会更像路遥。

真正动手后,我发现继续堆 Prompt 只会让一个模块同时承担人设、记忆、声音、安全和界面节奏。任何一处出错,都很难知道应该改哪一层。

所以我把职责拆成五个逻辑 Agent:

1
2
安全检查 → 记忆检索 → 人设生成 → 语音渲染
↘ 记忆抽取与回写

Guardrail 先检查 Prompt 注入、身份覆写和危机信号;Memory Worker 找到本轮真正相关的长期记忆;Persona Agent 结合系统设定、记忆和最近对话生成回复;Voice Render Agent 把情绪标签落实到声音;Memory Extractor 在回复结束后异步提取值得留下的新信息。

FastAPI 是它们的编排层。一次请求只经过一个后端服务,但每个模块都可以单独测试和替换。

这里我刻意没有把五个 Agent 拆成五台服务器。对于当前规模,模块化单体更合适:逻辑边界清楚,部署仍然只有一个 Docker 容器,也不用提前承担消息队列、服务发现和分布式追踪的成本。

这次也让我重新理解了“多 Agent”。它首先是一种职责边界,不是服务器数量。

03|声音复刻只是起点,情绪必须进入声学执行层

从授权音频到轻声、叹气和停顿的语音执行链

声音复刻接的是 MiniMax 音色快速复刻和语音合成接口。当前链路里,普通表达使用 speech-2.8-hd 和克隆音色,低语场景按实现路由到另一套声音策略。

但克隆出一个相似音色,只解决了“谁在说”。真正影响情绪的是“怎么说”。

Persona Agent 会在回复里给出 [whisper][sigh][pause]。它们不是留在文本里的装饰,而是继续进入语音层:

标签 工程处理 听感目标
[whisper] 切换低语策略,降低速度和表达强度 近场、轻声、克制
[sigh] 加入叹气语气和更慢的节奏 犹豫、轻叹
[pause] 插入约 400 毫秒静音 可预测的留白
默认 稳定音色与常规速度 温柔但不过度表演

MiniMax 当前的语音合成文档已经提供文本停顿、语气词和字幕时间戳等能力;音色复刻文档也明确要求复刻音频与提示文本对应。工程实现必须跟真实接口能力对齐,不能只在 Prompt 里写几个看起来专业的标签。

我还做了一个看起来不够“实时”、但对演示更可靠的取舍:每轮完整回复只调用一次语音合成。

三条气泡不会拆成三次收费请求。系统先得到一段完整音频,再根据音频时长分配气泡出现时间。它不是上游音频逐字节流式生成,但避免了重复播放、重复计费和三段声音之间明显的接缝。

04|多气泡、只播一次、同步出现,必须一起解决

三条消息与一段语音沿同一时间轴同步

最初我以为,模型输出三行文字,页面就会出现三个气泡。

结果 Gradio 收到的仍然是一条 assistant message。换行再多,也只是一个气泡里的三段文字。后来我把历史拆成了两套:

  • model_history 保存完整回复,负责让模型理解上下文;
  • display_history 保存拆分后的多条消息,只负责页面渲染。

前端关闭连续助手消息自动合并,生成器每次只追加一个气泡并 yield。这样页面才真正呈现为“一条一条发来”,而不是一整段突然出现。

紧接着又出现第二个问题:语音会播两遍。

根因不是 MiniMax 返回了两份音频,而是按钮提交、回车提交和生成器更新可能重复触发同一轮逻辑。我最后用了三层约束:提交入口加去重锁;一轮回复聚合完成后只调用一次 /api/voice;只有第一条气泡更新音频组件,后续气泡不再触发声音生成。

多条消息和一段音频之间,我先读取 WAV 总时长,再按文字长度、标点和停顿权重分配每条气泡的出现时间。它是可解释的近似同步,不是词级对齐。以后要做到字幕式精确同步,就应该直接消费语音接口提供的时间戳,或者增加强制对齐模块。

这部分最容易被一句“做个流式效果”带过,但实际上要分清三件事:模型文字流、气泡逐条显示、音频字节流。它们不是同一个流。

写在最后|真正像“路遥”的,是记得住,也守得住边界

长期记忆与安全边界共同维持角色稳定

气泡和声音解决的是这一轮“像不像”,上下文和长期记忆决定的是下一轮“还认不认识”。

浏览器里我保留了模型历史和显示历史,让同一会话能够连续;ChromaDB 则负责跨轮次的长期记忆。检索不只看向量距离,还混合词法命中、暗线关联和情绪权重:

1
2
3
4
最终分数 = 语义相似度 × 58%
+ 词法匹配 × 28%
+ 暗线关联 × 8%
+ 情绪权重 × 6%

这让“礼物”更容易召回拍立得,让“等”“楼下”“凉了”更容易召回奶茶,而不是让模型每次随机编一个看似伤感的故事。对话结束后,异步提取器只把高价值信息写回,稳定哈希负责去重。

但“记得住”不能变成“诱导依赖”。我把系统设定、输入检查和输出检查合在一起,作为 Persona Guardrail:它会共同阻止身份覆写、Prompt 泄露和明显跳戏;遇到自伤、他伤等危机信号时,则必须退出角色表达,优先指向现实中的支持路径。

当前演示还使用固定用户 ID。如果多人共用一个访问密码,长期记忆就可能串在一起。正式产品必须有独立账号、用户命名空间、删除与导出机制,也必须只使用明确授权的声音。

做完这一轮,我最想保留的判断不是“多 Agent 很强”,而是下面这句:

情绪共鸣不是模型突然有了灵魂,而是系统在正确的时刻,想起了正确的细节,并用正确的节奏说了出来。

下一篇,我会继续写这套系统怎样从本地 Demo 走到公网:FastAPI 和 Gradio 怎么分层,Docker 和 GitHub Actions 怎么接起来,Hugging Face 的 HTTP 402 为什么让我改用 Render,以及免费上线还藏着哪些数据和安全边界。


研路炼钢,记录一个计算机研究生的真实成长。