构建大模型叙事引擎:规则表达式与插值语法的解析
前置阅读:建议先读第三篇的”二、两阶段设计”和”三、区块扫描器”,理解扫描器如何输出
[]Block。本篇假设你已经知道 Parser 如何根据Title路由到不同解析函数。
一、叙事引擎的第三个问题:条件-动作怎么拆解?
区块切分完以后,最复杂的部分是规则表达式。一个叙事引擎需要回答:条件怎么写、动作怎么写、变量怎么引用。
这里有一条重要的设计边界:解析器只负责拆解,不负责求值。条件字符串(如 包含 "攻击" && 状态.堕落指数 > 80)原样存入结构体,引擎运行时再计算 true/false。为什么这样做?因为求值所需的运行状态在解析时还不存在——状态值是在对话过程中动态变化的,无法在加载阶段就确定。
这一篇要解决的就是拆解逻辑本身。
二、规则解析:拆解条件-动作
规则格式固定:[规则名] if 条件 -> 动作
[攻击] if 包含 "攻击" -> 注入 "贝利亚发动了猛烈的攻击"
[光之国] if 包含 "光之国" && 状态.情绪 == "暴怒" -> 注入 "光之国的记忆让贝利亚更加愤怒"
[高堕落] if 状态.堕落指数 > 80 -> 状态.情绪 = "癫狂"
插值语法出现在规则动作和文本区块中:
注入 "{角色名}的故乡是光之国"
这一篇要解决两个问题:
- 规则的条件和动作怎么拆解成可存储的结构?
- 插值语法怎么在解析层被识别和处理?
2.1 规则名提取
找到第一个 [ 和第一个 ],取中间内容:
func parseRuleLine(line string, lineNumber int) (*domain.Rule, error) {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "[") {
return nil, fmt.Errorf("规则必须以 '[' 开头")
}
idx := strings.Index(trimmed, "]")
if idx == -1 {
return nil, fmt.Errorf("缺少闭合的 ']'")
}
name := strings.TrimSpace(trimmed[1:idx])
if name == "" {
return nil, fmt.Errorf("规则名不能为空")
}
rest := strings.TrimSpace(trimmed[idx+1:])
// 接下来提取条件和动作...
}
2.2 条件与动作的拆分
用 if 和 -> 作为分隔符:
// 去掉 "if " 前缀
if !strings.HasPrefix(rest, "if ") {
return nil, fmt.Errorf("规则条件必须以 'if ' 开头")
}
rest = strings.TrimPrefix(rest, "if")
rest = strings.TrimSpace(rest)
// 取第一个 "->" 分割条件和动作
cond, action, ok := strings.Cut(rest, "->")
if !ok {
return nil, fmt.Errorf("规则缺少 '->'")
}
cond = strings.TrimSpace(cond)
action = strings.TrimSpace(action)
2.3 互斥组
动作中可能带有 [group:xxx] 标记:
[攻击] if 包含 "攻击" -> [group:combat] 注入 "贝利亚发动了猛烈的攻击"
解析时提取组名,存入 domain.Rule.Group:
group := ""
if strings.HasPrefix(action, "[group:") {
endIdx := strings.Index(action, "]")
if endIdx != -1 {
group = action[7:endIdx]
action = strings.TrimSpace(action[endIdx+1:])
}
}
互斥组的作用是:同一组内多条规则,只有第一条匹配的会被触发。这个逻辑在引擎运行时生效,解析层只需存好组名。
互斥组可以搭配任何动作类型,不仅限于 注入:
[高堕落] if 状态.堕落指数 > 80 -> [group:escalate] 状态.情绪 = "癫狂"
[失控] if 状态.情绪 == "癫狂" && 状态.堕落指数 > 90 -> [group:escalate] 注入 "{角色名}已完全失控"
2.4 引号处理
条件和动作中的字符串用 " 包裹。解析时我调用 unquote 剥离外层引号:
func unquote(s string) (string, error) {
s = strings.TrimSpace(s)
if len(s) >= 2 {
if (strings.HasPrefix(s, "\"") && strings.HasSuffix(s, "\"")) ||
(strings.HasPrefix(s, "“") && strings.HasSuffix(s, "”")) {
return s[1:len(s)-1], nil
}
}
return s, nil
}
支持中文引号是因为创作者可能使用中文输入法,“ 和 ” 比 " 更容易自然输入。
三、插值语法:{变量名} 的识别与替换
插值的核心需求:在任意文本中识别 {角色名} 并替换为对应的角色名称。 当前支持的插值变量只有一个——{角色名},它来自 【角色名】 区块,静态不变。
3.1 为什么只有 {角色名}?
你可能好奇:为什么状态变量(如堕落指数、情绪)不能直接用 {堕落指数} 插值?
因为状态变量在引擎中是运行时动态读写的——创作者通过 状态.键 = 值 来修改,通过 状态.键 > 值 来比较。如果允许 {堕落指数} 插值,就引入了一套平行机制:既可以在规则中通过 状态.堕落指数 引用它,又可以通过 {堕落指数} 引用它。两套语法做同一件事,徒增学习成本。
所以状态变量的引用统一使用 状态.键 语法,不设插值。引擎运行时的模板替换也只做一件事:把 {角色名} 替换为实际的角色名称。
3.2 替换时机:三个场景
插值替换在引擎中发生在三个不同场景:
CLI 欢迎界面显示时:世界观、开局场景在显示给人类看时替换一次。创作者看到的是”贝利亚奥特曼”而非
{角色名}。规则动作执行时:
注入 "{角色名}的故乡是光之国"在每次规则触发时执行替换。这是插值最核心的使用场景。Prompt 构建中:世界观和背景文本原样传给 LLM,不依赖引擎主动替换。LLM 从上下文中(”你是贝利亚奥特曼”)自然理解
{角色名}的含义。
为什么第三种场景不主动替换?
因为替换会改变文本的原始形态。如果将 {角色名}的故乡是光之国 替换为 贝利亚奥特曼的故乡是光之国,LLM 收到的是一段已经”固化”的叙述。而保持 {角色名} 原样,让 LLM 在生成时根据上下文动态决定如何使用这个名字——在某些叙事分支中,角色名可能发生变化(如角色改名、被遗忘等),这时保留占位符反而更灵活。
四、交汇点:当规则遇上插值
以这条规则为例:
[光之国] if 包含 "光之国" -> 注入 "{角色名}的故乡是光之国"
完整执行流程:
用户输入 "我要去光之国!"
│
▼
规则匹配:包含 "光之国" → true
│
▼
动作识别:提取动作类型为 "注入"
│
▼
运行时替换:{角色名} → "贝利亚奥特曼"
│
▼
追加记忆:"贝利亚奥特曼的故乡是光之国" 写入记忆库
│
▼
LLM 叙事:带着新记忆生成响应
为什么坚持把替换放在运行时?
因为引擎支持多分支故事线。如果加载时就替换成静态文本,所有分支共享同一个值,无法独立演化。运行时替换意味着每个分支读取自己的状态——状态变了,插值结果就变了。
骰子表达式的容错
骰子表达式(如 roll(1d100) >= 80)在解析时原样存储,运行时求值。如果表达式格式错误(如 roll(1d10 缺少括号),引擎不会报错,而是视为条件不满足(返回 false)。这是因为骰子表达式本身就是条件的一部分,解析层不负责验证运行时的正确性——格式错误就当作”不匹配”处理。
五、小结:解析层完整了
到这一篇为止,解析层覆盖了所有语法单元:
| 区块类型 | 解析函数 | 复杂度 | 说明 |
|---|---|---|---|
| 文本区块 | parseTextBlock |
低 | 拼接内容,保留换行 |
| 键值对列表 | parseKeyValuePairs |
中 | 支持中英文冒号,精确报报错 |
| 规则列表 | parseRules |
高 | 条件表达式、互斥组、骰子表达式 |
| 纯文本列表 | parsePlainList |
低 | 逐行提取 |
| 插值语法 | ReplacePlaceholders(运行时) |
中 | 解析时保留,运行时替换 |
最关键的一条边界:
解析器只负责”读出来”——把文本转化为结构化的数据。
引擎负责”算出来”——条件求值、变量替换、动作执行。
下一篇,我们将跨过这条边界,进入引擎的运行时。而引擎面对的第一个工程问题是:如何保证代码改动不破坏现有的解析行为?
答案是集成测试——用一组固定的 .meph 契约作为”看门狗”,每次改动后对比解析结果是否与预期一致。

