Skip to content

Latest commit

 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Lacuna — 半生成式叙事引擎

Lacuna(拉丁语):空白、缺口、间隙。 作者主动留白,LLM 填补留白,玩家选择决定填法。

作者写骨架,LLM 在边界内补全,玩家选择驱动微观分歧。 面向 VN(视觉小说)类型的独立游戏——关系驱动、多周目、文本短而精。

当前内容是验证用例《雨后旧校舍》——4 个节点 + 1 个结尾的悬疑 demo,原始设计稿在 demo-story/

生成端可以走 Claude API,也可以走任何 OpenAI-compatible 的本地服务(ollama / vLLM),见下文「本地 LLM 与 embedding」。有什么好的思路可以留言联系我


三层架构

职责 实现
Canon(结构化层) 角色硬设定、世界观、玩家累计状态、关系数值——每次必带,硬事实绝对优先 engine/canon.py + content/world.yaml + content/characters/
Memory(语义层) 场景过往的软描写、伏笔——hybrid 召回 = (关键词重叠 + embedding 相似度) × 时间衰减 × 情感分量,按视角与周目过滤 engine/memory.py + engine/embed.py + data/saves/<槽>/memory.db
Scene Contract(场景契约) 作者写的节点骨架——必须发生、不可揭露、NPC 知情边界、关系驱动的语气 engine/scene.py + content/scenes/

主流水线

load_scene → 校验前置条件 → 组装 prompt(hard_facts + 关系数值 + must_not_reveal + soft_memories)
  → LLM 流式生成(Opus 4.7 + 纯文本 [NARRATIVE]/[CHOICES] 格式)
  → 边收边 print(玩家约 1-2 秒看到第一个字)
  → 玩家选择 → 更新 canon(含关系数值 delta)→ 落盘断点 → 转下一场景
  → 后台异步:记忆提炼写入 memory + LLM-as-judge 查漏/查泄 → 写审计日志(不阻塞玩家)
  → 一周目 END → 摘要压进周目历史 → 可开下一周目(带记忆的角色会"记得")

设计哲学:作者主导,AI 补全,玩家驱动。LLM 不决定故事走向、不能揭露秘密、不能改写硬事实,只在作者预设的"受控空白"里写细节、对白、变体。


示例输出

下面是同一玩家在《雨后旧校舍》里实跑的两段输出(路径:教室直接追问真由 → 旧校舍门口等她 → 把日记给她看 → 天台沉默),LLM 实时生成、未经修饰。

第二节:旧校舍门口(体现关系数值影响)

玩家在第一节选了"直接追问真由",触发 mayu→player.trust −5 / distance +5。LLM 在没有任何额外提示的情况下,主动让真由的态度反映这个数值差

你回头的时候才发现她在。真由站在台阶下面那一截,没有再往上走,校服裙角被风掀了一下又落下。她抱着书包带,指节用力,脸却收得很平。先前在教室里,你当着所有人追问过她;她现在的眼神还留着那时候的刺。

"……你真的要进去。"她的声音被湿风削薄了,"我说过的话,你一句都没听。"她顿了很久,像在把另一句话咽回去,"门开着,不代表它在等你。"她终于抬眼看你,只看了一瞬,"……你要进,我不拦。可是别——"后半句没出口,她偏过脸去看那排银杏。

加粗部分是 LLM 把第一节的具体选择("当着所有人追问")织进了第二节的环境描写里——这正是引擎的"关系数值 + flag" 双驱动效果。

第四节:天台警告(体现 RAG 召回过往选择,"角色记得玩家"机制)

玩家累计选择:教室追问 → 等真由 → 给日记。LLM 在 04 一次性回响了三个具体过往选择,并主动写出元叙事点题:

"你来了。"她停顿了很久,像在挑选哪一个字最不会划伤人,"那天在教室……你问得很直。可你没有逼我。" 她终于转过身,眼睛比平时低,"日记你拿给我看的时候,我就该说的。是我没说。"

