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 要提供什么能力”,而不是“你必须用哪一个具体结构体”。
官方暴露出来的核心接口如下:
type Session interface {
ID() string // 当前 Session 的唯一标识
AppName() string // 当前 Session 归属的应用名
UserID() string // 当前 Session 归属的用户 ID
State() State // 当前 Session 的状态存储区
Events() Events // 当前 Session 已记录的完整事件历史
LastUpdateTime() time.Time // 最后一次更新 Session 的时间
}从这个接口你就能看出,一个 Session 至少包含这些核心成员:
ID():当前会话的唯一 ID。AppName():属于哪个应用。UserID():属于哪个用户。State():当前会话的状态存储区。Events():当前会话的事件历史。LastUpdateTime():最后更新时间。
这六个成员,基本就构成了 Session 的“数据骨架”。
其中最重要的是 State 和 Events。
State是结构化的可变状态,适合存业务变量,比如当前订单号、用户偏好、流程节点。Events是完整的事件流,适合记录用户输入、模型回复、工具调用、状态变更等全过程。
State 的接口很简单:
type State interface {
Get(string) (any, error) // 按 key 读取状态值
Set(string, any) error // 写入或覆盖某个状态值
All() iter.Seq2[string, any] // 遍历当前 Session 里的全部状态
}而 Event 本身也不是只有一段文本,它里面还带了调用链信息和状态变更信息:
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 创建和管理。
它的核心接口如下:
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
}最基础的创建方式
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
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 的生成链路一般是这样的:
- 应用先准备一个
session.Service。 - 新会话时调用
Create(...),老会话时用已有sessionID继续跑。 runner.Run(...)根据userID + sessionID找到对应 Session。- Agent 在运行过程中读取
Session.State()和Session.Events()。 - 新产生的回复、工具调用、状态改动会被包装成
Event并追加回 Session。
也就是说,Session 不是“跑完模型才顺便存一下”,而是整个 Runner 执行链路的核心容器。
下面给一个完整一点的例子:
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,上下文就自然延续了:
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 读到。
最常见的是三种上下文接口:
agent.ReadonlyContextagent.CallbackContextagent.InvocationContext
1. ReadonlyContext:只读地读 Session 信息
它常用于 InstructionProvider 这种动态提示词场景。
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()。
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。
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
最简单的方案就是:
sessionSvc := session.InMemoryService()它的特点也非常直接:
- 优点:零配置、启动快、示例代码最短。
- 缺点:进程一重启,Session 历史和 State 全丢。
- 适用场景:本地开发、demo、单元测试。
如果你只是本地验证 Agent 流程,InMemoryService 完全够用。
2. 持久化存储:MySQL + GORM
adk-go 官方已经提供了数据库版 SessionService:google.golang.org/adk/session/database。 它底层就是通过 GORM 连接关系型数据库,所以如果你项目本身就在用 GORM,这条路是最顺的。
最常见写法
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 层根本不用改。
r, err := runner.New(runner.Config{
AppName: "customer-service",
Agent: rootAgent,
SessionService: sessionSvc, // 可以是内存版,也可以是 MySQL 持久化版
})这就是接口抽象的价值。
这一层底层大概存了什么
从官方 session/database 文档可以看出,这个实现会通过 GORM 自动迁移内部存储模型,例如 storageSession、storageEvent。
你可以把它理解成至少会持久化两类信息:
- Session 元数据:
session_id / app_name / user_id / last_update_time - Session 内容:
state和events
也就是说,数据库版不是只存一个“会话壳子”,而是把整条会话线程和状态一起保存下来。
如果要换 PostgreSQL / SQLite
做法其实一样,只是换一个 GORM dialector:
// 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 接口。
例如你可以先定义自己的表:
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 去实现:
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,最重要的是记住三句话:
- Session 是单个会话线程的容器,不是长期 Memory。
- Session 的核心由
ID/AppName/UserID/State/Events/LastUpdateTime组成。 - 真正驱动 Session 生命周期的是
session.Service + runner.Run(...)。
你可以把它理解成 Agent 的“短期记忆体”和“当前运行沙盒”。 只要你把 Session 这层吃透了,后面再去看 State、Memory、Artifact、Multi-Agent 路由,很多地方都会一下子顺起来。