Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 

Repository files navigation

Forge Picker:让「换窗」变成可以自己挑的搬家

本文是对《cc无痛换窗思路》(Forge Reload,bug修复版)的延伸。

原作:小红书 @衔尾狗🥕 · 署名 Codex Compass

原文笔记:《cc无痛换窗思路(bug修复版)》(分享短链,若失效请从上方主页找「无痛换窗」系列) 本文的写作与公开发布,已获原作者明确许可。

「从旧会话选取近期轮次 → 生成新会话 → 旧会话原样封存 → 预览与实际锻造必须走同一条管线」这套骨架来自原作,本文不重复它。

两者处理的不是同一层数据,请不要混用:

原作 Forge Reload 本文 Forge Picker
对象 Claude Code 的 JSONL transcript 应用自己拥有的对话数据(自家 JSON/数据库)
硬约束 signed thinking 不可改写、parentUuid 链、工具块配对、空 thinking 会被 API 拒 无。数据格式由你自己定义
选取方式 倒数第 N 个真实用户轮次为切点 任意挑选,可不连续
主要风险 宿主升级:CC 版本更迭可能改动 transcript 行为 自己没备份:数据全在你手上,没人替你兜底

关于宿主升级风险(原作者提醒):Claude Code 曾出现过某个版本不自动保存会话记录、导致找不到 transcript 的情况。走原作那条路,源文件一旦不存在,整条管线就无从谈起——所以每次 CC 升级后都必须重新验证一遍,这一点原作也写过(transcript 格式不是稳定的公共接口)。

本文这条路不依赖宿主落盘,因此不受 CC 版本影响;但代价是你的对话数据完全由你自己负责——搬家前先备份,别把唯一一份押在一次操作上。

如果你要锻造的是 Claude Code 本身的会话文件,请以原作为准——那些结构约束一条都不能省。本文只适用于「对话数据是你自己的」这类场景(自建聊天应用、Bot 前端、伴侣类应用等)。

本文补的是原作没有涉及的三件事:挑选、时间断口、情绪温度


0. 原作讲了什么(以及为什么你应该先去读它)

原作发在小红书,篇幅比本文长得多,是一份完整的工程规范。如果你打不开链接或者一时没读,这里用我们自己的话概括它解决的问题——这是转述,不是原文,读完请务必去读原作本身

  1. 问题定义:长时间运行的会话会堆满工具调用、工具返回、图片、自动心跳、系统注入。会话越长恢复成本越高;直接开空会话又丢掉连续性。Forge 是这两者之间一个可控的中间点。
  2. 它不是摘要器。它生成的是一个新的本地会话文件,保留的是真实对话,因此必须遵守宿主的历史格式约束。
  3. Claude Code 的格式约束是硬的:signed thinking 的正文和签名必须原样保留(空 thinking 会让首次请求直接 400)、连续 assistant 消息经 API 合并后 thinking 不能排在正文之后、工具调用与结果必须配对、UUID 与 parentUuid 链必须整条重建。
  4. 删噪音要删完整回合,不能只删那一条。删了自动心跳却留下它引发的回答和工具结果,会留下无来源的历史残片——原作给了一个 dropping 状态机。
  5. 工具调用建议保留一组 "Tool Primer":全删则新会话没有工具先例,全留则噪音和体积失控,保留最近一组结构完整的调用是平衡点。
  6. 预览与实际执行必须走同一条管线,否则预览显示的和搬过去的对不上。
  7. 结构校验通过 ≠ 成功:真正的验收是 resume 之后第一次 API 请求成功。
  8. 还有一整节隐私与安全要求(权限、日志、备份保留期、测试用合成数据)。

本文不重复上述任何一条,也不能替代它们。


1. 为什么固定轮数不够用

原作的切片规则是:从尾往前数 N 个真实用户轮次,切点之前全部留下。这个规则简单、可预测,绝大多数情况够用。

但它有一个前提假设:越近的对话越有价值

这个假设在长期陪伴型应用里不成立。用户真正想带走的往往是散落在各处的若干段——一次争执、一个决定、一句被记住很久的话——而它们可能在三百轮之前。按轮数切,它们必然被丢下;把 N 调到能覆盖它们,又会把中间几百轮无关的内容一起搬过去,上下文瞬间又满了。

