Developer notes / Archive
SenrenTalk:一个多角色 AI 对话应用的完整架构拆解
从 React 与 Express 的入口开始,拆解 SenrenTalk 如何用 LangGraph、群聊房间调度、混合检索、记忆与 SSE,把角色陪伴体验落到可维护的工程链路中。
目录 / On this page
背景:死宅想要的不是一个会说“主人”的接口
先承认,这个项目的出发点确实带着一点死宅工程师的执念:既然角色陪伴都做到了聊天框里,那至少该让角色记得自己是谁、记得刚刚发生了什么,也别在三个人群聊时像抢麦的客服机器人一样轮流发送万能安慰。
最初的想法很朴素——给模型一份人设,接上聊天接口,等它自己“入戏”。实际跑起来才发现,模型很容易在长对话里忘词;角色 A 会突然继承角色 B 的口头禅;群聊一放开,大家又可能礼貌地把同一句话换个说法复读一遍。看似是提示词不够长,实际上是角色、记忆、房间规则和生成过程都缺少了明确的工程边界。
做角色扮演对话时,最容易先做出来的是一个“能回复”的聊天框;真正难的是让它在多轮交流里维持角色关系、在群聊中知道谁该说话,并且把模型的不确定性限制在可理解的产品规则里。
SenrenTalk 不是把一个大模型接口直接接到消息列表上。它把单个角色的一次发言抽成可复用的 LangGraph 执行单元,再由房间级协调器组织多人对话。这样做的目的不是让架构看起来更复杂,而是把“生成一句话”和“管理一场对话”分成两层可以分别调试的职责。
先看结果:产品要解决什么
项目面向角色陪伴和多角色群聊,而不是通用问答。单聊需要维持称呼、语气、关系和禁止项;群聊还要处理发言顺序、定向回应、何时结束以及同一角色的重复输出。

从界面看,它仍然是熟悉的聊天产品;从服务端看,每一次回复都要经过上下文、检索、记忆、校验和事件回传。将这些步骤显式化,才有机会定位“角色为什么跑偏”或“群聊为什么多说了一轮”。
全局架构:把聊天、调度和基础设施拆开
SenrenTalk 的请求从 React 前端进入 Express 服务。ApiService 负责并发控制与任务生命周期,AppRuntime 负责会话、附件和用户消息的持久化;之后 ChatSessionService 根据场景分流到单角色图或群聊协调器。
这里最关键的边界有三条:
- 单角色图负责生成一次有效发言。 它不关心整个房间应当轮到谁。
- 群聊协调器负责房间节奏。 它规划发言者、维护轮次状态,再重复调用单角色图。
- 记忆、检索与语音是能力服务。 它们增强对话,但不会改变前端、HTTP 入口和房间调度各自的职责。
这种分层也让可选能力有了清晰的降级路径。最小可用启动可以关闭 Elasticsearch 和 TTS,先验证单聊、群聊、SSE 与 SQLite;需要检索增强时,再构建对话索引并接入 embedding 服务。
一条消息如何变成流式回复
前端将文本、会话模式、参与者和可选附件提交给服务端。路由解析上传内容后,ApiService 会先检查同一个会话是否已有运行中的任务,避免同一房间同时启动两次生成;通过检查后,运行时保存用户消息并启动生成会话。
单角色 StateGraph 的核心不是“把提示词发给模型”,而是按顺序完成几次准备工作:
prepare_turn确定当前角色和本轮检索问题。extract_tags提取场景、情绪、功能和语气等线索;失败时退化为空结果,不阻塞主链路。retrieve_context对角色语料执行混合检索,结合向量、BM25 与标签匹配。retrieve_memory取回摘要、情景与核心记忆,并以不可信参考资料的身份进入后续提示词。build_prompt固定角色身份、自称、关系和禁止项,再交给模型流式生成。
生成过程中,前端不必等待整段文本完成。服务端会通过 SSE 持续发送 status 和 token,让界面按增量显示;回复成功后才发送 message_done。这条链路既能带来更及时的反馈,也让前端能展示当前处于检索、生成还是收尾阶段。
角色稳定不是一段人设,而是三层输入
参考“角色提示”和 AI 伴侣记忆的实践,比较稳妥的做法不是把越来越长的人设一直塞进对话,而是让不同信息各自承担不同职责。SenrenTalk 的现有链路恰好已经具备这三个位置:
- 角色锚点:
build_prompt固定身份、语气、自称、关系与禁止项。这些是每一轮都要出现的“不能变”。 - 当前现场:最近消息和
groupContext负责说明此刻谁在说话、正在讨论什么、是否被点名。这些信息必须新鲜,但不需要永久保存。 - 可召回经历:摘要、核心记忆与检索结果补回很久以前却和当前有关的事实;它们只作为参考资料注入,不能覆盖角色锚点。
这样划分之后,排查角色“串台”也有了顺序:先检查锚点是否丢失,再看房间上下文是否带错,最后检查召回内容是否过期或不相关。输出校验失败时,项目会回到检索阶段重新组织上下文,而不是只在末尾追加一句“请保持人设”;后者通常只能短暂修补结果,不能修正输入本身。
为什么输出校验必须回到整条链路
模型流式输出完成后,项目会检查禁止词、自称以及图片身份一致性等问题。如果第一次结果不通过,系统不是简单地替换一句文案,而是从检索、记忆、提示词到模型调用重新跑一遍;仍不通过则终止本次回复且不保存 assistant 消息。
这个取舍成本更高,但它把“角色约束”从一段静态提示词变成了可以失败、重试和观察的工程流程。对于角色扮演场景而言,宁可明确终止一次不可靠的回复,也不应把明显越界的内容当作正常对话历史写入数据库。
群聊不是多个角色顺序说话
群聊复用单角色图,但外面多了一层 GroupChatCoordinator。它不直接生成文本,而是维护一间“有状态的房间”:决定谁先回应、给角色补齐群聊上下文、记录本轮结果,并用房间级 SSE 事件让前端知道当前发生了什么。
项目提供三种房间模式:
single_round:默认只跑一轮,每名参与角色最多发言一次。free_chat:允许继续多轮,但受轮数、总消息数和空转阈值限制。host_mode:主持角色优先,其他角色按房间规则跟随。
当用户点名某个角色时,协调器会把目标角色提前到参与者顺序中,并把定向信息写进 groupContext。这比只在前端高亮某个名字可靠:后端真正执行的角色顺序、回复对象和结束原因都来自同一份房间状态。
共享的是现场,私有的是角色
多角色并不意味着把所有资料混成一个提示词。房间级 groupContext 只放模式、参与者、当前轮次、目标角色、最近消息和防重复提示,用来让每个角色理解“这一刻的共同现场”。角色档案、角色语料和召回记忆仍按当前角色进入单角色图,避免 A 的性格设定或旧经历被 B 当作自己的事实。
这也是群聊最值得守住的边界:共享历史用来衔接剧情,角色私有状态用来保持身份。 当用户点名某人时,只改变本轮优先顺序与定向上下文,不把其他角色的身份或长期记忆写进目标角色。相较于让多个模型盲目轮流发言,这种拆法更容易解释“为什么这次轮到它、它凭什么知道这些、它为什么没有继续说”。

