Genkit Go Agent Branching:用不可变 SnapshotID 实现会话分支与多路线 Agent 实战指南
【免费下载链接】skillsAgent Skills for Google products and technologies项目地址: https://gitcode.com/GitHub_Trending/skills29/skills
Agent 会话往往是"一条道走到黑"的线性对话,但在真实应用中,我们经常需要让同一段对话历史产生多种可能的走向——例如并行生成两版回复让用户挑选、基于某个历史检查点探索不同的回答策略,或在 UI 刷新后精确恢复某一次对话现场。Genkit Go 的实验性 Agent API 通过引入"不可变快照(Snapshot)"这一机制,为多轮会话提供了类似 git 的分支(Branching)能力:任何一次对话回合都会产生一个新的检查点,你可以从任意检查点 fork 出多个相互独立的时间线。本文将完整讲解 SnapshotID 的核心语义、分支代码范式、"二选一"变体模式、按会话与按快照恢复的区别,以及如何通过 HTTP 暴露快照读取能力,帮助你在 Genkit Go 中落地可回溯、可分支的多路线 Agent。
前置要求:分支能力属于Experimental / preview API。它依赖会话存储(Session Store)以保证快照持久化,建议先阅读 agents.md 了解 Agent 的基础定义与运行方式。
一、分支的核心思想:SnapshotID 是不可变检查点
要理解分支,首先要理解 Genkit Go 会话模型中的一个关键概念——SnapshotID(快照 ID)。
在多轮会话中,Agent 的每一次回合(turn)都会产生一个不可变的检查点(immutable checkpoint),它就像 git 的一次 commit:记录下那一刻的完整会话状态,且一经产生就永远不会被修改。这意味着你可以从同一个快照出发,fork 出任意多个独立的时间线——从某个快照继续的每一轮对话都会创建一个全新的、独立的快照,而原快照始终保持不变。
assistant := genkitx.DefineAgent(g, "assistant", aix.InlinePrompt{ ai.WithModelName("googleai/gemini-flash-latest"), ai.WithSystem("You are a helpful assistant."), }, aix.WithSessionStore(localstore.NewInMemorySessionStore[any]()), ) root, _ := assistant.RunText(ctx, "Hello!") checkpoint := root.SnapshotID // branch point这段代码中:
genkitx.DefineAgent(来自genkit/exp,别名genkitx)创建一个带会话存储的 Agent;- 第一轮
RunText(ctx, "Hello!")产生了一个根快照root; root.SnapshotID就是后续分支的"分叉点"(branch point)。
每一次回合都会返回一个全新的SnapshotID,因此分支之间在公共检查点之后永远不会共享历史——这正是分支与普通"继续对话"的本质区别。
包与导入别名说明:Agent API 分布在
genkit/exp(构造器,别名genkitx)与ai/exp(类型与选项,别名aix)两个包中,会话存储来自ai/exp/localstore。初始化时必须调用genkit.Init(ctx, genkit.WithExperimental(), ...)显式开启实验特性,否则genkitx.DefineAgent等构造器会直接 panic。详见 agents.md。
二、从检查点分支:两个完全独立的对话线
分支的语法非常轻量:只需在运行回合时传入aix.WithSnapshotID(id)选项,即可从指定快照继续。下面的完整示例展示了从同一个checkpointfork 出两条互不干扰的对话线:
// Branch A — forks from `checkpoint`. a1, _ := assistant.RunText(ctx, "My name is Bob.", aix.WithSnapshotID(checkpoint)) a2, _ := assistant.RunText(ctx, "What is my name?", aix.WithSnapshotID(a1.SnapshotID)) // -> Bob // Branch B — forks from the SAME `checkpoint`, fully independent. b1, _ := assistant.RunText(ctx, "My name is John.", aix.WithSnapshotID(checkpoint)) b2, _ := assistant.RunText(ctx, "What is my name?", aix.WithSnapshotID(b1.SnapshotID)) // -> John _ = a2 _ = b2执行过程可以这样理解:
| 回合 | 起点快照 | 结果快照 | 对话内容 |
|---|---|---|---|
root | (首次) | root.SnapshotID | "Hello!" |
a1 | checkpoint | a1.SnapshotID | "My name is Bob." |
a2 | a1.SnapshotID | a2.SnapshotID | 回答 "Bob" |
b1 | checkpoint | b1.SnapshotID | "My name is John." |
b2 | b1.SnapshotID | b2.SnapshotID | 回答 "John" |
两条分支都从同一个checkpoint出发,但各自的后续历史完全独立:分支 A 认为用户叫 Bob,分支 B 认为用户叫 John,彼此不互相污染。这背后是会话存储(localstore.NewInMemorySessionStore或localstore.NewFileSessionStore)在起作用——每次回合自动将快照落盘/持久化,从而让"从任意历史点恢复"成为可能。
三、"Pick a variant"模式:并行生成变体,用户择优续接
分支最有价值的实战场景之一,是"生成多个候选方案,让用户挑选后继续"。其思路是:从同一检查点并行发起多个回合,各自产生独立快照,用户选中的那个快照成为新的分支点。
以下示例用 Go 的sync.WaitGroup从同一checkpoint并行生成两个变体:
func twoVariants(ctx context.Context, agent *aix.Agent[any], checkpoint, text string) (a, b *aix.AgentOutput[any], err error) { var wg sync.WaitGroup var errA, errB error wg.Add(2) go func() { defer wg.Done(); a, errA = agent.RunText(ctx, text, aix.WithSnapshotID(checkpoint)) }() go func() { defer wg.Done(); b, errB = agent.RunText(ctx, text, aix.WithSnapshotID(checkpoint)) }() wg.Wait() if errA != nil { return nil, nil, errA } if errB != nil { return nil, nil, errB } return a, b, nil // a.SnapshotID != b.SnapshotID; both branch from the same point } // When the user picks a variant, its SnapshotID becomes the new branch point: // checkpoint = chosen.SnapshotID这段代码的关键点:
- 同一 checkpoint、同一文本、两次调用:由于每次
RunText都从快照生成新的独立快照,两个 goroutine 的结果a.SnapshotID != b.SnapshotID,且互不影响; - 并发安全:快照的不可变性保证了并发 fork 不会发生写冲突——没有数据被覆盖,也没有共享可变状态;
- 用户挑选后续接:当用户选择某个变体后,只需把
chosen.SnapshotID作为新的分支点继续对话即可(示例注释中的checkpoint = chosen.SnapshotID)。
当还不存在分支点怎么办?对于第一轮对话(此时还没有任何历史快照),直接省略调用选项开启一个全新会话,然后将返回结果上的SnapshotID作为之后的分支点即可。这也是整个分支能力的"种子"来源。
四、按会话恢复 vs 按快照恢复:选对正确的 API
Genkit Go 提供了两个语义不同、容易混淆的恢复选项:
| 选项 | 语义 | 适用场景 |
|---|---|---|
aix.WithSnapshotID(id) | 从精确的检查点 fork(即分支) | 需要回到某个特定历史时刻,或进行分支 |
aix.WithSessionID(id) | 继续该会话的最新快照 | 只关心"这段对话"而不关心具体快照 |
两者最重要的区别在于:如果历史发生过分支(fork),WithSessionID会选择"最近创建"的那条分支继续。因此当你的业务需要锁定某一条特定分支时,必须使用WithSnapshotID;只有当会话从未分叉、或你可以接受"沿最新分支继续"时,才使用WithSessionID。
需要补充的两个边界:
- 恢复会被拒绝的情况:如果最新快照是一个失败(failed)、被中止(aborted)或处于挂起死端(pending dead end,即还有后台任务在跑)的状态,恢复请求会被拒绝——此时应先等待或中止后台任务;
- 与客户端管理互斥:
aix.WithState(客户端管理状态,无存储)与WithSessionID/WithSnapshotID(服务端管理状态)互斥,不能混用。详见 agents-sessions.md。
五、从快照恢复历史:不运行回合也能读取现场
分支不仅支持"从快照继续对话",还支持只读地查看某个快照的完整状态。使用Agent.GetSnapshot可以在不触发任何模型调用的前提下读取快照内容——这对"页面刷新后恢复 UI"这类场景非常有用,例如浏览器 URL 中保存了一个SnapshotID,前端刷新后即可用它还原整个对话历史:
snap, err := assistant.GetSnapshot(ctx, snapshotID) if err != nil { log.Fatal(err) } for _, m := range snap.State.Messages { if m.Role == ai.RoleUser || m.Role == ai.RoleModel { fmt.Printf("%s: %s\n", m.Role, m.Text()) } }代码要点:
snap.State.Messages中是该快照时刻的全部历史消息,遍历并过滤出ai.RoleUser/ai.RoleModel两类角色即可重建对话界面;Agent.GetSnapshot会应用 Agent 上配置的aix.WithStateTransform(例如 PII 脱敏);- 如果 Agent 是客户端管理(无 Session Store),
GetSnapshot会返回FAILED_PRECONDITION错误。
与之配套的还有Agent.GetLatestSnapshot(ctx, sessionID),用于按会话 ID 获取该会话最新的快照。
通过 HTTP 暴露快照读取
在 HTTP 部署场景下,Agent 本身是api.BidiAction,通过genkit.Handler(agent)即可按"一次请求一个回合"的方式对外服务。若要支持远程客户端读取快照(例如前端应用按 URL 中的 SnapshotID 恢复会话),需要额外暴露Agent.GetSnapshotAction():
mux := http.NewServeMux() mux.HandleFunc("POST /api/weatherAgent", genkit.Handler(weatherAgent)) // Optional companions (needed for snapshot restore / branching / background): if snap := weatherAgent.GetSnapshotAction(); snap != nil { mux.HandleFunc("POST /api/weatherAgent/getSnapshot", genkit.Handler(snap)) } log.Fatal(server.Start(ctx, "127.0.0.1:8080", mux))更推荐的做法是使用genkit/exp内置的默认路由布局genkitx.AllAgentRoutes(g),它会根据每个 Agent 的能力自动挂载/agents/<name>(回合)、/agents/<name>/getSnapshot(快照读取,仅存储型 Agent)与/agents/<name>/abort(中止后台任务,仅可中止的存储),免去手写路由的样板代码,详见 agents-deployment.md。
请求体的init字段携带会话来源:服务端管理状态下传{"sessionId": ...}或{"snapshotId": ...},客户端管理状态下传{"state": ...},省略init则开启全新会话。这与 Go API 中的WithSessionID/WithSnapshotID/WithState一一对应。
六、分支的存储语义:被遗弃的分支与链剪枝
分支之所以能安全、廉价地存在,源于快照的不可变设计。文档明确给出了两条存储语义:
- 被遗弃的分支会一直保留:被丢弃的分支只是作为不可变快照静静地留在存储中,分支发生时不会有任何数据被覆盖。这保证了 fork 的任意分支(哪怕后来没人继续)都可以在之后被重新拾起;
- 链剪枝不影响兄弟分支:
FileSessionStore配置了localstore.WithMaxPersistedChainLength(n)时,只会沿着单条链的父级链接修剪最老的快照(即只保留该链最新的 n 个),兄弟分支会被独立保留,不会被连带删除。这意味着你可以放心地限制每条链的长度来控制存储占用,而不必担心误删并行分支。
pruning, err := localstore.NewFileSessionStoreany, // 每条链只保留最新 3 个快照 )从实现上看,localstore的两个单进程存储(NewInMemorySessionStore、NewFileSessionStore)都遵循aix.SessionStore接口约定(SaveSnapshot以原子方式读取-应用-写入,GetSnapshot/GetLatestSnapshot负责读取),其中GetLatestSnapshot返回CreatedAt最大、平局按SnapshotID排序的行的语义,也保证了"按会话恢复 = 沿最新分支"的行为是可预测的。多实例生产环境则应自行实现aix.SessionStore对接真实数据库。
七、实战要点小结
- 分支的前提是持久化:Agent 必须配置 Session Store(
aix.WithSessionStore(localstore.NewInMemorySessionStore[any]())或文件存储),快照才能跨回合保留,分支也才能成立; - 分支三件套:
out.SnapshotID记录分支点、aix.WithSnapshotID(id)执行 fork、Agent.GetSnapshot(ctx, id)无副作用地查看检查点内容; - 并发 fork 是安全的:快照不可变,从同一 checkpoint 并行发起多个回合不会互相覆盖,这是"Pick a variant"模式的基石;
- 精确恢复优先用快照:会话一旦分叉,
WithSessionID只会沿最新分支走,锁定特定分支必须用WithSnapshotID; - 结合其他 Agent 能力:分支与会话持久化、人工介入(interrupts)、后台执行、多 Agent 编排等能力正交组合,可构建出完整的可回溯、可干预、可并行的 Agent 应用;Agent API 的整体概览与 CLI 调试方式(如
genkit start、flow:run包装单回合)参见 SKILL.md 与 agents.md。
最后提醒:由于该 API 属于实验特性,导入路径与函数签名可能在小版本更新中变化,生产使用前请锁定依赖版本,并始终通过genkit.WithExperimental()显式开启。
【免费下载链接】skillsAgent Skills for Google products and technologies项目地址: https://gitcode.com/GitHub_Trending/skills29/skills
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考