……此刻她看你的方式和那次一样——像在确认你是不是还是那个会沉默、会等她、会把日记摊开放在她面前的人。她记得。每一步她都记得。 这件事让你背后发凉,比夜风更凉。

引擎给的 RAG 上下文(玩家选择历史 + 关系数值)真的进了模型的语义空间,不只是被复述,而是被模型自己点出——"她记得。每一步她都记得"是 LLM 自发的元叙事,不是任何 prompt 里的固定句。

这两段证明的不是"LLM 写得好"——任何强模型都能写好文学性文本。它们证明的是引擎架构成立:作者只写了 must_establish + npcs.knows + tone_options + canon_update.relations 这几个结构化字段,LLM 就能在严格不泄露真相的前提下,让玩家感觉"她记得我"。


项目结构

Lacuna/
├── README.md                    # 你现在在看的文件
├── .env.example                 # 环境变量模板(复制为 .env 后填 key)
├── .gitignore
├── requirements.txt             # Python 依赖(anthropic / pyyaml / python-dotenv)
├── play.py                      # 游戏入口,跑这个开始玩
│
├── engine/                      # 引擎核心(不依赖具体故事内容)
│   ├── __init__.py
│   ├── canon.py                 # Canon 层:角色硬设定 / 关系数值 / 玩家状态 / 周目历史
│   ├── memory.py                # Memory 层:hybrid scoring 软记忆召回(关键词 + embedding)
│   ├── embed.py                 # embedding 客户端(OpenAI-compatible /v1/embeddings,可选)
│   ├── scene.py                 # Scene Contract:场景契约 YAML 加载
│   ├── llm.py                   # 格式约定 + 流式切分 + 提炼/校验封装(服务商无关)
│   ├── providers/               # 服务商适配:anthropic_provider / openai_provider(本地 LLM)
│   ├── audit.py                 # 审计日志 + 作者复盘报告(python -m engine.audit)
│   ├── save.py                  # 存档槽:data/saves/<name>/ 目录管理
│   └── runner.py                # 主流水线 + system prompt
│
├── tests/                       # pytest,DRY_RUN 下离线跑,不碰真实 LLM
│
├── content/                     # 作者内容(改 YAML 就改了故事,不动代码)
│   ├── world.yaml               # 世界观 + 全局禁令 + 全局基调
│   ├── characters/
│   │   ├── player.yaml          # 玩家(转学生)角色卡
│   │   └── mayu.yaml            # NPC 真由角色卡 + 关系轴定义
│   └── scenes/                  # 场景节点(每个 YAML 一个节点)
│       ├── 01_classroom_rumor.yaml
│       ├── 02_old_school_gate.yaml
│       ├── 03_diary_found.yaml
│       ├── 04_rooftop_warning.yaml
│       └── 05_ending.yaml
│
├── demo-story/                  # 验证用例《雨后旧校舍》原始设计稿
│   ├── README.md
│   └── 00-15_*.md               # 17 份 markdown,作者素材
│
└── data/                        # 运行时自动生成,git 忽略
    ├── audit.jsonl              # 每次生成一条:文本 / 判官结果 / 关系快照(作者复盘用)
    └── saves/<槽名>/
        ├── canon.json           # 玩家 flag / 选择 / 关系数值 / 断点 / 周目历史
        └── memory.db            # SQLite 软记忆库(带周目标签 + 可选向量)

改内容 vs 改引擎

  • 想让游戏讲不一样的故事 → 改 content/(YAML),引擎一行代码不用动
  • 想让引擎能力更强(换召回策略、加新服务商等)→ 改 engine/,content 不用动

这种解耦是 Lacuna 的核心工程价值:作者和工程师可以并行迭代,互不阻塞


跑起来(Windows + PowerShell)

第一次设置

cd e:\STORY

# 用 uv 装 venv(推荐,速度最快)
uv venv
uv pip install -r requirements.txt

# 或用系统 Python(需要 PEP 668 允许的环境)
# python -m venv .venv
# .venv\Scripts\python.exe -m pip install -r requirements.txt

