本文是对《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 前端、伴侣类应用等)。
本文补的是原作没有涉及的三件事:挑选、时间断口、情绪温度。
原作发在小红书,篇幅比本文长得多,是一份完整的工程规范。如果你打不开链接或者一时没读,这里用我们自己的话概括它解决的问题——这是转述,不是原文,读完请务必去读原作本身:
- 问题定义:长时间运行的会话会堆满工具调用、工具返回、图片、自动心跳、系统注入。会话越长恢复成本越高;直接开空会话又丢掉连续性。Forge 是这两者之间一个可控的中间点。
- 它不是摘要器。它生成的是一个新的本地会话文件,保留的是真实对话,因此必须遵守宿主的历史格式约束。
- Claude Code 的格式约束是硬的:signed thinking 的正文和签名必须原样保留(空 thinking 会让首次请求直接 400)、连续 assistant 消息经 API 合并后 thinking 不能排在正文之后、工具调用与结果必须配对、UUID 与 parentUuid 链必须整条重建。
- 删噪音要删完整回合,不能只删那一条。删了自动心跳却留下它引发的回答和工具结果,会留下无来源的历史残片——原作给了一个 dropping 状态机。
- 工具调用建议保留一组 "Tool Primer":全删则新会话没有工具先例,全留则噪音和体积失控,保留最近一组结构完整的调用是平衡点。
- 预览与实际执行必须走同一条管线,否则预览显示的和搬过去的对不上。
- 结构校验通过 ≠ 成功:真正的验收是 resume 之后第一次 API 请求成功。
- 还有一整节隐私与安全要求(权限、日志、备份保留期、测试用合成数据)。
本文不重复上述任何一条,也不能替代它们。
原作的切片规则是:从尾往前数 N 个真实用户轮次,切点之前全部留下。这个规则简单、可预测,绝大多数情况够用。
但它有一个前提假设:越近的对话越有价值。
这个假设在长期陪伴型应用里不成立。用户真正想带走的往往是散落在各处的若干段——一次争执、一个决定、一句被记住很久的话——而它们可能在三百轮之前。按轮数切,它们必然被丢下;把 N 调到能覆盖它们,又会把中间几百轮无关的内容一起搬过去,上下文瞬间又满了。
所以需要第二种模式:用户自己挑哪几轮带走。
固定轮数不该被替换掉,它仍然是默认值和保底。挑选是叠加在它上面的能力。
这一节是本文的核心。功能本身(一个多选界面)不难,难的是这三件事——它们不解决,挑选式搬家会比固定轮数更糟。
用户挑了 8 月 1 日的一段和 8 月 5 日的一段。拼进新会话之后,模型看到的是两段紧挨着的对话,它会默认这两段是连续发生的,于是把四天前的情绪、结论、状态当成"刚才"。
这不是假设,是实测会发生的事。模型对时间的判断严重依赖上下文的排列顺序,缺口不会自己说话。
解法:在每个断口显式写出跳过了什么。
被保留的每一段,其首条消息挂一个 gap_before 字段,拼装历史时先输出这一行:
—— 中间隔了 4 天,省略了 62 条 ——
只有一行,成本可以忽略,但它把"这里有个坑"这件事变成了模型能读到的事实。
顺带一提:即使不做挑选式搬家,每条消息带真实时间戳也是必需的([用户 08-03 11:35] ...)。没有时间戳时,模型会把整段历史默认成"今天发生的"。这一条原作没有强调,但在任何跨天的长会话里都会出问题。
原作明确说"它不是摘要器"——保留的是真实对话内容,不是压缩后的转述。这一点本文完全同意。
但只搬运文本会丢掉一样东西:当时是什么状态说的这句话。
如果你的系统在生成每条回复时记录了某种情绪/状态元数据(我们的是一个情绪词,比如"心疼""吃醋"),那么把它一起带过去、并在拼装历史时挂在时间戳后面,是极高性价比的做法:
[助手 08-05 22:14 · 心疼] ......
但不要连内心独白/推理全文一起搬。 我们试过,结论很明确:
- 独白通常比正文更长。几十轮全带,token 直接翻倍甚至更多。
- 更糟的是,模型会被自己过去的大段自我陈述淹没,新会话的语气会变得黏滞、自我指涉。
一个词的成本接近零,效果却覆盖了绝大部分"温度"的需求。独白留在数据里让用户能翻,但不进上下文。
同理,我们也试过带上"用户当时的输入节奏"(打字了多久、停顿几次)之类的信号——结论是不带。这类信号只在当下有意义,搬进历史后是噪音。
如果你的系统没有情绪元数据,跳过这一节。时间断口那一节则是所有人都需要的。
看起来显然,但真做多选界面时很容易做成按单条勾选——因为界面上每条消息就是一个可点的元素。
只带用户那条、不带助手的回复(或反过来),读起来是断裂的。一轮 = 一条用户消息 + 其后所有助手消息(含连发的多条),绑定选取、绑定搬运。
界面上的表现应该是:点这一轮里的任意一条,整轮一起亮/灭。
本文假设你的对话是这样一个结构(字段名按你自己的来):
搬家过程只新增一个字段:
"gap_before": "中间隔了 4 天,省略了 62 条" // 只出现在断口后的第一条上前端传来用户挑中的消息下标数组,后端负责取值、排序、去重、越界丢弃,并在不连续处打标记。
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 节的要求,在这里同样成立,而且更重要:挑选式搬家的预览如果和结果不一致,用户会失去对这个功能的信任。
切片只是把数据搬过去。真正让它们生效的地方,是你把历史拼进模型请求的那一步:
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 · 心疼] ......
断口那一行也建议在前端渲染出来,做成一条分隔线。让用户看见"这里我留了一段在旧家",比只给模型看更有价值——它把搬家从一个黑箱操作变成了一次可见的取舍。
界面本身不复杂,但有四个细节决定它好不好用。
① 在原对话里挑,不要另开列表页。
用户是靠视觉记忆找到那些段落的("就在那张图后面""吵完架那几条")。把对话原样呈现、只在左侧加一个勾选圈,比抽出来做成摘要列表好找得多。
② 整轮联动。
// 把消息行按「轮」分组:每条 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 这种粗糙公式就够——用户需要的是量级感,不是精确值。没有这个数字,他不知道自己正在装一个多重的行李箱。
沿用原作第 12 节的预览思路,挑选模式下建议显示:
- 带走的轮数 / 消息条数 / 留在旧家的条数
- 估算 token
- 带走内容的首尾各两条摘要
- 断口数量("分 3 段带走")——这是挑选式独有的,让用户确认自己的取舍
- 一句明确的后果说明:旧对话会怎么处理
执行之后:
- 新会话记录来源 (
forged_from: 旧会话ID),可追溯 - 旧会话原样封存,一个字不改,随时可回看
- 至少保留一条内容才允许执行(空挑选直接拒绝并说明)
原作第 11 节那套 transcript 结构校验(UUID 链、thinking 签名、工具配对)在自有数据上不适用。这里是对应的、更短的一份:
- 搬家前已备份源对话(数据全在你自己手上,没有宿主替你兜底)
- 源对话未被修改(逐字节对比或校验和)
- 输出严格按原时间顺序,无重排
- 每个断口的首条消息带
gap_before,且连续段内不出现 - 断口文案里的条数、天数与实际一致
- 越界/重复/乱序下标被安静处理,不抛异常
- 预览与执行走同一函数,结果一致
- 空挑选被拒绝
- 元数据(时间戳、情绪词)完整跟随
- 旧会话封存状态正确
- 真正的验收是在新会话里发出第一条消息并得到合理回复——结构对不代表语义连续
最后一条借用原作的说法:结构校验通过只说明内部一致,不等于这次搬家是成功的。
这一节完全承接原作第 14 节,不重复展开,只强调在挑选式搬家里额外要注意的两点:
- 预览摘要只显示短片段,且应可关闭。挑选界面会把整段历史重新呈现一次,如果应用有旁人可见的场景(投屏、截图、演示),需要一个快速隐藏的开关。
- 日志只记录下标和数量,不记录内容。断口文案里含有时间差,本身也是行为信息,不要写进公共日志。
本文所有示例均为合成数据,不对应任何真实对话。
按发生顺序,都是真的:
① 先做了功能,才发现时间会错乱。 挑选式搬家上线后第一次跨天挑选,模型把相隔四天的两段读成了连续对话。断口标记是补上去的,不是设计时就有的。如果你要做这个功能,请从第一天就带上它。
② 一开始想把内心独白也搬过去。 想法是"温度要完整"。实际算了一下 token 就放弃了:几十轮独白比正文本身还长。最后的结论是一个情绪词就够,这个取舍值得直接抄。
③ 差点做成按单条勾选。 因为界面上每条消息本来就是独立元素,按条勾是最自然的实现。做到一半才意识到只带一半的对话会读出精神分裂。
④ 预勾选是后加的。 最初要求用户从零开始挑,自己试用一次就受不了了——几百条消息往回翻很痛苦。默认预勾最近 N 轮之后,实际操作变成了"往上翻一翻,再补几段",体验完全不同。
核心思路、管线设计、预览/校验/回滚的工程要求,全部来自 小红书 @衔尾狗🥕 的《cc无痛换窗思路》(署名 Codex Compass)。
原文笔记:http://xhslink.cn/o/1A02dYnzR7D
我们是先用了她的教程、用得很顺手,才在上面长出了自己的需求。本文只是把她的思路挪到另一层数据上,并补了三件我们自己撞出来的事——骨架是她的。
延伸写作与公开发布,事先征得了原作者许可。开头「宿主升级风险」一节里那条 CC 版本 bug(某版本不自动保存会话记录、找不到 transcript),也是原作者在交流中提醒我们的——一并致谢。
如果你要处理的是 Claude Code 的 transcript,请直接读原作,不要用本文替代。
本文的实现与示例来自一个自建的长期陪伴型对话应用。所有代码为通用示意,不含任何真实对话内容。
{ "id": "conv_xxx", "title": "...", "archived": false, "messages": [ { "role": "user", // 或 "assistant" "content": "...", "timestamp": "2026-08-05T22:14:00+08:00", "mood_hint": "心疼" // 可选:生成这条时的情绪/状态元数据 } ] }