使用 LLM API 构建文本角色扮演场景
当模型扮演带有规则旁白,且你的代码拥有状态时,文本角色扮演场景效果最佳。本文介绍了一个 Python 小型引擎:提示词、流式输出、记忆以及长会话的成本估算。

要点
- 分工:模型编写故事,你的代码存储事实、物品和规则。
- 流式输出回复,并保持每个流足够短,以便快速释放并发槽位。
- 100,000 token 的上下文窗口可以容纳非常长的会话,但摘要可以降低成本并减少漂移。
- 200 轮会话,每轮 6,000 输入 token 和 400 输出 token,成本约为 $0.22。
从旁白开始,而不是剧情
构建文本角色扮演场景的人通常从编写剧情开始。这是反过来的。场景是玩家和旁白之间的循环,旁白需要一个职位描述,然后才需要故事。模型必须知道谁在说话、它可以为玩家做什么决定、回复应该多长,以及它绝不应该做什么。其他一切都可以即兴发挥。
场景引擎的良好系统提示词有四个部分。首先是角色:游戏主持人、采访者、飞船计算机、戏剧中的角色。其次,用几句平实的话描述世界规则:基调、背景、存在什么以及不存在什么。第三是格式规则,因为你的代码可能需要解析它们:例如,“在每个回复末尾加一行以 OPTIONS: 开头”。第四是边界。此文本 API 经过调优,可以在合法成人使用中不进行内容拒绝,这使其适合黑暗小说、具有真正威胁感的反派和道德灰色剧情。它仍然有一条硬性红线:没有涉及未成年人的性内容。在你的使用条款中也加入该规则,如果你的场景面向成人,请在应用中实施年龄限制。
将提示词保持在约 600 字以内。长规则列表往往只能被部分遵循。如果你需要许多规则,对它们进行排序并删除最后三分之一。
带有流式输出的工作循环
流式输出在角色扮演中比在其他大多数用途中更重要。阅读故事的玩家希望模型在生成内容时文字就能出现,如果 300 个 token 一次性返回,会感觉比实际更慢。API 通过服务器发送事件(SSE)进行流式输出,使用 stream: true,OpenAI SDK 负责解析。该接口兼容 OpenAI,模型 ID 为 uncensored,本站的基础 URL 为 https://api.sdxlapi.com/v1。
from openai import OpenAI
client = OpenAI(base_url="https://api.sdxlapi.com/v1", api_key="YOUR_KEY")
SYSTEM = """You are the narrator of a noir mystery set in a rainy harbour city.
Write in second person, present tense, 120 to 200 words per reply.
Never decide what the player says or does. Invent details freely, but stay
consistent with the STATE block you are given.
End every reply with one line: OPTIONS: a | b | c"""
state = {"place": "Pier 9 office", "time": "23:40", "inventory": ["brass key"], "facts": []}
history = []
def turn(player_text: str) -> str:
history.append({"role": "user", "content": player_text})
msgs = [{"role": "system", "content": SYSTEM + "\nSTATE: " + str(state)}] + history[-20:]
stream = client.chat.completions.create(
model="uncensored", messages=msgs, stream=True,
temperature=0.9, top_p=0.95, max_tokens=500,
presence_penalty=0.3,
)
reply = ""
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
piece = chunk.choices[0].delta.content
reply += piece
print(piece, end="", flush=True)
history.append({"role": "assistant", "content": reply})
return reply
turn("I check the desk drawer.")设置针对小说进行了调优。温度在 0.8 到 1.0 左右可提供多样的散文,top_p 接近 0.95 可剔除最奇怪的词选择。较小的存在惩罚会阻止旁白反复回到相同的意象。设置 max_tokens 以限制回复长度;这是可选的,但如果没有它,健谈的旁白可能会写出远超你预期的内容。对于想要完全重播的场景,在测试时传入 seed 并保持其他参数固定。
让代码拥有事实
长角色扮演中最常见的失败是上下文漂移。第 5 轮时钥匙在玩家口袋里,到第 40 轮时钥匙在桌子上。店主先叫马拉,后来叫玛尔塔。读取 30,000 token 历史的模型通常能正确处理这种情况,但“通常”不是设计。将事实保存在一个小型结构中,让你的程序更新它,并将该结构放入每个请求中,就像上面的 STATE 行所做的那样。
你如何更新状态?在每轮之后询问模型报告更改,使用 JSON 模式进行第二次廉价调用。第一次调用为玩家编写故事。第二次读取新段落并仅返回更改的内容。
import json
def extract_changes(passage: str) -> dict:
r = client.chat.completions.create(
model="uncensored",
response_format={"type": "json_object"},
temperature=0,
max_tokens=250,
messages=[
{"role": "system", "content":
'Read the passage. Return JSON: {"place": str|null, "inventory_add": [str], '
'"inventory_remove": [str], "new_facts": [str]}. Only report what the passage states.'},
{"role": "user", "content": passage},
],
)
return json.loads(r.choices[0].message.content)
def apply(ch: dict):
if ch.get("place"):
state["place"] = ch["place"]
for item in ch.get("inventory_add", []):
if item not in state["inventory"]:
state["inventory"].append(item)
state["inventory"] = [i for i in state["inventory"] if i not in ch.get("inventory_remove", [])]
state["facts"] = (state["facts"] + ch.get("new_facts", []))[-30:]限制事实列表,就像切片一样,否则状态块会增长,直到它变得和它旨在替换的历史一样重。如果 JSON 回复无法解析,跳过该轮的更新并继续;遗漏一个事实的代价比崩溃更小。
骰子、生命值和计时器最好在纯代码中处理。当玩家尝试冒险时,在 Python 中掷骰子并告诉旁白结果:“玩家尝试失败。”然后模型编写后果。这可以防止模型悄悄让玩家每次都赢,语言模型往往这样做。
支持数小时运行的会话记忆
上下文窗口为 100,000 token,包含提示词和补全。典型的 250 token 回复加上 30 token 的玩家消息,每轮约 280 token,因此原始历史本身可以容纳数百轮。你可以全部发送。许多构建者仍然不这样做,原因有两个:成本随着每一轮增长,因为整个历史每次都作为输入计费,以及旧细节可能会分散模型注意力。
合理的布局保留三层:
- 固定: 系统提示词和状态块。始终发送。
- 摘要: 一两段持续更新的段落,每 10 到 15 轮通过简短调用重写。
- 最近轮次: 最后 10 到 20 条消息的逐字记录,以保持散文的节奏。
当最近轮次的窗口填满时,将最旧的十轮折叠到摘要中并删除它们。摘要调用可以使用低温度和 300 token 的限制。只要名字、承诺和未解决的威胁进入摘要,玩家很少会注意到压缩。在总结提示词中明确说明:“保留名字、债务、伤害、未解决的问题”。
如果你希望角色保持稳定的声音,请在固定层中提供简短的风格卡片:三句其说话方式的示例,以及两句它从不说的话。简短的示例比形容词更能塑造语气。
会话成本
定价为每 1M 输入 token $0.15,每 1M 输出 token $0.50,带有预付额度,无订阅。通过长会话工作。假设 200 轮,固定层、摘要和最近轮次加起来平均每请求 6,000 输入 token,每回复 400 输出 token(故事回复加上状态和摘要调用的小部分)。
输入:200 x 6,000 = 1,200,000 token,即 1.2 x $0.15 = $0.18。输出:200 x 400 = 80,000 token,即 0.08 x $0.50 = $0.04。会话成本为 $0.22。
比较重新发送整个原始历史的懒惰版本。到第 200 轮,历史约为 56,000 token,在整个会话中,平均输入每请求约为 28,000 token。那是 200 x 28,000 = 5,600,000 输入 token,或 5.6 x $0.15 = $0.84,加上相同的 $0.04 输出,即 $0.88。仍然负担得起,但质量没有提升,账单却是四倍。对于拥有许多玩家的应用,这个差距决定了你的利润。
失败或被拒绝的请求不会收费,且额度永不失效,因此未使用的余额只是等待。充值金额为 $20 至 $500,按整美元计算,从 $50 起有 5% 的奖励,从 $100 起有 10% 的奖励,从 $200 起有 15% 的奖励。完整选项列表请参见 定价页面。
同时运行多个玩家
完整密钥允许 8 个并发请求和每分钟 300 个请求。流式请求会占用其插槽直到流结束,因此如果有 8 个插槽且回复大约需要十秒,你可以每分钟服务大约 48 个回复流,前提是没有其他请求运行。这是一个粗略的估算,而非测量数据,因为速度会随负载和回复长度而变化。重点是为此进行规划:如果你的游戏同时在线玩家数超过插槽数,请在服务器中对请求进行排队,并显示简短的“叙述者正在思考”状态,而不是同时发送所有请求。
当你超过限制时,API 返回 HTTP 429。每分钟请求的限制代码为 rate_limited,同时请求过多则为 concurrency。临时过载也会返回 429,并带有 Retry-After: 5 头。简单的重试只需等待这五秒,就足以应对休闲游戏。同时读取响应头:X-RateLimit-Limit-Requests 和 X-RateLimit-Limit-Concurrency 显示你的上限,X-Balance-USD 显示剩余余额,你可以将其显示在管理界面中。
不要在与下一个玩家轮次并行运行状态提取调用。等待它,否则旁白将从过时的事实中编写。一个好的折衷方案是在流结束时立即开始提取,让玩家在运行时阅读。
将场景连接到你的图像流水线
本站专为在 Stable Diffusion 工作流中使用文本模型的人构建,场景非常适合这一点。API 本身仅文本:它不生成图像、音频或视频。但旁白可以编写场景描述,你的流水线将其转换为图像提示词。在重大场景更改后添加另一个 JSON 调用,要求提供设置的简短视觉描述,包括照明、情绪和相机角度,然后将该字符串传递给你运行的任何图像工具。文档页面 显示了提示词编写调用的请求形状,SD 提示词生成器 的文章更深入地介绍了这一方面。
要试用引擎,请创建账户并领取试用额度。新用户可以在 API 密钥页面 一键获得 $0.50 的试用额度,有效期 7 天,无需信用卡。根据上述数据,这足以进行几次完整的会话。试用密钥限制为 2 个并发请求和每分钟 60 个请求,适合单人测试。
让小说保持在小说中。当所有参与者都是成年人时,成人主题场景是合法使用;那条永不打破的规则是:禁止涉及未成年人的性内容。发送到 API 的提示词不用于训练。
更多指南
问答
模型可以扮演任何角色或题材吗?
它是经过调优的通用文本模型,不会拒绝合法的成人请求,因此恐怖、犯罪、戏剧和喜剧都可以。唯一的硬性限制是涉及未成年人的性内容。
上下文填满之前,会话可以持续多久?
上下文窗口容量为 100,000 个 token(包括提示词和补全)。通过摘要层,你可以在实践中远远超过该限制,因为旧轮次会被压缩。
我应该流式输出回复吗?
是的,对于任何由人实时阅读的内容。流式输出会在结束时提供用量信息,并且流会占用一个并发槽位直到完成。
有没有办法让场景完全重复?
传入相同的 seed 以及相同的消息和设置。这是一种测试辅助手段,不能保证字节完全相同的输出。
只差一张表单,即可获得密钥
创建账户,复制密钥,更改基础 URL。这就是全部设置。