- 装配只认 prompt_order.enabled;squash_system_messages 只合并相邻 system(新增 system 档,连接符按酒馆用单换行)
- 包装层:new_chat_prompt、示例对话(new_example_chat_prompt + <START> 切段 + name)、角色卡三块与 personality_format/scenario_format、{{user}}/{{char}} 取值、任务契约并进预设条目
- 工具协议:按 ToolBindings / 伪工具协议数据声明真 tools(presetToolPolicy=preset),工具描述用到的条目不再重复作为消息发出
- 正则:promptOnly 作用于对话历史(含 depth 过滤)、写入时脚本改写回复;{{//}} 注释按配平扫描
- tools/st-request-diff:真假上游抓包对比工具、三份预设的机制结论与实测报告
10 KiB
10 KiB
预设 = message[] 骨架:真多轮装配
状态:已实现(788 测试全绿)。剩:实测缓存收益、滑窗恢复策略、
{{charPrompt}}一族。
Context
问题:现在每条 LLM 请求实际只有「N 条预设 system 条目 + 1 条巨大 user 文本」。原因是两处拍平叠加:
src/worker/executor.ts:868交给预设层的任务消息只有[{system: 契约}, {user: assembleWorkerContext(...)}],历史被formatHistorySegment拼成一段带## 用户 / ## 助手的字符串。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,不落盘、不跨轮。
目标形态(游玩一轮)
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
- 先修好编译:
context-segments.ts补回export function assembleWorkerContext(params): string { return assembleWorkerContextSections(params).flat; }。旧调用点零改动,扁平输出与改动前逐字一致(chunks.map(text) + tail,"\n\n---\n\n"分隔)。 - worker 路径接上真多轮:
executor.ts里当worker.contextSegments含历史 ref 时,用workerContextToBags(sections)建WorldInfoPack,把它作为presetPack传给三条请求路径(completeStructured/completeOpts的complete|completeStream/runGmHarness暂不传)。messages保持现状(trace 与 harness 仍可用)。 - 保证不重复、不丢块:
workerContextToBags已把「史前块 / 历史 / 史后块 / 本轮 / 尾块」分好;历史只从chatHistory洞出一次。 - squash 保护:
shapeFramedMessages()只对历史区前后两段整形(已实现)。 - 测试:改
play-frame.test.ts的pack.history期望(现在应展开成多条);新增「历史洞展开成多轮」「chatHistory 不再恒空」「squash 不动历史」三组用例。 - 文档:更新
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
- 补
assembleWorkerContext()包装 executor.ts含历史 ref 时构造 pack 并作为presetPack传出(含 gm harness){{persona}}宏injection_depth深度插入- squash 缺省
strict,且不碰历史区 - 创作期历史滑窗暂时不限
- 测试:历史解析、深度插入、squash 保护、
{{persona}}、缺省 strict(788 全绿) - 文档:
docs/preset-format.md、docs/context-assembly.md - 滑窗恢复:块级裁剪 + 滞回(超过 2n 才裁到 n),前缀每 n 轮才断一次
- 缓存报告:
npm run stats:cache(src/cli/cache-report.ts),按「组内首次 / 组内后续」切命中率 - 实测:改后用
npm run stats:cache -- --days 3对照修复前的基线(首次 8.6%,后续 20.7%)
Verification
npx vitest run—— 788 全绿(已达成)。npx tsc --noEmit—— 本仓库有既有报错(dictate/round.ts、log.ts、phase-runtime.ts未用变量与skillPackRoot、session-manager.ts),本次未新增。- 预设试跑窗口:
assemblePresetProbe()输出应是system… / system… / assistant… / user… / user(turn),而不是一条大 user。 - 成本侧:
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。