资讯详情

资讯详情

AI酒馆群聊设计:从上下文隔离到导播模式的多角色状态管理

玩过 AI 酒馆的朋友大概都有这种体验你手里明明有一张很出彩的“骑士卡”和一张“法师卡”想让他们在同一个故事里碰面。标准做法是开两个窗口左边和骑士单聊右边和法师单聊。可这不叫群聊这叫两个平行的私聊房间他们永远不会知道对方的存在。所以当我开始准备 B 站 AI 创造公开赛的 AI 酒馆群聊项目时我最想解决的并不是“让三个角色都回一句话”而是另一个更关键的问题三个角色怎么才能看到彼此做的事、接住彼此的话还始终保持各自的性格不串味、不同质、不轮流念作文先说结论一个真正能互相接戏的 AI 群聊和一个“把三个模型的输出拼在一起”的聊天框之间隔着一整套状态机。群聊不是 API 调度问题而是提示词状态管理问题。这个判断决定了我整个项目的架构方向。这篇文章会把这个项目拆开讲清楚。你会看到从单聊升级成群聊时数据结构怎么设计模型上下文怎么构建调度器怎么决定“谁该接话、谁该闭嘴”以及为什么很多群聊 Demo 看起来像三个复读机在互相复读。1. AI 酒馆群聊到底是在解决什么问题AI 酒馆通常指 SillyTavern 这类面向角色扮演的前端工具。它的核心玩法是你导入一张角色卡角色卡里有名字、性格、说话风格甚至隐藏秘密。然后你和这个角色对话模型会按照角色卡的人设去扮演这个角色。这种模式天然是“一对一”的。但在很多叙事场景里一对一的约束太难受你想看两个角色因为价值观冲突吵起来。你想作为一个旁观者看三个陌生人在酒馆里互相试探。你想让其中一个角色注意到另一个角色偷看她的眼神产生支线剧情。群聊带来的不是“人数变多了”而是“互动关系变多了”。单聊里用户是模型唯一的信息来源群聊里角色之间会产生直接的信息传播——角色 A 说了什么会改变角色 B 的判断而角色 B 的反应又会反推角色 A 的下一句话。这才是接戏的本质每一个角色不仅要回应玩家还要回应其他角色的台词。听上去很自然但模型不是人。模型读到的只是一串文本。如果你只是把三个角色设定拼到一个系统提示词里再丢给他们同样的一段历史记录你大概率会看到骑士说话开始带法师的口癖。三个角色一起复述玩家刚说的话。每个人都像在写周报一样长篇大论地总结当前情况。角色之间几乎没有互动只是轮流对着玩家说话。要避免这些问题必须先改变一个认知群聊系统真正要维护的不是一个人设列表而是一份“剧本”也就是不断追加的事件序列。每个角色读到的是这份剧本的一部分而不是全部。2. 群聊的系统设计先分清两种工作模式在设计初期我对比过两条技术路线。理解这两条路线的区别基本就理解了 AI 酒馆群聊的难度分布。2.1 模式一舞台剧模式单上下文多角色把三个角色的设定、当前场景、历史对话全部合并进一个系统提示词让同一个 LLM 同时“扮演”三个角色。优点很明显只需要一次调用成本低延迟小。角色之间的转场由同一个模型完成信息天然一致。实现起来最快几分钟就能写出一个能跑的版本。缺点也致命LLM 不是天生会“分饰多角”的演员。上下文越长角色之间的边界越模糊。当模型读到 20 轮骑士台词之后再让它输出法师的台词它很容易把骑士的语气带过来。缺少“沉默”机制。如果你不加干预它会强迫每个人都开口。如果某个角色应该存在“不知道另一条线”的信息盲区单上下文模式很难表达。这种模式适合低成本的剧情生成工具比如快速生成一段两个角色的小剧场。但作为互动群聊它不够稳。2.2 模式二导播模式多上下文单角色把调度逻辑拆出一个“导播”角色。系统为每一位角色维护独立的历史记录和独立上下文。导播负责决定谁来发言谁来沉默然后把发言同步给其他人。每个模型调用只做一个任务扮演骑士。看到公开剧本法师刚刚挑衅了你。判断自己要不要回应。如果要回应输出骑士自己的台词或动作。这个模式的核心价值是“隔离”。骑士和法师哪怕在同一个故事里模型在生成骑士台词时也只负责骑士这一个视角不会被迫同时计算三个人的反应。代价是可能需要多次调用模型比如一轮里有 3 个角色接话就有 3 次请求。延迟上升调用成本上升。需要一个可靠的调度器以及一套能把公开剧本和角色私有记忆区分开的数据结构。从实际体感来看我非常推荐第二种路线。真正让角色“活”过来的不是让一个模型控制三个人而是让每个角色都拥有一个只属于自己的上下文窗口。2.3 两种模式对比对比维度舞台剧模式导播模式调用次数每轮 1 次按实际发言角色计算角色隔离性弱容易串味强每个角色只看自己视角实现成本低中高信息盲区支持差好接戏效果上限一般高适合阶段原型验证正式项目我在比赛中演示的版本采用的是导播模式。下面各节也主要以这条路线展开。3. 核心难点拆解角色为什么容易“串味”很多人把“串味”简单理解为提示词写得不够强。实际上角色串味有三个层次的原因。3.1 角色人格在同一段上下文里互相干扰假设你把骑士和法师的设定放在同一个系统提示词里。模型的注意力机制会把两个角色的描述放在相近的位置。生成“骑士台词”时它参考人物描述时可能把所有描述都看了一遍。这有点像三个人同时在你耳边说话而你要单独记录其中一个人的发言。你能录下来但很容易把背景音里的词汇挑进来。解决思路不是继续在提示词里强调“你绝对不能像法师”而是把两个角色的生成过程在物理上分开一次只扮演一个。3.2 历史消息缺少清晰的结构化标签这是我早期 Demo 里最常见的错误把历史消息拼成一段纯文本每个角色之间只隔一个换行。模型也许能凑合理解但当消息超过几十条时它容易混淆“谁说的”。正确做法是让每一条历史都带有明确的角色标签甚至带有“这条消息是旁白这条是动作这条是台词”的标记。3.3 缺少“不发言”选项有些模型在拿到“你是骑士”时无论情况是否适合都会自发地开口说话。如果三个角色同时开口群聊就变成了三条平行独白根本没有“接戏”。要让角色之间出现真正的碰撞你必须给模型一个合法的输出选项沉默。只有允许模型选择不说话它说出的话才有分量。这三个难点贯穿了整个项目的代码实现。接下来我们从环境准备开始一步步搭建。4. 环境准备与项目结构SillyTavern 的具体版本差异比较大。前端如何与群聊脚本通信不同版本有不同方式。这里不纠结于版本号我们只提炼一种通用的实现思路核心群聊逻辑独立成一个小型 Python 服务前端只负责把用户输入转发给服务再把服务生成的事件追加进聊天日志。我之前用项目结构如下你可以直接参考ai-groupchat/ ├── main.py # 命令行入口验证群聊流程 ├── director.py # 导播调度器 ├── prompts.py # 构造每个角色的独立 Prompt ├── config.yaml # 模型配置 └── characters/ ├── knight.md # 骑士角色卡 ├── mage.md # 法师角色卡 └── ranger.md # 游侠角色卡环境要求并不高Python 3.10 以上主要使用数据类和类型标注。需要一个可以通过 API 调用的大模型服务或本地部署服务。不需要复杂的 Web 框架。先用命令行把流程跑通再接前端。角色卡的内容建议单独使用 Markdown 文件管理不要在代码里写死。因为后续调角色性格最频繁操作的就是角色文件。拿骑士举例文件内容可以是# 角色兰斯洛特 - 身份流浪骑士 - 性格外表冷淡说话克制但有极强的正义感 - 说话方式句子短喜欢直呼其名很少说形容词 - 口头禅无过度表情会被视为软弱 - 弱点对“背叛”这个词高度敏感 # 角色目标 他正在酒馆里调查一桩旧案不愿被旁人察觉。这些内容最终会被拼进 Prompt。但关键在于骑士看到的 Prompt 里不会包含“法师心里想什么”只包含法师在公开场合说过的话。5. 核心代码实现数据结构、导播与 Prompt 构建5.1 定义数据模型我们先定义两个数据类Character 和 ChatEvent。# ai-groupchat/models.py from dataclasses import dataclass from typing import Literal, Optional dataclass class Character: 角色卡加载自 characters/*.md 或直接传入。 key: str # 唯一标识如 knight name: str # 展示名如 兰斯洛特 persona: str # 角色设定文本 style_hint: str # 额外发言风格约束 max_tokens: int 250 # 单次发言的上限 dataclass class ChatEvent: 一条群聊事件既可以是台词也可以是动作/旁白。 speaker: str # 谁产生的事件user 表示用户 text: str kind: Literal[speak, action, scene] speak # speak台词, action动作, scene导播/系统描述 tag: Optional[str] None # 例如直接点名某角色方便触发回复使用统一的事件模型有两个好处历史可以是“公开剧本”的扩充也方便后续接入前端日志。标签字段在执行“主动点名接话”时非常有用。5.2 导播调度器导播调度器是整个群聊的核心。它的职责不是生成台词而是回答三个问题当前这轮谁最应该开口该角色应该看到哪些公开历史该角色如果输出“沉默”是否被允许# ai-groupchat/director.py from typing import Callable, Optional from models import Character, ChatEvent class GroupChatDirector: 导播调度器维护一份公开剧本并让每位角色 基于自己的独立历史决定是否发言。 def __init__(self, characters: list[Character], call_llm: Callable): self.characters characters self.call_llm call_llm self.public_events: list[ChatEvent] [] self.private_histories: dict[str, list[ChatEvent]] { c.key: [] for c in characters } def add_event(self, event: ChatEvent): 把一条事件写入公开剧本并广播给所有角色。 self.public_events.append(event) for c in self.characters: self.private_histories[c.key].append(event) def step(self, user_text: str): # 1. 用户输入先进公开剧本 user_event ChatEvent(speakeruser, textuser_text, kindaction) self.add_event(user_event) # 2. 决定候选发言顺序默认从最近发言最少的角色开始 candidates sorted( self.characters, keylambda c: len(self.private_histories[c.key]) ) # 3. 逐个尝试让角色开口最多让 2 个角色在本轮回复 responses [] for char in candidates[:2]: result self._ask_character(char) if result is None: continue # 模型选择沉默 self.add_event(result) responses.append(result) return responses def _ask_character(self, char: Character) - Optional[ChatEvent]: 为单个角色构造上下文并解析输出。 prompt self._build_prompt(char) raw_output self.call_llm(prompt) return self._parse_output(char, raw_output) def _build_prompt(self, char: Character) - list[dict]: 子类中会详细实现见 5.3。 return [] def _parse_output(self, char, raw_output: str) - Optional[ChatEvent]: 按约定的 JSON 输出解析无法解析时按台词返回。 # 这里省略 JSON 解析细节完整版用 json.loads 并做异常兜底 return ChatEvent(speakerchar.key, textraw_output, kindspeak)这个实现是骨架级的但已经把关键设计表达清楚了角色们共用公开历史但每个角色的生成上下文是隔离的。真正的“性格”不是写在导播里而是写在各自的 Prompt 中。5.3 构建角色独立 Prompt模型的输入本质上是一段文本。所谓“独立”不是指模型有多个聊天界面而是每个角色调用时我们只把该角色的视角信息和公开事件放进去。# ai-groupchat/prompts.py from models import Character, ChatEvent def build_character_prompt( char: Character, history: list[ChatEvent], window_size: int 12 ) - list[dict]: lines [] lines.append(f你正在参与一场群聊。你的名字是{char.name}) lines.append(f【角色设定】\n{char.persona}) if char.style_hint: lines.append(f【发言风格】\n{char.style_hint}) lines.append( \n【规则】\n 1. 你只能从自己的视角出发给出你自己的台词或动作。\n 2. 禁止代替其他角色发言禁止输出其他人的台词。\n 3. 如果你认为自己此刻不应该开口只输出 JSON {kind:silence}\n 4. 其余情况只能输出 JSON {kind:speak,text:台词} 或 {kind:action,text:动作} ) history_text \n--- 公开历史 ---\n for ev in history[-window_size:]: speaker char.name if ev.speaker char.key else ev.speaker if ev.kind action: history_text f[动作] {speaker}{ev.text}\n else: history_text f{speaker}{ev.text}\n lines.append(history_text) lines.append(\n现在请回应这段剧情。只输出 JSON不要解释。) # 用 system 指令承载格式要求用 user 内容承载故事。 return [ {role: system, content: 你是一个遵循严格 JSON 输出的角色扮演模型。}, {role: user, content: \n.join(lines)}, ]这里最关键的设计是模型每次只需要输出一个小 JSON而不需要在同一段回复里控制整个群聊的发展方向。这能把“群聊失控”的概率大幅降低。5.4 模型调用函数为了让代码可运行我用一个可以用环境变量配置的通用调用函数封装模型 API。放在 main.py 里方便替换。# 不要写死在代码里 export LLM_BASE_URLhttps://your-endpoint.example.com/v1 export LLM_API_KEYsk-xxxx export LLM_MODELyour-model-name# ai-groupchat/main.py import os import json from models import Character, ChatEvent from director import GroupChatDirector from prompts import build_character_prompt def call_llm(messages: list[dict]) - str: 通用 OpenAI 兼容调用。请根据你的服务端实际情况调整。 import requests url os.environ[LLM_BASE_URL] /chat/completions headers { Authorization: fBearer {os.environ[LLM_API_KEY]}, Content-Type: application/json, } payload { model: os.environ[LLM_MODEL], messages: messages, temperature: 0.8, max_tokens: 300, } resp requests.post(url, headersheaders, jsonpayload, timeout60) resp.raise_for_status() return resp.json()[choices][0][message][content] def load_character_from_md(path: str, key: str) - Character: with open(path, r, encodingutf-8) as fp: text fp.read().strip() name 未命名 if text.startswith(# ): name text.split(\n)[0].removeprefix(# ).strip() return Character(keykey, namename, personatext) if __name__ __main__: chars [ load_character_from_md(characters/knight.md, knight), load_character_from_md(characters/mage.md, mage), load_character_from_md(characters/ranger.md, ranger), ] director GroupChatDirector(chars, call_llmcall_llm) print(输入内容开始群聊输入 /quit 退出。) while True: user_input input(\n你 ) if user_input.strip() /quit: break director.add_event( ChatEvent(speakeruser, textuser_input, kindaction) ) try: results director.step(user_input) for ev in results: print(f\n[{ev.speaker}] {ev.text}) except Exception as exc: print(调度出错, exc)这样命令行版本已经可以完整跑通用户输入一句话群聊导播会决定由哪两个角色接话并分别用各自独立上下文调用模型。6. 如何让三个角色真正“互相接戏”把上面的基础版本跑起来之后你会发现一个问题角色能各自回复但似乎经常各说各的。用户随口说了一句“下雨了”三个角色都评价下雨彼此毫无交流。要制造互动性需要再加入几个机制。6.1 让角色能“点名”或者“被点名”群聊里最自然的互动方式是一个角色对另一个角色提问。我们可以让模型在输出 JSON 时附加一个 target 字段表示这句话在针对谁。{kind: speak, text: 你手上的戒指是从哪里来的, target: mage}调度器解析到 target 后可以强制把目标角色放入下一轮候选。这样角色 A 抛出的球角色 B 就得接住。在解析函数里可以这样处理 targetdef _parse_output(self, char, raw_output: str): try: data json.loads(raw_output) except Exception: return ChatEvent(speakerchar.key, textraw_output, kindspeak) if data.get(kind) silence: return None if data.get(kind) action: return ChatEvent(speakerchar.key, textdata.get(text, ), kindaction) return ChatEvent( speakerchar.key, textdata.get(text, ), kindspeak, tagdata.get(target) )有了 target 之后导播就可以在下轮把“被点名但还没说话”的角色插到候选列表前面。6.2 允许沉默但别让沉默变成常态我在前文反复强调模型要有沉默选项。但真做起来如果沉默概率太高群聊会冷场。两个调节参数可以参考如果上一条消息 tag 指向某个角色这个角色沉默的可能性要降低。如果某个角色已经连续三轮没有发言调度器可以直接把它放进候选并让它尽量说点什么。思路是在导播层做“剧情推进”而不是依赖后续模型自觉。6.3 把动作和旁白也当成一种“发言”三个角色一直在说台词读久了会腻。更真实的演出应该是骑士没有回话只是把桌上的剑推向前方。法师看到剑瞳孔微微缩了一下。游侠在旁边吹了声口哨试图缓解气氛。所以在 Prompt 里我要求模型可以输出“动作”类型事件。动作有时比台词更有信息量也是触发其他角色接戏的钩子。当导播线程中加入一条 action 事件时下面角色读到这种短句动作会比读到长篇台词更容易产生反应。6.4 截取角色的“最近发言”作为准绳另一个让群聊更稳的小技巧是在构造每个角色的 Prompt 时抽出一段“最近公开剧情摘要”放在正文开头。模型即使上下文窗口很大也会在较长的历史里迷失重点。把最近 2 到 4 条关键事件单独拎出来会让当前轮次的回应更有针对性。这部分可以用一个简单的摘要函数实现例如取历史末尾几条且只保留含角色名的事件。完整项目里还可以接一层 LangChain 式摘要链但在比赛原型里截取最近事件已经够用。6.5 一次最小可验证的模拟输出为了不依赖外部 API 也能看到调度效果我建议提前写一个 mock 函数把输出替换成指定格式的字典。下面是一个模拟的运行效果示例你 三位晚上好我路过这里想打听一个人。 [骑士] 谁的名字 [游侠] 你最好先说说名字。酒馆里打听人要看那个人愿不愿意被知道。 你 一个脸上有疤的老兵据说以前是国王卫队的人。 [法师] 你说的人我见过。 [骑士] 我劝你别追问太多。这个示例不是某个模型稳定输出的结果而是用来演示群聊数据结构的形态不同角色在不同时刻接话有人主动提问有人抛钩子。你可以把这个当作自测目标。7. 接回 SillyTavern 前端时的几种改法命令行版本只是后端实验。如果要放到 SillyTavern 这类前端里需要在接口层做转换。具体接入方式受版本影响较大我提供几个可行的方向。7.1 把前端当成“用户输入代理”这是最稳妥的接法。SillyTavern 本身只承担显示角色消息的功能。用户在输入框里发一句话时触发一个脚本把这句话发到群聊后端。群聊后端跑完多轮角色发言后把生成好的事件批量返回再由脚本把它们逐条渲染成聊天记录。这样前端的角色显示列表可以每多一个角色就多“注册”一个虚拟发言者。7.2 用中间脚本模拟多个“酒馆账号”如果你只是想在现有版本里快速体验也可以在脚本层把后端返回的每条消息转换成普通用户消息发送到酒馆中酒馆再决定回调哪个角色。这种方法限制直接需要前端支持多个角色并行发言否则消息会乱序。7.3 最小改造原则接到前端时我强烈建议不要把复杂的群聊状态塞进前端的 UI 状态里。否则前端每升级一次你的代码就崩一次。把“群聊状态机”放在后端 Python 服务里前端只做输入输出会让系统可维护性好很多。8. 常见问题与排查思路问题现象可能原因排查方式解决方案角色 A 说话越来越像角色 B两个角色共用同一段上下文或历史太长导致边界模糊检查 Prompt 和角色文件减少单段历史长度改用导播模式每次只生成一个角色的输出每轮三个角色都抢着说话Prompt 里没有沉默选项或导播候选顺序设置不当查看调度日志确认本轮调用了哪些角色在 JSON 规则中明确允许 silence角色不接上一句的梗只顾自说自话模型只看到了太长的历史忽略了最近事件检查 Prompt 中的历史窗口长度提高最近事件优先级单独摘取最近 2 到 4 条输出经常不是合法 JSON模型版本为了角色扮演而拒绝了 JSON 约束查看原始输出在 system Prompt 中强调“这是剧本内部格式”并在解析失败时退化为纯文本延迟高导播模式调用次数多统计一轮请求数限流每轮最多只让 1 到 2 个角色发言长对话后逻辑混乱只做了截断没有摘要查看全部角色历史长度引入中段总结机制9. 从比赛原型到工程化的最佳实践这个项目虽然是从比赛衍生的原型但里面有不少经验放到通用多智能体协作上同样适用。第一角色卡与代码彻底分离。把性格写死在代码里会非常痛苦因为调 Prompt 的过程本质是“调演员”而演员的脚本应该放在人设文件里。我建议把所有角色设定统一放在 characters 目录用 Markdown 写作方便随时修改。第二给每个角色单独维护“可被记住的历史窗口”。不要无限保存所有历史否则模型迟早会在大段无关内容里迷失方向。最简单的策略是只保留倒数 N 条公开事件。更复杂一点的策略是用一条“记忆摘要”代替中段的旧剧情。第三把导播输出全部写成结构化日志。每次调度需要记录哪几个角色被选中谁选择了沉默谁生成了什么事件模型返回前是否发生了 JSON 解析异常。有了这些日志你才能定位“为什么骑士变成了复读机”。第四做好成本与延迟控制。导播模式的一大问题是调用次数多。假设每轮让两个角色发言每次涉及 1 次模型调用十轮就是二十多次请求。比赛演示时如果现场网络不稳定延迟太高会直接影响效果。建议准备一版“低功耗模式”每轮只让一个角色优先发言其他角色用摘要加一次调用补足。第五演示项目需要降低随机性。在 Demo 环境里我会把 temperature 从 0.9 降到 0.7 左右同时把“输出必须 JSON”的约束放到 system Prompt 里。虽然角色扮演通常需要温度高一些才有趣但现场演示最重要的指标是“能稳定复现互动效果”。第六审慎处理内容安全。角色扮演项目天然会生成大量自由文本。如果要把项目开源或用于比赛务必在 Prompt 中加一条安全约束明确角色对话不得生成违反平台规定的内容。在测试时也建议使用偏中性的角色卡例如骑士、法师、游侠这种奇幻模板避免涉及现实人物或敏感设定。如果这个项目继续往下做我会优先补三个方向一是长期记忆系统让角色记得几天前在酒馆里发生过的事二是群聊剧本的自动摘要解决长剧情上下文爆炸问题三是效果评估让“接戏质量”不再靠人工感觉而是能通过事件之间的语义相关性量化出来。做一个能让三个 AI 角色互相接戏的酒馆群聊这个项目最有价值的地方不在于“酒馆”这个应用场景而在于它展示了一种多角色状态管理的通用方法。它把每个角色当成一个独立的世界模型再用导播层解决人物之间的消息同步。这套方法完全可以迁移到多智能体辩论、多人游戏 NPC 协作、虚拟直播互动等场景中。如果你正准备复刻这个项目我的建议是先别急着接入完整 SillyTavern先把命令行版本跑通用户输入一句话让两个角色分别用独立上下文回应。等你看到“骑士不再帮法师说话”的那一刻你对大模型上下文管理和角色隔离的理解会比空读十篇文章都深刻。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →