Agent 上下文工程:从开源实现到 JSONL Context Agent
版本说明:本文保留为已发布快照。持续更新、经过源码核验和实践验证的版本,见 《Agent 工程实践》第 5 课。后续完整正文只在书籍仓库维护。
Agent 跑几个小时后,磁盘上可能有完整日志和全部消息,但模型窗口早已放不下。上下文工程要做的不是删得越多越好,而是把两个对象分开:
完整事实留在持久层,模型每轮只接收受预算控制的 Prompt View。
本课先看 Pi、OpenClaw 与 Hermes 怎样处理工具输出、会话记录、压缩和缓存,再把共同机制落到一个最小 JSONL Agent。
1. 先分清四个对象
| 名称 | 它保存什么 | 例子 |
|---|---|---|
| Artifact | Tool 产生的完整文件 | Bash 完整日志、构建产物 |
| Transcript | Session 实际发生过什么 | User、Assistant、Tool Call、Tool Result |
| Compaction Entry | 一次压缩后的恢复点 | Summary、Tail、切点信息 |
| Prompt View | 本轮真正发给 Model 的消息 | 摘要、最近原文、当前轮次 |
Artifact 不是 “ 所有原始记录 “。完整 Bash 输出属于 Artifact,完整会话事件属于 Transcript。二者都能留在磁盘,但只有经过 Harness 选择的部分才进入 Prompt View。
1 | Artifact / Transcript / Memory |
Compaction 也有两个含义:
- Compaction 动作:找切点、生成摘要、决定保留哪些原文。
- Compaction Entry:动作完成后写入 Session Store 的记录。
本文的 Python Agent 使用自包含结构:
1 | { |
Model 不会看到这个 JSON 外壳。Harness 把它展开成:
1 | summary + retained_tail + Compaction 之后追加的新 Message |
被摘要的 m1-m3 仍保留在更早的 Transcript 中。
2. Pi:先缩 Tool Result,再找安全切点
2.1 大输出留 Artifact,小视图进 Prompt
Pi 会限制 Tool Result:read 常保留文件 Head,bash 常保留日志 Tail,完整 Bash 输出另存 Artifact。Head 往往包含声明和配置,Tail 往往包含退出码与最终错误;这只是启发式,不保证真正原因一定在那里。
如果错误不在 Tail,Model 可以根据 Artifact 路径搜索完整日志,再读取命中附近的小片段。数据没有消失,只是没有一次性进入 Context。
2.2 JSONL 的物理顺序可以组成逻辑树
Pi Coding Agent CLI 默认每个 Session 使用一个 JSONL。id/parentId 把追加记录连成逻辑树:
1 | m1 |
用户回到 m1 重新尝试时,只需追加 m4(parentId=m1)。当前新分支是 m1 → m4;旧分支 m1 → m2 → m3 仍然存在,不需要复制或覆盖。
这不代表整个 Pi 只能使用 JSONL。Agent Core 也提供可选 SQLite Backend。顺序追加、人工检查适合 JSONL;并发、事务和复杂查询出现后再考虑数据库。
2.3 Split Turn 不能拆开 Tool Call 与 Result
一个超大 Turn:
1 | User |
安全切点只能位于已经闭合的 Tool 对之后:
1 | tool_call A | result A 不安全 |
若选择第二个位置,Prefix Summary 的原材料是 User + call A + result A;Suffix 保留 call B + result B + final 原文。切点前的用户目标必须进入摘要,否则后半段不知道自己在完成什么。
这叫 Split Turn Compaction。它压缩一个 Turn 的前缀,不是拆散协议配对。
3. OpenClaw:Transcript、Prompt 与 Memory 分层
3.1 当前持久 Session 热路径是 SQLite
OpenClaw 过去使用过 sessions.json + Transcript JSONL。当前持久 Session 与 Transcript 热路径已经迁移到每 Agent 一个 SQLite:
1 | ~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite |
旧 JSONL 仍可能作为迁移输入、重置归档、导入导出或支持材料。Incognito Session 则只保存在进程内存:进程退出后无法恢复,也不会进入普通 SQLite Session。
3.2 Pruning 与 Compaction 不是一件事
| 机制 | 改变什么 | 是否写持久恢复点 |
|---|---|---|
| Pruning | 本轮 Prompt 中的旧 Tool Result | 否 |
| Compaction | 较早历史在后续 Prompt 中的表示 | 是 |
| Transcript | 保存完整会话事实 | 它本身就是记录 |
Pruning 可以把本轮 Prompt 中的旧结果改成头尾片段或占位符,磁盘 Transcript 不动。Compaction 生成持久 Summary,供重启和后续请求继续使用。
3.3 有损 Compaction 前先尝试 Memory Flush
Summary 可能漏掉跨 Session 仍有用的规则。OpenClaw 的部分运行路径会在 Compaction 前尝试 Memory Flush,把稳定事实写入工作记忆,再进行有损压缩。
Memory Flush 不是保存 “ 真实上下文 “,也不是复制整段 Transcript。筛选时先问两句:这条信息换一个 Session 后是否仍有用?它是否已经明确确认?项目规则、明确决定和用户偏好可能值得晋升;一次构建失败、临时日志、未确认推断和敏感内容通常只应留在 Transcript 或当前 Summary。
1 | 稳定、跨 Session 有用 → Memory |
4. Hermes:Prompt 变短不一定更便宜
Hermes 当前主 Session Store 是 ~/.hermes/state.db SQLite,并维护会话、消息与全文搜索。它对旧 Tool Result 的处理还要计算 Prompt Cache 代价。
Provider 缓存的是稳定输入前缀。删除一条很早的消息,会让后面的整个前缀发生变化;虽然窗口少了 200 Tokens,却可能损失大量缓存复用,增加重算、延迟和语义损失。
1 | 压缩净收益 |
因此 Hermes 的部分 Prune 机制设置最小回收门槛,Micro-compaction 默认关闭。是否开启不能只看 Prompt 是否变短,还要看缓存折扣、首 Token 延迟和实际会话长度。
5. 三个项目共同解决什么
| 问题 | Pi | OpenClaw | Hermes |
|---|---|---|---|
| 持久 Session | CLI 默认 JSONL;Core 可选 SQLite | 持久热路径 SQLite;Incognito 在内存 | SQLite |
| 超长 Tool Result | Head/Tail + Artifact | Prompt Pruning | Tool Result Prune |
| 持久压缩 | Compaction Entry | Summary | 活动视图/后继 Session |
| 长期事实保护 | Context Files | Memory Flush | Memory Hook |
| 特别权衡 | 安全切点与分支树 | Transcript/Prompt 分离 | Prompt Cache 回收门槛 |
共同点不是文件扩展名,而是:持久层保留可追查事实,Prompt View 只投影当前任务需要的短视图。
6. 迁移到最小 JSONL Context Agent
当前本地 Agent 是单用户、单进程,不需要提前引入数据库。一个 Session JSONL 和 Artifact 目录足够:
1 | .agent_state/ |
第一版只使用两种 Entry:
1 | {"type":"message","message":{"role":"tool","tool_call_id":"call_7","content":"..."}} |
不复制 Pi 的 id/parentId,因为当前 Agent 没有分支树需求。需要 /tree 或回到旧节点重试时再增加。
6.1 Tool 只把短视图送进 Prompt
| Tool | Prompt View | 完整事实 |
|---|---|---|
read_file | 最多 50KB Head、truncated、next_offset | Workspace 文件 |
run_bash | 最后 50KB、Exit Code、Artifact Path | 完整日志 Artifact |
write_file | Path、状态、写入字节数 | 目标文件 |
write_file 不回显全部内容;内容已经在 Tool Call 中出现,又写入磁盘,Result 再复制只会污染 Context。run_bash 与 write_file 需要批准,但批准不等于沙箱:cwd=workspace 不能阻止获批命令访问绝对路径、网络或当前用户有权读取的文件。
6.2 Tool Call 先落盘,再执行副作用
1 | 1. Assistant Tool Call 写入 JSONL |
若文件已经写入,程序却在第 4 步前崩溃,JSONL 会留下孤立 Tool Call。它是 “ 可能已经执行 “ 的恢复证据,不能直接发给 Model,也不能自动重跑。恢复程序应检查真实文件并询问用户。
这仍不是完整 Execution Ledger。第 7 课会加入 execution_id、idempotency_key 和 approved → running → succeeded/failed/unknown 状态。
6.3 第二次 Compaction 必须继承旧 Summary
Compaction 有两个预算:COMPACT_AT 决定何时压缩,TAIL_BUDGET 决定保留多少最近原文。
第一次压缩:
1 | summary_1 = summarize(m1-m80) |
继续到 m140,若第二次切点在 m120:
1 | summary_2 = summarize(summary_1 + m81-m120) |
若第二次只摘要 m81-m120,完全不带 summary_1,m1-m80 的信息会永久消失。下一次压缩只需携带最新的 summary_2,因为它已经递归包含 summary_1;不必把所有旧 Summary 再重复塞入输入。压缩完成后也要重新读取 JSONL 构造 Prompt View,旧 Python history 不会自动更新。
7. 运行代码
完整实现 706 行,不再复制进正文:
先运行不调用真实模型的自检:
1 | git clone https://github.com/unix2dos/agent-engineering-book.git |
预期输出:
1 | self-check passed |
在线运行:
1 | python -m pip install openai |
输入 /context 可以查看保守的 UTF-8 Bytes 估算。真实 Token 口径可能不同,这个数字只做本地预检查。
主动回忆自测
- Artifact、Transcript、Compaction Entry 与 Prompt View 分别保存什么?
- Model 会直接收到 Compaction JSON 外壳吗?Prompt View 怎样组成?
- Pruning 与 Compaction 分别改变什么?
- Pi 的
id/parentId为什么能在 JSONL 中表达分支? read_file保留 Head、run_bash保留 Tail 的理由是什么?- Split Turn 的安全切点必须满足什么条件?
- OpenClaw 的普通持久 Session 与 Incognito Session 有何区别?
- Memory Flush 为什么不能把整段 Transcript 都写进长期 Memory?
- 回收少量 Token 为什么可能因 Prompt Cache 失效而得不偿失?
- 第二次 Compaction 为什么必须继承旧 Summary?
展开查看简答
- Artifact 保存完整 Tool 产物;Transcript 保存会话事实;Compaction Entry 保存压缩恢复点;Prompt View 是本轮模型输入。
- 不会。Harness 展开为
summary + retained_tail + 后续新 Message,旧原文仍在 Transcript。 - Pruning 只改本轮 Prompt;Compaction 写入持久 Summary,改变后续恢复视图。
- 新节点只需指向父节点;追加
m4(parentId=m1)即可保留旧分支并创建新分支。 - 文件入口通常在 Head,命令退出与最终错误通常在 Tail;不足时再通过 Artifact 搜索回查。
- 不能把同一个 Tool Call 与 Tool Result 分到切点两侧,Prefix Summary 还要保留用户目标。
- 普通 Session 写 SQLite,可重启恢复;Incognito 只在内存中,进程退出后消失。
- Transcript 含临时日志、失败尝试、未确认推断和敏感内容;Memory 只应保存未来仍稳定有用的信息。
- 删除早期消息会改变后续稳定前缀,损失缓存复用;节省的输入可能小于重算、延迟和语义代价。
- 新 Summary 必须覆盖 “ 旧 Summary + 新进入 Prefix 的消息 “,否则更早历史会在第二次压缩后丢失。