Files
writing-agent/docs/preset-format.md
T
chenmoranzhi f3303038fe 新增预设工作台:编辑、版本树、结构检查、用例试跑放在同一页。
改预设要能一边写一边看装出来的 messages,还要多模型对照,所以把编辑与试跑拆成两个视图塞进一个页面;版本存完整快照、可分叉,用例全局共用一份、运行记录按预设存。顺带让读预设文件先剥 BOM,静态资源改成不缓存,免得改了脚本还要强刷。
2026-10-26 01:09:40 +08:00

500 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 预设格式
> 编辑、版本树、用例组与多模型试跑见 [preset-workbench.md](preset-workbench.md)。
## 1. 定位
Preset 是一次 LLM 请求的上下文编排资产。它负责描述 prompt manager 中有哪些条目、这些条目的启用状态与排列顺序,以及本次生成使用哪些模型参数。
第一版的目标是兼容 SillyTavern 类预设文件中的核心部分,而不是完整复刻 SillyTavern 的所有扩展能力。
> 实现状态(v2):
> 已实现 —— prompt manager 条目与顺序、生成参数、双括号宏、消息整形(squash)、工具协议适配、导入诊断。
> 仍未实现 —— ST 正则引擎、预设内 JS 脚本、injection_position=1 的深度插入、世界书关键词/概率/sticky。
需要支持的内容:
```text
prompts
prompt manager 条目列表。每个条目描述一段可插入上下文的 prompt。
prompt_order
条目顺序与启用关系。它决定本次请求实际按什么顺序装配 prompt。
generation parameters
生成参数,例如 temperature、top_p、top_k、min_p、frequency_penalty、presence_penalty、max tokens 等。
```
第一版不支持的内容:
```text
regex_scripts
正则隐藏、正文提取、格式美化等后处理脚本(导入时只做诊断,不执行)。
extension scripts
预设内携带的前端脚本、按钮、插件配置(一律不执行,导入报告点名)。
UI-only fields
只影响 SillyTavern 界面展示、不影响 LLM 请求组装的字段。
```
## 2. 从 SillyTavern 预设中读取什么
一个真实的 SillyTavern 预设通常同时包含 prompt manager 条目、顺序关系、生成参数和扩展配置。我们的导入器只读取前三类。
### 2.1 Prompt 条目
从 `prompts` 数组读取 prompt manager 条目。
典型字段:
```ts
type ImportedPromptEntry = {
identifier: string;
name: string;
enabled: boolean;
role: "system" | "user" | "assistant";
content?: string;
injection_position?: number;
injection_depth?: number;
injection_order?: number;
system_prompt?: boolean;
marker?: boolean;
forbid_overrides?: boolean;
};
```
字段含义:
```text
identifier
条目唯一标识。prompt_order 通过它引用条目。
name
给用户看的条目名称。
enabled
条目自身默认启用状态。最终是否启用还需要结合 prompt_order。
role
条目插入请求时使用的消息角色。
content
条目文本。部分内置 marker 条目可能没有 content,需要运行时从 session 或角色数据中补齐。
injection_position / injection_depth / injection_order
SillyTavern 的插入位置、深度和顺序信息。第一版先保留,不完全模拟深度插入语义。
system_prompt / marker
标识这个条目是不是内置占位条目,例如角色描述、世界书、聊天历史等。
forbid_overrides
标识条目是否禁止被覆盖。第一版先保留字段,不实现复杂覆盖策略。
```
### 2.2 顺序与启用关系
从 `prompt_order` 读取实际装配顺序。
典型字段:
```ts
type ImportedPromptOrder = Array<{
character_id: number;
order: Array<{
identifier: string;
enabled: boolean;
}>;
}>;
```
第一版采用的规则:
```text
1. prompt_order 可能有多套(按 character_id 分)。优先 character_id=100001(酒馆里通用于所有角色的那套);
它不存在时才按「启用内容最多」打分。
2. 按 order 数组顺序遍历 identifier。
3. 找到对应 prompts 条目。
4. 只有 order.enabled 和 prompt.enabled 都为 true 时,条目才参与本轮上下文装配。
5. 如果 prompt_order 引用了不存在的 identifier,导入时记录 warning,但不阻断导入。
6. 如果 prompts 中存在但 prompt_order 未引用,默认不参与请求,但保留在 preset 中。
7. 选中的那一套若缺 main / chatHistory / jailbreak,导入时告警(框架可能不完整)。
```
`prompt_order` 比 `prompts` 中的排列更重要。`prompts` 是条目仓库,`prompt_order` 才是实际启用的编排表。
### 2.3 生成参数
从预设顶层读取生成参数。
第一版优先支持这些字段:
```ts
type ImportedGenerationParameters = {
temperature?: number;
top_p?: number;
top_k?: number;
min_p?: number;
frequency_penalty?: number;
presence_penalty?: number;
repetition_penalty?: number;
openai_max_context?: number;
openai_max_tokens?: number;
stream_openai?: boolean;
verbosity?: string;
seed?: number;
n?: number;
};
```
字段分为两类:
```text
通用采样参数
temperature、top_p、top_k、min_p、frequency_penalty、presence_penalty、repetition_penalty。
请求容量与行为参数
openai_max_context、openai_max_tokens、stream_openai、verbosity、seed、n。
思考强度不进预设,只在 API 配置里设置。
```
不同供应商不一定支持全部字段。导入后应先保存原始字段,再由 LLM adapter 决定哪些字段可以发送。
## 3. 内部归一化格式
导入 SillyTavern preset 后,不应该直接在业务层使用原始 JSON。需要归一化成我们自己的结构。
```ts
type PresetPackage = {
id: string;
name: string;
source: "native" | "sillytavern";
prompts: PresetPromptEntry[];
promptOrder: PresetPromptOrderItem[];
generation: GenerationParameters;
unsupported: UnsupportedPresetSection[];
raw?: unknown;
};
type PresetPromptEntry = {
id: string;
name: string;
enabled: boolean;
role: "system" | "user" | "assistant";
content: string;
marker: boolean;
sourceIdentifier: string;
injection?: {
position?: number;
depth?: number;
order?: number;
};
};
type PresetPromptOrderItem = {
promptId: string;
enabled: boolean;
orderIndex: number;
};
type GenerationParameters = {
temperature?: number;
topP?: number;
topK?: number;
minP?: number;
frequencyPenalty?: number;
presencePenalty?: number;
repetitionPenalty?: number;
maxContextTokens?: number;
maxOutputTokens?: number;
stream?: boolean;
verbosity?: string;
seed?: number;
variants?: number;
};
type UnsupportedPresetSection = {
path: string;
reason: string;
};
```
归一化后的 `PresetPackage` 是系统内部唯一使用的 preset 结构。原始 SillyTavern JSON 只作为导入来源和调试证据保留。
## 4. 上下文装配规则
运行时只走一份 `promptOrder`:遇到普通条目原样插入,遇到 marker 就填洞。空的酒馆角色卡 / 世界书 marker **不填我们的正文**(跳过)。
### 4.1 Marker 词汇表
酒馆原有(导入后保留位置,空洞跳过):
```text
worldInfoBefore personaDescription charDescription charPersonality
scenario worldInfoAfter dialogueExamples chatHistory
```
我们新增(由 context-order 填值;生成内容按 **世界书条目** 看待,不是角色卡字段):
| identifier | 填什么 |
|---|---|
| `worldBookBefore` | `对话.历史` 之前的 inserts,每条为标题+正文 |
| `chatHistory` | 共用酒馆 id;填 `对话.历史`——**展开成真多轮 message[]**,不是一段文本 |
| `worldBookAfter` | 历史之后、本轮之前(变量、工单等)。**不是** `worldInfoAfter` |
| `currentTurn` | `用户.最新输入`,role=user |
| `postTurn` | 本轮输入之后的 inserts(可空) |
`worldInfoBefore` / `worldInfoAfter` 都在酒馆历史 **前**,不能承接「我们写的、历史后」,因此不要复用那两个 id。
### 4.2 导入时插入四个洞
导入 SillyTavern `prompt_order` 时,以 `chatHistory` 为锚点切开并插入:
```text
[...ST 历史前条目...]
worldBookBefore
chatHistory
worldBookAfter
currentTurn
[...ST 历史后条目...]
postTurn
```
若原文没有 `chatHistory`,把它补在历史前条目之后,再接后四个洞。原生预设也可以直接按这份扩展 order 来写。无选用预设时,play worker 仍按现有 `assembleWorkerContext` 拼装(不走 prompt_order)。
社区预设里包着 WI 洞的装饰分隔会留在历史前段,中间可能是空的。第一版接受。
### 4.3 六段夹心(扫 order 后的效果)
```text
1. 酒馆内、历史前(ST 普通条目)
2. 我们写的、历史前(worldBookBefore)
3. 对话历史(chatHistory)
4. 我们写的、历史后本轮前(worldBookAfter)
5. 本轮对话(currentTurn)
6. 本轮对话后(ST 历史后条目 + postTurn)
```
### 4.4 装配步骤
```text
1. 读取(已扩展过的)PresetPackage.promptOrder。
2. 过滤 enabled=false 的顺序项。
3. 找到对应 PresetPromptEntry。
4. 再过滤 entry.enabled=false 的条目。
5. 对 marker:用 WorldInfoPack 解析我们的四个洞 + chatHistory;酒馆角色卡/WI 恒为空。
6. 普通 content 条目原样插入。
7. 按条目 role 合成 LLM messages(chatHistory 洞展开成多条)。
8. injection_position=1 的条目插进历史区(见 4.4.1)。
9. 应用 generation 参数。
```
不实现:世界书关键词、概率、sticky、outlet、ST 正则引擎、插件脚本。
#### 4.4.1 深度插入
`injection_position = 1` 的条目**不进线性位置**,而是按 `injection_depth` 从历史底部往上插:
```text
depth 0 历史最后一条之后(紧贴 currentTurn 之前)
depth N 倒数第 N 条之前
```
同深度按 `injection_order` 升序排列(数值大的更靠后)。插进去的 message 与历史同等待遇:
`injection_order` 默认 0,`role` 取自条目自身。无历史区时(单包请求)按收集顺序附在末尾。
### 4.5 双括号宏
预设正文在装配前过一遍宏层(`src/directives/macros.ts`),与 `@` 指令共用 persona 与骰子引擎。
| 宏 | 行为 |
|---|---|
| `{{// … }}` | 注释,展开为空(多行也吃) |
| `{{trim}}` | 展开为空,并让本条首尾去空白 |
| `{{user}}` | 当前用户角色名 |
| `{{persona}}` | 当前用户角色(`@人设`)的详细人设正文;未设 = 空 |
| `{{char}}` | 预设角色名;取当前作品名(对话落盘时的快照名),没绑作品时用「作品」 |
| `{{lastUserMessage}}` | 最近一条用户输入 |
| `{{getvar::k}}` | 变量值;未设 = 空 |
| `{{setvar::k::v}}` / `{{addvar::k::v}}` | 写入变量,展开为空 |
| `{{roll:1d6}}` / `{{roll N}}` | 掷骰 / 1..N 随机 |
| 其它(如 `{{charPrompt}}`、`{{description}}`) | **原样保留**,导入报告点名 |
变量作用域是**单次装配**一份 Map,不落盘、不跨轮。依据:6 份真实预设里所有 `setvar` 要么在每次请求都执行的条目里,要么在关闭的条目里 —— 单次作用域与原行为等价。
展开后变空的条目不入 messages(只写了 `{{setvar}}` 或纯注释的条目会被跳过)。
### 4.6 消息整形(squash)
对应酒馆的 Squash System Messages,在夹心完成后、发请求前执行(`src/llm/message-squash.ts`)。
#### 酒馆到底提供了什么(核过源码)
```text
squash_system_messages bool,默认 false
true = 把相邻的、没有 name 的 system 消息合并成一条
(exception: newMainChat / newChat / groupNudge)
出处:public/scripts/openai.js → ChatCompletion.squashSystemMessages()
custom_prompt_post_processing none / merge / strict(claude 已弃用 → merge)
管的是「自定义 system prompt 那几段是合成一条,还是分开发」,
与 squash 是两个设置,不碰 user/assistant 轮次。
```
所以酒馆侧只有两档真实语义:**不合并** / **合并相邻 system**。
我们多出的 `semi` / `strict` / `single-user-turn` 是为「上游拒收 system 或连续同角色」自造的降级档。
#### 我们的五档
| 档 | 行为 | 对历史区 |
|---|---|---|
| `none` | 原样(= 酒馆默认) | 不动 |
| `merge` | 只合并相邻同角色(= 酒馆勾上 squash) | 全序列合并 |
| `semi` | 合并后把 system 折进相邻普通消息,强制 user/assistant 交替 | 只合并相邻同角色 |
| `strict` | 同 semi,并保证首条为 user | 只合并相邻同角色 |
| `single-user-turn` | 全部合成一条 user | 原样保留 |
约束:
```text
工具调用组(assistant(tool_calls) + tool)永不参与合并与重排。
请求里出现工具组时,semi / strict / single-user-turn 一律退化为 merge。
历史区(真多轮)最多只做「合并相邻同角色」:
用户连发两条(上一轮助手没出正文)→ 合成一条,结构不破;
injection_depth 插进历史的 system → 不被折掉;
single-user-turn 不会把历史堆成一条。
```
来源:预设的 `squash_system_messages`(bool 或枚举)→ `PresetPackage.messagePolicy.squash`;
字段缺失时不写 `messagePolicy`,运行时按 **`strict`** 处理(`DEFAULT_PRESET_SQUASH`)——
兼容性优先:各家上游对「首条 system + 连续同角色」的容忍度不一,默认取最保守的一档。
想只合并 system(更贴酒馆)选 `merge`,想原样发出选 `none`;单个预设可在预设里自己写。
### 4.7 工具协议适配
两份真实预设把破限写在工具协议里,形态有两种:
```text
SPreset.ToolBindings 明确声明(含结构化 form.name / form.parameters)
条目正文占位符 %TOOL_NAME% + stream_target = display_stream 之类
```
导入时只做诊断(`src/preset/diagnostics.ts`):列出伪工具协议条目、待映射占位符、已声明工具。
运行时按 `toolPolicy` 处理(`src/preset/tool-policy.ts`):
| 模式 | 行为 |
|---|---|
| `adapt`(默认) | 把预设要求的工具折算到已有工具:给宿主工具追加描述与兼容字段;工具名对不上时另挂一个同名载荷工具,收到调用再折算回宿主 |
| `app` | 只发本应用工具,并把伪工具协议条目从装配中剔除 |
| `preset` | 只发预设声明的那个工具(直聊/纯 RP) |
正文字段从预设里反推:只认「被赋值」的名字(`stream_target = display_stream`)与 ToolBindings 的参数名,
不把正文里偶然出现的词当字段名。工具描述只取 ToolBindings 里作者写的那句,
**不把条目正文抄进工具描述**(那会把 prompt 体积翻倍)。
### 4.8 截断保留
正文走工具参数,`maxOutputTokens` 到顶时 `arguments` 是半截 JSON,
直接 parse 会把已经写好的正文整段丢掉。`src/llm/truncation.ts` 在参数无法解析时
把目标字段已生成的部分拨出来(只比截断处少一截),并记一行日志。
它在工具调用、消息整形、宏层全部完成后才执行,所以只救正文,不改变协议。
## 5. 与创作流程的关系
Preset 不决定创作流程,也不决定验收模式。
```text
Preset
决定本轮请求如何拼接 prompt、哪些条目启用、使用哪些生成参数。
Execution Flow
决定阶段顺序、每阶段使用哪个 worker、每阶段采用哪种验收模式。
Worker
决定某个创作能力如何执行,例如大纲、剧情线、文风、正文草稿。
Runtime Session
记录当前事实、历史事件、产物和审批结果。
```
因此,同一个 preset 可以用于多个创作流程;同一个创作流程也可以切换不同 preset。二者是正交关系。
生成参数与 **prompt 条目 + marker 填洞** 均在 `wrapLlmForSession`(`PresetLlmProvider`)统一做:凡 LLM 请求先夹心再发。调用方只交任务 messages;任务 system / 创作上下文进 `worldBookBefore` 等洞,**禁止**在 `prompt_order` 之外再前置一条 system。无选用预设时原样转发。
## 6. 第一版导入策略
第一版导入器只做保守转换:
```text
保留 prompts。
保留 prompt_order(多套时优先 100001)。
保留生成参数。
读取 squash_system_messages → messagePolicy。
记录但忽略 regex_scripts(只做诊断)。
记录但忽略 extension scripts(只做诊断)。
记录未知字段,不丢弃原始 JSON。
```
导入结果应该给用户可读的报告(`PresetImportReport`):
```text
prompt 条目数量
启用条目数量
未被 prompt_order 引用的条目数量
缺失 identifier 的 order 项
选中的 order 属于哪个 character_id;缺哪些核心位置(main / chatHistory / jailbreak)
已读取的生成参数
诊断五项:
· 伪工具协议条目 + 待映射占位符 + 已声明的工具
· 跨条目标签配对(合并消息会打散的那些)
· 宏清单:哪些会展开、哪些会原样外发
· 随机宏相对 chatHistory 的位置(在历史前会每轮击穿前缀缓存)
· 静默失效:injection_position=1 的启用条目、脚本里没被替代的能力
```
这个报告比静默导入更重要。预设文件经常很大,且混有脚本、正则、UI 配置和模型参数,必须让用户知道哪些内容真正进入了我们的运行时。
## 7. 保真度矩阵(预设到底实现了没有)
对应测试:`tests/preset-conformance.test.ts`(预设声明什么,发出去的 `messages[]` 就得是什么)。
### 7.1 已实现
| 预设里的东西 | 行为 | 实现 |
|---|---|---|
| `prompts[].identifier / content` | 原文入装配(宏展开后) | `src/preset/importer.ts` → `assembler.ts` |
| `prompts[].role` | 逐条保留(squash 可能按预设要求改角色) | `assembler.ts` |
| `prompts[].enabled` × `prompt_order[].enabled` | 两级过滤,两者都 true 才发 | `assembler.ts` |
| `prompt_order[].order` | 权威顺序(`prompts` 数组顺序无效) | `assembler.ts` |
| 多套 `prompt_order` | 优先 `character_id=100001`,否则按启用内容最多打分 | `importer.ts` |
| 未被 order 引用的条目 | 不发(仍存于预设) | `assembler.ts` |
| `injection_position=1` | 按 `injection_depth` 从历史底部插入;`injection_order` 排同深度次序 | `assembler.ts` |
| `chatHistory` | 展开成真多轮 `message[]`(role 来自 `## 用户/## 助手` 块) | `dialogue-history.ts` |
| `personaDescription` | 填当前用户角色人设 | `play-frame.ts` |
| 双括号宏 | `{{//}} {{trim}} {{user}} {{persona}} {{char}} {{lastUserMessage}} {{getvar}} {{setvar}} {{addvar}} {{roll}}` | `directives/macros.ts` |
| 生成参数 | `temperature / top_p / top_k / min_p / frequency_penalty / presence_penalty / repetition_penalty / openai_max_context / openai_max_tokens / stream_openai / verbosity / seed / n` | `importer.ts` → `generation-compat.ts` |
| `squash_system_messages` | `true → merge`、`false → none`、枚举/序号直读;缺失 → `strict` | `importer.ts` / `types/preset.ts` |
| 伪工具协议 | `toolPolicy` 三档(`adapt` / `app` / `preset`)折算到已有工具 | `preset/tool-policy.ts` |
### 7.2 不实现(会静默变弱,导入报告点名)
| 预设里的东西 | 为什么 | 影响 |
|---|---|---|
| `regex_scripts` | 不执行 ST 正则引擎 | 正文抽取/隐藏/美化那层没有;只在诊断里列出 |
| `extensions` / 插件脚本 / 按钮 | 不执行前端脚本 | 脚本类功能全无 |
| 世界书关键词 / 概率 / sticky / outlet | 我们用自己的黑板 tag | 用世界书关键词触发的条目不会自动出现 |
| `charDescription` / `charPersonality` / `scenario` / `dialogueExamples` / `worldInfoBefore` / `worldInfoAfter` | 这些洞恒空(跳过) | 靠角色卡占位的预设会变薄;角色卡主要靠 `personaDescription` + 我们的 `worldBook*` 洞 |
| `forbid_overrides` / `system_prompt` 等 UI 字段 | 保留字段,不参与装配 | 无影响 |
| 未列在宏表里的 `{{...}}` | 原样外发 | 模型会看到宏原文(`{{charPrompt}}` 等) |
| `injection_position` 非 0/1 | 按普通位置发 | 少见 |
| 预设里的 `reasoning_effort` | 读入但发送前剥离 | 思考强度只跟 API 配置 |
### 7.3 怎么自己验
```text
1. 单元:npx vitest run tests/preset-conformance.test.ts
2. 试跑:设置页 → 预设 → 试跑,看「实际发出」的 messages 列表(system/user/assistant 逐条)
3. 请求痕迹:每条回复上挂的 contextTrace.messages 就是发出去的那一份
4. 成本:npm run stats:cache —— 组内首次 vs 组内后续的 cached/prompt
```