# 酒馆精简版规格书 —— card-analyst + roleplay > 基于 SillyTavern 源码精读(浅克隆于 `/home/jiang/st-src`,commit `8172dcd`,2026-07-07)。 > 本文档回答两个问题:① ST 的卡解析与 prompt 组装到底怎么做的(可复制的机制);② 我们在 DSH 里做精简版的具体规格,以及产出物的表现力预期。 --- # 第一部分:ST 源码精读结论 ## 1. 卡解析管线 ### 1.1 卡的存在形式 - **PNG 卡**:角色立绘 + 元数据藏在 PNG 的 `tEXt` chunk 里。 - **JSON 卡**:V2(`chara_card_v2`)/ V3(`chara_card_v3`)纯文本 JSON,或 V1 扁平 JSON。 ### 1.2 PNG chunk 提取(`src/character-card-parser.js`) ST 用 `png-chunks-extract` + `png-chunk-text` 两个 npm 包,算法完全可以手写复刻: 1. 解析 PNG 二进制,按 `length(4B) + type(4B) + data + crc(4B)` 遍历所有 chunk,筛出 `tEXt`。 2. `tEXt` 的 data 是 `keyword \0 text`,keyword/text 均为 **Latin-1** 编码(PNG 规范)。 3. **`ccv3` chunk 优先,`chara` 次之**。`text` 部分整体是 **base64 编码的 UTF-8 JSON**: `Buffer.from(textChunks[i].text, 'base64').toString('utf8')` 即得原始 JSON 字符串。 4. 写入时 ST 会同时写 `chara`(V2 JSON)和 `ccv3`(把 `spec: "chara_card_v3"`、`spec_version: "3.0"` 塞进同一 JSON 的副本)两个 chunk,插在 `IEND` 之前。 > 关键点:我们不需要任何依赖 —— 手写一个 ~40 行的 PNG chunk 遍历即可。注意 tEXt 的 keyword 是 Latin-1,但 JSON 内容本身是 ASCII 安全的 base64,解码顺序:`tEXt text → base64 → utf8`。 ### 1.3 三种规范的校验(`src/validator/TavernCardValidator.js`) | 规范 | 判定条件 | |---|---| | **V1** | 顶层扁平含 6 个必填字段:`name, description, personality, scenario, first_mes, mes_example` | | **V2** | `spec === "chara_card_v2"` 且 `spec_version === "2.0"`,`data` 含 14 个必填字段(见下),`character_book`(若有)须为 `{extensions, entries[]}` | | **V3** | `spec === "chara_card_v3"` 且 `3.0 ≤ spec_version < 4.0`,`data` 为任意对象(校验最宽松) | V2 `data` 的 14 个标准字段(`public/scripts/char-data.js` 类型定义 + validator): `name, description, personality, scenario, first_mes, mes_example, creator_notes, system_prompt, post_history_instructions, alternate_greetings[], tags[], creator, character_version, extensions{}` 另有可选 `character_book`(**随卡携带的世界书**:`{entries: [{keys, secondary_keys, content, constant, selective, insertion_order, enabled, position, extensions}], extensions}`)。 ### 1.4 归一化(`src/endpoints/characters.js`) 一切输入最终归一化为 **V2 内部结构**: - 无 `spec` → 视为 V1 → `charaFormatData()` 按字段表转换:V1 扁平字段 → 同时写顶层 + `data.*`(双写兼容),`creatorcomment` → `creator_notes`,`talkativeness`/`fav`/`tags` 规范化,`extensions` 深合并,`world` 指向的世界书文件内联成 `data.character_book`。 - 有 `spec` → `readFromV2()`:把 `data.name / description / personality / scenario / first_mes / mes_example / tags / talkativeness / fav` **上提(hoist)到顶层**(chat 用顶层值),并保留 `char.data` 原样 + `json_data` 存原始 JSON 备份。 - V3 同理走 `readFromV2`(V3 的 `data.name` 等与 V2 同名,天然兼容;`system.prompt` 这类 V3 新组织字段保留在 `data` 里,客户端按需读取)。 > 我们做精简版时照抄这一套:**顶层为准,data 备份,json_data 保底**。 --- ## 2. Prompt 组装管线(chat-completions 路径) ST 的组装在客户端 `public/scripts/openai.js` + `PromptManager.js`。机制分四层:**宏替换 → Prompt 集合 → 分块注入 → token 预算**。 ### 2.1 宏替换(substituteParams) 生成前先把提示词文本里的 `{{macro}}` 替换为运行时值。标准宏:`{{char}}`(角色名)、`{{user}}`(用户名)、`{{charIfNotGroup}}`、`{{personality}}`、`{{scenario}}`、`{{summary}}`(当前摘要)、`{{words}}`(摘要字数上限)、`{{time}}`、`{{date}}`、`{{bias}}` 等,且支持自定义宏(`macros/` 下的注册表,甚至支持带参数的复杂宏)。 ### 2.2 PromptManager 默认集合与顺序(`PromptManager.js` L2001/L2087) 每个 prompt 项有 `identifier / role / system_prompt / marker / content / injection_position / injection_depth / injection_order`。默认 12 项,顺序即最终对话中的相对位置: ``` 1 main Write {{char}}'s next reply in a fictional chat between {{charIfNotGroup}} and {{user}}. 2 worldInfoBefore [marker] 世界书(前置) 3 personaDescription [marker] 用户人设 4 charDescription [marker] 角色描述(卡内 description) 5 charPersonality [marker] 性格(卡内 personality) 6 scenario [marker] 场景(卡内 scenario) 7 enhanceDefinitions (默认关) If you have more knowledge of {{char}}, add to the character's lore... 8 nsfw (默认空) 辅助提示 9 worldInfoAfter [marker] 世界书(后置) 10 dialogueExamples [marker] 范例对话 11 chatHistory [marker] 聊天历史 12 jailbreak Post-History Instructions(卡内 post_history_instructions 覆盖此位) ``` 关键规则: - **marker** 项是占位集合,运行时被注入内容替换;非 marker 项是固定文本。 - 角色卡的 `system_prompt` **覆盖** `main` 项(除非该项 `forbid_overrides` 或对该角色禁用);`post_history_instructions` 覆盖 `jailbreak` 项。 - `injection_position`:`ABSOLUTE`(钉在固定深度)、`IN_PROMPT`/`BEFORE_PROMPT`(相对 main 注入)、或默认按集合顺序。 - 用户可在 UI 里增删/排序/开关/改 role(system/user/assistant),这是 ST 表达力的核心来源之一。 ### 2.3 最终注入顺序(`openai.js` `populateChatCompletion` L1176) 1. 预留 3 tokens(回复前缀 priming)。 2. 依次注入:`worldInfoBefore` → `main` → `worldInfoAfter` → `charDescription` → `charPersonality` → `scenario` → `personaDescription`。 3. 注入有序的 `nsfw`、`jailbreak` 及用户自定义的相对位置 prompts。 4. `enhanceDefinitions`、`bias`(若启用)。 5. **相对扩展提示**注入到 main 前后:`summary`(摘要,默认深度 2)、`authorsNote`(作者注)、`vectorsMemory`、`vectorsDataBank`、`smartContext`(向量记忆/语义检索块)。 6. **范例对话块** `dialogueExamples`:每条范例前插 `[Example Chat]`(`new_example_chat_prompt`)头,每条消息带 `name`(`example_user`/`example_assistant`),逐块 `canAffordAll` 校验,装不下就截断。 7. **聊天历史块** `chatHistory`:先插 `[Start a new Chat]`(`new_chat_prompt`)头;然后**从最新消息往回贪心填充**,每条经 `promptManager.preparePrompt` + token 计数,`canAfford` 失败即停(这就是"窗口截断"的真相 —— 纯 token 预算驱动,不是按条数)。 8. **control prompts 收尾**(永远在最后):impersonate 替身提示、quietPrompt(静默提示,如摘要/翻译请求)、continue 续写前导(assistant prefill)。 9. 可选 `squashSystemMessages()`:把相邻、无 name 的 system 消息用 `\n` 合并成一条(省 token、避免某些 API 报错)。 > `dialogueExamples` 与 `chatHistory` 的相对先后由 `pin_examples` 开关决定(默认范例在历史**之后**,即"越靠近输入越重要"的位置给历史)。 ### 2.4 范例对话的解析(`openai.js` `parseExampleIntoIndividual` L720) `mes_example` 是**文本块**,解析算法: 1. 把 `` 替换为 `{Example Dialogue:}`,`\r` 全部去掉。 2. 按 `\n` 分行,**跳过首行**(惯例是 "This is how {char} should talk" 之类的说明)。 3. 逐行扫描:以 `{{user}}:` 开头 → 进入用户消息;以 `{{char}}:`(或群聊各角色名 `X:`)开头 → 进入角色消息;其余行**续接到当前消息**。 4. 每条消息去掉名字前缀并 trim,输出 `{role: user/assistant, content, name: example_user/example_assistant}`。 5. 多个范例块之间由空行/`` 分隔,成为"数组的数组"。 ### 2.5 聊天历史的加载(`openai.js` `getMessages` 区域,L630 附近) 聊天记录持久化为 jsonl,每条消息对象含 `role / content / name / media / invocations / ...`;组装前把顶层 `char` 字段、用户人设、世界书内容等拼装进 `messages`(含注入标记 `injected`),再交给 `populateChatHistory` 做预算填充。多模态(图/视频/音频内联)和 tool call 消息也在这一步处理。 ### 2.6 Token 预算机制(`openai.js` `ChatCompletion` 类 L3822) - `setTokenBudget(context, response)` → `budget = context - response`(总上下文减去留给回复的空间)。 - 每条消息/集合添加前 `checkTokenBudget`:**强制项超限直接抛 `TokenBudgetExceededError`**(提示用户调大 context);历史类可裁剪项超限则跳过。 - `reserveBudget` / `freeBudget`:对"最后才插入的 control prompts"先预留后释放,保证它们不挤占历史窗口。 - 实际 token 数由本地 tokenizer 计数(`tokenizers.js`,按 API 选择 claude/llama/bert 等模型文件,或服务端 `api/tokenizers` 接口)。 ### 2.7 文本补全路径(instruct 模式) 非 chat API(如 KoboldAI/老式 API)走 `instruct-mode.js`:把上面同一套内容按 `instruct` 模板(`{{system}}`/`{{prompt}}`/`{{history}}`/`{{input}}` 等占位符 + 每角色行前缀 + 停止词)**拍平为一段文本**。我们的 DSH agent 用的是 chat 能力模型,走 chat-completions 路径即可,instruct 模式只在附录里作为可选项提一句。 --- ## 3. 历史、记忆与世界书 ### 3.1 摘要(`public/scripts/extensions/memory/index.js`,旧 summarize 已并入) - **触发条件**:距上次摘要的消息数 ≥ `promptInterval`(默认 10),或距上次摘要的**字数** ≥ `promptForceWords`(可关)。 - **摘要 prompt**(原文): > `Ignore previous instructions. Summarize the most important facts and events in the story so far. If a summary already exists in your memory, use that as a base and expand with new facts. Limit the summary to {{words}} words or less. Your response should include nothing but the summary.` (`{{words}}` 默认 500) - **增量式**:把现有摘要拼进上下文,要求"以旧摘要为基底 + 扩展新事实"——这是长线记忆的关键设计,我们照抄。 - **注入**:摘要作为聊天里的一条特殊消息(`extra.memory` 标记),以模板 `[Summary: {{summary}}]` 包裹,注入在**历史深度 2**(倒数第 2 条消息附近),不占历史窗口的"配额"。 - 摘要保存在聊天 jsonl 中,随历史走。 ### 3.2 World Info / 世界书(`public/scripts/world-info.js`) - **条目**:`keys[]`(主关键词)、`secondary_keys[]`、`content`、`constant`(常驻)、`selective`(需次关键词逻辑)、`insertion_order`(排序)、`enabled`、`position`(`before_char` / `after_char` / `@D` 深度注入)、`scan_depth`、`match_whole_words`、`case_sensitive`、`probability`、`group` 等。 - **扫描**:取最近 `scanDepth`(默认 6,上限 30)条消息拼成文本;关键词可整词匹配(自定义词边界正则)或大小写不敏感,`/regex/` 包裹的 key 按正则匹配。 - **激活**:命中的条目按 `insertion_order` 升序排列;`constant` 恒在;`selective` 需次关键词按 AND/OR 逻辑再筛。 - **注入**:`before_char` → `worldInfoBefore` 标记位;`after_char` → `worldInfoAfter`;`@D` → 钉在历史第 D 条深度。格式模板 `wi_format` 默认 `{0}`(即只放内容,可改成 `[World Info: {0}]` 之类)。 - 每张角色卡可自带 `character_book`(卡内世界书),也有全局世界书。**这是 ST"沉浸感"的第二个核心来源**(第一个是可编辑 prompt 集合)。 ### 3.3 用户人设(persona) `personaDescription` 位置可选 `IN_PROMPT`(作为 system 条目)或 `IN_CHAT`(作为历史开头的一条横幅消息)。 --- ## 4. 关键默认值速查 | 项 | 值 | 出处 | |---|---|---| | main prompt | `Write {{char}}'s next reply in a fictional chat between {{charIfNotGroup}} and {{user}}.` | openai.js L101 | | enhanceDefinitions | `If you have more knowledge of {{char}}, add to the character's lore and personality to enhance them but keep the Character Sheet's definitions absolute.` | PromptManager.js L2052 | | impersonation_prompt | `[Write your next reply from the point of view of {{user}}, using the chat history so far as a guideline for the writing style of {{user}}. Don't write as {{char}} or system. Don't describe actions of {{char}}.]` | openai.js L104 | | new_chat_prompt | `[Start a new Chat]` | openai.js L107 | | new_example_chat_prompt | `[Example Chat]` | openai.js L109 | | scenario_format / personality_format | `{{scenario}}` / `{{personality}}`(直接透传) | openai.js L112-113 | | openai_max_context / max_tokens | 4k 默认 / 300 | openai.js L403-404 | | 摘要 prompt / 模板 / depth / interval | 见 3.1 / `[Summary: {{summary}}]` / 2 / 10 条 | memory/index.js | | WI 扫描深度 / 格式 | 6 / `{0}` | world-info.js | --- # 第二部分:精简版实现规格(DSH 双 Agent) ## 0. 总体架构 ``` ~/.dsh/tavern/ ├── cards/ │ └── / # 每张卡一个目录(id 由名字 hash 生成) │ ├── meta.json # 原始卡信息:来源文件、格式版本、作者、标签、原始 JSON 备份(json_data) │ ├── story.md # 讲述者上下文源:角色档案 + 世界观 + 风格 + 范例对话 │ ├── state.json # 结构化状态机(ST 没有、我们补的) │ ├── analysis-log.md # 解析过程记录:置信度、缺失字段、补充说明 │ ├── chat.jsonl # 对话历史(与 ST 同构) │ └── summary.md # 增量摘要 └── (无全局世界书,v0 一律并入 story.md) ``` **Agent 边界**:`card-analyst` 只写 `cards//` 下的产物;`roleplay` 只读 story.md/state.json/summary.md 并追加 chat.jsonl、覆写 state.json。互不越界。 ### 0.1 硬性设计约束:单对话 · 单角色 本项目只服务一个场景:**一个用户、一个角色、一段连续对话**。这是设计决策,不是功能缺失,所有后续取舍以此为准。 由此直接消灭的 ST 遗产(逐个点名,全部不实现): | ST 遗产 | 源码位置 | 消灭原因 | |---|---|---| | 群聊引擎 | `public/scripts/group-chats.js` + `src/endpoints/groups.js` | 多角色才需要 | | group nudge 提示 | `group_nudge_prompt`(openai.js L1361,注入于 chatHistory 后) | 多角色才需要 | | 群聊开场头 | `new_group_chat_prompt`(`[Start a new Group Chat]`) | 只留 `[Start a new Chat]` | | `selected_group` 分支 | `populateChatCompletion`/`populateChatHistory` 内多处(L884/890/1073) | 整个分支删除 | | 范例对话按群成员命名 | `parseExampleIntoIndividual` 的 `groupBotNames`/`appendNamesForGroup`(L721/737) | 只剩 `{{user}}:` / `{{char}}:` 两种前缀 | | 群成员专属问候 | `group_only_greetings` | 只需 `first_mes` + `alternate_greetings` | | 角色名消毒 | `promptManager.sanitizeName`(群聊防重名用) | 双名固定({{user}}/{{char}}),无需消毒 | | 按角色独立 prompt 顺序 | PromptManager 的 character 策略 + per-character promptOrder | 单角色 = 全局策略,集合直接写死 | | 多聊天文件管理 | ST 每角色多 chat 文件 + 切换 | **一卡一对话**:`chat.jsonl` 即全部历史,要重开就重置 | **单对话带来的额外简化**(比"单角色"更进一步): - 无聊天列表/切换/重命名;`chat.jsonl` 是唯一事实源,随卡目录走。 - 无 swipe(多候选回复)需求:每轮一答,想重答说「换一句」重生成即可。 - 无 `chat_size`/跨会话统计等元数据。 - 历史填充时不需要 group nudge 的预留预算,预算公式更干净。 > 若未来要支持"同一角色多个存档",只需把 `chat.jsonl`/`state.json`/`summary.md` 放进 `/saves//` 子目录,组装逻辑一行不改——单对话约束把扩展点收敛在文件布局上。 ## 1. Agent 1:card-analyst(酒馆-管理员) ### 1.1 输入与嗅探 - 接受:`.png` / `.json` / `.txt`(内容为 JSON)的绝对路径,或直接粘贴 JSON。 - 嗅探顺序:文件头 `\x89PNG` → PNG 管线;否则尝试 `JSON.parse` → JSON 管线;都失败 → 提示用户或降级为纯文本说明。 ### 1.2 PNG 管线(手写,无依赖) 1. 遍历 chunk:`length(4B BE) + type(4B) + data + crc(4B)`,收集 `tEXt`。 2. 对每个 tEXt:`data.split('\0', 2)` → `[keyword, text]`(Latin-1)。 3. 优先取 keyword 小写为 `ccv3` 的 chunk,其次 `chara`。 4. `Buffer.from(text, 'base64').toString('utf8')` → JSON.parse。 (容错:若 base64 解码失败,尝试直接 utf8 解析;PNG 里绝无第三种情况。) ### 1.3 JSON 管线(V1/V2/V3 归一化) 严格照抄 ST 校验表(见 1.3),归一化规则: ``` 统一内部结构 = { spec_version, # '1' | '2.0' | '3.x'(记录来源) name, description, personality, scenario, first_mes, mes_example, alternate_greetings[], creator_notes, system_prompt, post_history_instructions, tags[], creator, character_version, character_book?, # 卡内世界书条目(若有) extensions{} # 原样保留 } ``` - V1 → 字段直搬(creatorcomment → creator_notes)。 - V2 → `data.*` 直读,顶层只做备份。 - V3 → `data.*` 直读(V3 与 V2 字段同名兼容);若遇到 `system.prompt`/`system.post_history_instructions` 这种 V3 新组织,做一次映射 `system.prompt → system_prompt`。 - 每步把"用了哪个字段、缺了什么、填了什么默认"记进 `analysis-log.md`。 ### 1.4 三个产物的生成规则 **`story.md`**(面向 roleplay 的上下文源,结构对应 ST 的 prompt 区块): ``` # (角色卡) ## 身份档案 ← description(若有,全文保留;太长按段落提炼,原文进 meta.json) ## 性格 ← personality ## 场景与开端 ← scenario + first_mes 提炼出的初始情境 ## 世界观与背景 ← character_book.entries[].content 按 insertion_order 拼接;constant 条目优先 ## 说话风格 ← 由 personality + 范例对话归纳(3-5 条规则) ## 范例对话 ← mes_example 原样保留(这是风格保真的核心!) ## 创作约束 ← system_prompt(作为"系统级约束"小节)+ post_history_instructions(作为"每轮后检查"小节) ``` 规则:**凡原文,一字不改**;只有 description 过长时才允许压缩并在 analysis-log 里注明。 **`state.json`**(v0 固定 schema,控制体积): ```json { "schema": 1, "affection": 0, // 好感度 -10..10 "mood": "neutral", // 情绪词 "location": "tavern", // 当前场景位置(从 scenario 初始化) "relationship": "stranger",// 关系档位 "flags": {}, // 剧情旗标:{"met_innkeeper": true} "scene": "first_meeting", // 当前剧情阶段 "notes": "" // 讲述者自由备注(限制 100 字内) } ``` 约束:**≤ 150 token**;缺失字段一律用默认值并写进 analysis-log;字段语义与取值范围写进 story.md 尾部"状态说明"一节,让讲述者知道怎么更新。 **`analysis-log.md`**:输入文件、格式版本、置信度(高/中/低)、缺失/猜测字段清单、压缩说明、可用的 alternate_greetings 列表(给 roleplay 做开场替换用)。 ### 1.5 兜底路径 - PNG 无 tEXt 元数据(纯图卡)→ 用视觉模型读图:立绘外观 → 生成"外观"小节;图内文字(若有)→ 作为 description 候选;其余字段标"缺失"。 - JSON 解析失败 / 字段全空 → 明确报"不是可用的角色卡",不硬造。 ### 1.6 模型精修步骤(已固化为 `tavern/polish-story.js`) 解析器产出的是"确定性基线"(原文直搬 + 结构占位)。真正的 card-analyst 在解析之后执行**模型精修**,把 story.md 升级为完整形态: 1. **输入**:parse-card.js 产出的 story.md(身份档案/场景/开场白/范例对话)+ meta.json + 初版 state.json。 2. **模型归纳**(单次结构化 JSON 调用,deepseek-v4-flash,**开启推理**、`response_format: json_object`、max_tokens 2500): - `personality`:字符串数组,**每条完整陈述句**(禁止单词),≥4 ≤6 条,须有原文证据;原文有 Personality 列表时直接提炼整合。 - `speech_style`:字符串数组,≥4 ≤6 条可执行规则(口吻/称呼/修辞/句式/动作习惯);**无 mes_example 时每条标注(推断)**。 - `state_init`:location / scene / mood / relationship / notes,从原文提取;禁止输出 unknown,提取不到用中性默认。 3. **补足重试**:条目 <4 时追加一轮"合并已有内容补足",仍不足再对性格做专项补全——保证质量下限。 4. **落盘**:基于行的插入(找下一个 `## ` 头,**绕开 JS 正则多行陷阱**——`\n*$` 在多行模式下会在每个换行前匹配),性格插在身份档案后、说话风格插在性格后;`状态说明` schema 表追加到尾部(幂等);state.json 合并 state_init(已有非默认值不动);analysis-log 追加精修记录。 5. **铁律**:原文小节(身份档案/场景与开端/开场白/世界观/范例对话)一字不动。 > 实测教训(2026-08-22,Kagami 卡): > - `thinking: {type:'disabled'}` + JSON 模式会让该模型输出极简(性格只给 1-2 个单词)→ 精修步骤**必须开启推理**。 > - 模型可能把数组字段返回成逗号拼接字符串 → 落盘前强转:数组→逐条编号行,字符串→原样。 > - 状态初始化质量随推理开启显著提升(Kagami:`estate / 婚后初遇 / guarded / stranger` 全部从原文正确提取)。 ## 2. Agent 2:roleplay(酒馆-讲述者) ### 2.1 指令识别 | 用户说 | 动作 | |---|---| | 「加载/安装/开始这张卡」+ 路径 | 校验 cards//story.md 存在(不存在 → 引导先找管理员),读入状态 | | 「换开场」 | 用 alternate_greetings 里的另一条替代 first_mes | | 「查看状态」 | 输出 state.json 摘要 + summary.md | | 「重置/重启」 | 清 chat.jsonl,恢复 state.json 初始值,重新开场 | | 普通输入 | 正常扮演一轮 | ### 2.2 每轮上下文组装(把 ST 12 项折叠成固定 8 段) 顺序(自顶向下): ``` 1 main "你正在扮演 {{char}},与 {{user}} 进行一场沉浸式文字角色扮演。\n严格遵守 story.md 中【身份档案】【性格】【创作约束】,不要跳出角色。" 2 personaDescription 用户人设(会话启动时让用户简述,或默认空) 3 charDescription ← story.md【身份档案】 4 charPersonality ← story.md【性格】 5 scenario ← story.md【场景与开端】 6 worldInfoBefore ← story.md【世界观与背景】(扁平化,全部注入;v0 不做关键词触发) 7 dialogueExamples ← story.md【范例对话】(保留 name,同 ST 解析法) 8 chatHistory ← chat.jsonl 从新往旧贪心填充 ── 收尾(control)── 9 state 注入 ← 当前 state.json,渲染为一行:「当前状态:好感={affection} 心情={mood} 地点={location} 阶段={scene} 旗标={flags}」+ post_history_instructions ``` - **摘要块**:若 summary.md 存在,作为 system 消息插在第 6 段与第 7 段之间,格式 `[Summary: ...]`。 - **role 纪律**:charDescription/charPersonality/scenario 用 system role(贴近 ST 默认);范例与历史用 user/assistant + name。 - **token 预算**:固定 `budget = context - response`(context 默认 16k、response 默认 800,可配);历史从新到旧贪心填充,装不下就停;`[Start a new Chat]` 头照抄。 ### 2.3 每轮后处理 1. 追加历史:`{"role":"assistant","name":"","mes":...,"ts":...}` 到 chat.jsonl。 2. **更新 state.json**:由模型按固定 schema 输出增量(用一次独立的、压缩的 tool 调用或结构化输出),只改 diff 的键,`notes` ≤ 100 字。 3. **摘要触发**:距上次摘要 ≥ 10 条 或 ≥ 500 词 → 用 ST 的增量式 prompt(原文见 3.1,`{{words}}=300`)生成/扩展 summary.md;摘要视为历史里的一条特殊消息(防重复计数)。 ### 2.4 开场(first_mes) 加载完成后,讲述者把 `first_mes` 作为首条 assistant 消息输出;`first_mes` 里的 `{{user}}`/`{{char}}` 宏照 ST 规则替换。 ## 3. 与 ST 的映射与取舍 | ST 能力 | 我们 v0 | 说明 | |---|---|---| | PNG/JSON V1/V2/V3 卡解析 | ✅ 完整复刻 | 核心技术风险,已拆解为纯算法 | | Prompt 集合(12 项可编辑) | ⚠️ 折叠为固定 8 段 | 表达力主来源之一,v0 牺牲灵活性换确定性;story.md 小节结构预留了恢复空间 | | 宏替换 | ✅ 子集({{user}}/{{char}}/{{time}}) | 不需要通用宏引擎 | | token 预算贪心截断 | ✅ 照抄 | 简单可靠 | | 范例对话解析 | ✅ 照抄 | 风格保真的关键 | | 增量摘要 | ✅ 照抄 | 长线记忆关键 | | 世界书关键词触发 | ❌ v1 再上 | v0 整体注入,够用且省事 | | 正则/替换、多角色群聊、作者注、向量记忆 | ❌ 永久砍掉 | 见 0.1 硬性约束:单对话单角色;正则/作者注/向量记忆与"精简"目标冲突 | | 结构化 state 追踪 | ✅ **我们独有** | ST 没有,靠历史隐式承载;这是本方案的差异化亮点 | | UI | ❌ 无 | agent 即界面,对话即操作 | --- # 第三部分:表现力与效果评估 ## 1. 产出物长什么样(模拟示例) 以一张典型的 V2 中文"酒馆老板娘"卡为例: **`story.md`(节选)** ```markdown # 艾琳·红橡木 ## 身份档案 红橡木酒馆的老板娘,三十出头,栗色卷发,常年系着沾了酒渍的围裙。 待客热络,账算得极快,但没人知道她为什么在深山里开这家店。 (原文保留,长段略) ## 性格 开朗泼辣、刀子嘴豆腐心;对熟客护短,对生客先打量三秒再笑。 ## 场景与开端 你是一个雨夜推门而入的旅人。店里只剩一桌客人, 艾琳擦着杯子抬头看你:「这种天赶路,是嫌命长还是钱多?」 ## 世界观与背景 (character_book 条目按序拼接:红橡木镇、铁匠老巴、后山矿洞传说……) ## 说话风格 - 口语化,爱用短句和反问 - 称呼顾客「你」,自称「老娘/我」 - 句尾常有笑骂,动作描写夹在对话里 ## 范例对话(原样) {{user}}: 来一杯麦酒。 {{char}}: 「麦酒是吧。」她把杯子往吧台上一磕,溅出两滴, 「三个铜板。雨天涨价,这是规矩。」 {{user}}: 你这规矩比雨还大。 {{char}}: 「呵,那你是没赶上我心情好的时候。」她咧嘴一笑, 「心情好的时候——翻倍。」 ``` **`state.json`** ```json {"schema":1,"affection":0,"mood":"neutral","location":"red_oak_inn","relationship":"stranger","flags":{"rainy_night":true},"scene":"first_meeting","notes":""} ``` **首轮扮演(roleplay 输出节选)** > 「三个铜板。雨天涨价,这是规矩。」她把杯子往吧台上一磕,溅出的两滴在木纹上洇开。……(动作+对话+心理,风格与范例一致) ## 2. 效果预期分档 | 卡的质量 | 预期体验 | |---|---| | 字段齐全的中文 V2/V3 卡(含 mes_example、character_book) | **≈ 原版 ST 七八成功力**。范例对话原样注入 + 世界观整段注入 + 状态机加持,人设稳定、风格贴合、长线有记忆 | | 只有 description/personality 的简卡 | 表现力主要看模型;story.md 会较短,风格靠 personality 归纳,效果 5-6 成 | | 纯图无 JSON 的卡 | 靠视觉兜底只有"外观",性格/背景全靠模型脑补,3-4 成,且不可复现(每次解析可能不同) | | 英文卡 | 直接翻译有损耗:双关语、方言腔、说话风格会扁平化;建议 v1 加"翻译+风格注释"两栏 | | 高质量日系/中文长卡(几万 token 描述) | **v0 的瓶颈**:全文塞入会爆 context,压缩又伤细节。v1 该上世界书关键词触发或向量检索 | **与 ST 的体感差异**: - **更稳**:state.json 让"好感度/剧情进度"显式可查可改,ST 里这些只能靠模型在历史里隐式维持,长对话必漂移。我们这是补丁,不是平替。 - **更省**:无 UI、无正则、无插件体系,但 prompt 组装的核心机制(预算、范例、摘要)一个不少。 - **更简**:世界书不触发只整包注入,context 利用率低,长世界观卡会吃亏。 ## 3. 结论 - **可行性**:高。全部关键机制都已在 ST 源码中定位并可在无依赖情况下复刻;唯一需要模型发挥的地方(story.md 归纳、state 更新、摘要)正是 LLM 的强项。 - **风险点**:① 长卡 context 溢出(v1 解决:世界书触发/摘要分层);② 英文卡风格损耗(v1 解决:双栏翻译);③ 范例对话里 ST 特有宏/占位符残留(解析时清洗)。 - **建议 v0 验收标准**:用 3 张不同质量的真实卡跑通「解析 → 开场 → 10 轮对话 → 查状态 → 摘要」全链路,并检查:角色从不自称"作为AI"、风格 10 轮内不漂、状态数值与剧情一致。 --- # 第四部分:对 ST 的针对性优化 > ST 的"累赘"不是 bug,是**长寿命 Web 应用 + 全功能平台**的必然代价:实时 UI(token 计数要在输入框打字时即时显示)、通用宏语法、多 API 后端、插件生态、十年兼容性。我们是"每回合一次组装"的 agent,这些代价大部分可以直接不付。下面按「照抄」、「改写」、「砍掉」三档列出优化。 ## 1. 计算开销:每回合重复劳动(ST 最大浪费) ### 1.1 逐消息、逐回合 token 计数 → 写时计数 + 缓存 **ST 现状**(已确认):`Message.fromPromptAsync/createAsync` 在每条消息构造时调 `tokenHandler.countAsync`(openai.js L3467/3492/3506);每回合把**整段历史重新构造一遍** → 200 条历史 = 每回合 200 次 tokenizer 调用;`squashSystemMessages` 合并 system 消息后**还要再重算一次**(L3847);本地 tokenizer 模型文件合计约 18MB(llama3.json 6MB、claude.json 1.7MB…),大模型单次计数可达几十毫秒。 **我们的做法**: - **写时计数**:每条消息落盘 `chat.jsonl` 时算一次 token 数,和内容一起存(`{"mes":..., "tokens": 137}`)。 - **静态块缓存**:story.md 的 8 个区块在加载时渲染并计数一次,之后每回合直接取用,零重算。 - **预算填充变成纯加法**:历史填充只需对已缓存的 tokens 做累加,每回合新成本 = 只计 1-2 条新消息。 - 不追求与 API 完全一致的精确计数:预算只是控制截断的标尺,`字符数/2.5`(中英混合粗略估计)都够用,v0 连本地 tokenizer 模型都不需要加载。 ### 1.2 历史每回合全量重格式化 → 写时格式化 **ST 现状**:每回合把每条历史消息 `new Prompt(chatPrompt) → preparePrompt → Message.fromPromptAsync` 全流程重走一遍(含 name 消毒、宏替换、media 检查、tool invocation 深拷贝)。 **我们的做法**:消息在**追加时**就格式化成最终渲染形态(含 name 前缀)存入 jsonl;组装时按预算从尾到头直接拼接缓存文本。每回合对历史零处理成本。 ### 1.3 世界书/摘要每回合全量重扫 → 增量(v1 生效) **ST 现状**:`WorldInfoScanner` 每回合重建 depth buffer(最近 N 条消息全文),再对**每条 entry 的每个 key** 做大小写转换/整词正则/递归匹配(world-info.js);摘要检查从历史尾部往前扫"距上次摘要的消息数与词数"(memory/index.js)。 **我们的做法**(v1 若上世界书触发):预编译所有 key 的匹配器(一次),每回合只扫**新增的 1-2 条消息**;摘要计数在 `meta.json` 里维护游标(`last_summary_msg_idx` + `words_since`),追加消息时累加,触发时归零——不扫历史。 ## 2. 结构冗余:三份数据三套逻辑 → 单一规范 ### 2.1 角色卡三份冗余存储 **ST 现状**(已确认):`charaFormatData` 对每个字段**顶层 + data.\* 双写**,`readFromV2` 再 hoist 回来,外加 `json_data` 存第三份原始 JSON;还有 V1 `creatorcomment` → V2 `creator_notes` 这类兼容映射,和"Spec v2 data mismatch"警告逻辑——同一份信息三个副本、两套读写路径。 **我们的做法**:**解析即归一**。card-analyst 一次性把 V1/V2/V3 归一到内部唯一 schema(见第二部分 1.3),`meta.json` 只留一份原始 JSON 备份供审计;roleplay 永远只读归一后结构,**不接触任何版本兼容逻辑**。磁盘与代码都省一半。 ### 2.2 Prompt 集合机制 → 固定模板 **ST 现状**:12 项 prompt ×(marker / role / injection_position / injection_depth / injection_order / 每角色覆盖 / 全局或按角色策略 / quick-edit UI)——为"用户任意排序和改写"服务的一整套状态机。 **我们的做法**:8 段固定模板(第二部分 2.2),顺序写死在代码里。**表达力的弹性来源从"UI 可编辑"改为"story.md 可编辑"**——用户想改角色,改的是文件,不是拖 12 个 prompt 项。功能等价,复杂度降一个量级。 ### 2.3 双 API 路径 → 只留 chat **ST 现状**:chat-completions(`openai.js`)+ text-completions(`instruct-mode.js` + `prompt-converters.js`)两套语义几乎相同的组装,各配一套格式(消息数组 vs 拍平文本 + 停止词)。 **我们的做法**:DSH 模型全部走 chat 能力,只实现 chat 路径。instruct 模式不写。 ## 3. 过度设计:直接砍掉的部分(对精简版零损失) | ST 能力 | 砍掉的理由 | |---|---| | **宏引擎**(10 个文件,MacroLexer/Parser/CST Walker…≈17 万字节) | 我们只需 `{{user}}/{{char}}/{{time}}` 三五个,一次正则替换搞定 | | **本地 tokenizer 模型**(9 个文件 ≈18MB) | 见 1.1,用估算 + 写时缓存 | | **群聊/多角色**(`group-chats.js`/`groups.js`/group nudge/`new_group_chat_prompt`/按成员命名/`sanitizeName`) | **永久砍掉**,见第二部分 0.1 硬性约束清单 | 单对话单角色,一行群聊代码都不写 | | **媒体内联**(图/视频/音频 chunk、vision token 定价) | v0 纯文本;视觉留给 card-analyst 解析纯图卡 | | **tool-calling / reasoning interleave / logprobs** | 角色扮演用不上 | | **deep/sliding window UI 设置** | 我们直接按预算填充,这两个设置本就不影响算法 | | **per-character prompt order 存储 + 全局/按角色策略** | 见 2.2 | | **i18n / 主题 / DOM 渲染 / SSE 流式 UI** | agent 形态不需要 | | **PNG 双 chunk 写入(chara + ccv3)** | v0 只读不写;将来写也只写一个 | ## 4. 优化收益速估 | 场景 | ST 每回合成本 | 我们每回合成本 | |---|---|---| | 200 条历史、无缓存 | ~200 次 tokenizer 调用 + 200 次消息重构 | 追加时算过;组装 = 1 次读文件 + 预算累加 | | 500 条世界书条目 | ~500×6 次关键词匹配 | v0 无触发;v1 ≈ 新增 1-2 条消息的匹配 | | 摘要检查 | 从尾向前扫全部历史数词 | meta.json 游标 O(1) | | 角色数据 | 3 副本读写 + 兼容映射 | 1 份归一结构 | > 结论:**不追求"比 ST 快"——那没有意义(我们是低频 agent,不是打字即更新的 Web UI)。真正的优化是把 ST 因"平台化"背上的复杂度税全部卸载,让我们的 v0 只剩"解析一次 + 每回合拼一次 + 写一次"三条最小路径。** 这也是第二部分规格能比 ST 代码量少一个数量级的原因。 ## 5. 已被 v0 规格采纳的优化(对照第二部分) | 优化项 | 落地位置 | |---|---| | 写时计数/格式化 | chat.jsonl 每条含 tokens + 渲染文本(2.3) | | 静态块缓存 | story.md 加载时渲染一次(2.2) | | 增量摘要计数 | meta.json 游标(2.3) | | 单一规范存储 | meta.json + 归一 schema(1.4) | | 固定模板 | 8 段组装(2.2) | | state 只写 diff | state.json 增量更新(2.3) | | 无本地 tokenizer | 估算 + 缓存(2.2) | --- # 附录 A:真实卡验证记录(2026-08-22) > 用 5 张 chub.ai 真实角色卡 + 1 张合成 PNG 验证了解析管线(`tavern/parse-card.js`),产物在 `tavern/cards//`。 ## 验证结果 | 卡 | 角色名 | 格式 | 规模亮点 | 解析结果 | |---|---|---|---|---| | Saint Claudine's | Saint Claudine's | V2 JSON | description 8.6k、世界书 26 条、21 开场 | ✅ 世界书扁平化正常 | | Empress of Iron | Blanche | V2 JSON | description 2.3k、mes_example 5.1k、2 世界书 | ✅ 范例对话原样保留 | | Kiara | Kiara | V2 JSON | description 6.2k、13 开场 | ✅ | | Kagami | Kagami | V2 JSON | description 2.9k | ✅ 缺字段日志正确 | | Tiffany Rogers | Taffy | V2 JSON | description 3.5k、mes_example 1.7k | ✅ | | 合成 PNG | 自检卡 | PNG V2 | 1x1 RGBA + chara chunk | ✅ tEXt 提取 + base64 解码通过 | | Please fuck my Idiot Sister!(真 PNG) | Ashly & Lea | PNG V2 | 1.35MB / 1024×1024 / description 9.3k | ✅ 真实 PNG 提取通过(chara chunk) | ## 对规格的修正(真卡暴露的事实) 1. **chub 导出的都是 V2**,顶层只有 `spec / spec_version / data` 三键;V1/V3 在实际流通中少见,但解析器保留。 2. **现代卡 `personality`/`scenario`/`system_prompt`/`post_history_instructions` 几乎全空**(五张全为 0),人设全部塞进 `description`,且常带 XML 风格标签块(``、``、``…)。 → **story.md 模板修正**:「性格」「场景与开端」章节经常为空;agent 版 card-analyst 应**从 description 归纳性格/风格**,而不是指望这两个字段;`## 身份档案` 原样保留 description(XML 块恰好是作者精心组织的设定,保留即保真)。 3. **mes_example 两极分化**:有(Blanche 5.1k,含 `` 分隔 + 首行指令说明)与无(其余三张)。 → 有则原文保留(`parseExampleIntoIndividual` 处理 ``/首行);无则风格只能靠模型从 description/first_mes 归纳,`analysis-log` 已如实标注"风格保真度下降"。 4. **Saint Claudine's 是 scenario 卡**(description 明言 "{{char}} is not a single character but a scenario"),AI 扮演场景内所有 NPC。 → 与"单对话单角色"硬约束冲突;但作为**世界书解析压力测试**极有价值(26 条目含关键词头)。若用户要单角色体验,应优先选 Blanche/Kagami 这类单体角色卡。 5. **`character_version` 可能为 `"main"`**(chub 导出怪癖),meta 记录原样即可,不做校验。 6. **state.json 地点启发式必须扫描 description**(已修):现代卡 scenario 常空,只扫 scenario+first_mes 会把地点全部标成 unknown。v0 规则版够用;agent 版由模型初始化。 ## 遗留验证项 - [x] **真实 PNG 卡**:`main_please-fuck-my-idiot-sister-..._spec_v2.png`(1.35MB/1024×1024,chara chunk)提取通过。注:该卡为**双角色卡**(Ashly & Lea,tags 含 Multiple Characters),与单角色约束冲突,仅作 PNG 管线测试。 - [ ] story.md 超长卡(description >8k 或 世界书 >30 条)的 token 预算实测。