
字节笔记本
2026年10月24日 · 约 15 分钟读完
会话分叉与压缩:上下文装不下了怎么办

本文是《从 0 写 Agent》系列第 11 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「会话分叉与压缩」。
前三章我们让 Agent 有了记忆。但记忆会带来两个新问题,历史太长怎么办,想从中间分叉出新对话怎么办。
这一章讲两个高级 Session 能力,
ForkSession(会话分叉)和 compaction(历史压缩)。前者让你"基于某段对话开个分支",后者让长对话不至于撑爆上下文。还有一个省 token 的利器,服务端状态(previous_response_id)。
引子:两个真实的痛点
痛点一:分叉。 你和 Agent 讨论方案 A,聊了 20 轮。现在你想"基于前 10 轮的讨论,探索方案 B",不想要第 11-20 轮的内容。怎么办?
笨办法,手动复制历史,删掉后半段。但这破坏原对话,且容易出错。
痛点二:太长。 一个客服 Agent,和用户聊了 200 轮,历史 5 万 token。每次 Run 都要把这 5 万 token 发给模型,上下文爆了、费用爆炸。
笨办法,截断,只留最近 N 条。但丢失了早期上下文,Agent 变成"失忆金鱼"。
分叉和压缩,就是解这两个痛点的。

源码对照:ForkSession,会话分叉
agents-go 的分叉在 agents/session_fork.go,提供两个函数:
ForkSession,完整克隆
// agents/session_fork.go:11
func ForkSession(ctx context.Context, src, dst Session) error {
items, _ := src.GetItems(ctx, 0) // 读全部历史
dst.Clear(ctx) // 清空目标
dst.AddItems(ctx, items) // 写入
return nil
}就是把 src 的全部历史复制到 dst。 dst 现在是 src 的完整副本,之后两者独立演进。
这有什么用?比如,你想在不影响主对话的情况下,试验一个"如果 Agent 更激进会怎样"的分支。ForkSession 复制一份,在副本上改 Agent 配置跑,主对话不受影响。
ForkSessionAt,从第 N 条分叉
更有用的是从中间分叉:
// agents/session_fork.go:31
func ForkSessionAt(ctx context.Context, src, dst Session, n int) error {
items, _ := src.GetItems(ctx, 0)
dst.Clear(ctx)
if n > len(items) { n = len(items) }
dst.AddItems(ctx, items[:n]) // 只复制前 n 条
return nil
}只复制前 N 条历史到 dst。 这就是"回到过去某个点,开个新分支"。
回到开头的例子,讨论方案 A 聊了 20 轮,想基于前 10 轮探索方案 B:
// 主会话:20 轮讨论方案 A
// 现在分叉
branchSess := memory.NewFileSession("sessions", "branch-b")
agents.ForkSessionAt(ctx, mainSess, branchSess, 10) // 复制前 10 条
// 在 branchSess 上探索方案 B,mainSess 不受影响
agents.Run(ctx, agent, "我们换个思路,探索方案 B", RunOptions{Session: branchSess})这就是 git 分支的对话版,回到某个 commit,开个新分支。 对"探索不同方案"、"what-if 分析"、"回溯调试"非常有用。
IndexOfItemID,按消息 ID 定位分叉点
但"第 N 条"是个数字,用户更自然的是"从那条消息分叉"。agents-go 提供了 IndexOfItemID(session_fork.go:60):
// 拿到历史
items, _ := sess.GetItems(ctx, 0)
// 找到 ID 为 "msg_abc123" 的消息在第几条
idx, ok := agents.IndexOfItemID(items, "msg_abc123")
if ok {
agents.ForkSessionAt(ctx, src, dst, idx+1) // 包含这条,往后分叉
}注意,只有模型产生的 item 才有 server 分配的 ID(output message、function_call 等)。用户手写的"easy message"没有 ID,不会被匹配。这是 OpenAI Responses API 的设计。
长历史压缩:Compaction
分叉解决了"开分支",但没解决"主对话太长"。这时候要压缩,把老历史总结成一条 summary,替换掉原始的几十条历史。
原理:用模型总结历史
压缩的核心思路,让模型读一段老历史,生成一条总结,然后用这条总结替换老历史。
压缩前(30 条历史,15000 token):
[user: 你好] [assistant: 你好] ... [user: 讨论需求] [assistant: 方案是...]
压缩后(2 条历史,500 token):
[system: 之前讨论的总结:用户要做 X,方案是 Y,约束是 Z]
[user: 继续讨论实现细节]从 15000 token 压到 500 token,但保留了关键信息。
CompactionAwareSession
agents-go 的压缩通过 CompactionAwareSession 接口实现(第 8 章提过,session.go:44):
type CompactionAwareSession interface {
Session
RunCompaction(ctx context.Context, args CompactionArgs) error
}Agent 循环在每次 Run 结束、存完历史后,会检查 Session 是不是 CompactionAwareSession。如果是,调 RunCompaction,让 Session 自己决定要不要压缩、怎么压缩。
为什么让 Session 自己决定,而不是 runner 决定?因为压缩策略和存储强相关,文件 Session 压缩要重写文件,数据库 Session 压缩要删行插 summary。只有 Session 实现自己知道怎么高效压缩。
OpenAI 的 responses.compact
agents-go 提供了一个基于 OpenAI responses.compact API 的 compaction session(agents/session.go 注释提到的 openai.CompactionSession)。它调 OpenAI 服务端的压缩 API,服务端直接帮你把历史总结,你不用自己写总结逻辑。
// 概念示意
sess := openai.NewCompactionSession(...)
// Run 结束后,runner 调 sess.RunCompaction()
// → 调 OpenAI responses.compact API
// → 服务端总结历史
// → Session 内部用 summary 替换老历史这是个很聪明的省 token 方式,压缩也在服务端做,不占你的 API 额度做总结。
服务端状态:省 token 的另一种方式
除了压缩,还有个完全不同的省 token 思路,让服务端记住历史,你只发"增量"。
previous_response_id
OpenAI 的 Responses API 支持 previous_response_id,你告诉服务端"上次响应的 ID",服务端会自动把上次的历史接上,你这次的请求不用重发历史。
第一次请求:
input = [user:"你好"]
→ 服务端返回 response_id="resp_001", 内容="你好!"
第二次请求:
previous_response_id = "resp_001" ← 只发这个,不发历史
input = [user:"我叫张三"] ← 只发新输入
→ 服务端自动拼接:[user:"你好", assistant:"你好!", user:"我叫张三"]
→ 返回"好的张三"省了什么? 第二次请求只发了 1 条新输入,而不是 3 条完整历史。对话越长,省得越多,100 轮对话,第 101 轮只发 1 条,不发 100 条。
agents-go 在 RunOptions 里支持这个(run.go 的 UsePreviousResponseID)。runner 会自动维护 previousResponseID 光标,每次只发增量。
ConversationID
更新的 Responses API 还有 ConversationID,整个对话用一个 ID 标识,服务端管全部历史。比 previous_response_id 更省心(不用维护光标链)。
代价与限制
服务端状态不是没代价,
- 绑定 provider。 历史存在 OpenAI 那边,换 provider 就丢了。
- 不能过滤。 你不能"只给模型看部分历史",服务端给的是全部。
- 隐私。 对话历史在服务端,敏感场景要考虑。
- 不能本地审计。 你手上没有完整历史文件。
所以 agents-go 让你选,用 Session(本地存历史,可控)还是用服务端状态(省 token,但历史在云端)。validateServerState(run.go:531)甚至禁止两者混用,因为它们的语义冲突(一个本地管历史,一个服务端管,会重复)。
设计权衡:三种"管理长对话"的策略对比
| 策略 | 怎么做 | 优点 | 缺点 |
|---|---|---|---|
| 截断(limit) | 只取最近 N 条 | 简单 | 丢早期上下文 |
| 压缩(compaction) | 老历史总结成 summary | 保留关键信息 | 总结可能丢细节;要额外 API 调用 |
| 服务端状态(prev_id) | 服务端管历史,只发增量 | 省 token | 绑 provider;历史在云端 |
| 分叉(fork) | 从某点开新对话 | 探索多方案 | 不省 token,是组织手段 |
真实场景通常组合用,日常用截断(limit=50)+ 压缩(定期 compaction);探索方案用分叉;省 token 用服务端状态。agents-go 把这四种都支持了,让你按场景组合。
小结
读完这一章,你应该能回答:
- ForkSession 干什么?,复制会话历史,开个独立演进的分支
- ForkSessionAt 为什么有用?,从中间某点分叉,像 git 的分支
- IndexOfItemID 的限制?,只有模型产生的 item 有 server ID,用户消息没有
- 压缩怎么省 token?,老历史总结成一条 summary,替换几十条原始历史
- 服务端状态怎么省 token?,历史存在 provider 那边,每次只发增量
- 为什么禁止混用?,本地 Session 和服务端状态语义冲突,会导致历史重复
Session 的"管理长对话"是个系统工程,存(8-10 章)+ 分叉/压缩/省 token(本章)。 但光有记忆还不够,Agent 还得能在危险操作前暂停,等人审批。这是第 12 章的 HITL。
下一篇
第 12 章,Human-in-the-Loop:让人审批工具调用。当 Agent 要执行危险操作(删数据、发邮件、付款),它不能自作主张,必须暂停等人确认。我们会看 NeedsApproval 怎么标记危险工具,以及"中断→审批→恢复"的状态机怎么设计。
本文是《从 0 写 Agent》系列第 11 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。