# 配置 API key
copy .env.example .env
notepad .env   # 填 ANTHROPIC_API_KEY,可选 ANTHROPIC_BASE_URL(企业代理)

.venv\Scripts\python.exe play.py

直接调 venv 里的 python.exe 不需要 Activate.ps1绕开 PowerShell 执行策略问题

按数字选择 → 回车。存档自动写到 data/saves/default/,中途 Ctrl+C 退出,下次运行从断点续。

.venv\Scripts\python.exe play.py --slot alice    # 另一个存档槽
.venv\Scripts\python.exe play.py --list          # 看所有槽的进度
.venv\Scripts\python.exe play.py --new           # 清掉当前槽彻底重来(周目历史也清)
.venv\Scripts\python.exe play.py --start 03_diary_found   # 从某场景开始(调试)
.venv\Scripts\python.exe -m pytest tests -q      # 离线单测(DRY_RUN,不调 LLM)

多周目

一周目走到 END 后会问"开始第 N+1 周目?"。同一个槽内:玩家 flag / 关系数值回到 YAML 起始值,但本周目的结局、全部选择、结束时的关系数值被压进 canon.jsonhistory

角色 YAML 里标了 remembers_across_playthroughs: true 的角色(demo 里是真由)在场时,prompt 会多一块"跨周目记忆"——只属于该角色,system prompt 明令"只能调语气和小动作,不得说出、不得让其他角色察觉"。玩家角色不带记忆,所以软记忆库也按周目隔离,上一周目的环境细节不会流进本周目的召回。

这是引擎级机制:哪个角色记得、记得多少,是作者的内容决定(改 YAML),引擎只负责把它安全地喂给 LLM。场景也可以用 entry_conditions.min_playthrough: 2 做 NG+ 专属节点。

审计闭环

流式输出打出去的字收不回来,所以引擎不做生成时拦截,做事后审计:每个场景生成完,后台 Haiku 当判官查两件事——must_establish 漏了什么(missing)、must_not_reveal + 全局 forbidden 泄了什么(leaked)——连同原文、选项、玩家所选、当时的关系数值一起追加到 data/audit.jsonl

.venv\Scripts\python.exe -m engine.audit
# 审计报告  共 10 次生成
泄露 1  漏事实 2  回退 0  判官失败 0

场景                        生成  泄露   漏  回退   判失败
01_classroom_rumor         2   0   1   0     0
04_rooftop_warning         2   1   0   0     0
...
# 违规明细(泄露优先)
## 04_rooftop_warning  08-28 21:14  default/周目1  model=anthropic:claude-opus-4-7
  [泄露] 真由经历过上一周目
  文本: 她的手指停在栏杆上……

作者拿报告去改 YAML:反复泄露的场景收紧 must_not_reveal 措辞或 npcs.knows,反复漏的事实换更具体的表述。判官自己也可能误判,所以所有生成都记(含通过的),原文就在手边可复核。

判官的两道防幻觉措施:每条约束单独一次 yes/no 问答(让小模型做列表筛选它会把清单搞混、报出不存在的条目);每条泄露判定必须附正文原句引用,引不出来的判定直接丢弃。判官准不准,用 tools/judge_bench.py 拿参照判官量。


环境变量

写在 .env.env.example 是模板):

