叙事引擎与大模型:从自由叙事到契约约束
我用大模型做叙事的第一天,就遇到了一个问题。
我定义了一个角色:贝利亚奥特曼,性格狂傲、蔑视光之国、崇尚力量。前三轮对话表现完美——每次回答都带着那种令人满意的反派质感。第五轮开始,他开始跟我讲人生哲理,像个温和的哲学家。第十轮,他自称“光之国的守护者”,完全忘记了自己是谁。
这不是模型不够好。这是“自由生成”的固有缺陷——大模型没有“必须遵守规则”的内置机制。你可以在系统提示里写“你是一个狂傲的反派”,但模型对“狂傲”的理解是概率性的,会在长上下文中逐渐衰减,被用户的输入、模型的输出、甚至 token 顺序漂移所覆盖。
我需要一种方式,把规则写下来,让模型必须遵循。于是我开始写 Mephisto。
但在写引擎代码之前,我先要解决一个更基础的问题:这些规则应该用什么格式来写?
它需要承载的内容包括角色名(单行)、世界观(多行)、状态变量(键值对)、行为规则(条件-动作)。每种内容的结构和解析方式都不同,这个格式直接决定了创作者是把时间花在写故事上,还是花在调试语法上。
这篇文章记录了我在这件事上的取舍和最终设计。
一、先看目标:一份真实的 .meph 契约
在讨论任何设计之前,先看成品。下面是一份完整的 .meph 契约文件,来自项目中的 data/sample.meph:
【角色名】
贝利亚奥特曼
【锚点】
- 核心信念:力量就是一切
- 说话风格:狂傲、嘲讽、不容置疑
【世界观】
光之国是宇宙中最强大的文明,也是贝利亚的故乡。
但他早已被驱逐,如今他带着对光之国的憎恨归来。
【状态】
- 堕落指数:50
- 情绪:暴怒
- 位置:宇宙空间站
【规则】
[攻击] if 包含 "攻击" -> 注入 "贝利亚发动了猛烈的攻击"
[防御] if 包含 "防御" || 包含 "防守" -> 注入 "贝利亚摆出了防御姿态"
[光之国] if 包含 "光之国" -> 注入 "{角色名}的故乡是光之国,也是他最大的仇恨来源"
[高堕落] if 状态.堕落指数 > 80 -> 状态.情绪 = "癫狂"
这就是一份契约的全部。创作者看到的不是 { 和 },不是转义引号,不是缩进层级,而是:
【角色名】下面直接写名字【锚点】下面用- 键: 值列出核心人格【规则】下面用[名] if 条件 -> 动作定义行为
注意规则中的 {角色名}——这是插值语法,运行时会被替换为当前状态中的实际角色名。它让规则可以复用,不必在每个动作里硬编码角色名字。
看起来像文档。但程序可以精确地解析它,并且当它出错时,能告诉创作者“第 12 行(区块「状态」):列表项必须以 ‘-’ 开头”,而不是 unexpected token at position 246。
接下来的问题就是:怎么走到这一步的?
二、JSON:对程序友好,对人残忍
JSON 是最自然的第一选择。结构清晰、解析简单、任何语言都有现成库。我最初也确实用 JSON 写过原型,但很快发现了一个致命问题。
上面那条规则在 JSON 里长这样:
{
"rules": [
{
"name": "攻击",
"condition": "包含 \"攻击\"",
"action": "注入 \"贝利亚发动了猛烈的攻击\""
}
]
}
创作者的意图是:
包含 "攻击"
在 JSON 里必须写成:
"condition": "包含 \"攻击\""
问题不在于语法“有多难”,而在于心智切换成本。创作者在书写时不能直接表达自己的意图,必须时刻思考“我是在写 JSON 还是在写规则”。在大型契约中,这种成本是持续累积的——你阅读的不是内容,而是在不断核对“这一行有多少个反斜杠”。
更糟糕的是错误信息。一个常见的错误:在 "rules" 数组的最后一个元素后面多加了一个逗号。JSON 解析器报错:
Unexpected token } in JSON at position 246
创作者需要复制粘贴去数“position 246”在哪。这个体验对于非技术用户几乎是毁灭性的。
JSON 对程序友好,但对人不友好。 而这份文件的作者是创作者,不是程序员。
三、YAML:简单的幻觉
YAML 看起来解决了 JSON 的可读性问题:
role_name: 贝利亚奥特曼
rules:
- name: 攻击
condition: 包含 "攻击"
action: 注入 "贝利亚发动了猛烈的攻击"
缩进取代了括号和逗号,确实更像“写文档”了。
但 YAML 的问题比 JSON 更隐蔽。它不是“语法错误”的问题,而是“逻辑错误”的问题——文件能读,但行为完全不对。
想象一下:你在 YAML 中定义一个多行文本块(比如世界观),用 | 标记:
worldview: |
光之国是宇宙中最强大的文明。
贝利亚被驱逐后,一直在寻找复仇的机会。
然后在下面继续定义一个列表。缩进稍微偏差一个空格,解析器就可能把多行文本块的后续行当作列表的子元素。结果就是:世界观内容被截断,列表结构被破坏,没有任何报错,引擎运行时产生非预期的行为。
更隐蔽的是,中文全角空格和英文半角空格在视觉上几乎无法分辨。创作者在编辑器中敲了一个全角空格( )而非半角空格(),YAML 解析器不会把它当作缩进的一部分,而是抛出语法错误,或者诡异地将整段文本识别为键名。没有语法高亮的情况下,这种错误几乎无法肉眼排查。
创作者不是在阅读内容,而是在不断核对“这一行到底缩进了几个空格”。对于上百行的契约文件,这种认知负担是持续累积的。
YAML 的“简单”是一个错觉。 它对人友好,但对程序来说,它的规则比 JSON 更复杂、更难以预测。
四、纯文本:自由但模糊
既然结构化格式都有问题,那能不能干脆不用结构,直接用纯文本写?
比如这样:
角色名是贝利亚奥特曼。
世界观是光之国。
规则是如果用户提到攻击,就执行攻击。
对创作者来说,这是最自然的方式——没有任何学习成本。
但问题是:程序看不懂。它不知道“角色名是贝利亚奥特曼”这句话里的“是”是声明还是叙述,不知道“世界观是光之国”和下一句“规则是……”之间是什么关系。纯文本的自然语言对人类来说是清晰的,但对程序来说,它是模糊的、歧义的、无法精确解析的。
如果我用纯文本,就需要设计一套隐式约定——比如靠关键词匹配来识别区块,靠换行来区分条目。这比显式语法更难保证正确性。
纯文本对创作者最友好,但对程序几乎不友好。
五、三个方案的对比
| 格式 | 对创作者友好 | 对程序友好 | 核心问题 |
|---|---|---|---|
| JSON | ❌ | ✅ | 转义地狱、报错信息不可读 |
| YAML | ⚠️ | ✅ | 缩进敏感,多行文本与列表易混淆 |
| 纯文本 | ✅ | ❌ | 结构模糊,程序无法精确解析 |
我需要一种格式:写起来像文档,读起来有结构。 它必须同时满足两个条件:
- 创作者可以自然地书写,不需要学习 JSON 转义或 YAML 缩进规则
- 程序可以精确地解析,并且报错时能告诉创作者具体位置和原因
这意味着我无法使用任何现成的通用格式——我需要设计一种专门针对“叙事契约”这个场景的格式。
六、.meph 的设计原则
回到开篇那份 .meph 契约。它的设计基于三条原则:
1. 用人类语言做边界,消除括号恐惧
创作者看到的不是 { 和 },而是 【角色名】。中文书名号对中文创作者来说比花括号自然得多。【角色名】 本身说明了区块的内容是什么,不需要额外注释。
更重要的是,区块标题被限定在白名单内(角色名、锚点、规则、状态 等)。如果创作者写了 【脚色名】(错别字),解析器不会把它当作区块开始,而是报错“第 1 行:内容出现在任何区块之外”——创作者立刻就能发现并修复。
2. 区分“语义区块”而非“数据结构”
在 JSON 中,创作者需要自己决定用对象还是数组,这属于实现细节。在 .meph 中,创作者只需要知道“这是一个列表”或“这是一段话”。“角色名是单行文本”和“规则是列表”由解析器根据区块名识别,不由创作者声明。
3. 语法贴近自然逻辑
规则采用 [规则名] if 条件 -> 动作 的直观写法。条件中的逻辑运算符用 包含、状态.键 > 值 这类可读性强的表达,而不是纯符号。
七、代价
这套设计不是没有代价的:
- 解析器需要手写:我不能用
json.Unmarshal或yaml.Unmarshal,需要自己写 Lexer 和 Parser - 需要维护白名单:新增区块时要同步更新
knownBlocks列表 - 需要文档:创作者需要学习这个格式的写法——虽然学习成本比 JSON 低,但毕竟需要学习
- 工具链缺失:没有现成的语法高亮、格式化、校验工具
但这个取舍的衡量标准很简单:这份文件的作者是谁?
如果是程序员写、程序读,JSON 够用了。如果是创作者写、程序读,就需要一种“以人为中心”的格式。而叙事引擎的目标用户正是创作者——写故事的人。
这个取舍,我认为是值得的。
八、小结
这篇文章回答了“用什么格式”这个问题。答案是 .meph——一种专门为叙事契约设计的、对创作者友好的文本格式。
但“设计”只解决了一半问题。下一篇文章要回答的是:怎么让程序精确地读取这个格式,并在出错时报出“第 12 行(区块「状态」):列表项必须以 ‘-’ 开头”,而不是 position 246?
答案是手写区块扫描器——一个逐行扫描、精确绑定行号、白名单前置的轻量级 Lexer。我们下一篇见。

