1. 从一次 CtrlC 说起Agent 会话为什么能接着聊你在本地跑一个长任务模型正在改第 7 个文件终端里工具调用一条接一条。这时候你手滑按了 CtrlC进程没了。重新启动输入继续它居然真的从第 7 个文件接着往下改前面聊过的约束、改过的路径、定过的方案一样没丢。这件事日常到没人多看一眼但它背后要回答的问题一点都不日常进程都杀掉了会话凭什么能恢复turn 跑到一半切模型正在飞的请求怎么办上下文压缩之后被压掉的旧消息去哪了中断的那半条消息会不会丢Pi 的 Harness 工程就是回答这些问题的。Harness 直译是马具——模型有力气但没手没记忆得给它套上一副身体工具是手session 是记忆事件流是神经。这副身体要解决的核心矛盾是有些东西必须持久化消息、配置变更、压缩边界有些东西根本没法持久化工具函数、provider 实例、hook 闭包。Pi 的答案是划一条线——数据归 session代码归宿主恢复永远从持久化边界重启从不指望接上一条跑到一半的模型输出流。这篇就按能跟做的路子来先讲清楚 Harness 的持久化骨架长什么样再给出可复制的config.toml/settings.json配置然后跑一次验证——重启会话后检查 JSONL 是否追加、恢复结果是否和压缩前状态一致。适合正在做 Agent 工程、被会话状态搞到头大的同学。2. 前置准备TaoToken 接入与 Harness 运行环境Harness 本身不产生模型能力它只是编排层。你要跑通持久化与恢复得先有一个稳定的模型入口。我这边用 TaoToken 做统一接入原因是它的 API 兼容主流协议harness 里切换 provider 时不用改代码结构配置项挪一挪就行。先拿 Key。打开控制台在 API Keys 页面创建一个新 key权限按最小化给——只勾选你要用的模型范围。创建后立刻复制页面刷新就看不到了。控制台入口https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewriteAPI Keys 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewriteAPI 基地址用https://taotoken.net/api注意这个地址不带任何查询参数直接写进配置即可。环境变量建议这样设避免 key 硬编码进仓库export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://taotoken.net/api验证 key 是否可用先发一个最小请求别急着上 harnesscurl -s https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: claude-sonnet-4-5, messages: [{role: user, content: reply with ok}], max_tokens: 16 }返回里能看到choices[0].message.content就说明链路通了。这一步别跳过——harness 的报错经常被包装成会话恢复失败实际根因是 key 或 base_url 写错先隔离掉这个变量。3. 可复制配置config.toml 与 settings.json 骨架Harness 的持久化行为由三块配置决定会话文件写在哪、什么时候触发压缩、恢复时从哪个入口读。下面这份config.toml是我实测下来比较稳的骨架字段名按你的 harness 实现微调结构可以直接抄。[harness] # 会话持久化根目录JSONL 按 session_id 分文件 session_dir ./.harness/sessions # 只追加写入禁止原地改写历史 entry append_only true # 恢复时从 leaf 回溯应用压缩边界后投影成上下文 restore_mode leaf_backtrack [harness.phase] # idle 下允许结构性操作prompt / compact / navigate allow_structural_ops [idle] # turn 中途的配置变更进 pending 队列save_point 统一 flush pending_flush_on save_point # 中断走正常收尾aborted 消息照常落盘 abort_as_normal_turn true [harness.compaction] # 触发方式阈值 / overflow / 手动 triggers [threshold, overflow, manual] # 保留最近约 20000 token 作为短期工作记忆 keep_recent_tokens 20000 # 切点必须是合法消息边界禁止切在 toolResult 中间 legal_boundary [user, assistant, bash] # 摘要结构固定便于恢复后模型快速对齐 summary_schema [Goal, Constraints, Progress, Decisions, Next Steps, CriticalContext] # 已有摘要时做增量更新不回头重读完整历史 incremental_summary true [provider] base_url https://taotoken.net/api api_key_env TAOTOKEN_API_KEY model claude-sonnet-4-5对应的settings.json管运行时依赖注入。注意这里只放恢复时宿主必须重新提供的东西——工具注册表、hook 处理器、资源加载器这些没法序列化进 JSONL只能每次启动重新挂载。{ harness: { sessionFile: ./.harness/sessions/current.jsonl, runtimeDependencies: { toolRegistry: ./tools/index.js, hookHandlers: ./hooks/index.js, resourceLoader: ./resources/loader.js, systemPromptResolver: ./prompt/resolver.js }, modelSwitch: { affectCurrentTurn: false, queueUntilSavePoint: true }, activeTools: [read_file, write_file, bash, search], thinkingLevel: medium } }如果你用 CC Switch 做多环境切换配置示例长这样。核心是把 base_url 和 key 的引用分开切换时只动 provider 段session 目录保持不变——否则你会以为恢复失败其实是切到了另一个空会话目录。{ profiles: { taotoken-default: { baseUrl: https://taotoken.net/api, apiKeyEnv: TAOTOKEN_API_KEY, model: claude-sonnet-4-5, sessionDir: ./.harness/sessions }, taotoken-coding: { baseUrl: https://taotoken.net/api, apiKeyEnv: TAOTOKEN_API_KEY, model: claude-sonnet-4-5, sessionDir: ./.harness/sessions, thinkingLevel: high } }, active: taotoken-default }两个容易踩的点先提醒session_dir在切换 profile 时必须一致不然恢复读的是另一个文件append_only千万别为了清理文件改成 false一旦原地改写parent 指针链就断了回溯直接失效。4. 验证请求重启后检查 JSONL 追加与恢复一致性配置写完跑一次完整验证。目标是确认三件事JSONL 是追加而非覆盖、压缩前后状态一致、重启后能接着聊。第一步起一个会话让它产生足够多的消息触发一次压缩。# 启动 harness指定 session 文件 harness run --config ./config.toml --settings ./settings.json # 会话内连续发几轮直到 usage 越过阈值 帮我重构 utils 目录先读文件再改 继续把测试也补上 再检查一遍 import 路径第二步观察 JSONL 文件。每一行是一个 entry第一行是 header之后是 message / model_change / compaction / branch_summary / leaf。压缩发生后你应该看到一条compactionentry带summary和firstKeptEntryId两个关键字段。# 看 entry 类型分布 jq -r .type ./.harness/sessions/current.jsonl | sort | uniq -c # 看压缩 entry 的边界 jq -c select(.typecompaction) | {summary_len: (.summary|length), firstKeptEntryId} \ ./.harness/sessions/current.jsonl第三步也是最关键的一步重启会话检查恢复结果。先记下压缩前的消息条数和最后一条消息的 id重启后再对比。# 重启前记录状态 jq -s {total: length, last: .[-1].id} ./.harness/sessions/current.jsonl # CtrlC 退出重新启动 harness run --config ./config.toml --settings ./settings.json --resume # 重启后再次记录total 应该只增不减 jq -s {total: length, last: .[-1].id} ./.harness/sessions/current.jsonl判断标准很明确total只增不减说明是追加写入last的 id 如果和重启前一致说明没有产生多余的空 entry恢复后发一句继续模型能接上压缩前的上下文说明buildContext()的投影逻辑正确——它从 leaf 回溯应用firstKeptEntryId边界把摘要垫在上下文最前面。想更直观地看投影结果可以在 harness 里加一个调试入口打印实际发给模型的消息数组// debug/context-dump.js const ctx await harness.buildContext(); console.log(JSON.stringify({ messageCount: ctx.messages.length, firstRole: ctx.messages[0]?.role, hasSummary: ctx.messages[0]?.content?.includes([compaction summary]), keptFrom: ctx.firstKeptEntryId }, null, 2));跑一次你会看到hasSummary: true、keptFrom等于压缩 entry 里的firstKeptEntryId。这就对上了——磁盘上历史一行没少发给模型的上下文却瘦了一圈。5. 本篇常见错排查报错一恢复后模型失忆前面聊的全不记得。九成是session_dir不一致。检查config.toml和 CC Switch profile 里的路径是否指向同一个文件。另一个可能是restore_mode被改成了full_replay它不应用压缩边界会把所有历史塞回去反而触发 overflow。报错二JSONL 里出现重复 entry 或 parent 指针断裂。这是append_only被关掉、或者写入时没走message_end事件导致的。Harness 的写入时机由 phase 决定idle 立即写turn 按消息边界写save_point 统一 flush pending。绕过这套机制直接fs.writeFile就会破坏树结构。报错三压缩后请求被 provider 拒收提示 tool_use 没有对应的 tool_result。切点选错了。legal_boundary必须排除 toolResult 中间位置因为 tool_use 和 tool_result 严格配对从中间断开模型会看到一个没有回应的工具调用。检查你的切点选择逻辑确保停在 user / assistant / bash 消息的起点。报错四turn 中途切模型恢复后配置错乱。配置变更必须进 pending 队列等 save_point 统一 flush。如果写盘太早配置 entry 会排到本轮还没落盘的消息前面重放顺序就错了。确认queueUntilSavePoint: true生效。报错五CtrlC 后那半条消息丢了。检查abort_as_normal_turn。中断应该走正常收尾路径等运行 settle被中断的消息以 aborted 状态落盘然后回到 idle。如果实现里给 abort 单独设了跳过写入的分支就会丢消息。报错六压缩成本随会话变长线性上升。说明没走增量摘要。incremental_summary: true时旧摘要当底稿只叠上新折叠的消息不回头重读完整历史。每次只总结上次保留、这次变旧的增量单次压缩处理量始终有界。6. 长期编码与 Agent 场景的接入建议会话持久化和上下文压缩跑通之后下一步通常是把它接到长期运行的编码 Agent 上。这时候单次会话的配置就不够了你需要考虑跨会话的状态管理、多分支的 fork 与回溯、以及压缩策略随任务类型动态调整。如果你在做长期编码或 Agent 类项目建议直接看 Coding Plan 的配置方式它把会话目录、压缩阈值、模型切换策略打包成了可复用的 profile省得每次手写config.tomlCoding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite想先验证模型在压缩前后的行为差异可以用模型对话页面手动构造长上下文观察摘要生成的质量模型对话https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite接入过程中遇到恢复失败、压缩边界报错这类问题优先查接入文档里的错误码说明大部分坑都有人踩过接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite最后说个实测感受。Harness 这套设计最值得学的不是某个具体机制而是它划线的思路把确定能恢复的和必须重新提供的分开把现在能写的和必须攒着的分开。线划清楚了会话中断后再打开还能接着聊这件事就从魔法变成了理所当然。模型能力越强harness 越应该轻——给足信息、工具、时间和预算剩下的交给模型自己。