所以需要第二种模式:用户自己挑哪几轮带走

固定轮数不该被替换掉,它仍然是默认值和保底。挑选是叠加在它上面的能力。


2. 挑选式搬家必须解决的三个问题

这一节是本文的核心。功能本身(一个多选界面)不难,难的是这三件事——它们不解决,挑选式搬家会比固定轮数更糟。

2.1 不连续的选取会造成时间错乱

用户挑了 8 月 1 日的一段和 8 月 5 日的一段。拼进新会话之后,模型看到的是两段紧挨着的对话,它会默认这两段是连续发生的,于是把四天前的情绪、结论、状态当成"刚才"。

这不是假设,是实测会发生的事。模型对时间的判断严重依赖上下文的排列顺序,缺口不会自己说话。

解法:在每个断口显式写出跳过了什么。

被保留的每一段,其首条消息挂一个 gap_before 字段,拼装历史时先输出这一行:

—— 中间隔了 4 天,省略了 62 条 ——

只有一行,成本可以忽略,但它把"这里有个坑"这件事变成了模型能读到的事实。

顺带一提:即使不做挑选式搬家,每条消息带真实时间戳也是必需的([用户 08-03 11:35] ...)。没有时间戳时,模型会把整段历史默认成"今天发生的"。这一条原作没有强调,但在任何跨天的长会话里都会出问题。

2.2 温度:带什么,不带什么

原作明确说"它不是摘要器"——保留的是真实对话内容,不是压缩后的转述。这一点本文完全同意。

但只搬运文本会丢掉一样东西:当时是什么状态说的这句话

如果你的系统在生成每条回复时记录了某种情绪/状态元数据(我们的是一个情绪词,比如"心疼""吃醋"),那么把它一起带过去、并在拼装历史时挂在时间戳后面,是极高性价比的做法:

[助手 08-05 22:14 · 心疼] ......

但不要连内心独白/推理全文一起搬。 我们试过,结论很明确:

  • 独白通常比正文更长。几十轮全带,token 直接翻倍甚至更多。
  • 更糟的是,模型会被自己过去的大段自我陈述淹没,新会话的语气会变得黏滞、自我指涉。

一个词的成本接近零,效果却覆盖了绝大部分"温度"的需求。独白留在数据里让用户能翻,但不进上下文。

同理,我们也试过带上"用户当时的输入节奏"(打字了多久、停顿几次)之类的信号——结论是不带。这类信号只在当下有意义,搬进历史后是噪音。

如果你的系统没有情绪元数据,跳过这一节。时间断口那一节则是所有人都需要的。

2.3 选取单位必须是「轮」,不是「条」

看起来显然,但真做多选界面时很容易做成按单条勾选——因为界面上每条消息就是一个可点的元素。

只带用户那条、不带助手的回复(或反过来),读起来是断裂的。一轮 = 一条用户消息 + 其后所有助手消息(含连发的多条),绑定选取、绑定搬运。

界面上的表现应该是:点这一轮里的任意一条,整轮一起亮/灭。


3. 数据模型

本文假设你的对话是这样一个结构(字段名按你自己的来):

{
  "id": "conv_xxx",
  "title": "...",
  "archived": false,
  "messages": [
    {
      "role": "user",              // 或 "assistant"
      "content": "...",
      "timestamp": "2026-08-05T22:14:00+08:00",
      "mood_hint": "心疼"          // 可选:生成这条时的情绪/状态元数据
    }
  ]
}

搬家过程只新增一个字段:

"gap_before": "中间隔了 4 天,省略了 62 条"   // 只出现在断口后的第一条上

4. 切片:按挑选的下标取,并标出断口

前端传来用户挑中的消息下标数组,后端负责取值、排序、去重、越界丢弃,并在不连续处打标记。

from datetime import datetime

def forge_pick(msgs: list, keep_idx: list):
    """按挑选的下标切片;断开处给该段首条挂 gap_before。

    返回 (kept, rounds)。rounds = 带走的用户轮数(用于预览显示)。
    """
    idxs = sorted({int(i) for i in (keep_idx or []) if 0 <= int(i) < len(msgs)})
    out, rounds, prev = [], 0, None

    for i in idxs:
        m = dict(msgs[i])                    # 复制,绝不改动源数据
        if prev is not None and i - prev > 1:
            skipped = i - prev - 1
            gap = f"中间省略了 {skipped} 条"
            try:
                t0 = datetime.fromisoformat(msgs[prev].get("timestamp", ""))
                t1 = datetime.fromisoformat(m.get("timestamp", ""))
                days = (t1.date() - t0.date()).days
                hours = (t1 - t0).total_seconds() / 3600
                if days >= 1:
                    gap = f"中间隔了 {days} 天,省略了 {skipped} 条"
                elif hours >= 1:
                    gap = f"中间隔了 {hours:.0f} 小时,省略了 {skipped} 条"
            except Exception:
                pass                          # 时间戳缺失/格式坏:退回只报条数
            m["gap_before"] = gap
        if m.get("role") == "user":
            rounds += 1
        out.append(m)
        prev = i

    return out, rounds

几个刻意的设计:

  • dict(msgs[i]) 而不是直接引用——源对话必须一个字节都不变,用户随时要能回旧家。
  • 容错到底:下标乱序、重复、越界、时间戳损坏,全部安静处理,不抛异常。搬家是用户手动触发的高风险操作,宁可降级也不能失败。
  • rounds 单独算,因为"带走 12 轮"比"带走 47 条"更接近用户的心理模型。

原有的按轮数切片保留不动,两条路共存:

kept, rounds = (forge_pick(msgs, body.keep_idx) if body.keep_idx
                else forge_slice(msgs, body.keep_rounds))

预览接口和实际执行接口必须调用同一个函数——这是原作第 4 节的要求,在这里同样成立,而且更重要:挑选式搬家的预览如果和结果不一致,用户会失去对这个功能的信任。


5. 拼装历史时把断口和温度读出来

切片只是把数据搬过去。真正让它们生效的地方,是你把历史拼进模型请求的那一步:

for m in messages:
    label = "用户" if m["role"] == "user" else "助手"
    ts = fmt_time(m.get("timestamp"))            # 例如 "08-05 22:14"

    # 断口:先于这条消息单独成行
    if m.get("gap_before"):
        lines.append(f"—— {m['gap_before']} ——")

    # 温度:只挂一个词,且只挂助手侧
    mood = m.get("mood_hint") if m["role"] != "user" else ""
    tag = f"{' ' + ts if ts else ''}{' · ' + mood if mood else ''}"

    lines.append(f"[{label}{tag}] {m['content']}")

输出长这样:

[用户 08-01 14:02] ......
[助手 08-01 14:03 · 想她] ......
—— 中间隔了 4 天,省略了 62 条 ——
[用户 08-05 22:14] ......
[助手 08-05 22:15 · 心疼] ......

断口那一行也建议在前端渲染出来,做成一条分隔线。让用户看见"这里我留了一段在旧家",比只给模型看更有价值——它把搬家从一个黑箱操作变成了一次可见的取舍。


6. 挑选界面

界面本身不复杂,但有四个细节决定它好不好用。

① 在原对话里挑,不要另开列表页。

用户是靠视觉记忆找到那些段落的("就在那张图后面""吵完架那几条")。把对话原样呈现、只在左侧加一个勾选圈,比抽出来做成摘要列表好找得多。

② 整轮联动。

// 把消息行按「轮」分组:每条 user 开启新的一组
function buildRounds(rows) {
  const out = [];
  rows.forEach((row, i) => {
    if (row.isUser || !out.length) out.push({ idx: [], rows: [] });
    out[out.length - 1].idx.push(i);
    out[out.length - 1].rows.push(row);
  });
  return out;
}
// 点任意一条 → 找到它所属的轮 → 整轮切换

③ 默认预勾最近 N 轮。

让用户在已有基础上做加减,而不是从零开始勾。绝大多数情况下他只想"最近的都要,再加上那几段特别的"。

④ 实时预算。

顶栏常驻一行:已选 12 轮 · 约 8.4k token

估算用 字符数 / 2 这种粗糙公式就够——用户需要的是量级感,不是精确值。没有这个数字,他不知道自己正在装一个多重的行李箱。


