构建大模型叙事引擎:运行时闭环与多分支存档
前置阅读:这是本系列的最后一篇,建议先读前五篇——第一篇了解
.meph格式,第二篇上手实操,第三、四篇理解解析器如何输出domain.Contract,第五篇理解测试如何保障行为稳定。本篇假设你已经知道引擎拿到了contract结构体,要解决的核心问题是:怎么驱动它运转起来?
一、叙事引擎的第五个问题:怎么让规则活起来?
解析器把契约变成了结构体,但结构体不会叙事。一个叙事引擎还需要运行时——输入 → 匹配 → 执行 → 输出的完整闭环。
这里要解决的工程问题有三个:
- 规则怎么实时匹配? 用户输入到达时,引擎需要遍历所有规则、评估条件、决定触发哪些动作——这个过程必须在毫秒级完成,不能影响用户体验。
- LLM 怎么遵守规则? 规则不能直接约束 LLM 的行为,它们必须通过 Prompt 间接生效。怎么把规则”翻译”成 LLM 能理解的格式,是这个环节的核心难点。
- 状态和记忆怎么持续? 每一轮对话都会改变角色的状态和记忆池,但下一轮对话开始时,引擎必须恢复上一轮的完整状态。没有持久化,就没有长线叙事。
这一篇解决的就是这三个问题。第四、五篇解决的规则匹配细节(两阶段匹配、骰子判定)此处不再展开,聚焦运行时如何把整条链路串起来。
引擎拿到 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.go 的 RenderPrompt)是五层结构:
【格式硬性要求】
(NarrativeConstraints——禁止括号、禁止剧本标记、禁止独角戏)
【世界观】
(context)
【角色名】
你是 {角色名},一个 {锚点风格} 的存在。你的背景:{角色背景}
【当前状态】
(从运行时 `map[string]any` 渲染而来)
【命运的推动】
(对话历史——命运与角色的交替记录)
【你记得的过往】
(运行时动态累积的记忆)
【此刻】
(user_input)
【要求】
(NarrativeConstraints,再次强调)
约束在两端出现两次,中间夹着上下文。这样做有两个原因:
- 首因效应:LLM 最先看到约束,优先级最高
- 近因效应: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 dark→story.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 存档。
保存时机:不是每轮都写磁盘的低效模式,而是分两层:
- 每轮对话后:Session 层(
cmd/mephisto/session.go)调用engine.Save(),实时持久化进度 - 退出时:
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() 内部流程:
- 提取间隔判断:
ShouldExtract——轮数 % 5 == 0 时触发(ExtractInterval = 5) - 调用 LLM 提取:取最近 10 轮对话(
ExtractWindow = 10),生成关键事件摘要(每条不超过 20 字) - 语义去重:
shared.DeduplicateMemories基于关键词 Jaccard 相似度去重,语义相近但表述不同的记忆自动合并。例如”浮士德在书斋中遇到了梅菲斯特”与”梅菲斯特在深夜来访浮士德的书斋”——两条记忆共享浮士德、梅菲斯特、书斋等多个关键词,被判定为描述同一事件而合并成一条 - 追加 + 压缩:超过上限(
MaxLimit = 30)时自动压缩,保留最近 5 条 + 3-5 条摘要
为什么这样设计?
- 用户无感知:流式输出已经完成,用户正在阅读或思考回复内容。记忆提取在后台悄悄进行,用户不需要”等待”。
- 逻辑简单:同步调用比异步 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、分支存档、记忆提取 |