变量 默认 说明
ANTHROPIC_API_KEY (必填) Anthropic key 或企业代理 key
ANTHROPIC_BASE_URL (默认官方) 第三方自建代理时填
STORY_GEN_MODEL claude-opus-4-7 主生成模型,第三方代理需用精确ID
STORY_LIGHT_MODEL claude-haiku-4-5 校验 + 记忆提炼模型(异步跑,不影响首字延迟)
STORY_GEN_THINKING disabled disabled | adaptive。adaptive 思维质量更高但慢 5-10 秒
STORY_GEN_EFFORT low low | medium | high | xhigh | max。低 = 快但偶尔变浅
STORY_DRY_RUN (空) 非空时跳过所有 LLM 调用,全走作者预写的 fallback——离线测试管线用
STORY_PROVIDER anthropic anthropic | openai | claude-cli。openai 接任何 OpenAI-compatible 端点(本地 LLM,离线目标下的正式选项);claude-cli 用本机已登录的 Claude Code 子进程,只给 tools/judge_bench.py 当参照
STORY_LIGHT_PROVIDER STORY_PROVIDER 判官 + 记忆提炼用的服务商 / 模型,可与写手不同
STORY_CLI_TIMEOUT / STORY_CLAUDE_CLI 180 / PATH 里的 claude claude-cli 子进程超时秒数 / 可执行文件路径
STORY_OPENAI_BASE_URL http://localhost:11434/v1 openai provider 的端点(ollama 默认端口)
STORY_OPENAI_API_KEY local 本地服务随便填,不能空
STORY_GEN_TEMPERATURE 0.8 openai provider 的采样温度
STORY_EMBED_MODEL (空) 设了就开 embedding 召回,如 bge-m3;不设 = 关键词召回
STORY_EMBED_BASE_URL / STORY_EMBED_API_KEY 沿用 OPENAI 的 embedding 端点可以和生成端点不同(Claude 生成 + 本地向量)
STORY_EMBED_WEIGHT / STORY_EMBED_MIN_SIM 3 / 0.52 一次满分语义匹配折合几个关键词;无关键词命中时的余弦下限(按 bge-m3 实测校准:相关 0.56-0.62、无关 0.40-0.50)

本地 LLM 与 embedding

engine/providers/ 把服务商差异收在两个方法后面(stream_text / complete_json),llm.py 的格式约定和解析逻辑对两边一视同仁。接 ollama:

ollama pull qwen2.5:7b
ollama pull bge-m3          # 中文 embedding,CPU 也够快

.env

STORY_PROVIDER=openai
STORY_OPENAI_BASE_URL=http://localhost:11434/v1
STORY_OPENAI_API_KEY=ollama
STORY_GEN_MODEL=qwen2.5:7b
STORY_EMBED_MODEL=bge-m3

embedding 与生成解耦——只设 STORY_EMBED_MODEL 不改 STORY_PROVIDER,就是"Claude 写文本 + 本地算向量"。开了 embedding 后,作者不必再靠 soft_hooks.keywords 去猜记忆里会出现什么词;旧记忆没有向量也照常按关键词召回,中途开启不用重建库。

引擎的目标是完全离线:写手、判官、记忆提炼、embedding 全部本地模型。判官和写手可以是不同的本地模型(STORY_LIGHT_PROVIDER / STORY_LIGHT_MODEL),但都应该是本地的。

本地判官准不准,用 tools/judge_bench.py:它拿审计日志里的文本,让本地判官和一个参照判官(claude-cli = 本机已登录的 Claude Code 子进程,或 anthropic)各判一遍,以参照为准算精确率 / 召回率、列出分歧条目。参照判官只在这个开发工具里出现,是量尺不是零件——不要把它写进运行配置。

.venv\Scripts\python.exe -m tools.judge_bench --local openai --ref claude-cli

首轮 bench 结果(2026-08-28,RTX 3060,5 场景 × 约 10 条约束,参照 Haiku):

本地判官 missing 精确率 / 召回率 leaked 备注
qwen2.5:7b 100% / 25% 无误报,抓到了"越过门"的越界 默认;和写手同一模型,不用换载
qwen3:8b 100% / 25% 无误报 必须关思考(STORY_OPENAI_NO_THINK=1,默认开),否则 JSON 为空
qwen2.5:14b 67% / 50% 无误报 最准;9 GB 显存,和 7B 写手不能同驻 12 GB 卡,场景间会换载

"召回率 25%"看着低,人工复核后 3 条"参照报了本地没报"里有 2 条是参照判官过严(正文确实回响了承诺和追问;"三楼"没写但"旧校舍/雨后"都在)——参照不是金标准,分歧明细都打印出来就是为了人看。三个本地判官都做到了报出来的都有据可查,这对作者复盘工具来说是对的方向。

