Agent 上下文工程:从开源实现到 JSONL Context Agent

版本说明:本文保留为已发布快照。持续更新、经过源码核验和实践验证的版本,见 《Agent 工程实践》第 5 课。后续完整正文只在书籍仓库维护。

Agent 跑几个小时后,磁盘上可能有完整日志和全部消息,但模型窗口早已放不下。上下文工程要做的不是删得越多越好,而是把两个对象分开:

完整事实留在持久层,模型每轮只接收受预算控制的 Prompt View。

本课先看 Pi、OpenClaw 与 Hermes 怎样处理工具输出、会话记录、压缩和缓存,再把共同机制落到一个最小 JSONL Agent。

1. 先分清四个对象

名称它保存什么例子
ArtifactTool 产生的完整文件Bash 完整日志、构建产物
TranscriptSession 实际发生过什么User、Assistant、Tool Call、Tool Result
Compaction Entry一次压缩后的恢复点Summary、Tail、切点信息
Prompt View本轮真正发给 Model 的消息摘要、最近原文、当前轮次

Artifact 不是 “ 所有原始记录 “。完整 Bash 输出属于 Artifact,完整会话事件属于 Transcript。二者都能留在磁盘,但只有经过 Harness 选择的部分才进入 Prompt View。

1
2
3
4
5
6
7
8
Artifact / Transcript / Memory
|
| 按预算投影
v
Prompt View
|
v
Model

Compaction 也有两个含义:

  • Compaction 动作:找切点、生成摘要、决定保留哪些原文。
  • Compaction Entry:动作完成后写入 Session Store 的记录。

本文的 Python Agent 使用自包含结构:

1
2
3
4
5
6
{
"type": "compaction",
"summary": "m1-m3 的摘要",
"retained_tail": ["m4 原文", "m5 原文"],
"is_split_turn": false
}

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
2
3
m1
|- m2 -> m3
`- m4

用户回到 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
2
3
4
5
6
User
Assistant(tool_call A)
Tool(result A)
Assistant(tool_call B)
Tool(result B)
Assistant(final)

安全切点只能位于已经闭合的 Tool 对之后:

1
2
3
4
tool_call A | result A       不安全
result A | tool_call B 安全
tool_call B | result B 不安全
result B | final 安全

若选择第二个位置,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
2
3
稳定、跨 Session 有用 → Memory
当前任务进度 → Summary
完整发生事实 → Transcript

4. Hermes:Prompt 变短不一定更便宜

Hermes 当前主 Session Store 是 ~/.hermes/state.db SQLite,并维护会话、消息与全文搜索。它对旧 Tool Result 的处理还要计算 Prompt Cache 代价。

Provider 缓存的是稳定输入前缀。删除一条很早的消息,会让后面的整个前缀发生变化;虽然窗口少了 200 Tokens,却可能损失大量缓存复用,增加重算、延迟和语义损失。

1
2
3
4
5
压缩净收益
= 回收 Token
- 摘要调用
- 缓存失效
- 语义损失

因此 Hermes 的部分 Prune 机制设置最小回收门槛,Micro-compaction 默认关闭。是否开启不能只看 Prompt 是否变短,还要看缓存折扣、首 Token 延迟和实际会话长度。

5. 三个项目共同解决什么

问题PiOpenClawHermes
持久 SessionCLI 默认 JSONL;Core 可选 SQLite持久热路径 SQLite;Incognito 在内存SQLite
超长 Tool ResultHead/Tail + ArtifactPrompt PruningTool Result Prune
持久压缩Compaction EntrySummary活动视图/后继 Session
长期事实保护Context FilesMemory FlushMemory Hook
特别权衡安全切点与分支树Transcript/Prompt 分离Prompt Cache 回收门槛

共同点不是文件扩展名,而是:持久层保留可追查事实,Prompt View 只投影当前任务需要的短视图。

6. 迁移到最小 JSONL Context Agent

当前本地 Agent 是单用户、单进程,不需要提前引入数据库。一个 Session JSONL 和 Artifact 目录足够:

1
2
3
4
.agent_state/
|- session-demo.jsonl
`- artifacts/
`- run-<uuid>.log

第一版只使用两种 Entry:

1
2
{"type":"message","message":{"role":"tool","tool_call_id":"call_7","content":"..."}}
{"type":"compaction","summary":"旧历史摘要","retained_tail":[...],"is_split_turn":false}

不复制 Pi 的 id/parentId,因为当前 Agent 没有分支树需求。需要 /tree 或回到旧节点重试时再增加。

6.1 Tool 只把短视图送进 Prompt

ToolPrompt View完整事实
read_file最多 50KB Head、truncatednext_offsetWorkspace 文件
run_bash最后 50KB、Exit Code、Artifact Path完整日志 Artifact
write_filePath、状态、写入字节数目标文件

write_file 不回显全部内容;内容已经在 Tool Call 中出现,又写入磁盘,Result 再复制只会污染 Context。run_bashwrite_file 需要批准,但批准不等于沙箱:cwd=workspace 不能阻止获批命令访问绝对路径、网络或当前用户有权读取的文件。

6.2 Tool Call 先落盘,再执行副作用

1
2
3
4
5
1. Assistant Tool Call 写入 JSONL
2. 用户批准
3. 执行 Tool
4. Tool Result 写入 JSONL
5. 再请求 Model

若文件已经写入,程序却在第 4 步前崩溃,JSONL 会留下孤立 Tool Call。它是 “ 可能已经执行 “ 的恢复证据,不能直接发给 Model,也不能自动重跑。恢复程序应检查真实文件并询问用户。

这仍不是完整 Execution Ledger。第 7 课会加入 execution_ididempotency_keyapproved → running → succeeded/failed/unknown 状态。

6.3 第二次 Compaction 必须继承旧 Summary

Compaction 有两个预算:COMPACT_AT 决定何时压缩,TAIL_BUDGET 决定保留多少最近原文。

第一次压缩:

1
2
summary_1 = summarize(m1-m80)
tail_1 = m81-m100

继续到 m140,若第二次切点在 m120

1
2
summary_2 = summarize(summary_1 + m81-m120)
tail_2 = m121-m140

若第二次只摘要 m81-m120,完全不带 summary_1m1-m80 的信息会永久消失。下一次压缩只需携带最新的 summary_2,因为它已经递归包含 summary_1;不必把所有旧 Summary 再重复塞入输入。压缩完成后也要重新读取 JSONL 构造 Prompt View,旧 Python history 不会自动更新。

7. 运行代码

完整实现 706 行,不再复制进正文:

先运行不调用真实模型的自检:

1
2
3
git clone https://github.com/unix2dos/agent-engineering-book.git
cd agent-engineering-book
python examples/lesson_05_context_compaction.py --self-check

预期输出:

1
self-check passed

在线运行:

1
2
python -m pip install openai
python examples/lesson_05_context_compaction.py

输入 /context 可以查看保守的 UTF-8 Bytes 估算。真实 Token 口径可能不同,这个数字只做本地预检查。

主动回忆自测

  1. Artifact、Transcript、Compaction Entry 与 Prompt View 分别保存什么?
  2. Model 会直接收到 Compaction JSON 外壳吗?Prompt View 怎样组成?
  3. Pruning 与 Compaction 分别改变什么?
  4. Pi 的 id/parentId 为什么能在 JSONL 中表达分支?
  5. read_file 保留 Head、run_bash 保留 Tail 的理由是什么?
  6. Split Turn 的安全切点必须满足什么条件?
  7. OpenClaw 的普通持久 Session 与 Incognito Session 有何区别?
  8. Memory Flush 为什么不能把整段 Transcript 都写进长期 Memory?
  9. 回收少量 Token 为什么可能因 Prompt Cache 失效而得不偿失?
  10. 第二次 Compaction 为什么必须继承旧 Summary?
展开查看简答
  1. Artifact 保存完整 Tool 产物;Transcript 保存会话事实;Compaction Entry 保存压缩恢复点;Prompt View 是本轮模型输入。
  2. 不会。Harness 展开为 summary + retained_tail + 后续新 Message,旧原文仍在 Transcript。
  3. Pruning 只改本轮 Prompt;Compaction 写入持久 Summary,改变后续恢复视图。
  4. 新节点只需指向父节点;追加 m4(parentId=m1) 即可保留旧分支并创建新分支。
  5. 文件入口通常在 Head,命令退出与最终错误通常在 Tail;不足时再通过 Artifact 搜索回查。
  6. 不能把同一个 Tool Call 与 Tool Result 分到切点两侧,Prefix Summary 还要保留用户目标。
  7. 普通 Session 写 SQLite,可重启恢复;Incognito 只在内存中,进程退出后消失。
  8. Transcript 含临时日志、失败尝试、未确认推断和敏感内容;Memory 只应保存未来仍稳定有用的信息。
  9. 删除早期消息会改变后续稳定前缀,损失缓存复用;节省的输入可能小于重算、延迟和语义代价。
  10. 新 Summary 必须覆盖 “ 旧 Summary + 新进入 Prefix 的消息 “,否则更早历史会在第二次压缩后丢失。

参考资料