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

构建大模型叙事引擎:运行时闭环与多分支存档

前置阅读:这是本系列的最后一篇,建议先读前五篇——第一篇了解 .meph 格式,第二篇上手实操,第三、四篇理解解析器如何输出 domain.Contract,第五篇理解测试如何保障行为稳定。本篇假设你已经知道引擎拿到了 contract 结构体,要解决的核心问题是:怎么驱动它运转起来?

一、叙事引擎的第五个问题:怎么让规则活起来?

解析器把契约变成了结构体,但结构体不会叙事。一个叙事引擎还需要运行时——输入 → 匹配 → 执行 → 输出的完整闭环。

这里要解决的工程问题有三个:

  1. 规则怎么实时匹配? 用户输入到达时,引擎需要遍历所有规则、评估条件、决定触发哪些动作——这个过程必须在毫秒级完成,不能影响用户体验。
  2. LLM 怎么遵守规则? 规则不能直接约束 LLM 的行为,它们必须通过 Prompt 间接生效。怎么把规则”翻译”成 LLM 能理解的格式,是这个环节的核心难点。
  3. 状态和记忆怎么持续? 每一轮对话都会改变角色的状态和记忆池,但下一轮对话开始时,引擎必须恢复上一轮的完整状态。没有持久化,就没有长线叙事。

这一篇解决的就是这三个问题。第四、五篇解决的规则匹配细节(两阶段匹配、骰子判定)此处不再展开,聚焦运行时如何把整条链路串起来。


引擎拿到 contract 后进入一个循环:接收用户输入 → 匹配规则 → 执行动作 → 调用 LLM → 返回响应。这个循环构成了引擎的运行时。

但在实现这个循环之前,有几个工程问题必须先解决——它们决定了引擎是”能跑”还是”能用”。

二、小说级终端流:全角缩进与流式拦截

LLM 的流式输出是逐块返回的。引擎通过 onChunk 回调拦截每一块,然后直接写到终端。

这里有一个设计细节:全角缩进(  

onChunk := func(chunk string) {
    for _, ch := range chunk {
        if ch == '\n' {
            fmt.Println()
            needIndent = true
            inParagraph = false
        } else {
            if !inParagraph && needIndent {
                fmt.Print("  ")  // 每个段落开头空两格
                needIndent = false
            }
            fmt.Print(string(ch))
            inParagraph = true
        }
    }
}

效果:终端输出的文本,每个自然段开头都有两个全角空格,看起来像一本实体书。这个细节对创作者的体验提升是巨大的——它把”终端输出”变成了”小说页面”。

但流式输出背后有一个更关键的工程决策:动作执行器统一处理流式回调

  • 状态修改动作:立即返回结果,然后通过回调模拟逐字符输出
  • LLM 动作:真正的流式输出,逐块回调
  • 静态文本:立即返回,然后模拟流式输出

所有动作最终都通过 onChunk 输出,不管来源是什么。这保证了终端的输出体验是一致的。

三、五层三明治 Prompt:把约束焊死在上下两端

LLM 叙事最大的问题是格式跑偏——它经常输出括号剧本流:

(冷笑一声)【贝利亚】:你们太弱了。

这破坏了沉浸感。解决方案是把格式约束放在 Prompt 的顶部和底部,形成三明治结构。v1.1.0 的实际 Prompt 渲染(internal/core/llm/prompt.goRenderPrompt)是五层结构:

【格式硬性要求】
(NarrativeConstraints——禁止括号、禁止剧本标记、禁止独角戏)

【世界观】
(context)

【角色名】
你是 {角色名},一个 {锚点风格} 的存在。你的背景:{角色背景}

【当前状态】
(从运行时 `map[string]any` 渲染而来)

【命运的推动】
(对话历史——命运与角色的交替记录)

【你记得的过往】
(运行时动态累积的记忆)

【此刻】
(user_input)

【要求】
(NarrativeConstraints,再次强调)

约束在两端出现两次,中间夹着上下文。这样做有两个原因:

  1. 首因效应:LLM 最先看到约束,优先级最高
  2. 近因效应:LLM 最后看到的约束会影响最终输出

两次强调,确保格式约束不会被中间的上下文稀释。

3.1 确定性渲染:排序保证 Cache 命中

这里有一个微妙的工程问题:Go 的 map 遍历顺序是随机的。

在引擎运行时,契约中的 【状态】 会被转换为 map[string]any 以便快速读写。当 Prompt 渲染 【当前状态】 时,如果每次遍历顺序不同,生成的 Prompt 文本就会变化——哪怕状态内容完全一样。这会导致 LLM 的 KV Cache 完全失效,每次都要重新计算。

解决方案:对 state 的键做排序(sort.Strings),确保渲染顺序稳定。这样在状态不变时,生成的 Prompt 文本字节级一致,LLM 服务的 KV Cache 能够命中,降低延迟和 Token 消耗。

3.2 反独角戏约束

NarrativeConstraints 中有一条被刻意强调:

每段回复必须包含至少一名其他角色(非玩家)的对话和动作反应。如果场景中没有其他角色,请引入或创造至少一个互动对象。禁止只有玩家独角戏。

这是为了解决叙事中的”空旷感”。如果 LLM 只回应玩家输入,不引入其他角色互动,故事会变成单人独白,失去戏剧张力。这条约束强制 LLM 在每轮回复中至少引入一个互动对象,让世界活起来。

四、Mother-Child 存档机制

每一轮对话结束后,引擎会自动保存当前状态到子版文件。

命名规则(v1.1.0 起与 Flutter 版对齐,点分隔):

  • 母版 story.meph → 默认子版 story.child.meph
  • 分支 --branch darkstory.dark.meph
func BuildChildPath(filename string, branch string) string {
    dir := filepath.Dir(filename)
    base := filepath.Base(filename)
    ext := filepath.Ext(base)
    name := strings.TrimSuffix(base, ext)

    // 已是子版:直接覆盖(避免嵌套生成)
    if isChildFileName(name) {
        return filename
    }

    if branch != "" {
        return filepath.Join(dir, name+"."+branch+ext)
    }
    return filepath.Join(dir, name+childSuffix+ext) // childSuffix = ".child"
}

isChildFileName 精准识别已存在的子版:xxx.child(默认子版)或 xxx.分支名(分支名以字母开头)。这样 my_story_1.meph(数字序号)不会被误判为子版。

子版文件是完整的 .meph 契约,包含:

  • 母版的所有静态区块(角色名、世界观、角色背景、开局场景、锚点、规则)
  • 更新后的 【状态】
  • 累积的 【记忆】
  • 最近的 【历史】

这意味着一份静态契约可以演化出无数个动态子版:

story.meph (母版,只读)
    ├── story.child.meph (主线存档)
    ├── story.dark.meph (黑暗分支)
    ├── story.light.meph (光明分支)
    └── story.experimental.meph (实验分支)

每个分支独立演化,互不影响。项目自带的 data/dantes.meph 就伴随一个已运行的 data/dantes.child.meph 存档。

保存时机:不是每轮都写磁盘的低效模式,而是分两层:

  1. 每轮对话后:Session 层(cmd/mephisto/session.go)调用 engine.Save(),实时持久化进度
  2. 退出时defer 再保存一次,确保退出前状态落盘

保存时的规则保鲜Save() 有一个精妙设计——保存前先读取磁盘上的子版文件(若存在),以磁盘上的 【规则】 区块为最新规则。这样用户在编辑器中对规则区块的实时修改不会被自动保存覆盖。这同时支撑了 v1.0.3 引入的规则热重载session.go 通过 fsnotify 监听子版文件变更,500ms 防抖后调用 ReloadContract 重新解析,只替换规则、保留状态和历史,让”编辑规则 → 保存 → 立即生效”成为可能。

加载时

  • 默认加载子版(如果存在)
  • --reset 忽略子版,从母版重新开始
  • --branch dark 加载对应的分支文件

注意:直接运行子版文件会覆盖原文件——引擎会将任何 .meph 文件视为母版,并生成对应的子版。如果不想丢失进度,请避免对子版文件直接运行 run 命令。

这个设计的价值:创作者可以在关键时刻分叉故事线,探索不同走向,而不丢失任何进度。

五、记忆提取:流式输出后的同步编织

记忆提取是长线叙事的关键——它把关键事件从对话历史中提取出来,压缩后长期保存,在每一轮中注入 LLM 上下文。

但提取需要调用 LLM,会耗时数秒。如果放在流式输出之前执行,用户每 5 轮就要等几秒才能看到第一个字。

解决方案很简单:先输出,后提取。

每一轮对话的流程是这样的:

用户输入


规则匹配 + 动作执行 + LLM 流式输出(用户看到文字逐字出现)


流式输出完成,用户读完回复


引擎 Run 同步执行记忆提取(此时代码在 Run 内部,用户已读完响应)


返回给 Session,Session 调用 Save 自动保存子版


显示输入提示,等待下一轮

注意和旧版本的区别:记忆提取在 engine.Run() 内部同步执行,子版保存由 Session 层在每轮 Run 返回后调用。两者解耦——引擎负责叙事与记忆,Session 负责持久化。

// internal/core/engine/engine.go

func (e *Engine) Run(input string, onChunk func(string)) (string, error) {
    // ... 规则匹配、LLM 调用、流式输出 ...

    // 4. 记录角色响应
    runtime.AddHistory("assistant", response)

    // 5. 记忆提取(每 N 轮,同步调用)
    e.processMemories()   // ← 用户此时已读完响应

    return response, nil
}

processMemories() 内部流程:

  1. 提取间隔判断ShouldExtract——轮数 % 5 == 0 时触发(ExtractInterval = 5
  2. 调用 LLM 提取:取最近 10 轮对话(ExtractWindow = 10),生成关键事件摘要(每条不超过 20 字)
  3. 语义去重shared.DeduplicateMemories 基于关键词 Jaccard 相似度去重,语义相近但表述不同的记忆自动合并。例如”浮士德在书斋中遇到了梅菲斯特”与”梅菲斯特在深夜来访浮士德的书斋”——两条记忆共享浮士德、梅菲斯特、书斋等多个关键词,被判定为描述同一事件而合并成一条
  4. 追加 + 压缩:超过上限(MaxLimit = 30)时自动压缩,保留最近 5 条 + 3-5 条摘要

为什么这样设计?

  1. 用户无感知:流式输出已经完成,用户正在阅读或思考回复内容。记忆提取在后台悄悄进行,用户不需要”等待”。
  2. 逻辑简单:同步调用比异步 goroutine 更容易控制——没有竞态条件,没有”保存时记忆还没写完”的问题。

提取失败只会静默记录一条日志(提取函数返回错误则直接跳过),对话可以继续——只是本轮的记忆没有被保存。

六、完整闭环

把所有部分串起来,引擎的每一轮对话是这样运转的:

用户输入


规则匹配
    │   ├── 被动规则(状态修改 + 注入记忆)批量执行,多条同时触发
    │   └── 主动规则(LLM 指令 / 静态文本)互斥匹配,只取第一条


执行动作 → LLM 调用(60 秒超时保护,失败时 ⚠️ 降级为静态响应)
    │                        ↑——超时只保护 LLM 调用阶段

流式输出(全角缩进 + 逐块回调)


记录 assistant 历史 → (同步)记忆提取(每 5 轮触发一次,无流式等待)


Session 层调用 Save → 自动保存到子版文件(story.child.meph)


规则热重载监听(后台异步 fsnotify,不阻塞主循环)


等待下一轮输入

几个运行时的健壮性细节值得说明:

  • LLM 超时降级:整个 LLM 调用包裹在 60 秒超时上下文(context.WithTimeout)中。超时或失败时,引擎通过 onChunk 输出 (⚠️ LLM 调用失败:请求超时,已降级为静态响应),再返回默认静态文本。告诉用户”LLM 挂了”比让用户猜”角色沉默了”更好——前者是可诊断的工程问题,后者可能被误解为叙事设计。
  • 调试与静默:调试信息(--debug)写入 os.Stderr,普通输出(--quiet)不干扰调试信息。两者可同时启用。

这就是 Mephisto 的运行时。

七、代价与局限

这套机制不是没有代价的:

1. 记忆提取依赖 LLM 质量

如果主模型状态不佳,提取的摘要可能跑偏——甚至篡改关键事实(如将”击败”误写为”放逐”)。这是一种”幻觉”风险。

当前应对策略有两个层面:

  • 提示词保护:提取和压缩的提示词中明令禁止修改角色的核心设定(角色名、锚点内容、状态值等),在 DeepSeek 和 GPT-4 上效果可靠。
  • 模型选择建议:对于 7B 本地模型,摘要质量下降明显。如果必须使用轻量模型,建议关闭自动记忆提取(设置 ExtractInterval = 0),改为手动管理记忆。

未来可以进一步加强:实现记忆后验验证——提取结果返回后,由引擎检查是否与核心设定冲突,发现有矛盾直接丢弃该条记忆。

2. 分支切换需要手动管理

子版文件是独立存储的,切换分支需要用户主动指定 --branch。不像真正的版本控制有 diff 和 merge,分支之间的内容不会自动同步。

3. 流式输出占用终端

全角缩进和流式输出在终端看起来很好,但如果用户想复制粘贴文本,缩进和换行符会一起被复制——有时会产生干扰。

八、小结

六篇走完了一条完整的路径:

篇目 解决的问题 核心产出
第一篇 用什么格式写规则? .meph 格式设计
第二篇 第一步跑什么? 从零写浮士德契约并运行
第三篇 怎么精确解析并报错? 区块扫描器 + Parser
第四篇 规则和变量怎么解析? 规则表达式 + 插值语法
第五篇 怎么保证不改坏? Golden File 测试
第六篇 怎么让契约活起来? 五层 Prompt + 分支存档 + 记忆提取

合在一起,就是一个完整的长线叙事引擎:

契约(.meph)


解析器(第三、四篇)──→ Contract


引擎(第六篇)──→ 规则匹配 + LLM 流式叙事 + 记忆提取


子版存档(story.child.meph)──→ 长期连续性 + 多分支

速查卡:完整流程

.meph 契约文件

    ▼ 扫描器(行号绑定)

    ▼ 区块列表 []Block

    ▼ Parser(按区块标题路由)

    ▼ domain.Contract
    │   ├─ RoleName
    │   ├─ Anchor
    │   ├─ State
    │   ├─ Worldview
    │   └─ Rules(条件原样存储,运行时求值)

    ▼ 引擎
    │   ├─ 五层三明治 Prompt(顶部约束 → 上下文 → 角色 → 状态/历史/记忆 → 底部约束)
    │   ├─ 规则匹配(被动批量 + 主动互斥)
    │   ├─ 动作执行(注入 / 状态修改 / LLM 调用 / 静态文本)
    │   ├─ 流式输出(全角缩进)
    │   ├─ 记忆提取(每 5 轮,同步调用,语义去重)
    │   ├─ LLM 超时降级(60 秒,⚠️ 提示 + 静态响应)
    │   └─ 子版存档(story.child.meph,点分隔命名)

    ▼ 引擎循环
        用户输入 → 规则匹配 → 执行动作 → LLM 流式叙事
        → 记忆提取 → Session 自动保存 → 热重载监听 → 等待下一轮

如果你想快速定位某篇的具体内容,对照上面的流程节点查找:

流程节点 对应文章 关键概念
.meph 格式 第一篇 区块标题、规则语法
上手操作 第二篇 从零写契约、无 LLM 模式
扫描器 第三篇 行号绑定、白名单前置
Parser 第四篇 规则拆解、插值语法
测试体系 第五篇 Golden File、错误场景测试
引擎运行时 第六篇 五层 Prompt、分支存档、记忆提取

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

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