claude-cli provider 的隔离措施(实测缺一不可):--setting-sources "" 不加载 CLAUDE.md / 记忆 / 插件、--system-prompt 替换默认提示、--tools "" 纯问答、子进程剔除 ANTHROPIC_*(否则会拿 .env 的企业 key 走代理)、--json-schema 结构化输出、不能用 --bare(跳过登录凭证)。

qwen2.5:7b 在 RTX 3060 上的实测(5 场景约 100 秒,边生成边出字):

  • 写手:格式遵循尚可,choices 数量偶尔不对(引擎保留正文、只换选项,不整段回退);会把 tone_options 的模板句原样抄进正文,文学性明显弱于 Opus
  • 判官:误判多——让它"从清单里挑出泄露的"会把根本没写的秘密报成泄露、把正文句子当成条目。引擎因此改成逐条 yes/no(每条约束单独一问,小模型做二元判断可靠得多)+ 判定必须引用原句(模糊匹配:7B 会把"她"改成"真由"、会少抄几个字,所以用最长公共子串而不是严格子串;引不出来的判定丢弃)。准确率用 tools/judge_bench.py 对照量化
  • 小模型 JSON 输出不稳:provider 先试 json_object 模式,不支持就退回 prompt 约束 + 容错解析

看审计报告里的回退率和判官失败率就知道某个模型撑不撑得住。

写手基线tools/scene_bench.py,每场景独立生成 N 个样本、不注入软记忆、逐一过判官——单次跑局噪声太大,比较 prompt 改动必须用它)。qwen2.5:7b 首轮基线(n=15):泄露 1/15(且是 tone_option 内容矛盾教出来的)、漏事实 8/15(集中在对白类事实和动作节点,环境类基本不漏)、choices 格式失败 3/15(正文保留、选项回退作者预设)。

用它迭代出来的三条 content 写作经验(对小模型写手效果实测显著):

  1. 契约不能自相矛盾:tone_option 里"门后传来真由的声音"和 forbidden 的"不要描写门后内容"打架,写手照着 tone_option 写、判官照着 forbidden 抓。禁令措辞里写清边界("隔着门板一句含糊人声属于允许的演出"),写手和判官才对齐
  2. 对白类事实要附对白示意:"真由透露她见过类似的结局"总被跳过(漏 3/4);在事实后括号附一句可用对白("如:'我见过…和这次很像的结局。'说到一半停住")后降到 1/4,且写手会照示意的省略号节奏演
  3. 名词条目要补谓词:"雨后的旧校舍三楼"这种纯名词事实,写手会丢掉"三楼";写成"场景发生在…(要明确点出'三楼')"

场景 YAML 字段速查

最小可工作的场景:

id: "01_xxx"
title: "..."
entry_conditions: {}                  # any_flag / all_flags / relations / min_playthrough,见下
pov: "player"                         # 视角角色 id
present_characters: ["player", "mayu"]
must_establish: ["事实1", "事实2"]      # 必须在 narrative 中出现,缺一不可
choices:
  - label: "玩家可见的选项文案"
    intent: "internal_intent_id"
    transition: "02_next" 或 "END"
    canon_update:
      flags_add: ["..."]
      choice_log: "..."
      relations:                      # 可选:关系数值变化
        - subject: mayu
          target: player
          delta: { trust: 10, distance: -5 }
fallback_narrative: |                 # LLM 失败时的兜底文本
  ...

进阶字段(用了会让 LLM 表现更好):

字段 作用
author_intent 单句话告诉 LLM 这一节为什么存在,避免文本游离
must_not_reveal 场景级禁令清单。优先级高于 must_establish——绝对不能泄露的内容写这里
npcs.{id}.knows / does_not_know 场景级 NPC 知情边界。比如 Marn 在世界设定里知道某事,但本场不能说
npcs.{id}.attitude_baseline 该 NPC 在本场景的开场态度
tone_options 关系数值驱动的语气模板(high_trust / default / low_trust 等),LLM 根据当前 trust 数值挑
tone_local 本场景额外的语气微调(不依赖关系数值的硬约束)
length_hint 段落数 + 每段字数建议
soft_hooks.keywords 用于召回过往软记忆的关键词

