Genkit Go Agent Branching:用不可变 SnapshotID 实现会话分支与多路线 Agent 实战指南
2026/9/13 14:20:07 网站建设 项目流程

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!"
a1checkpointa1.SnapshotID"My name is Bob."
a2a1.SnapshotIDa2.SnapshotID回答 "Bob"
b1checkpointb1.SnapshotID"My name is John."
b2b1.SnapshotIDb2.SnapshotID回答 "John"

两条分支都从同一个checkpoint出发,但各自的后续历史完全独立:分支 A 认为用户叫 Bob,分支 B 认为用户叫 John,彼此不互相污染。这背后是会话存储(localstore.NewInMemorySessionStorelocalstore.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一一对应。

六、分支的存储语义:被遗弃的分支与链剪枝

分支之所以能安全、廉价地存在,源于快照的不可变设计。文档明确给出了两条存储语义:

  1. 被遗弃的分支会一直保留:被丢弃的分支只是作为不可变快照静静地留在存储中,分支发生时不会有任何数据被覆盖。这保证了 fork 的任意分支(哪怕后来没人继续)都可以在之后被重新拾起;
  2. 链剪枝不影响兄弟分支FileSessionStore配置了localstore.WithMaxPersistedChainLength(n)时,只会沿着单条链的父级链接修剪最老的快照(即只保留该链最新的 n 个),兄弟分支会被独立保留,不会被连带删除。这意味着你可以放心地限制每条链的长度来控制存储占用,而不必担心误删并行分支。
pruning, err := localstore.NewFileSessionStoreany, // 每条链只保留最新 3 个快照 )

从实现上看,localstore的两个单进程存储(NewInMemorySessionStoreNewFileSessionStore)都遵循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 startflow:run包装单回合)参见 SKILL.md 与 agents.md。

最后提醒:由于该 API 属于实验特性,导入路径与函数签名可能在小版本更新中变化,生产使用前请锁定依赖版本,并始终通过genkit.WithExperimental()显式开启。

【免费下载链接】skillsAgent Skills for Google products and technologies项目地址: https://gitcode.com/GitHub_Trending/skills29/skills

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询