☰
解密 Pi 的 Harness 工程:Agent 会话持久化与恢复的配置骨架
2026/9/26 3:51:00 网站建设 项目流程

1. 从一次 Ctrl+C 说起:Agent 会话为什么能"接着聊"

你在本地跑一个长任务,模型正在改第 7 个文件,终端里工具调用一条接一条。这时候你手滑按了 Ctrl+C,进程没了。重新启动,输入"继续",它居然真的从第 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_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite
  • API Keys 管理:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite
  • 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite

API 基地址用https://taotoken.net/api,注意这个地址不带任何查询参数,直接写进配置即可。环境变量建议这样设,避免 key 硬编码进仓库:

export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api"

验证 key 是否可用,先发一个最小请求,别急着上 harness:

curl -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(.type=="compaction") | {summary_len: (.summary|length), firstKeptEntryId}' \ ./.harness/sessions/current.jsonl

第三步,也是最关键的一步:重启会话,检查恢复结果。先记下压缩前的消息条数和最后一条消息的 id,重启后再对比。

# 重启前记录状态 jq -s '{total: length, last: .[-1].id}' ./.harness/sessions/current.jsonl # Ctrl+C 退出,重新启动 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生效。

报错五:Ctrl+C 后那半条消息丢了。检查abort_as_normal_turn。中断应该走正常收尾路径:等运行 settle,被中断的消息以 aborted 状态落盘,然后回到 idle。如果实现里给 abort 单独设了跳过写入的分支,就会丢消息。

报错六:压缩成本随会话变长线性上升。说明没走增量摘要。incremental_summary: true时,旧摘要当底稿,只叠上新折叠的消息,不回头重读完整历史。每次只总结"上次保留、这次变旧"的增量,单次压缩处理量始终有界。

6. 长期编码与 Agent 场景的接入建议

会话持久化和上下文压缩跑通之后,下一步通常是把它接到长期运行的编码 Agent 上。这时候单次会话的配置就不够了,你需要考虑跨会话的状态管理、多分支的 fork 与回溯、以及压缩策略随任务类型动态调整。

如果你在做长期编码或 Agent 类项目,建议直接看 Coding Plan 的配置方式,它把会话目录、压缩阈值、模型切换策略打包成了可复用的 profile,省得每次手写config.toml:

  • Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite

想先验证模型在压缩前后的行为差异,可以用模型对话页面手动构造长上下文,观察摘要生成的质量:

  • 模型对话:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite

接入过程中遇到恢复失败、压缩边界报错这类问题,优先查接入文档里的错误码说明,大部分坑都有人踩过:

  • 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite

最后说个实测感受。Harness 这套设计最值得学的不是某个具体机制,而是它划线的思路:把"确定能恢复的"和"必须重新提供的"分开,把"现在能写的"和"必须攒着的"分开。线划清楚了,会话中断后再打开还能接着聊这件事,就从"魔法"变成了"理所当然"。模型能力越强,harness 越应该轻——给足信息、工具、时间和预算,剩下的交给模型自己。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询