adk-go Session详解

如果说 Agent 决定了“怎么思考”,那么 Session 决定的就是“这次对话记住了什么”。
在 adk-go 里,Session 不只是一个聊天 ID,它同时承担了会话历史、短期状态、运行上下文入口这三层职责。

很多人第一次看 ADK 时,会把 Session 和 Memory 混在一起。其实两者边界很清楚:

  • Session 负责单次会话线程内的上下文、事件历史和状态。
  • Memory 负责跨 Session 的长期知识检索。

所以如果你要解释“为什么这个 Agent 能接着上一轮继续聊”,第一责任人通常不是 Memory,而是 Session。

Session 的内部成员

先说一个 Go 里的关键点:adk-go 并没有暴露一个 Session struct 给你直接 new,而是暴露了一个 session.Session 接口。
也就是说,框架更在意的是“Session 要提供什么能力”,而不是“你必须用哪一个具体结构体”。

官方暴露出来的核心接口如下:

1
2
3
4
5
6
7
8
9
type Session interface {
ID() string // 当前 Session 的唯一标识
AppName() string // 当前 Session 归属的应用名
UserID() string // 当前 Session 归属的用户 ID

State() State // 当前 Session 的状态存储区
Events() Events // 当前 Session 已记录的完整事件历史
LastUpdateTime() time.Time // 最后一次更新 Session 的时间
}

从这个接口你就能看出,一个 Session 至少包含这些核心成员:

  1. ID():当前会话的唯一 ID。
  2. AppName():属于哪个应用。
  3. UserID():属于哪个用户。
  4. State():当前会话的状态存储区。
  5. Events():当前会话的事件历史。
  6. LastUpdateTime():最后更新时间。

这六个成员,基本就构成了 Session 的“数据骨架”。

其中最重要的是 StateEvents

  • State 是结构化的可变状态,适合存业务变量,比如当前订单号、用户偏好、流程节点。
  • Events 是完整的事件流,适合记录用户输入、模型回复、工具调用、状态变更等全过程。

State 的接口很简单:

1
2
3
4
5
type State interface {
Get(string) (any, error) // 按 key 读取状态值
Set(string, any) error // 写入或覆盖某个状态值
All() iter.Seq2[string, any] // 遍历当前 Session 里的全部状态
}

Event 本身也不是只有一段文本,它里面还带了调用链信息和状态变更信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type Event struct {
model.LLMResponse // 复用底层模型返回内容,比如文本、parts、候选结果等

ID string // 当前事件自己的唯一 ID
Timestamp time.Time // 事件生成时间

InvocationID string // 这条事件属于哪一次 invocation
Branch string // 当前事件所在的分支,常用于多分支流程
Author string // 事件作者,常见值如 user / agent / tool

Actions EventActions // 事件附带的动作信息,比如状态变化、转交等
LongRunningToolIDs []string // 当前事件里涉及到的长时运行工具 ID
}

type EventActions struct {
StateDelta map[string]any // 本次事件对 Session State 产生的增量修改
ArtifactDelta map[string]int64 // 本次事件对 Artifact 的增量修改
RequestedToolConfirmations map[string]toolconfirmation.ToolConfirmation // 请求用户确认的工具调用
TransferToAgent string // 如果发生了 Agent 转交,这里记录目标 Agent 名称
Escalate bool // 是否要求向更高层 Agent 升级处理
SkipSummarization bool // 是否跳过摘要压缩
}

所以从工程角度看,Session 其实就是:

  • 一份会话级元数据
  • 一块可读写状态区
  • 一条完整事件流水

Session 的基本设置

Session 本身一般不手动构造,而是交给 session.Service 创建和管理。

它的核心接口如下:

1
2
3
4
5
6
7
type Service interface {
Create(context.Context, *CreateRequest) (*CreateResponse, error)
Get(context.Context, *GetRequest) (*GetResponse, error)
List(context.Context, *ListRequest) (*ListResponse, error)
Delete(context.Context, *DeleteRequest) error
AppendEvent(context.Context, Session, *Event) error
}

最基础的创建方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

import (
"context"
"fmt"
"log"

"google.golang.org/adk/session"
)

func main() {
ctx := context.Background()
sessionSvc := session.InMemoryService()

createResp, err := sessionSvc.Create(ctx, &session.CreateRequest{
AppName: "customer-service",
UserID: "u_1001",
SessionID: "sess_001",
State: map[string]any{
"user:name": "张三",
"user:city": "上海",
"app:tenant": "cn-prod",
"temp:current_intent": "refund",
},
})
if err != nil {
log.Fatal(err)
}

s := createResp.Session
fmt.Println("session id:", s.ID())
fmt.Println("app name:", s.AppName())
fmt.Println("user id:", s.UserID())
fmt.Println("event count:", s.Events().Len())

city, err := s.State().Get("user:city")
if err != nil {
log.Fatal(err)
}
fmt.Println("user city:", city)
}

这里有一个非常值得注意的点:State 的 key 是支持作用域语义的。

adk-go 官方内置了三个前缀:

前缀 含义 典型用途
app: 应用级共享状态 多用户共享配置、租户级参数
user: 用户级共享状态 用户画像、昵称、偏好、会员等级
temp: 当前 invocation 临时状态 单轮推理中的中间变量、临时结果

也就是说:

  • app: 适合“整个应用共享”的值。
  • user: 适合“同一个用户跨多个 Session 复用”的值。
  • temp: 适合“只在这一次调用里临时存在”的值。

其中 temp: 最特殊,它不是拿来长期保留的,而是拿来存本轮运行中的临时变量。

这里的“作用域语义”,你可以简单理解成:
同样是在 State 里存一个值,但这个值到底应该在哪个范围内生效、应该被谁复用、应该保留多久,框架会通过 key 前缀把这层含义表达出来。

如果只停留在“定义”层面,这一节会很抽象。真正写代码时,你可以把它理解成一句话:

你在 State 里存值时,不只是“存一个变量”,而是在顺手告诉框架和后续代码,这个变量应该按什么范围来使用。

获取、列出、删除 Session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
getResp, err := sessionSvc.Get(ctx, &session.GetRequest{
AppName: "customer-service",
UserID: "u_1001",
SessionID: "sess_001",
})
if err != nil {
log.Fatal(err)
}
fmt.Println("loaded session:", getResp.Session.ID())

listResp, err := sessionSvc.List(ctx, &session.ListRequest{
AppName: "customer-service",
UserID: "u_1001",
})
if err != nil {
log.Fatal(err)
}
fmt.Println("session count:", len(listResp.Sessions))

err = sessionSvc.Delete(ctx, &session.DeleteRequest{
AppName: "customer-service",
UserID: "u_1001",
SessionID: "sess_001",
})
if err != nil {
log.Fatal(err)
}

Session 是如何生成的,怎么使用的

在实际运行里,Session 的生成链路一般是这样的:

  1. 应用先准备一个 session.Service
  2. 新会话时调用 Create(...),老会话时用已有 sessionID 继续跑。
  3. runner.Run(...) 根据 userID + sessionID 找到对应 Session。
  4. Agent 在运行过程中读取 Session.State()Session.Events()
  5. 新产生的回复、工具调用、状态改动会被包装成 Event 并追加回 Session。

也就是说,Session 不是“跑完模型才顺便存一下”,而是整个 Runner 执行链路的核心容器。

下面给一个完整一点的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package main

import (
"context"
"fmt"
"log"
"os"

"google.golang.org/adk/agent"
"google.golang.org/adk/agent/llmagent"
"google.golang.org/adk/provider/openai"
"google.golang.org/adk/runner"
"google.golang.org/adk/session"
"google.golang.org/genai"
)

func main() {
ctx := context.Background()

model, err := openai.NewModel(openai.Config{
ModelName: "deepseek-chat",
BaseURL: "https://api.deepseek.com/v1",
APIKey: os.Getenv("DEEPSEEK_API_KEY"),
})
if err != nil {
log.Fatal(err)
}

rootAgent, err := llmagent.New(llmagent.Config{
Name: "assistant",
Model: model,
Instruction: "你是一个客服助手。回答用户问题时尽量简洁,并利用上下文保持连续对话。",
})
if err != nil {
log.Fatal(err)
}

sessionSvc := session.InMemoryService()

r, err := runner.New(runner.Config{
AppName: "customer-service",
Agent: rootAgent,
SessionService: sessionSvc,
})
if err != nil {
log.Fatal(err)
}

createResp, err := sessionSvc.Create(ctx, &session.CreateRequest{
AppName: "customer-service",
UserID: "u_1001",
SessionID: "sess_001",
State: map[string]any{
"user:name": "张三",
"user:city": "上海",
},
})
if err != nil {
log.Fatal(err)
}

msg := genai.NewContentFromText("你好,我刚才住在哪个城市?", genai.RoleUser)

for event, err := range r.Run(ctx, "u_1001", createResp.Session.ID(), msg, agent.RunConfig{}) {
if err != nil {
log.Fatal(err)
}

fmt.Printf("author=%s final=%v partial=%v\n",
event.Author, event.IsFinalResponse(), event.Partial)

// 这里通常会把 event 转给前端、日志系统,或者 SSE/WebSocket 输出。
}
}

这段代码里,Session 的角色很清楚:

  • Create() 负责生成一个会话容器。
  • runner.Run() 负责把用户输入送进这个会话。
  • Runner 内部会继续把新的 Event 追加回这个会话。

如果你第二轮继续传同一个 sessionID,上下文就自然延续了:

1
2
3
4
5
6
7
8
nextMsg := genai.NewContentFromText("那我的名字呢?", genai.RoleUser)

for event, err := range r.Run(ctx, "u_1001", "sess_001", nextMsg, agent.RunConfig{}) {
if err != nil {
log.Fatal(err)
}
fmt.Println("next event author:", event.Author)
}

所以 Session 的真正使用方式不是“手动拼历史”,而是“把同一个 sessionID 持续交给 Runner”。

Session 的上下文(Context)

在 ADK 里,Session 不会孤零零地存在,它总是通过 Context 被 Agent 读到。

最常见的是三种上下文接口:

  1. agent.ReadonlyContext
  2. agent.CallbackContext
  3. agent.InvocationContext

1. ReadonlyContext:只读地读 Session 信息

它常用于 InstructionProvider 这种动态提示词场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
agent, err := llmagent.New(llmagent.Config{
Name: "profile-aware-agent",
Model: model,
InstructionProvider: func(ctx agent.ReadonlyContext) (string, error) {
city, _ := ctx.ReadonlyState().Get("user:city")
return fmt.Sprintf(
"当前用户ID=%s,会话ID=%s,用户城市=%v。请结合这些上下文回答。",
ctx.UserID(),
ctx.SessionID(),
city,
), nil
},
})

这里你能拿到:

  • ctx.UserID()
  • ctx.AppName()
  • ctx.SessionID()
  • ctx.InvocationID()
  • ctx.ReadonlyState()

也就是说,动态提示词完全可以按用户、会话、状态来实时生成。

2. CallbackContext:在回调里读写 Session State

如果你想在 Agent 执行前后打点、写临时状态、记录本轮时间戳,CallbackContext 会更方便,因为它比只读上下文多了 State()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
agent, err := llmagent.New(llmagent.Config{
Name: "trace-agent",
Model: model,
BeforeAgentCallbacks: []agent.BeforeAgentCallback{
func(ctx agent.CallbackContext) (*genai.Content, error) {
err := ctx.State().Set("temp:request_started_at", time.Now().Format(time.RFC3339))
return nil, err
},
},
AfterAgentCallbacks: []agent.AfterAgentCallback{
func(ctx agent.CallbackContext) (*genai.Content, error) {
err := ctx.State().Set("temp:last_agent", ctx.AgentName())
return nil, err
},
},
})

这类写法非常适合做:

  • 请求链路追踪
  • 调试标记
  • 单轮执行中的临时变量缓存

3. InvocationContext:Agent 运行时拿到完整 Session

如果你写的是自定义 Agent,那么 Run(...) 里拿到的就是 agent.InvocationContext

1
2
3
4
5
6
7
8
9
10
func myRun(ctx agent.InvocationContext) iter.Seq2[*session.Event, error] {
return func(yield func(*session.Event, error) bool) {
currentSession := ctx.Session()

userName, _ := currentSession.State().Get("user:name")
fmt.Println("current user:", userName)
fmt.Println("current session:", currentSession.ID())
fmt.Println("history size:", currentSession.Events().Len())
}
}

InvocationContext 比较像“本次运行的总入口”,它除了能拿到 Session(),还可以拿到:

  • Agent()
  • Artifacts()
  • Memory()
  • UserContent()
  • InvocationID()
  • RunConfig()

所以从调用链上说:

  • Session 是数据容器
  • Context 是运行时访问 Session 的入口

Session 记录的内存存储与持久化存储

这里要先澄清一个容易混淆的点:

这里说的“存储”是 Session 本身的历史记录和状态存储,不是 ADK 里那个长期记忆 memory.Service

1. 内存存储:InMemoryService

最简单的方案就是:

1
sessionSvc := session.InMemoryService()

它的特点也非常直接:

  • 优点:零配置、启动快、示例代码最短。
  • 缺点:进程一重启,Session 历史和 State 全丢。
  • 适用场景:本地开发、demo、单元测试。

如果你只是本地验证 Agent 流程,InMemoryService 完全够用。

2. 持久化存储:MySQL + GORM

adk-go 官方已经提供了数据库版 SessionService:google.golang.org/adk/session/database
它底层就是通过 GORM 连接关系型数据库,所以如果你项目本身就在用 GORM,这条路是最顺的。

最常见写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package main

import (
"context"
"fmt"
"log"

"gorm.io/driver/mysql"
"gorm.io/gorm"

"google.golang.org/adk/session"
sessiondb "google.golang.org/adk/session/database"
)

func main() {
ctx := context.Background()

dsn := "root:123456@tcp(127.0.0.1:3306)/adk_demo?charset=utf8mb4&parseTime=True&loc=Local"

sessionSvc, err := sessiondb.NewSessionService(
mysql.Open(dsn),
&gorm.Config{},
)
if err != nil {
log.Fatal(err)
}

// 自动创建/更新 Session 相关表结构
if err := sessiondb.AutoMigrate(sessionSvc); err != nil {
log.Fatal(err)
}

createResp, err := sessionSvc.Create(ctx, &session.CreateRequest{
AppName: "customer-service",
UserID: "u_1001",
SessionID: "sess_mysql_001",
State: map[string]any{
"user:name": "张三",
"user:city": "上海",
},
})
if err != nil {
log.Fatal(err)
}

fmt.Println("created session:", createResp.Session.ID())

getResp, err := sessionSvc.Get(ctx, &session.GetRequest{
AppName: "customer-service",
UserID: "u_1001",
SessionID: "sess_mysql_001",
})
if err != nil {
log.Fatal(err)
}

fmt.Println("loaded session:", getResp.Session.ID())
fmt.Println("event count:", getResp.Session.Events().Len())
}

这段代码的意义很大,因为它说明了一个工程上很舒服的特性:

  • 你的业务代码依然只依赖 session.Service
  • 你只是在初始化时把实现从 InMemoryService() 换成了 database.NewSessionService(...)

也就是说,Runner 层根本不用改。

1
2
3
4
5
r, err := runner.New(runner.Config{
AppName: "customer-service",
Agent: rootAgent,
SessionService: sessionSvc, // 可以是内存版,也可以是 MySQL 持久化版
})

这就是接口抽象的价值。

这一层底层大概存了什么

从官方 session/database 文档可以看出,这个实现会通过 GORM 自动迁移内部存储模型,例如 storageSessionstorageEvent

你可以把它理解成至少会持久化两类信息:

  1. Session 元数据:session_id / app_name / user_id / last_update_time
  2. Session 内容:stateevents

也就是说,数据库版不是只存一个“会话壳子”,而是把整条会话线程和状态一起保存下来。

如果要换 PostgreSQL / SQLite

做法其实一样,只是换一个 GORM dialector:

1
2
3
4
5
// PostgreSQL
// sessiondb.NewSessionService(postgres.Open(dsn), &gorm.Config{})

// SQLite
// sessiondb.NewSessionService(sqlite.Open("adk.db"), &gorm.Config{})

所以 adk-go 这一层的数据库适配思路非常朴素:

  • 框架对上层暴露统一的 session.Service
  • 底层交给 GORM 和不同数据库驱动去适配

如果你想用自己的 GORM 表结构

官方 session/database 已经足够大多数场景,但有些团队会希望:

  • state 单独拆表
  • 给 event 增加租户字段、审计字段
  • 对 event 内容做分区、冷热分层

这时候最直接的办法,就是自己实现 session.Service 接口。

例如你可以先定义自己的表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type ChatSession struct {
ID string `gorm:"primaryKey;size:64"`
AppName string `gorm:"index:idx_app_user,priority:1;size:128"`
UserID string `gorm:"index:idx_app_user,priority:2;size:128"`
StateJSON datatypes.JSON `gorm:"type:json"`
LastUpdateTime time.Time `gorm:"index"`
CreatedAt time.Time
UpdatedAt time.Time
}

type ChatEvent struct {
ID string `gorm:"primaryKey;size:64"`
SessionID string `gorm:"index;size:64"`
InvocationID string `gorm:"index;size:64"`
Author string `gorm:"size:64"`
Branch string `gorm:"size:255"`
ContentJSON datatypes.JSON `gorm:"type:json"`
ActionsJSON datatypes.JSON `gorm:"type:json"`
Timestamp time.Time `gorm:"index"`
CreatedAt time.Time
}

再由你自己的 repository 去实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type MySessionService struct {
db *gorm.DB
}

func (s *MySessionService) Create(ctx context.Context, req *session.CreateRequest) (*session.CreateResponse, error) {
// 1. 把 req.State 序列化成 JSON
// 2. 写入 ChatSession
// 3. 返回一个符合 session.Session 接口的对象
panic("implement me")
}

func (s *MySessionService) Get(ctx context.Context, req *session.GetRequest) (*session.GetResponse, error) {
panic("implement me")
}

func (s *MySessionService) List(ctx context.Context, req *session.ListRequest) (*session.ListResponse, error) {
panic("implement me")
}

func (s *MySessionService) Delete(ctx context.Context, req *session.DeleteRequest) error {
panic("implement me")
}

func (s *MySessionService) AppendEvent(ctx context.Context, sess session.Session, event *session.Event) error {
// 1. 落 event
// 2. 合并 StateDelta
// 3. 更新 session 的 LastUpdateTime
panic("implement me")
}

这条路线更重,但自由度最高,适合:

  • 你已经有成熟的会话表设计
  • 你要做复杂审计
  • 你要把会话数据对接公司现有数据平台

如果只是普通业务系统,我的建议仍然是优先用官方的 session/database,因为它已经把 GORM 接入这一层封装好了。

最后总结

理解 adk-go 的 Session,最重要的是记住三句话:

  1. Session 是单个会话线程的容器,不是长期 Memory。
  2. Session 的核心由 ID/AppName/UserID/State/Events/LastUpdateTime 组成。
  3. 真正驱动 Session 生命周期的是 session.Service + runner.Run(...)

你可以把它理解成 Agent 的“短期记忆体”和“当前运行沙盒”。
只要你把 Session 这层吃透了,后面再去看 State、Memory、Artifact、Multi-Agent 路由,很多地方都会一下子顺起来。