提示词模板: 动态发送提示词#
理论篇#
在传统的角色卡编写中, 我们通常会录入角色的固定人设和世界的故事背景. 更用心的作者或许还会进一步设想角色在不同阶段下的反应: 在恋爱后与初识时截然不同的态度, 或是在某个重大事件发生前后世界的变迁.
然而在实际应用中, 要实现这种动态变化相当困难. 因为 AI 在每次交互时都会读取你所提供的全部信息, 但它怎么分配注意力对于我们而言是未知的, 非常容易混淆不同情境下的设定.
举个例子: 我先是设定了 100 字的现代背景, 之后又根据主角穿越后的情况, 补充了 300 字的古代背景.
我的设想是, 只有在角色触发“穿越”事件后, AI 才应采用古代背景进行描述.
但结果往往是, AI 仅仅因为古代背景的篇幅更长, 就错误地将其作为当前的主要设定, 导致整个故事线陷入混乱, 无法按预期展开.
要解决这个问题, 思路其实非常直接: 让 AI 在特定条件下 "看" 不到那部分暂时无关的设定.
下面是一份典型的 "全蓝灯" 提示词:
【这是一个现代世界,故事发生在中国】 // 背景或世界观
【络络:女、17岁、高中生、喜欢吃炸鸡】 // 人物设定
【络络在与user熟悉之前,会非常拘谨】 // 低好感度时候的表现
【络络与user熟悉之后,会非常话痨】 // 高好感度时候的表现
对于如此简短的描述, AI 或许还能分辨出络络当前应处于何种状态. 但当设定变得复杂, 比如世界观冗长或角色众多时, AI 就往往难以准确处理了.
这时, 我们便可以利用提示词模板插件来编写. 它允许我们用 EJS (Embedded JavaScript) 语法来写提示词, 将该不该发送某段提示词和当前的聊天情况进行关联.
当好感度较低时,我们只发送:
【这是一个现代世界,故事发生在中国】
【络络:女、17岁、高中生、喜欢吃炸鸡】
【络络现在的好感度是{{format_message_variable::络络.好感度}},因此她应当表现得非常拘谨】 // 低好感度时候的表现
而随着好感度的不断升高, 发送的提示词则变为:
【这是一个现代世界,故事发生在中国】
【络络:女、17岁、高中生、喜欢吃炸鸡】
【络络现在的好感度是{{format_message_variable::络络.好感度}},因此她应当表现得像个话痨】 // 高好感度时候的表现
这样一来, AI 只会收到络络此时应该有的表现, 就不会混淆络络在两种状态下的表现了.
当然, 上面的例子只是为了说明原理. 在实际创作中, 为了避免让玩家察觉到角色性格的突兀转变, 我们可能需要追求更平滑的过渡.
因此, 一个更优的设计可以是这样:
【这是一个现代世界,故事发生在中国】
【络络:女、17岁、高中生、喜欢吃炸鸡】
当好感度为0~40时发送:
【络络现在的好感度是{{format_message_variable::络络.好感度}}。在此阶段,她应当表现得非常拘谨。随着好感度的提升,她可能会慢慢变得愿意与人交谈】
当好感度为40~80时发送:
【络络现在的好感度是{{format_message_variable::络络.好感度}}。在此阶段,她表现得相对平和,已经能与<user>进行简单的交流。随着好感度的提升,她甚至可能会主动和<user>开玩笑】
当好感度为80~100时发送:
【络络现在的好感度是{{format_message_variable::络络.好感度}}。在此阶段,她对于陌生人可能依旧拘谨,但对于熟人,尤其是<user>,一定会表现得非常话痨】
像这样分阶段、渐进式的提示词, 其效果显然比之前 "一刀切" 的状态切换要自然得多.
不过一切提示词都取决于你的实际需求. 如果你想要实现的是世界的突变而非角色的动态成长, 例如一觉醒来世界从现代变为古代, 那么你的提示词自然也无需进行这样平滑的过渡.
理解了以上内容, 你便掌握了利用 EJS 选择性发送提示词的核心理论.
实操篇#
接下来, 让我们进入实操环节. 我们将继续以 "好感度动态人设" 为例, 亲手实现不同好感度下发送不同提示词的功能.
在开始之前, 请放轻松. 如果你没有任何编程基础, 可能会觉得下面的符号有些陌生. 这完全正常!
我们的目标是理解其原理. 在学会之后, 你就可以让 AI 为你编写 EJS 代码或自己更准确地指挥 AI 进行编写代码, 并且能够自己看看他出错在什么地方.
区分代码与文本的核心语法: <%_ _%>#
首先我们要明白, 提示词模板扩展的 EJS 语法是一种能将 "代码指令" 嵌入到 "普通文本" 中的技术. 为了让系统知道哪部分是给它下达指令的代码, 哪部分是需要发送给 AI 的故事情节, 我们需要一个特殊的标记. 这个标记就是 <%_ _%>.
你可以把它想象成一对 "特殊的括号". 所有被这对括号包裹起来的内容, 都会被系统理解为一条需要执行的代码指令; 而括号外面的所有内容, 则被视为普通的提示词文本, 和正常世界书编写内容没有区别.
用 if 设置条件#
在 EJS 中, 我们最常使用的指令就是 if.
其基本结构是:
if (设定的条件) {
这里是条件成立时,才会被发送的提示词
}
这行代码的意思是: 如果 (if) 括号里的 "条件" 成立了, 那么花括号 {} 里的提示词就会被发送.
现在, 我们将这个结构用 EJS 的 "特殊括号" 包裹起来. 请注意, if (...) { 是指令的开始部分, 而 } 是指令的结束部分, 它们需要被分别包裹:
<%_ if (设定的条件) { _%>
这里是条件成立时,才会被发送的提示词
<%_ } _%>
你看, 通过换行, 整个结构变得清晰易读. 现在, 你再回头看一些 MVU 卡中的代码, 是不是感觉变得能看懂些了?
用 getvar() 获取变量#
但在实际代码中, if 后的括号里并不是 "某个条件" 这几个字, 而是一长串代码. 我们就是通过编写这长串代码, 将提示词与变量情况相关联, 从而判断提示词是否该被发送: 比如角色好感度大于 30.
getvar() 函数就是我们获取变量数据的信使. 它能准确地从我们之前设置的变量 (stat_data) 中, 取出我们需要的那个变量值. 令人惊讶地是 (并非), 用它获取变量值的方法和 {{format_message_variable::变量}} 差不多!
假设我们的变量结构是这样的:
export const Schema = z.object({
角色: z.object({
络络: z.object({
好感度: z.coerce.number().transform(value => _.clamp(value, 0, 100)),
}),
}),
})
角色:
络络:
好感度: 30
那么请回想一下, 我们该怎么用 {{format_message_variable::变量}} 获取络络的好感度值? 答案是 {{format_message_variable::stat_data.角色.络络.好感度}}.
与之相应地, 我们用 getvar() 获取络络好感度的方法是 getvar('stat_data.角色.络络.好感度').
组装一个完整的EJS代码块#
现在, 我们将所有部件组装起来, 看看一个完整、正确的 EJS 代码块是什么样的:
<%_ if (getvar('stat_data.角色.络络.好感度') < 30) { _%>
这里是当络络的好感度小于30时,我们希望AI看到的专属描述
<%_ } _%>
用 matchChatMessages() 模拟绿灯#
除了 getvar(), 你也可以在 if 中使用 matchChatMessages() 来像绿灯那样, 只在正文最后一次用户输入和最后一次 AI 回复中提到了某个关键字时发送一段提示词. 例如:
<%_ if (matchChatMessages(['络络', '笨蛋'])) { _%>
这里是当正文最后一次用户输入和最后一次 AI 回复中提到了 "络络" 或 "笨蛋" 时,我们希望AI看到的专属描述
<%_ } _%>
当然你也许想扫描更多楼层或者仅扫描用户输入, 则你可以按 matchChatMessages 接口来调整扫描方式. 例如:
<%_ if (matchChatMessages(['络络', '笨蛋'], { start: -4 }) { _%>
这里是当正文最后 4 楼中提到了 "络络" 或 "笨蛋" 时,我们希望AI看到的专属描述
<%_ } _%>
用 else 表示条件不成立时发送提示词#
你已经学会了 if, 它可以处理 "条件成立时发送提示词". 但如果我们的逻辑不止 "条件成立时发送提示词", 还有 "条件不成立时发送提示词" 的情况呢? 让我们用 else (否则) 来处理:
if (设定的条件) {
这里是条件成立时,才会被发送的提示词
} else {
这里是条件不成立时,才会被发送的提示词
}
这样一来, 我们可以对络络低好感度和高好感度的情况分别发送提示词:
<%_ if (getvar('stat_data.角色.络络.好感度') < 30) { _%>
这里是当络络的好感度小于30时,我们希望AI看到的专属描述
<%_ } else { _%>
这里是当络络的好感度大于等于30时,我们希望AI看到的专属描述
<%_ } _%>
用 else if 构建多层逻辑#
为了让好感度变化更加平滑, 我们可以增加更多条件判断, 让不同区间下的好感度 (低好感、中好感、高好感) 对应有完全不同的提示词.
if (设定的条件1) {
这里是条件1成立时,才会被发送的提示词
} else if (设定的条件2) {
这里是上面的条件1不成立,而条件2成立时,才会被发送的提示词
} else {
这里是所有条件都不成立时,才会被发送的提示词
}
else if可以理解为 "否则,如果……". 它在前一个
if条件不成立时, 提供一个新的判断条件. 你可以添加任意多个else if来构建更复杂的逻辑链.else可以理解为 "在其他所有情况下". 它总是放在逻辑链的最后, 当前面所有的
if和else if条件都不成立时, 它会提供一个最终的、默认的备用方案.
让我们用络络好感度来举一个更生动的例子:
<%_ if (getvar('stat_data.角色.络络.好感度') < 30) { _%>
【络络对你态度平淡,甚至有些冷漠】
<%_ } else if (getvar('stat_data.角色.络络.好感度') < 60) { _%>
【络络对你抱有好感,但仍保持着一些距离】
<%_ } else { _%>
【络络现在非常信任你,愿意和你分享她的小秘密】
<%_ } _%>
这段代码的逻辑非常清晰, 而且是按顺序执行的:
- 首先检查
if 程序会先判断 "络络的好感度" 是否小于 30.
如果成立 (比如好感度是 20), 则发送第一段描述, 然后整个逻辑块结束, 后面的else if和else都不会被执行.- 然后检查
else if 如果第一个
if条件不成立 (比如好感度是 45), 程序会接着判断else if的条件, 即好感度是否小于 60.
如果成立, 则发送第二段描述, 然后逻辑块结束.- 最后执行
else 如果前面的
if和else if条件都不成立 (比如好感度是 80), 程序就会执行else部分, 发送最后那段默认的描述.
你看, 通过 if、else if 和 else 的组合, 我们可以像搭建阶梯一样, 构建出层次分明、逻辑严谨的互动反应.
再举一个判断文本是否相等的简单例子:
<%_ if (getvar('stat_data.事件.天气') === '晴天') { _%>
【今天阳光明媚,适合出门散步】
<%_ } else if (getvar('stat_data.事件.天气') === '雨天') { _%>
【外面下着雨,记得带伞】
<%_ } else { _%>
【今天天气一般】
<%_ } _%>
我们用 === 来判断变量是否 "等于" 某个值 (这里是 '晴天' 或 '雨天').
如果天气是 "晴天", AI 会看到第一句话.
否则,如果天气是 "雨天", AI 会看到第二句话.
在其他所有情况下 (比如天气是 "多云" 或 "阴天"), AI 看到的都会是最后那句 "天气一般".
恭喜你! 现在, 你已经掌握了利用 EJS 编写动态提示词的核心逻辑. 通过 if、else if 和 else 的组合, 你已经可以构建出丰富多变的互动逻辑了.
用 print() 在代码内输出提示词#
我们之前的方式看起来很乱: 每个 if、else if、else 周围都要包裹 <%_ _%>, 而 【今天阳光明媚,适合出门散步】 等不包裹 <%_ _%>.
这是因为 if、else if、else 等是代码部分, 而 【今天阳光明媚,适合出门散步】 等是提示词部分.
但我们其实也可以将整个内容都作为代码, 在代码内用 print() 输出提示词:
<%_
if (getvar('stat_data.事件.天气') === '晴天') {
print('【今天阳光明媚,适合出门散步】');
} else if (getvar('stat_data.事件.天气') === '雨天') {
print('【外面下着雨,记得带伞】');
} else {
print('【今天天气一般】');
}
_%>
这样写起来清晰多了!
另外, 我们还能利用 getwi 获取其他世界书条目的内容用于 print. 假设我们有 天气-晴天、天气-雨天、天气-一般 三个额外的世界书条目, 里面分别对应三种情况写了很多提示词. 那我们可以将 逻辑控制-天气 条目写成:
<%_
if (getvar('stat_data.事件.天气') === '晴天') {
print(await getwi('天气-晴天'));
} else if (getvar('stat_data.事件.天气') === '雨天') {
print(await getwi('天气-雨天'));
} else {
print(await getwi('天气-一般'));
}
_%>
验证发送结果#
你可以通过酒馆助手提供的来查看结果是否被正确发送.
或者你可以用 alert(表达式) 或 toastr.info('消息') 等来在提示词模板处理到某个地方时弹出一个值:
<%_ if (getvar('stat_data.事件.天气') === '晴天') { _%>
<%_ alert('触发了晴天提示词'); _%>
【今天阳光明媚,适合出门散步】
<%_ } else if (getvar('stat_data.事件.天气') === '雨天') { _%>
<%_ alert('触发了雨天提示词'); _%>
【外面下着雨,记得带伞】
<%_ } else { _%>
<%_ alert(`什么都没触发, 因为天气变量的值是: ${getvar('stat_data.事件.天气')}`); _%>
【今天天气一般】
<%_ } _%>
触发了 else 情况的提示词#
如果你有代码经验, 也可以按 f12 打开浏览器开发者工具, 然后在条目内容里用 <%_ debugger; _%> 触发浏览器的断点调试.
让 AI 为你编写 EJS 代码#
AI 很会 Embedded JavaScript 语法, 你可以让它替你编写. 在其中, 你可以用 TavernHelper 来访问酒馆助手的功能如 TavernHelper.getWorldbook, 或者直接访问提示词模板的功能如 getvar.
为了方便 AI 编写, 建议你按照实时编写前端界面或脚本配置 Cursor, 将酒馆助手的 @types 文件夹 (配置后你会直接得到) 和提示词模板的 reference_cn.md 发给它.
如果需要检查 EJS 语法是否正确, 你可以下载 webstorm, 它对 .ejs 结尾的提示词模板语法文件有直接的报错检查.
或者, 青空莉在门之主写卡助手中提供了生成或转换成动态化提示词用于让 AI 编写 EJS 代码. 你可以直接使用这个写卡助手, 或者复制它的提示词.
额外知识#
用 <%= _%> 填写提示词#
除了用 <%_ 代码 _%> 执行代码逻辑, 提示词模板还支持用 <%= 表达式 _%> 来将表达式的值直接填入提示词, 比如 <%= _.random(0, 10) _%> 会随机发送一个 0 到 10 之间的整数.
也就是说, <%= 表达式 _%> 起到了与宏类似的效果.
当然 <%= 表达式 _%> 能直接执行代码, 所以比宏更灵活.
比如, 我们可以获取发送提示词时的时间发给 AI: (只是展示可以做到, 具体你可以让 AI 编写)
<%= new Date(Date.now()).toISOString() _%>
2025-12-08T15:26:38.314Z
又比如, 我们可以获取数组中的随机一个元素发给 AI: (只是展示可以做到, 具体你可以让 AI 编写)
<%= _.sample(['一', '二', '三']) _%>
一 或者 二 或者 三.
再比如, 我们可以调整变量的展示格式: (只是展示可以做到, 具体你可以让 AI 编写)
<%= JSON.stringify(getvar('stat_data')) _%>
{"角色":{"络络":{"好感度":30,"心情":"开心"},"青空莉":{"好感度":60,"心情":"郁闷"}},"世界":{"日期":"2025-07-26","时间":"21:00"}}
<%= YAML.stringify(getvar('stat_data'), { blockQuote: 'literal' }) _%>
角色:
络络:
好感度: 30
心情: 开心
青空莉:
好感度: 60
心情: 郁闷
世界:
日期: 2025-07-26
时间: 21:00
或者, 假设 stat_data.角色 中存储了其他角色, 我们可以这样列出好感度低于 30 的所有角色: (只是展示可以做到, 具体你可以让 AI 编写)
当前好感度在 30 以下的人物:
<%=
JSON.stringify(
_(getvar(data, 'stat_data.角色'))
.pickBy(角色 => 角色.好感度 < 30)
.values()
.value(),
)
_%>
变量不存在该怎么办#
MVU zod 可以在游玩中途插入变量. 比如物品栏中原本没有口香糖, 但随着游玩, AI 为物品栏添加了口香糖.
EJS 该如何处理变量不存在的情况? 你可以:
- 判断变量是否存在
getvar('stat_data.角色.络络.好感度') !== undefined
- 变量存在则使用值,不存在则使用默认值 (defaults)
getvar('stat_data.角色.络络.好感度', { defaults: 0 })
让变量不能被 AI 更新或对 AI 不可见#
有时候, 角色卡的不同开局差异很大: 开局 1 是魔法世界而开局 2 是科技世界. 那么玩家在选择了开局 1 后, 世界书应该只启用魔法世界相关的条目, 而永远不会启用科技世界相关的条目.
这样一来, 我们确实可以为开局 1 只启用魔法世界世界书, 而为开局 2 只启用科技世界世界书.
——但我们无法避免 AI 修改它:
通过变量列表中的
{{format_message_variable::stat_data}}, AI 能得知世界.类型变量的值;即使变量更新规则中写明
世界.类型变量永不变化, AI 也可能犯蠢更新它.
MVU zod 允许你给变量名前面加一个 _, 来表示这个变量不能被 AI 更新:
世界:
_类型: 魔法
这样一来, 即使 AI 输出对应的变量更新命令, 也不能成功更新这个变量.
可再仔细想想, 我们此处的世界.类型变量除了用来选择发送什么提示词外, 并没有其他用途, AI 没甚至必要知道它!
酒馆助手允许你给变量名前面加一个 $, 来表示这个变量不该被 AI 看见: {{format_message_variable::stat_data}} 将不会展示它.
世界:
$类型: 魔法
当前时间: 2025-12-08 15:00:00
世界:
当前时间: 2025-12-08 15:00:00
据此, 我们再从变量更新规则中删去世界.$类型对应的更新规则, 那么 AI 就完全不知道有世界.$类型这个变量存在了, 也就不会更新它.
激活绿灯条目#
默认情况下, 提示词模板是在世界书激活后才处理世界书内容, 也就是说, 在世界书激活过程中, 酒馆看到的是:
<%_ if (getvar('stat_data.事件.天气') === '晴天') { _%>
【今天阳光明媚,适合出门散步】
<%_ } else if (getvar('stat_data.事件.天气') === '雨天') { _%>
【外面下着雨,记得带伞】
<%_ } else { _%>
【今天天气一般】
<%_ } _%>
而不是【今天阳光明媚,适合出门散步】、【外面下着雨,记得带伞】或【今天天气一般】中的一种.
因此你无法用 <%_ if (...) _%> 来激活绿灯条目……吗?
针对 1.13.4 及以上的酒馆版本, 你可以在条目内容开头加上一行
@@preprocessing来要求提示词模板在世界书激活前处理这个条目. 这样一来, 这个条目的内容在激活前已经变成【今天阳光明媚,适合出门散步】、【外面下着雨,记得带伞】或【今天天气一般】中的一种, 能正确激活绿灯.如果要让低版本酒馆也支持, 请继续阅读下去. 我们会在下一章介绍如何用酒馆助手脚本做到.
提示
更多提示词模板功能请参考官方文档.