群聊里另一个实际问题是“换皮复读”。协调器会比较同一角色上一条已保存回复与本次新回复;若过于相似,先删除新消息、恢复共享历史,并附加避免重复的指令重跑一次。第二次仍相似则跳过该角色并发布 role_skipped,而不是让一句改写过的重复话继续占据对话。
同样地,单个角色执行失败不会中断整轮其他角色。协调器会记录失败、发布带角色标识的错误事件,并继续处理仍可执行的发言者。这种容错的粒度更符合群聊的直觉:一个角色失效不应让整个房间失去响应。
体验收尾:文本、记忆和语音不必绑在一起
SSE 除了单角色的 status、token 与 message_done,群聊还提供 round_started、round_plan、round_stats、role_skipped 和 room_finished 等房间级事件。前端可以因此解释“为什么现在是这个角色说话”“这一轮为什么结束”,而不是只在消息突然出现后让用户猜测系统状态。
文本成功后,记忆提取、核心记忆巩固和 TTS 会在后台继续执行。语音成功时再发送 audio_ready,失败则发送 audio_failed。这样首条文本回复不需要等待音频合成或长期记忆写入,用户体验和后端收尾任务不会互相阻塞。
目前的边界与取舍
这套架构并不承诺所有依赖在每台机器上都已就绪:
- Elasticsearch 与 embedding 服务是检索增强的前提,但不是最小聊天流程的前提。
- TTS 可以关闭,文本生成和房间调度仍可运行。
- 输出校验与反复读保护是兜底机制,不等于模型永远不会出现边界案例。
- 图片身份判断在多人、遮挡、低清或误导性提问下仍需谨慎处理。
对这个项目来说,重要的不是追求“每个功能都同步完成”,而是明确哪些结果必须在用户面前立刻可靠,哪些工作可以在后台完成、失败或降级。
参考与延伸阅读
- 万字详解:混元大模型 + GraphRAG + 知识图谱实现永久记忆的专属 AI 伴侣:从角色设定、情绪状态到记忆写入与召回,展示中文陪伴型角色如何维持连续对话。
- 角色提示:赋予模型特定身份:聚焦角色信息重入、特征提取和一致性监控,适合作为“角色不串台”的工程检查清单。
- 构建 AI 多智能体框架详解:规划器、调度执行器与记忆管理:拆解群组协作、调度职责及多智能体记忆应当如何隔离。
- 看 AgentRun 如何玩转记忆存储,最佳实践来了!:用会话历史、长期偏好和显式会话状态三层记忆解释持久化设计的边界。
♪
0:00 / 0:00