Narrative Engine 静态分析:模块设计与工作原理
对象:Sagesheep/NarrativeEngine-P(MIT)· 方法:纯静态分析
一、总体架构
Narrative Engine 是一个单体桌面/本地 Web 应用:人类玩家通过聊天界面与"AI 主持人(AI DM)"互动,AI 负责叙事、判定与世界推进。技术栈为 React 19 + Zustand 5 + Vite 8(前端,src/)+ Node.js Express 5 ESM(后端,server/),另有一个平台无关的纯逻辑包 packages/engine(@narrative/engine),以及一个运行时插件系统(mods/、public/bundled-mods/)。版本 2.0.0(package.json:4)。
┌─────────────────────────────────────────────────────────────────────┐
│ 浏览器 (Electron / http://localhost:5173) │
│ ChatArea/Composer → useAppStore (Zustand 6 slices) │
│ turnOrchestrator.runTurn → turnStages(7阶段) │
│ ├ contextGatherer (并发召回: planner/semantic/recommender/lore) │
│ ├ payloadBuilder (stable/world/volatile/history 拼装) │
│ └ llmService.sendMessage (流式, 工具调用, DSML 回退) │
│ │ Vite proxy /api → :3001 (或 /api/llm/proxy 兜底) │
└──────────┼──────────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Node 后端 server.js (127.0.0.1:3001, CORS 白名单) │
│ vault(KeyVault AES-256-GCM) · 16+ routers (/api/*) │
│ services/archiveService (appendScene 同步写盘) │
│ lib/vectorStore (better-sqlite3 + sqlite-vec, 3 张 vec0 表) │
│ lib/embedder (@huggingface/transformers · mxbai-embed-large-v1 q8) │
│ lib/nlp (NPC 名/见证人/重要性 6-pass 启发式) │
└──────────┬──────────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 持久化 │
│ data/settings.json · data/embeddings.db (向量) │
│ data/campaigns/<id>.{json,state,lore,npcs,locations,archive.md, │
│ archive.index.json,chapters,timeline,entities,facts,overworld, │
│ divergence,relationship-memory.*}.json │
│ data/backups/ · data/.embeddings_cache │
└─────────────────────────────────────────────────────────────────────┘
数据流方向:前端 Zustand store 是运行时状态源,后端以"每战役多 JSON 文件"为持久层,向量/语义记忆放在 SQLite 侧;LLM 调用全部由前端发起(llmService.ts),经后端 /api/llm/proxy 兜底转发以绕开 CORS(server.js:102、src/services/llm/llmFetch.ts)。
二、核心工作流
链路 A:玩家输入 → 上下文装配 → LLM 调用 → 状态更新
- 掷骰/注入(Stage 1)
src/services/turn/turnStages.ts:92resolveEngineRolls:预掷引擎骰池,解析玩家 armed 的 dice/loot/one-shot,把结果作为"事实断言"拼进ctx.finalInput(:112)。 - 挂用户气泡(Stage 2)
turnStages.ts:168addUserTurnMessage:同步加入聊天列表,保证重异步前可见。 - 上下文装配(Stage 3)
src/services/turn/contextGatherer.ts:228gatherContext:6 路并发——planner(LLM)、semantic-candidates(服务端向量检索)、archive-recall(混合召回)、recommender(LLM 选上下文)、lore-rules(IDF+RRF)、dynamic-elevation/slotted-RAG,用Promise.race做超时兜底(:324-330)。召回入口memory.recall角色在:155。 - 导演阶段(Stage 5)
turnStages.ts:245runDirectorStage:确定性 watchdog 案卷 +(pro/max 档)一次阻塞式 Director Brief LLM 调用,为下一回合写"作家简报"。 - 组装 payload(Stage 6)
turnStages.ts:434buildTurnPayload→src/services/payload/payloadBuilder.ts:116buildPayload:5 块拼装(stable/world/volatile/history + final-user 贡献注册表),带 Anthropiccache_control标注。 - 生成(Stage 7)
turnStages.ts:546runGenerationStage→ 递归executeTurn(:590):sendMessage流式输出,工具调用循环(每回合上限MAX_TOOL_CALLS_PER_TURN=5,:48/:603),三级重试(重试→去工具重试→放弃,:842-887),产出 SwipeVariant 并 stamppendingCommit。 - 提交与状态更新
src/services/turn/pendingCommit.ts+postTurnPipeline.ts:91runPostTurnPipeline:runArchiveTrack(:175)把整回合写入.archive.md(api.archive.append,:213)并刷新 index/timeline/chapters;随后跑 post-commit 轨迹(NPC 更新、事件抽取、章节自动封印、重要性评分、道具/角色扫描),Arc 引擎 tick 与 NPC Agency tick 也在提交后触发。
链路 B:记忆召回(混合检索)
src/services/archive-memory/recall.ts:24 retrieveArchiveMemory:对 archive 索引做 IDF 加权关键词排序 + 服务端向量排序,经 RRF 融合(:110),再把"分歧寄存器"(divergence)场景强制前置(:117-131),最终按共识上限 dynamicMax 截断;:149 fetchArchiveScenes 按 token 预算取回逐字场景文本。深搜路径 deepArchiveSearch.ts:279 是"章节扫描→场景扫描→分区摘要"的两轮 LLM 管线。
关键函数/文件索引(按职责)
| 职责 | 函数 | 位置 |
|---|---|---|
| 回合主循环 | runTurn |
src/services/turn/turnOrchestrator.ts:155 |
| 回合阶段编排 | resolveEngineRolls/gatherTurnContext/runDirectorStage/runGenerationStage |
src/services/turn/turnStages.ts:92/188/245/546 |
| 上下文装配 | gatherContext(6 路并发) |
src/services/turn/contextGatherer.ts:228 |
| Prompt 拼装 | buildPayload |
src/services/payload/payloadBuilder.ts:116 |
| 流式调用 | sendMessage |
src/services/llm/llmService.ts:22 |
| 协议适配 | getApiFormat/buildChatBody |
src/utils/llmApiHelper.ts:54/269 |
| 回合提交 | runPostTurnPipeline/runArchiveTrack |
src/services/turn/postTurnPipeline.ts:91/175 |
| 混合召回 | retrieveArchiveMemory/fetchArchiveScenes |
src/services/archive-memory/recall.ts:24/149 |
| 深搜 | deepArchiveScan |
src/services/archive-memory/deepArchiveSearch.ts:279 |
| 摘要压缩 | shouldCondense/getCondenseBudgetRatio |
src/services/archive-memory/condenser.ts:14/6 |
| 章节封印 | runCombinedSeal |
src/services/turn/postTurnPipeline.ts:373 |
| NPC 代理 tick | runAgencyTick |
src/services/npc/agency/agencyEngine.ts:53 |
| 向量存储 | initDb/createSearchFn/mmrSelect |
server/lib/vectorStore.js:121/225/52 |
| 本地嵌入 | embedText/embedBatch |
server/lib/embedder.js:118/129 |
| 场景归档 | appendScene |
server/services/archiveService.js:84 |
| 原子写盘 | writeJson/getNextSceneNumber |
server/lib/fileStore.js:91/162 |
三、模块逐一分析
1. LLM 提供商抽象层(重点)
- 类型
src/types/llm.ts:ApiFormat = 'openai'|'ollama'|'claude'|'gemini'|'comfyui'|'openrouter'(:5);LLMProvider(:59)、AIPreset(:77,五角色 story/summarizer/utility/auxiliary/image)、AppSettings(:103)。 - 协议适配
src/utils/llmApiHelper.ts:getApiFormat(:54)判定格式,getChatUrl(:86)拼接各厂商端点,buildChatBody(:269)按格式构造请求体(Claude 消息块转换:126、Gemini 转换:217),extractStreamDelta/ExtractStreamToolCall(:432/:452)解析各格式流式增量。 - 流式调用
src/services/llm/llmService.ts:22sendMessage:按format分支解析(Ollama NDJSON:117、Claude/Gemini SSE:127、OpenAI SSE:149);DSML 回退——当本地/DeepSeek 模型不原生支持 function calling 时,从正文<|DSML|>function_calls>标签解析工具调用(:192-223)。 - 队列/限流
src/services/llm/llmRequestQueue.ts:按端点自适应并发(云端∞、本地=1),429/503/529 退避。
2. 记忆系统(重点:摘要 + 向量)
- 无损场景归档
server/services/archiveService.js:84appendScene:同步写.archive.md(**[USER]**/**[GM]**块,:96-108),索引项由启发式生成。 - 向量库
server/lib/vectorStore.js:better-sqlite3 + sqlite-vec,3 张vec0表(archive/lore/rules,cosine,:142-162),MMR 多样性重排(:52,λ=0.7),嵌入版本化(EMBEDDING_VERSION=1,:13)。 - 嵌入
server/lib/embedder.js:mxbai-embed-large-v1,q8、1024 维、CPU、LRU 512(:5/:7/:14),全本地。 - 自动摘要(condenser)
src/services/archive-memory/condenser.ts:VERBATIM_WINDOW=10(:4),三档压缩比 tight 0.5 / 默认 0.75 / deep 0.90(:6-12)。 - 深搜
src/services/archive-memory/deepArchiveSearch.ts:279:章节扫描→场景钻取→分区摘要→合并(:206summarizePartitions处理超预算)。 - 章节自动封印
archiveChapterEngine.ts(软上限CHAPTER_SCENE_SOFT_CAP=25,types/archive.ts:53)。
3. NPC 状态与代理(重点)
- 数据模型
src/types/character.ts:177NPCEntry(约 40 字段):六轴PersonalityHex(:264,−3..+3)、三层 wants(:267)、目标记录Goal(:280)、RelationGraph(:295)、pressure/repression/relationMeter/signatureKit。 - 代理引擎
src/services/npc/agency/agencyEngine.ts:53runAgencyTick:心跳骰(:83)、时间跳跃检测(:72)、目标升级(:100)、碰撞检测/解缠。 - 检测/画像
npcDetector.ts(7-pass 名字抽取)、npc-generation/shared.ts(画像生成)、npcPressureTracker.ts(被忽视/被互动压力)。
4. 世界模拟
- Arc 引擎:纯逻辑已迁至计算 mod(
src/services/arc/index.ts:1-14说明),宿主侧仅剩arcSpawn.ts(+1 LLM 生成弧)。 - 叙事事件引擎(Surprise/Encounter/World Event,DC 衰减)+ 骰子公平池,见
src/services/engine/engineRolls.ts。 - 世界地图
src/services/mapEngine/worldOrchestrator.ts+ PixiJS 渲染。
5. 持久化与存档格式(重点)
- 路径
server/lib/fileStore.js:DATA_DIR/CAMPAIGNS_DIR(:11-12),原子写writeJson(tmp+rename,:91-101)。 - 存档文件集
BUILTIN_CAMPAIGN_FILE_SUFFIXES(:235-245):一个战役 = 21 个 JSON/MD 文件(state/lore/npcs/locations/archive.md/archive.index/chapters/timeline/entities/facts/overworld/divergence/relationship-memory.*)。 - 向量另存
data/embeddings.db(全局 SQLite,按 campaign_id 隔离)。 - API 密钥保险库
server/vault.js:AES-256-GCM + PBKDF2,/api/vault/*11 个端点。
6. 插件/Mod 系统
mods/(用户安装)与 public/bundled-mods/(随应用发布,如 enemies)通过 server/lib/modLoader.js 加载;turn 阶段暴露 turn.start/turn.generated 等核心事件与 prompt interceptor/fact publisher 钩子(turnOrchestrator.ts:187、turnStages.ts:365/415)。
7. 数据模型(类型层)
src/types/ 12 个文件:GameContext(gamecontext.ts:156,约 70 字段,含 playerCharacter: NPCEntry)、ArchiveIndexEntry/ArchiveScene/ArchiveChapter(archive.ts)、TimelineEvent(archive.ts:166,带 SUPERSEDE 规则 :160)、DivergenceEntry/Register(divergence.ts,含 knownBy 知识边界 :22)、SemanticFact(archive.ts:119)。
8. 骰子与自动判定
- 骰子系统
src/types/gamecontext.ts:67-101:DieType(d2~d100,各带OutcomeBand结果带)+DiceCategory+ 三门RollDefinition(优势/数量/聚合),默认 d20 带 Catastrophe→Narrative Boon 五带(:379-387)。 - 引擎
src/services/engine/engineRolls.ts:rollEngines(惊喜/遭遇/世界事件三引擎,DC 逐回合衰减)、rollDiceFairness(预掷技能池)、resolveManualRoll;engine/diceTier.ts做 tier 映射。池模式与roll_dice工具互斥(turnStages.ts:606-612)。 - 战利品
packages/engine/src/loot/lootEngine.ts+src/services/engine/lootEngine.ts:纯骰子+JSON 的战利品树遍历,运行时零 LLM。
四、GM/玩家端区分
不区分 GM/玩家双端。 这是单用户应用:人类是唯一玩家,AI 扮演主持人(README:10 "Your AI Dungeon Master")。后端日志自称 "GM-Cockpit API"(server.js:149),实为"玩家操控 AI GM 的座舱"。玩家对 GM 的控制通过 OOC 通道实现:Ask GM(src/services/ooc/oocService.ts)、Absolute Command(一次性 OOC 强指令,不进历史,turnStages.ts:267)。无多用户/联网 GM 客户端——grep 无 multiplayer/GM client/player client 命中。
五、与 Z.R.I.C 功能对照表
| Z.R.I.C 能力 | 是否支持 | 实现位置(文件:行) |
|---|---|---|
| 剧本/世界观解析 | ✅ | src/services/lore/loreChunker.ts、loreRetriever.ts(IDF+RRF) |
| 推演分支 | ✅(部分) | Swipe 多结局变体(swipeGeneration.ts)、World Arc(arc/)、叙事事件引擎 |
| 自动判定 | ✅ | engine/engineRolls.ts、engine/diceTier.ts、roll_dice 工具 |
| 世界状态 | ✅ | Divergence Register(divergence.ts)、Timeline(archive.ts:166)、GameContext.canonState |
| NPC 情绪/记忆 | ✅(强) | PersonalityHex 漂移、relationshipMemory.ts、relationMeter、pressure、repressionPressure |
| 地图 | ✅ | mapEngine/worldOrchestrator.ts(Perlin+Voronoi)、OverworldCanvas.tsx |
| 触发器 | ✅ | NPC behavioralTriggers(character.ts:135)、引擎 DC 衰减、压力阈值 |
| 时间线 | ✅ | TimelineEvent + timelineResolver.ts(supersede) |
| 记忆系统 | ✅(强) | 混合召回 + 向量 + 摘要 + 深搜 + 章节封印(见三.2) |
| 本地模型 | ✅ | Ollama(llmService.ts:117)、本地 ONNX 嵌入(embedder.js) |
| 许可 | ✅ MIT | README.md:369、package.json |
六、设计评价
优点
- 记忆是真正的分层体系:无损逐字归档(
archiveService.js:95)+ 章节摘要 + 向量检索 + 混合 RRF 融合(recall.ts:110)+ 两轮深搜 + 动态摘要压缩(condenser.ts),是同类项目中最完整的记忆实现。 - 提供商抽象干净且实用:
getApiFormat单点判定 +buildChatBody按格式分派 + DSML 回退解析(llmService.ts:192),用一套OpenAIMessage统一了 OpenAI/Ollama/Claude/Gemini/DeepSeek,且对 reasoning/thinking 预算做了逐厂商映射(llmApiHelper.ts:14-28)。 - 提交可靠性设计严谨:同步写盘 + 场景编号跨文件取最大值(
fileStore.js:162)+ 丢响应后按userSnippet前缀对账防重复归档(postTurnPipeline.ts:36-56)+ 崩溃后persistTurnState立即落盘(turnStages.ts:787),明显为"长线多会话不丢档"下过功夫。 - NPC 代理可解释且分层:六轴人格 + 三层目标 + 心跳骰 + 碰撞解缠,全部确定性骰子驱动、可按 AI tier 关闭,避免无脑烧 LLM(
agencyEngine.ts:80标注 "+0 LLM")。
缺陷
- 复杂度失控、演进未同步文档:
ARCHITECTURE.md描述的runTurn()(509 行、contextGatherer14 变量、payloadBuilder5 块)已被重构为turnStages.ts8 阶段 +TurnContext数据总线 + 贡献注册表 + mod 钩子,但文档与代码注释大量保留"阶段 3.2/Phase 5.2/WO-P1-03"等工作单痕迹(turnStages.ts:166-179、payloadBuilder.ts:90-113),新读者极难对齐。 - "事实来源单一化"被打散:世界状态同时散落在 Divergence Register、Timeline、
GameContext(~70 字段)、NPC 字段、relationship-memory 文件五处(fileStore.js:235),存在多套并存/需迁移的旧字段(@deprecated的diceConfig、inventory、legacy 字符串characterProfile,gamecontext.ts:56/163/512),一致性维护成本高。 - 前端直连 LLM、后端仅代理:
llmService.ts在浏览器内发起所有模型调用,/api/llm/proxy只是 CORS 兜底(llmFetch.ts),意味着"任意 OpenAI 兼容端点"的密钥、请求都从前端发出,虽用settingsCrypto.ts加密,但架构上缺少统一服务端 LLM 网关。 - 关键状态为内存态、依赖前端存续:核心游戏循环
runTurn在浏览器跑,pendingCommit/swipe 快照存于内存(pendingCommit.tsPendingTurnSnapshot 单例),依赖persistTurnState补写磁盘;一旦前端崩溃于两次写之间仍有丢档窗口。
七、结论
它是什么:一个功能极其丰富、以"AI 当主持人、玩家单人游玩"为形态的本地自托管 TTRPG 引擎,其核心竞争力是把"长线战役记忆"做成了分层可检索的工程系统(无损归档 + 向量 + 摘要 + 深搜 + 章节封印),并在 NPC 代理、世界弧、骰子公平性、知识边界(knownBy/见证人)等维度远超一般"套壳聊天"项目。
它不是什么:它不是多人 GM/玩家分离的桌面跑团工具(无联网、无双端、无实时多人),也不是"剧本解析→确定性分支树"的叙事引擎(分支靠 LLM + 骰子概率涌现,而非静态剧本图),更不是轻量可读的示例项目——它是一台由大量工作单(WO-*)驱动的、仍在高速演进中的重型单机引擎。