ByteNoteByteNote
会话分叉与压缩:上下文装不下了怎么办
字

字节笔记本

2026年10月24日 · 约 15 分钟读完

会话分叉与压缩:上下文装不下了怎么办

API中转
¥120

CLAUDE.md 与自动记忆:Agent 的项目记忆

本文是《从 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 变成"失忆金鱼"。

分叉和压缩,就是解这两个痛点的。


Hooks:运行时事件钩子参考

源码对照:ForkSession,会话分叉

agents-go 的分叉在 agents/session_fork.go,提供两个函数:

ForkSession,完整克隆

go
// 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 条分叉

更有用的是从中间分叉:

go
// 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:

go
// 主会话: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):

go
// 拿到历史
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,替换掉原始的几十条历史。

原理:用模型总结历史

压缩的核心思路,让模型读一段老历史,生成一条总结,然后用这条总结替换老历史。

text
压缩前(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):

go
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,服务端直接帮你把历史总结,你不用自己写总结逻辑。

go
// 概念示意
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",服务端会自动把上次的历史接上,你这次的请求不用重发历史。

text
第一次请求:
  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 更省心(不用维护光标链)。

代价与限制

服务端状态不是没代价,

  1. 绑定 provider。 历史存在 OpenAI 那边,换 provider 就丢了。
  2. 不能过滤。 你不能"只给模型看部分历史",服务端给的是全部。
  3. 隐私。 对话历史在服务端,敏感场景要考虑。
  4. 不能本地审计。 你手上没有完整历史文件。

所以 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 把这四种都支持了,让你按场景组合。


小结

读完这一章,你应该能回答:

  1. ForkSession 干什么?,复制会话历史,开个独立演进的分支
  2. ForkSessionAt 为什么有用?,从中间某点分叉,像 git 的分支
  3. IndexOfItemID 的限制?,只有模型产生的 item 有 server ID,用户消息没有
  4. 压缩怎么省 token?,老历史总结成一条 summary,替换几十条原始历史
  5. 服务端状态怎么省 token?,历史存在 provider 那边,每次只发增量
  6. 为什么禁止混用?,本地 Session 和服务端状态语义冲突,会导致历史重复

Session 的"管理长对话"是个系统工程,存(8-10 章)+ 分叉/压缩/省 token(本章)。 但光有记忆还不够,Agent 还得能在危险操作前暂停,等人审批。这是第 12 章的 HITL。


下一篇

第 12 章,Human-in-the-Loop:让人审批工具调用。当 Agent 要执行危险操作(删数据、发邮件、付款),它不能自作主张,必须暂停等人确认。我们会看 NeedsApproval 怎么标记危险工具,以及"中断→审批→恢复"的状态机怎么设计。


本文是《从 0 写 Agent》系列第 11 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。

相关文章

分享: