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

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

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

前两篇完成了格式设计和区块切分。现在要处理解析层最复杂的两个语法单元:规则的条件-动作表达式,以及 {变量} 插值语法

规则区块长这样:

[攻击] if 包含 "攻击" -> 注入 "贝利亚发动了猛烈的攻击"
[光之国] if 包含 "光之国" && 状态.情绪 == "暴怒" -> LLM: 以嘲讽语气回应
[高堕落] if 状态.堕落指数 > 80 -> 状态.情绪 = "癫狂"

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

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

这一篇要解决两个问题:

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

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

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

关键设计决策:解析器只做“拆解”,不做“求值”。

条件字符串(如 包含 "攻击" && 状态.堕落指数 > 80)被原样存入 domain.Rule.Cond。引擎运行时会调用 ConditionEvaluator 去真正求值。解析和评估是两个独立的阶段,中间隔着清晰的边界。

1.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:])
    // 接下来提取条件和动作...
}

1.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)

1.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:])
    }
}

互斥组的作用是:同一组内多条规则,只有第一条匹配的会被触发。这个逻辑在引擎运行时生效,解析层只需存好组名。

1.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
}

支持中文引号是因为创作者可能使用中文输入法," 更容易自然输入。

二、插值语法:{变量名} 的识别与替换

插值的核心需求:在任意文本中识别 {变量名} 并替换为对应的值。

变量来源有两个:

  1. 角色名{角色名} 来自 【角色名】 区块
  2. 状态变量{情绪}{堕落指数} 来自 【状态】 区块

2.1 关键决策:解析器不负责替换

这是整个插值系统最重要的设计决策。

如果解析器在加载时就把 {堕落指数} 替换成 50,这个值就被“冻住”了。但运行中堕落指数可能涨到 85——那时插值结果就错了。

所以解析器的职责是:{变量名} 当作普通文本原样存储。运行时由专门函数完成替换:

func ReplacePlaceholders(template string, vars map[string]string) string {
    for k, v := range vars {
        template = strings.ReplaceAll(template, "{"+k+"}", v)
    }
    return template
}

只有 7 行,但够用了——需求就是“把 A 替换为 B”,不需要条件、循环、函数调用。

2.2 为什么不用 text/template?

Go 标准库的 text/template 功能强大,支持条件、循环、函数调用。但我刻意避开了它。

text/template 的写法是:

{{.RoleName}}的堕落指数已达 {{.CorruptionLevel}}

.meph 的写法是:

{角色名}的堕落指数已达 {堕落指数}

区别在于:创作者不需要理解“点号表示法”和“模板上下文”,他们只需要看到花括号就知道这是一个变量。插值语法是设计给故事作者看的,不是给 Go 程序员看的。 更复杂的语法意味着更多的学习成本和错误机会。

2.3 替换时机:两个阶段

  1. 静态文本显示时【世界观】【开局场景】 在会话启动时替换一次,之后不再变化。
  2. 规则动作执行时注入 "{角色名}的堕落指数已达 {堕落指数}" 在每次触发时重新计算,反映最新状态。

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

以这条规则为例:

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

完整执行流程:

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


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


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


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


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


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

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

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

四、小结:解析层完整了

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

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

最关键的一条边界:

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

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

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

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

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