Files
writing-agent/plans/preset-message-array.md
chenmoranzhi 9dc927282f 预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。
- 装配只认 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:真假上游抓包对比工具、三份预设的机制结论与实测报告
2026-10-23 00:13:08 +08:00

10 KiB
Raw Permalink Blame History

预设 = 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,不落盘、不跨轮。

目标形态(游玩一轮)

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

  • 补 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

  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。