# 预设 = message[] 骨架:真多轮装配 > 状态:**已实现**(788 测试全绿)。剩:实测缓存收益、滑窗恢复策略、`{{charPrompt}}` 一族。 ## Context **问题**:现在每条 LLM 请求实际只有「N 条预设 system 条目 + 1 条巨大 user 文本」。原因是两处拍平叠加: 1. `src/worker/executor.ts:868` 交给预设层的任务消息只有 `[{system: 契约}, {user: assembleWorkerContext(...)}]`,历史被 `formatHistorySegment` 拼成一段带 `## 用户 / ## 助手` 的字符串。 2. `src/preset/play-frame.ts` 的 `frameMessagesWithPreset()` 只有 body ≥2 且末条是 user 时才切出历史袋 —— 实际永远只有 1 条,于是 `chatHistory` 洞**恒空**,整包上下文落进 `currentTurn`。 后果: - **成本**:历史 blob 每轮整体变化,前缀在 `currentTurn` 处断,前 N-1 轮重发全价。对上 `docs/briefs/prompt-cache-audit-2026-10-07.md` §5「未打折的是几乎每次的首填大包」。 - **表达**:模型没有 assistant 轮可模仿节奏;`injection_depth` 无意义;`squash` 的 `semi/strict` 在给拍平打补丁。 **目标**:预设回归它本来的职责 —— 提供 `message[]` 的结构指导;内容(我们自己的上下文)按预设的洞折进去;历史是真多轮。 --- ## 确认后的模型(用户要确认的那一条) > 预设 = 「最后发出去的 messages 长什么样」的声明式骨架;宏在装配期展开;运行时只负责把每块内容填进骨架留的洞里,绝不追加在骨架之外。 ### 预设负责 | 职责 | 载体 | |---|---| | 有哪些条目、什么角色(system / user / assistant) | `prompts[].role` | | 谁参与本次请求 | `prompt_order[].enabled` + `prompts[].enabled` | | 装配顺序(谁在前、谁在后、谁进历史深处) | `prompt_order[].orderIndex`,未来 `injection.depth` | | 正文里的宏(`{{user}}` 等) | `prompts[].content`,装配期展开 | | 生成参数 | 顶层 `temperature/top_p/...` | | 发出前整形要求(squash) | `messagePolicy.squash` | ### 运行时负责(只填洞,不加料) 5 个自有洞 + 1 个共用洞: | identifier | 填入内容 | 形态 | |---|---|---| | `worldBookBefore` | 历史之前的上下文块(worker 契约、角色卡、设计资料) | 一段 system 文本 | | `chatHistory` | `对话.历史` | **真多轮 message[]** | | `worldBookAfter` | 历史之后、本轮之前(变量 / 真值 / 本轮状态) | 一段 system 文本 | | `currentTurn` | `用户.最新输入` | 一条 user | | `postTurn` | 本轮之后的插入(本步任务、提示) | 一段 system 文本 | ### 宏(装配期展开,`src/directives/macros.ts`) `{{// 注释}}`、`{{trim}}`、`{{user}}`、`{{char}}`、`{{lastUserMessage}}`、`{{getvar::k}}`、`{{setvar::k::v}}`、`{{addvar::k::v}}`、`{{roll:1d6}}`。 未知宏**原样保留**(与酒馆一致),并在导入诊断 `diagnostics.macros[].known=false` 里点名。 作用域:单次装配一份 Map,不落盘、不跨轮。 ### 目标形态(游玩一轮) ```text system 预设条目:main / 文风 / 破限(ST 历史前) system worldBookBefore:worker 契约 + 角色卡 + 设计资料(史前块) system worldBookAfter:变量.当前 / 真值(史后块) assistant 开场白 ← chatHistory 洞展开 user 「我推门进去」 ← chatHistory 洞展开 assistant 「门后是雨……」 user 「我抬头」 ← chatHistory 洞展开 user currentTurn:用户.最新输入 system postTurn:本步任务 / 提示(若有) ``` 前 6 条是稳定前缀 → 可命中缓存;只有末尾随轮次变化。 --- ## 当前代码状态(已落地) | 文件 | 改动 | |---|---| | `src/skills/dialogue-history.ts` | `parseHistoryMessages()`:`## 用户` / `【用户】` / `用户:` → message[];`CURRENT_TURN_TAG` / `isCurrentTurnRef` 迁入 | | `src/skills/context-segments.ts` | `assembleWorkerContextSections()` + `workerContextToBags()`;`assembleWorkerContext()` 保留为 `.flat` 包装 | | `src/preset/world-info-pack.ts` | `history: string → ChatMessage[]`;`chatHistory` 不再字符串填洞 | | `src/preset/assembler.ts` | 历史洞展开多条;`PRESET_HISTORY_MARK` / `splitPresetHistory()`;`injection_position=1` 深度插入 | | `src/preset/play-frame.ts` | 不再把正文拍平成 `【用户】/【助手】`;`shapeFramedMessages()`:历史上只用「合并相邻同角色」 | | `src/llm/preset-wrapper.ts` | `options.presetPack` 通道;squash 只整形历史前后两段;tool 循环尾原样接在后面 | | `src/llm/client.ts` | `CompleteOptions.presetPack`(不进请求体) | | `src/worker/executor.ts` | 含 `对话.历史` 的 worker 走 `presetPack`(含 gm harness) | | `src/runtime/driver.ts` / `src/worker/gm-harness.ts` | 透传 `presetPack` | | `src/direct-chat/turn.ts` | `priorMessages` 直传真多轮;种子前情无块头时进 `worldBookBefore` | | `src/preset/probe.ts` | 历史解析 + `presetPack` | | `src/directives/macros.ts` / `expand.ts` | 新增 `{{persona}}` → `@人设` | | `src/types/preset.ts` | `DEFAULT_PRESET_SQUASH = "strict"` | | `src/dictate/history-window.ts` | 缺省改为不限(-1),先让前缀能命中 | ## 已定决策 | 问题 | 结论 | |---|---| | 预设没写 squash | 缺省 `strict`(兼容性优先);单个预设可自行写;设置页仍可覆盖 | | 酒馆真实 squash 语义 | 核过源码:`squash_system_messages` 是 bool(默认 false)= 合并相邻无 name 的 system;`merge`/`strict` 属于另一个设置 `custom_prompt_post_processing` | | 历史区要不要参与整形 | 参与,但只用「合并相邻同角色」:用户连发两条会合并,`injection_depth` 的 system 不被折掉 | | `injection_depth` | 已实现:depth 0 = 历史末尾,同深度按 `injection_order` 升序 | | 创作期 worker | 不特殊化;含历史 ref 的真多轮,纯数据 worker 保持单包 | | `{{persona}}` | 接 `@人设`(当前用户角色详细人设) | | `{{charPrompt}}` 一族 | 不补:我们靠世界书建角色卡,未知宏原样保留 + 导入报告点名 | | 变量 / 真值 | 不动:继续当 `worldBookAfter` 的文本段 | | 创作期历史滑窗 | 暂时关闭(不限) | --- ## Approach 1. **先修好编译**:`context-segments.ts` 补回 `export function assembleWorkerContext(params): string { return assembleWorkerContextSections(params).flat; }`。旧调用点零改动,扁平输出与改动前逐字一致(`chunks.map(text) + tail`,`"\n\n---\n\n"` 分隔)。 2. **worker 路径接上真多轮**:`executor.ts` 里当 `worker.contextSegments` 含历史 ref 时,用 `workerContextToBags(sections)` 建 `WorldInfoPack`,把它作为 `presetPack` 传给三条请求路径(`completeStructured` / `completeOpts` 的 `complete`|`completeStream` / `runGmHarness` 暂不传)。`messages` 保持现状(trace 与 harness 仍可用)。 3. **保证不重复、不丢块**:`workerContextToBags` 已把「史前块 / 历史 / 史后块 / 本轮 / 尾块」分好;历史只从 `chatHistory` 洞出一次。 4. **squash 保护**:`shapeFramedMessages()` 只对历史区前后两段整形(已实现)。 5. **测试**:改 `play-frame.test.ts` 的 `pack.history` 期望(现在应展开成多条);新增「历史洞展开成多轮」「chatHistory 不再恒空」「squash 不动历史」三组用例。 6. **文档**:更新 `docs/preset-format.md` §4(洞 + 装配规则 + 宏)、`docs/context-assembly.md` §2/§9(历史是 message 序列)。 ### 不在本轮(建议单独排) - `injection_position=1` / `injection_depth` 深度插入(真多轮之后才有意义)。 - 历史滑窗策略:`projection=summary`(最近 20 段)在块级滑动时会整体位移、击穿前缀缓存。需要「按块裁剪 + 仅在超阈值截断」。 - 主世界层 `runGmHarness` 的多轮化(它是 tool loop,前缀已在循环内复用)。 --- ## Files to modify - `src/skills/context-segments.ts` — 补 `assembleWorkerContext` 包装(关键:让编译先过) - `src/worker/executor.ts` — `presetPack` 接线 - `tests/play-frame.test.ts`、`tests/direct-chat.test.ts`、`tests/world-info-pack.test.ts`、`tests/preset-probe.test.ts` — 期望更新 - `docs/preset-format.md`、`docs/context-assembly.md` ## Reuse(已存在,别重写) | 用途 | 位置 | |---|---| | 历史文本 ↔ 结构 | `src/skills/dialogue-history.ts`:`parseHistoryMessages()`(新)、`projectDialogueHistory()`、`appendDialogueHistoryTurn()` | | 分袋 | `src/skills/context-segments.ts`:`workerContextToBags()`(新)、`renderContextSegmentBody()` | | 洞词汇表 / 扩展 order | `src/preset/markers.ts`:`applyExtendedMarkers()`、`MARKER_DEFS` | | 装配 | `src/preset/assembler.ts`、`src/preset/play-frame.ts` | | 整形 | `src/llm/message-squash.ts` + `shapeFramedMessages()` | | 宏 | `src/directives/macros.ts` + `src/directives/expand.ts` | ## Steps - [x] 补 `assembleWorkerContext()` 包装 - [x] `executor.ts` 含历史 ref 时构造 pack 并作为 `presetPack` 传出(含 gm harness) - [x] `{{persona}}` 宏 - [x] `injection_depth` 深度插入 - [x] squash 缺省 `strict`,且不碰历史区 - [x] 创作期历史滑窗暂时不限 - [x] 测试:历史解析、深度插入、squash 保护、`{{persona}}`、缺省 strict(788 全绿) - [x] 文档:`docs/preset-format.md`、`docs/context-assembly.md` - [x] 滑窗恢复:块级裁剪 + 滞回(超过 2n 才裁到 n),前缀每 n 轮才断一次 - [x] 缓存报告:`npm run stats:cache`(`src/cli/cache-report.ts`),按「组内首次 / 组内后续」切命中率 - [ ] 实测:改后用 `npm run stats:cache -- --days 3` 对照修复前的基线(首次 8.6%,后续 20.7%) ## Verification 1. `npx vitest run` —— 788 全绿(已达成)。 2. `npx tsc --noEmit` —— 本仓库有**既有**报错(`dictate/round.ts`、`log.ts`、`phase-runtime.ts` 未用变量与 `skillPackRoot`、`session-manager.ts`),本次未新增。 3. 预设试跑窗口:`assemblePresetProbe()` 输出应是 `system… / system… / assistant… / user… / user(turn)`,而不是一条大 user。 4. 成本侧:`npm run stats:cache`(推荐 `--days 3`)。看两件事: - `worker:world-simulator` / `direct-chat` 的「组内后续」命中率是否明显高于「组内首次」; - 整体是否高于修复前基线(最近 30 天:首次 8.6%、后续 20.7%)。 基线出处:`docs/briefs/prompt-cache-audit-2026-10-07.md` §3。