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" 双驱动效果。
玩家累计选择:教室追问 → 等真由 → 给日记。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 的核心工程价值:作者和工程师可以并行迭代,互不阻塞。
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.json 的 history。
角色 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) |
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 写作经验(对小模型写手效果实测显著):
- 契约不能自相矛盾:tone_option 里"门后传来真由的声音"和 forbidden 的"不要描写门后内容"打架,写手照着 tone_option 写、判官照着 forbidden 抓。禁令措辞里写清边界("隔着门板一句含糊人声属于允许的演出"),写手和判官才对齐
- 对白类事实要附对白示意:"真由透露她见过类似的结局"总被跳过(漏 3/4);在事实后括号附一句可用对白("如:'我见过…和这次很像的结局。'说到一半停住")后降到 1/4,且写手会照示意的省略号节奏演
- 名词条目要补谓词:"雨后的旧校舍三楼"这种纯名词事实,写手会丢掉"三楼";写成"场景发生在…(要明确点出'三楼')"
最小可工作的场景:
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。
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 自决。
- 加场景:在
content/scenes/下新建<id>.yaml,参考速查;让某场景的 choice 的transition指过来即可 - 加角色:
content/characters/<id>.yaml,场景的present_characters引用 - 改世界观:
content/world.yaml的hard_facts/forbidden/tone_global - 换召回策略:设
STORY_EMBED_MODEL即可上 embedding;要换打分公式改 engine/memory.py 的recall(),schema 不变 - 换服务商:在 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 之类)
调质量(如果默认效果不够好):
.env加STORY_GEN_EFFORT=medium—— 兼顾质量与速度.env加STORY_GEN_THINKING=adaptive—— 最高质量但首字会回到 ~10 秒
调速度(如果默认还嫌慢):
- 切
STORY_GEN_MODEL=claude-haiku-4-5-20251001—— 快 2 倍,文本质量降一档 STORY_PROVIDER=openai指到企业代理或本地 ollama——不用改代码
- demo-story/ — 《雨后旧校舍》原始设计稿(17 份 markdown,作者素材)