entry_conditions 支持(同时给出时全部要满足):

entry_conditions:
  any_flag: [a, b]                    # 任一
  all_flags: [c]                      # 全部
  relations:                          # 关系数值区间
    - { subject: mayu, target: player, axis: trust, min: 65 }
  min_playthrough: 2                  # 二周目起开放(NG+ 专属场景)

完整示例参考 content/scenes/02_old_school_gate.yaml


角色 YAML 关键字段

id: "mayu"
display_name: "真由"
role: "同班同学"

hard_facts:
  - "安静、敏感、克制"
  - "经历过上一周目(隐藏真相,不可揭露)"

voice:
  description: "短句、停顿多、情绪压在话里"
  examples: ["今晚别去那里。", "你其实已经看过那本日记了吧。"]

# 二周目起带着上一周目的记忆进场(引擎级 NG+;不写 = 不记得)
remembers_across_playthroughs: true

starting_state:
  relations:
    player:
      affection: 50
      trust: 50
      distance: 50
      guilt: 0

# 当一个新关系第一次被建立(canon_update.relations)时,引擎从这里读初始值
default_relation_axes:
  trust: 50
  affection: 50
  distance: 50
  guilt: 0

关系轴是自定义的:不同 demo 可以用 trust/affection/fear,也可以用 trust/affection/distance/guilt,引擎按角色的 default_relation_axes 自决。


如何扩展

  1. 加场景:在 content/scenes/ 下新建 <id>.yaml,参考速查;让某场景的 choice 的 transition 指过来即可
  2. 加角色content/characters/<id>.yaml,场景的 present_characters 引用
  3. 改世界观content/world.yamlhard_facts / forbidden / tone_global
  4. 换召回策略:设 STORY_EMBED_MODEL 即可上 embedding;要换打分公式改 engine/memory.pyrecall(),schema 不变
  5. 换服务商:在 engine/providers/ 加一个实现两个方法的类,get_provider() 里注册

当前性能 & 已知局限

延迟(实测 Opus 4.7 + 流式 + low effort):

  • 玩家选完 → 看到第一个字:~1-2 秒
  • 第一个字 → 全文打完:~5-8 秒(边读边出,无等待感)

已知 trade-off

  • 流式输出放弃了 JSON schema 强约束,靠 system prompt 约定 + Opus 4.7 指令遵循。偶尔格式跑偏会触发 fallback,监控 console
  • 校验是事后审计而不是拦截——泄露的文本玩家已经看到了,作者靠 python -m engine.audit 复盘改 YAML。不 retry:流式打出去的文本无法收回
  • 判官(Haiku / 本地小模型)自己会误判,审计报告是线索不是判决,原文都记着可复核
  • 不开 embedding 时是关键词召回,soft_hooks.keywords 写得好,软记忆呼应才好;开了 embedding 后余弦阈值(STORY_EMBED_MIN_SIM)要按模型调
  • 不做自由文本输入——这是设计决定不是欠账:自由输入会打穿 must_not_reveal 防线,玩家交互始终是作者预设的 choices
  • 记忆召回是暴力扫描(上限 2000 条/周目),体量再大要换 ANN 索引(sqlite-vec 之类)

调质量(如果默认效果不够好):

  • .envSTORY_GEN_EFFORT=medium —— 兼顾质量与速度
  • .envSTORY_GEN_THINKING=adaptive —— 最高质量但首字会回到 ~10 秒

调速度(如果默认还嫌慢):

  • STORY_GEN_MODEL=claude-haiku-4-5-20251001 —— 快 2 倍,文本质量降一档
  • STORY_PROVIDER=openai 指到企业代理或本地 ollama——不用改代码

文档索引

  • demo-story/ — 《雨后旧校舍》原始设计稿(17 份 markdown,作者素材)

About

Lacuna — 半生成式叙事引擎。作者写骨架,LLM 在边界内填补留白,玩家选择决定填法。

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages