// Created At 2026-07-20// P3
// Go · DSL · LLM · Engineering

构建大模型叙事引擎:规则表达式与插值语法的解析

前置阅读:建议先读第三篇的”二、两阶段设计”和”三、区块扫描器”,理解扫描器如何输出 []Block。本篇假设你已经知道 Parser 如何根据 Title 路由到不同解析函数。

一、叙事引擎的第三个问题:条件-动作怎么拆解?

区块切分完以后,最复杂的部分是规则表达式。一个叙事引擎需要回答:条件怎么写、动作怎么写、变量怎么引用。

这里有一条重要的设计边界:解析器只负责拆解,不负责求值。条件字符串(如 包含 "攻击" && 状态.堕落指数 > 80)原样存入结构体,引擎运行时再计算 true/false。为什么这样做?因为求值所需的运行状态在解析时还不存在——状态值是在对话过程中动态变化的,无法在加载阶段就确定。

这一篇要解决的就是拆解逻辑本身。


二、规则解析:拆解条件-动作

规则格式固定:[规则名] if 条件 -> 动作

[攻击] if 包含 "攻击" -> 注入 "贝利亚发动了猛烈的攻击"
[光之国] if 包含 "光之国" && 状态.情绪 == "暴怒" -> 注入 "光之国的记忆让贝利亚更加愤怒"
[高堕落] if 状态.堕落指数 > 80 -> 状态.情绪 = "癫狂"

插值语法出现在规则动作和文本区块中:

注入 "{角色名}的故乡是光之国"

这一篇要解决两个问题:

  1. 规则的条件和动作怎么拆解成可存储的结构?
  2. 插值语法怎么在解析层被识别和处理?

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 替换时机:三个场景

插值替换在引擎中发生在三个不同场景

  1. CLI 欢迎界面显示时:世界观、开局场景在显示给人类看时替换一次。创作者看到的是”贝利亚奥特曼”而非 {角色名}

  2. 规则动作执行时注入 "{角色名}的故乡是光之国" 在每次规则触发时执行替换。这是插值最核心的使用场景。

  3. Prompt 构建中:世界观和背景文本原样传给 LLM,不依赖引擎主动替换。LLM 从上下文中(”你是贝利亚奥特曼”)自然理解 {角色名} 的含义。

为什么第三种场景不主动替换?

因为替换会改变文本的原始形态。如果将 {角色名}的故乡是光之国 替换为 贝利亚奥特曼的故乡是光之国,LLM 收到的是一段已经”固化”的叙述。而保持 {角色名} 原样,让 LLM 在生成时根据上下文动态决定如何使用这个名字——在某些叙事分支中,角色名可能发生变化(如角色改名、被遗忘等),这时保留占位符反而更灵活。

四、交汇点:当规则遇上插值

以这条规则为例:

[光之国] if 包含 "光之国" -> 注入 "{角色名}的故乡是光之国"

完整执行流程:

用户输入 "我要去光之国!"


规则匹配:包含 "光之国" → true


动作识别:提取动作类型为 "注入"


运行时替换:{角色名} → "贝利亚奥特曼"


追加记忆:"贝利亚奥特曼的故乡是光之国" 写入记忆库


LLM 叙事:带着新记忆生成响应

为什么坚持把替换放在运行时?

因为引擎支持多分支故事线。如果加载时就替换成静态文本,所有分支共享同一个值,无法独立演化。运行时替换意味着每个分支读取自己的状态——状态变了,插值结果就变了。

骰子表达式的容错

骰子表达式(如 roll(1d100) >= 80)在解析时原样存储,运行时求值。如果表达式格式错误(如 roll(1d10 缺少括号),引擎不会报错,而是视为条件不满足(返回 false)。这是因为骰子表达式本身就是条件的一部分,解析层不负责验证运行时的正确性——格式错误就当作”不匹配”处理。

五、小结:解析层完整了

到这一篇为止,解析层覆盖了所有语法单元:

区块类型 解析函数 复杂度 说明
文本区块 parseTextBlock 拼接内容,保留换行
键值对列表 parseKeyValuePairs 支持中英文冒号,精确报报错
规则列表 parseRules 条件表达式、互斥组、骰子表达式
纯文本列表 parsePlainList 逐行提取
插值语法 ReplacePlaceholders(运行时) 解析时保留,运行时替换

最关键的一条边界:

解析器只负责”读出来”——把文本转化为结构化的数据。
引擎负责”算出来”——条件求值、变量替换、动作执行。

下一篇,我们将跨过这条边界,进入引擎的运行时。而引擎面对的第一个工程问题是:如何保证代码改动不破坏现有的解析行为?

答案是集成测试——用一组固定的 .meph 契约作为”看门狗”,每次改动后对比解析结果是否与预期一致。

项目地址:https://github.com/yuelinghuashu/mephisto

如果这篇文章对你有帮助,可以请我喝杯咖啡 ☕️
Ali PayWechat Pay
© 2026 MOONGATE