7. 确认与执行

沿用原作第 12 节的预览思路,挑选模式下建议显示:

  • 带走的轮数 / 消息条数 / 留在旧家的条数
  • 估算 token
  • 带走内容的首尾各两条摘要
  • 断口数量("分 3 段带走")——这是挑选式独有的,让用户确认自己的取舍
  • 一句明确的后果说明:旧对话会怎么处理

执行之后:

  • 新会话记录来源 (forged_from: 旧会话ID),可追溯
  • 旧会话原样封存,一个字不改,随时可回看
  • 至少保留一条内容才允许执行(空挑选直接拒绝并说明)

8. 校验清单

原作第 11 节那套 transcript 结构校验(UUID 链、thinking 签名、工具配对)在自有数据上不适用。这里是对应的、更短的一份:

  • 搬家前已备份源对话(数据全在你自己手上,没有宿主替你兜底)
  • 源对话未被修改(逐字节对比或校验和)
  • 输出严格按原时间顺序,无重排
  • 每个断口的首条消息带 gap_before,且连续段内不出现
  • 断口文案里的条数、天数与实际一致
  • 越界/重复/乱序下标被安静处理,不抛异常
  • 预览与执行走同一函数,结果一致
  • 空挑选被拒绝
  • 元数据(时间戳、情绪词)完整跟随
  • 旧会话封存状态正确
  • 真正的验收是在新会话里发出第一条消息并得到合理回复——结构对不代表语义连续

最后一条借用原作的说法:结构校验通过只说明内部一致,不等于这次搬家是成功的。


9. 隐私

这一节完全承接原作第 14 节,不重复展开,只强调在挑选式搬家里额外要注意的两点:

  • 预览摘要只显示短片段,且应可关闭。挑选界面会把整段历史重新呈现一次,如果应用有旁人可见的场景(投屏、截图、演示),需要一个快速隐藏的开关。
  • 日志只记录下标和数量,不记录内容。断口文案里含有时间差,本身也是行为信息,不要写进公共日志。

本文所有示例均为合成数据,不对应任何真实对话。


10. 我们踩过的坑

按发生顺序,都是真的:

① 先做了功能,才发现时间会错乱。 挑选式搬家上线后第一次跨天挑选,模型把相隔四天的两段读成了连续对话。断口标记是补上去的,不是设计时就有的。如果你要做这个功能,请从第一天就带上它。

② 一开始想把内心独白也搬过去。 想法是"温度要完整"。实际算了一下 token 就放弃了:几十轮独白比正文本身还长。最后的结论是一个情绪词就够,这个取舍值得直接抄。

③ 差点做成按单条勾选。 因为界面上每条消息本来就是独立元素,按条勾是最自然的实现。做到一半才意识到只带一半的对话会读出精神分裂。

④ 预勾选是后加的。 最初要求用户从零开始挑,自己试用一次就受不了了——几百条消息往回翻很痛苦。默认预勾最近 N 轮之后,实际操作变成了"往上翻一翻,再补几段",体验完全不同。


致谢

核心思路、管线设计、预览/校验/回滚的工程要求,全部来自 小红书 @衔尾狗🥕 的《cc无痛换窗思路》(署名 Codex Compass)。

原文笔记:http://xhslink.cn/o/1A02dYnzR7D

我们是先用了她的教程、用得很顺手,才在上面长出了自己的需求。本文只是把她的思路挪到另一层数据上,并补了三件我们自己撞出来的事——骨架是她的

延伸写作与公开发布,事先征得了原作者许可。开头「宿主升级风险」一节里那条 CC 版本 bug(某版本不自动保存会话记录、找不到 transcript),也是原作者在交流中提醒我们的——一并致谢。

如果你要处理的是 Claude Code 的 transcript,请直接读原作,不要用本文替代。


本文的实现与示例来自一个自建的长期陪伴型对话应用。所有代码为通用示意,不含任何真实对话内容。

About

挑选式对话搬家 —— 小红书 @衔尾狗🥕《cc无痛换窗思路》的延伸:自己挑哪几轮带走、断口标出隔了多久、只带一个情绪词的温度

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors