ByteNoteByteNote
让 Agent 记住对话:Session 接口设计
字

字节笔记本

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

让 Agent 记住对话:Session 接口设计

API中转
¥120

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

本文是《从 0 写 Agent》系列第 8 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「Session:让 Agent 记住对话」。

Part I 的 Agent 有个致命缺陷,它没有记忆。每次 Run 都是独立的,Agent 不记得上一次说了什么。

这一章讲 Session,让 Agent 记住对话历史。我们会看 agents.Session 接口为什么这么设计,为什么有三个方法(不是两个、不是五个),以及 agents-go 提供的三种后端各自适合什么场景。


引子:没有记忆的 Agent 没用

go
// 第一次对话
agents.Run(ctx, agent, "我叫张三,住在北京", opts)
// → "好的,张三!"

// 第二次对话
agents.Run(ctx, agent, "我叫什么名字?", opts)
// → "抱歉,我不知道你的名字。"

这就是 Part I Agent 的局限。 每次调用 Run,输入只有当前这一句。模型不知道之前发生过什么。

真实场景里,用户和 Agent 是连续对话的,"我刚才说的那个方案,再细化一下"、"上次那个 bug 修了吗"、"基于我们之前的讨论……"。Agent 必须记得之前的对话,否则就是金鱼记忆。


Hooks:运行时事件钩子参考

原理:Session 就是"对话历史的持久化"

Session 的本质很简单,它存着过去的对话历史(消息、工具调用、工具输出),下次 Run 时把这些历史 prepend 到当前输入里。

text
第一次 Run:
  输入 = "我叫张三"
  → Agent 跑完,历史 = [user:"我叫张三", assistant:"好的"]
  → Session 存下这段历史

第二次 Run:
  输入 = "我叫什么?"
  Session 历史被 prepend:[user:"我叫张三", assistant:"好的"]
  完整输入 = [user:"我叫张三", assistant:"好的", user:"我叫什么?"]
  → Agent 看到完整历史,回答"张三"

就这么简单。Session 不是什么神秘的东西,就是一个"历史数组"的持久化。 但"持久化"二字背后有大量工程,存哪、怎么存、怎么并发安全、怎么压缩、怎么分叉。


源码对照:Session 接口

agents-go 的 Session 接口在 agents/session.go:16:

go
// agents/session.go:16
type Session interface {
    // 取历史。limit<=0 全取;>0 取最近 limit 条
    GetItems(ctx context.Context, limit int) ([]TResponseInputItem, error)
    // 追加历史
    AddItems(ctx context.Context, items []TResponseInputItem) error
    // 删除并返回最后一条(用于"撤回"或压缩)
    PopItem(ctx context.Context) (*TResponseInputItem, error)
    // 清空
    Clear(ctx context.Context) error
}

四个方法,每个都有理由。 不是随便定的:

为什么有 GetItems(limit)

limit 参数是为了"只取最近 N 条"。为什么不是全取?因为对话一长,历史会爆,几百轮对话可能几万 token,全塞进模型上下文,要么超长报错,要么烧钱。

limit > 0 时只取最近 N 条(GetItems 实现里通常是"取最后 N 条,但顺序还是 oldest-first")。这让调用方能控制"给模型看多少历史"。

为什么有 AddItems 而不是 AddItem

AddItems 接收数组而不是单条。因为一次 Run 结束后,要追加的不止一条,用户输入、模型回复、工具调用、工具输出,可能是好几条。批量写比单条写高效(尤其对数据库后端,一次事务 vs 多次事务)。

为什么有 PopItem

这个最不直观,PopItem 删除并返回最后一条。什么时候用?

  • 撤回:用户说"撤回我上一条",删掉它
  • 压缩:历史太长时,把多条历史 pop 出来,让模型总结成一条 summary,再 push 回去(第 11 章的 compaction)

PopItem 是"操作最后一条"的能力,没有它,压缩和撤回都做不了。

为什么有 Clear

重置对话。用户说"重新开始",或者调试时清空。


源码对照:InMemorySession,最简实现

agents-go 内置了一个 InMemorySession(session.go:54),就是个用 mutex 保护的切片:

go
// agents/session.go:54
type InMemorySession struct {
    mu    sync.Mutex
    items []TResponseInputItem
}

func (s *InMemorySession) GetItems(_ context.Context, limit int) ([]TResponseInputItem, error) {
    s.mu.Lock()
    defer s.mu.Unlock()
    if limit <= 0 || limit >= len(s.items) {
        return append([]TResponseInputItem(nil), s.items...), nil  // 返回副本!
    }
    return append([]TResponseInputItem(nil), s.items[len(s.items)-limit:]...), nil
}

注意两个细节:

1. sync.Mutex 保护并发

Session 会被多个 goroutine 访问(Agent 循环读写、可能还有并发的用户操作)。mutex 保证并发安全。

2. GetItems 返回副本

go
return append([]TResponseInputItem(nil), s.items...), nil

不是返回原切片,而是复制一份。 为什么?因为如果返回原切片的引用,调用方修改它会影响 session 内部状态,这是并发 bug 的温床。返回副本,调用方爱怎么改怎么改,不影响 session。

注释里特意提醒(session.go:53):"items share underlying pointers with the store; treat returned items as read-only",返回的切片是新的,但里面的 item 指针共享,所以仍然要当只读对待。


三种 Session 后端

agents-go 提供了三种 Session 实现,对应三种存储后端:

后端包存哪适合场景
InMemorySessionagents(核心包)进程内存测试、短命对话
FileSessionmemoryJSONL 文件单机持久化、个人工具
SQLSessionsessionsSQLite/Postgres多用户、服务端、需要查询

为什么要三种? 因为 Session 接口是抽象,不同场景的存储需求不同:

  • 测试:不需要持久化,内存最快(InMemorySession)
  • 个人 CLI 工具:要持久化但不想装数据库,文件就够(FileSession)
  • Web 服务:多用户并发,要查询、要事务,必须数据库(SQLSession)

接口的威力,同一套 Agent 循环代码,换 Session 实现就换存储,循环本身一行不改。


Session 怎么接入 Run

在 RunOptions 里传入 Session:

go
sess := memory.NewFileSession("sessions", "user-123")  // sessions/user-123.jsonl

agents.Run(ctx, agent, "我叫张三", agents.RunOptions{
    Session: sess,
    ModelProvider: p,
})
// 这次 Run 结束后,历史自动存进 user-123.jsonl

// 下次 Run,历史自动从文件读出来 prepend
agents.Run(ctx, agent, "我叫什么?", agents.RunOptions{
    Session: sess,  // 同一个 session
    ModelProvider: p,
})
// → "张三"

Agent 循环怎么用 Session 的(在 run.go 里):

  1. Run 开始时,调 sess.GetItems() 拿历史
  2. 把历史 prepend 到当前输入前面
  3. 跑循环
  4. 循环结束,把"这次的用户输入 + 生成的 items"调 sess.AddItems() 存回去

Session 对循环透明,循环只管"拿到完整输入→跑→把新 items 存回去",不关心存哪。


CompactionAwareSession:能自我压缩

注意 Session 接口之外,还有个 CompactionAwareSession(session.go:44):

go
// agents/session.go:44
type CompactionAwareSession interface {
    Session
    RunCompaction(ctx context.Context, args CompactionArgs) error
}

这是 Session 的"扩展接口",能自我压缩的 Session。当历史太长时,runner 调 RunCompaction,Session 自己决定怎么压缩(比如调 OpenAI 的 responses.compact API 把老历史总结成一条 summary)。

为什么是单独的接口而不是 Session 的方法? 因为不是所有 Session 都能压缩,内存 Session 没法调 API,文件 Session 压缩要额外逻辑。把压缩做成可选接口,让需要它的后端实现,不需要的不用管。

这是 Go 接口设计的惯用法,小接口 + 可选扩展接口,比一个臃肿的大接口好。


设计权衡:Session 该存什么格式

Session 存的是 []TResponseInputItem,OpenAI Responses API 的输入项格式。为什么存这个,而不是存自定义的对话结构?

因为 Session 的内容最终要发给模型,而模型用的是 Responses API 格式。 存这个格式,读取后直接 prepend 到输入,零转换。如果存自定义结构,每次读出来要转换,徒增复杂度。

这也是 agents-go 用 OpenAI 格式作为 lingua franca 的体现(第 2 章讲过),input/output/session 三套类型都是 Responses 类型别名,统一了对话表示。


设计权衡:Session 是 per-user 还是 per-conversation

这是个使用层面的设计问题,Session 的 ID 该怎么定?

方案含义适合
per-user一个用户一个 session,所有对话共享历史个人助手(一个用户一个 Agent)
per-conversation一个对话一个 session,不同对话独立多轮任务(每次任务独立)
per-thread一个话题一个 session,同话题延续论坛式、工单式

agents-go 不强制,Session ID 是个字符串(NewFileSession(dir, sessionID) 的第二个参数),你爱怎么定怎么定。但这个决定影响很大,per-user 会导致历史无限增长(要靠 compaction),per-conversation 会丢失跨对话的连续性。

agents-server(Part VI)的做法是,每个 session 一个记录,可以 fork(分叉)、可以 pin(置顶),让用户自己组织。这是更灵活的方案。


实战对照:mini-claude-code 的 Session

mini-claude-code 实现了 Part II 的 Session(session.go),是 FileSession 的极简版,每条消息一行 JSON,存到 .mini-claude/<项目名>.jsonl:

go
// mini-claude-code/session.go
type Session struct {
    mu   sync.Mutex
    path string  // .mini-claude/example.jsonl
}

func (s *Session) Load() ([]Message, error)        // 启动时读全部
func (s *Session) Append(msgs []Message) error     // 每轮追加新增消息
func (s *Session) Clear() error                    // /clear 命令

和 agents-go FileSession 的区别,agents-go 的 Session 接口有 GetItems(limit)/AddItems/PopItem/Clear 四个方法(支持压缩和撤回)。mini-claude-code 砍到三个(Load/Append/Clear),因为 MVP 不做压缩和撤回,只要"退出不丢、下次能恢复"。

崩溃安全也做了,Append 先把所有消息 marshal 到 buffer 再单次 Write(防中途失败留下半行);Load 逐行解析,坏行跳过不报错(防单行损坏波及全文件)。这两个是第 9 章的核心设计,在 mini-claude-code 里都有。

实测:第一次跑完退出,第二次启动显示"📂 已恢复 12 条历史记录",用户说"刚才那个 bug 修一下",Agent 不用重新读文件就直接修,Session 跨会话生效。


小结

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

  1. Session 是什么?,对话历史的持久化,让 Agent 跨 Run 记住上下文
  2. Session 接口四个方法?,GetItems(取,支持 limit)、AddItems(批量加)、PopItem(删最后一条,用于撤回/压缩)、Clear(清空)
  3. 为什么有三个后端?,内存(测试)、文件(单机持久化)、数据库(多用户服务端),接口让循环代码不变
  4. InMemorySession 为什么返回副本?,防止调用方修改影响内部状态,并发安全
  5. CompactionAwareSession 为什么单独?,压缩是可选能力,小接口+扩展接口优于臃肿大接口

Session 让 Agent 从"金鱼记忆"变成"有上下文的伙伴"。 但仅仅存历史还不够,历史会无限增长,怎么压缩?怎么分叉?这些是第 9-11 章的内容。


下一篇

第 9 章,深入 FileSession,agents-go 怎么用 JSONL 文件做崩溃安全的持久化。临时文件 + fsync + rename 的原子替换、per-path 锁、坏行不销毁……这是纯文件系统工程,每个决策都有清晰的反例驱动。


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

相关文章

分享: