一直觉得用 LLM 写小说有个问题:所有角色的”声音”都是同一个模型发出来的,很难有真正的个性差异。
一个自然的想法是:把每个角色拆开,给每个角色单独的 system prompt,让他们各自扮演,然后轮流发言,把对话拼成一个场景。这跟直接给一个 prompt 让模型写完有什么本质区别?
我做了一个实验来对比两者。
实验设计
场景选了一个悬疑问询场景——暴风雪夜晚,废弃别墅里发现尸体,侦探问询四个嫌疑人/证人。角色设定如下:
- 旁白:只负责场景描写和动作描述,不写台词,电影感短句风格
- 林浩(侦探):40 岁刑警,说话简洁有力,每次输出动作+一两句台词
- 张薇(嫌疑人):死者秘书,表面配合,实则惊恐,有隐瞒
- 陈默(嫌疑人):死者合伙人,西装笔挺,冷静得反常,擅长反问
- 王老(证人):老管家,说话迂回,用暗示方式透露线索
方式一(直接写作):把背景和角色信息塞进一个 prompt,叫模型直接写 600-800 字的完整场景。
方式二(多角色扮演):每个角色有独立的 system prompt。所有角色共享一个 story_log(对话历史),按 旁白 → 侦探 → 张薇 → 陈默 → 王老 的顺序轮流调用,每次调用前把已有的 story_log 作为 context 传入,跑 3 轮(共 15 次 API 调用)。
实现
项目结构很简单:
novel-roleplay/├── config.py # 故事背景 + 角色 system prompt├── direct_writer.py # 方式一├── roleplay_writer.py # 方式二└── compare.py # 主程序,rich 输出对比多角色扮演的核心逻辑是维护一个共享的 story_log,每个角色轮到时看到完整的故事进展:
story_log: list[dict] = [ {"role": "user", "content": f"【故事背景】\n{STORY_PREMISE.strip()}\n\n现在开始按顺序写作,你是其中一个参与者。"},]
for round_num in range(1, ROLEPLAY_ROUNDS + 1): for role_name in role_order: role_cfg = ROLES[role_name] context_msgs = list(story_log) context_msgs.append({"role": "user", "content": f"现在轮到【{role_name}】发言。请继续推进故事,紧接上文内容。"})
text, usage = _call(client, role_cfg["system"], context_msgs) # 把这段发言追加进共享 story_log,下一个角色能读到 story_log.append({"role": "assistant", "content": f"【{role_name}】{text}"})每次调用用 deepseek-chat 模型,temperature=0.92,单次最多 300 tokens。
DeepSeek API 完全兼容 OpenAI Python SDK,只需要换 base_url:
from openai import OpenAI
client = OpenAI( api_key=os.environ["DEEPSEEK_API_KEY"], base_url="https://api.deepseek.com",)结果
跑一次的数据:
| 指标 | 直接写作 | 多角色扮演 |
|---|---|---|
| 字数(含标点) | 1128 | 1673 |
| API 调用次数 | 1 | 15 |
| 耗时 | 11.1s | 26.4s |
| 输入 tokens | 162 | 10836 |
| 输出 tokens | 698 | 1062 |
| 总 tokens | 860 | 11898 |
token 消耗差了将近 14 倍。输出 tokens 只差了 50%,大头在输入 tokens——差了 67 倍。
每次调用都携带完整的 story_log。第 1 轮第 1 个角色调用时 story_log 只有背景信息,没什么压力。但到第 3 轮第 5 个角色时,story_log 里已经有 14 段角色发言,加上背景和本轮提示,每次调用的 prompt 就有几千 token。15 次调用累计下来,输入 token 急剧膨胀。每个角色想”读懂当前进展”,就必须看到前面所有的输出,结构决定的。
两种方式写出来的东西差在哪
直接写作 的优点是叙事连贯,节奏控制在模型一个人手里,不会出现前后矛盾或突然换风格。缺点是所有角色的声音质感很接近——张薇的惊恐和陈默的冷静,在语言层面的差异不够大,都是同一个”作家”在写不同角色的台词。
多角色扮演 里,陈默确实比直接写作版更有个性。比如第一轮他的发言:
陈默(将西装袖口的纽扣慢条斯理地扣上):“你确定那是我的声音,张秘书?还是说,你更希望那是我的声音?”
这句”你更希望那是我的声音?“的反将一军,在直接写作版里没有出现过——直接写作版的陈默更被动,是在回答问题。多角色版的陈默是独立的一个 agent,他的 system prompt 告诉他”擅长反问,每句话都在试探侦探底线”,他就真的在用反问施压,主动博弈,而直接写作版的陈默只是被动回答问题。
王老的迂回感也更强。直接写作版里他给出了直接的关键线索(九点半听到争吵声、说了”背叛”),多角色版里他一直在绕:
王老(缓缓从阴影中走出):“老爷待我三十年,我这把老骨头该说的不该说的,心里都有数。只是这雪夜里的脚步声,可不止两双啊…”
信息量更模糊,但角色质感反而更强。
不过多角色扮演也有一个明显问题:旁白越来越重复。每轮旁白都会提到”壁炉”和”暴风雪”,第二轮甚至加了”【旁白】“的字符前缀(模仿其他角色的输出格式),这是 story_log 里的格式污染——前面的角色发言都带着【角色名】前缀,旁白自己写着写着也学会了加。
token 膨胀怎么处理
这是所有多 agent 写作系统的通病,有几个常见方向:
滑动窗口:只保留最近 N 段发言。简单粗暴,但会导致角色”遗忘”早期情节。对悬疑类型影响很大——林浩第一轮问的问题,到第三轮就忘了自己问过了。
摘要压缩:达到某个 token 阈值时,把旧的 story_log 压缩成一段摘要,替换原内容。压缩质量依赖模型本身,如果摘要丢失了关键细节(比如王老手上的伤痕),后续角色就没有上下文来呼应。
结构化状态:不用自然语言的 story_log,而是维护一个结构化的”当前事件状态”——谁说了什么、揭露了什么线索、场景在哪。每个角色调用前只注入和他相关的状态字段。实现复杂但 token 效率最高。
当前实验用的是最朴素的方案:每次都传完整 story_log。对于 3 轮 × 5 角色的规模还撑得住,如果要跑 10 轮以上,token 消耗会非常难看。
小结
两种方式没有绝对的优劣,取决于你要什么:
- 要一段质量稳定、叙事连贯的文字 → 直接写,快、便宜、不容易出格式问题
- 要角色声音有明显个性差异、对话有真实的博弈感 → 多角色扮演,代价是 token 消耗大幅上升,且随轮次线性膨胀
有意思的一点是:多角色扮演的关键在于给每次调用套上不同的身份约束,让模型在一个压缩的角色视角里工作。这个约束是有效的——结果里可以看到明显的角色差异,不是随机噪声。
学术界已经有正式研究在探索这个方向(比如 ACL 2025 里的 multi-agent story writing 工作),基本结论也一致:多 agent 在复杂角色交互场景里比单 prompt 更有优势,但 context 管理是核心工程挑战。
实验的完整代码结构放在上面,拿去改着玩就行。