【google-adk专栏】05.从启动器到事件循环:亲手驱动 Runner
文末有我的微信,需要这个 google-adk 专栏同款代码包(本篇的 runner_demo 直驱入口、状态实测脚本与全部 agent 配置都在里面,跟着文章每一步都能对上号)的朋友们,加好友备注"代码"即可,打包直接发送。
前四篇里,agent 树、审批、工作流产线都跑起来了,但每次运行都经过同一个入口:launcher。go run 一个入口文件,默认进 console 终端,或者加 web webui api 开网页面板。这套用法的前提是旁边坐着一个人。一旦想把 agent 嵌进自己的程序——定时任务扫选题、消息队列来稿自动跑产线、把文案能力做成接口给别人调——launcher 就帮不上忙了。本篇把启动器拆开,认识里面真正的发动机 Runner,用三十行代码亲手驱动它;再借官方 Runtime 文档把 Runner 的运行机制讲透;然后把循环里流动的两类数据拆开:Event 讲怎么消费,State 讲怎么读写。前面四篇还留下几处"就这样跑起来了"的疑问:console 为什么固定执行第一个 agent?events 表里的记录是谁写的?第 3 篇的审批答复怎么找到挂起的调用?第 4 篇的产线怎么从事件历史里恢复现场?本篇之后,它们会在同一个机制里得到解答。
本篇主要内容:
-
换个姿势跑 agent:从启动器到三十行代码 -
Runner 的分工:一个协调器,一群执行者 -
完整时序:一次带工具调用的事件循环 -
Go 的"暂停与恢复":迭代器当 yield -
四条值得记住的运行时行为 -
Event:拆开一封信,写好消费循环 -
State:前缀定去处,三张表分头存,写入走事件
版本约定:除特别指出,本手册的 Go 版 ADK 即 google.golang.org/adk/v2(v2.6.0),genai 为 google.golang.org/genai v1.76.0,openai-go 为 github.com/openai/openai-go/v3 v3.88.0,Go 工具链为 1.26+。
5.1 换个姿势跑 agent:三十行代码直驱
5.1.1 启动器是给人用的
回顾一下前四篇的运行方式,全部经由 launcher。它替你做了三件事:把 agent 装配好,把交互界面支起来(终端或网页),把会话和账本管起来。这套服务的对象是"坐在屏幕前的人"。
场景一换,它就不合适了:
-
每天早上六点扫一遍热搜,把值得写的选题交给 title agent 出标题。没有人守在终端前敲回车。 -
消息队列收到用户来稿,自动触发一轮文案产线。没有网页可开。 -
把文案能力做成 HTTP 服务,给自己的 App 调用。调用方要的只是一次函数调用,交互循环派不上用场。
三个场景的共同点:驱动 agent 的不再是人的输入,而是程序里的事件。所以需要一套编程式的运行接口:在代码里创建 agent、发起一次运行、拿到回复。
5.1.2 剥开 console:里面住着一个 Runner
这套接口不用发明,ADK 本来就有,launcher 只是在外面加了交互层。把 console 启动器抽到只剩骨架,如代码清单 5-1 所示。
代码清单 5-1 console.go:启动器的核心骨架(简化自 adk v2.6.0 cmd/launcher/console/console.go,错误处理省略)
userID, appName := "console_user", "console_app"
sessionService := config.SessionService // ①
if sessionService == nil {
sessionService = session.InMemoryService() // 没配就退回内存版
}
resp, _ := sessionService.Create(ctx, &session.CreateRequest{
AppName: appName, UserID: userID,
})
rootAgent := config.AgentLoader.RootAgent() // ②
r, _ := runner.New(runner.Config{ // ③
AppName: appName,
Agent: rootAgent,
SessionService: sessionService,
// ……(省略 ArtifactService 等可选配置)
})
for {
userInput := <-inputChan // 等一行输入(来自标准输入)
userMsg := genai.NewContentFromText(userInput, genai.RoleUser)
for event, err := range r.Run(ctx, userID, resp.Session.ID(), userMsg, agent.RunConfig{
StreamingMode: streamingMode,
}) { // ④
// 打印回复文本、处理错误、收集审批中断……
}
}
骨架只做四件事。①配会话服务:启动器配置里有就用(我们的项目配的是第 3 篇落地的 GORM SQLite),没配退回纯内存版,进程一退出账本就没了;随后创建一个会话,拿到 ID。②取根 agent,第 2 篇说过的"console 固定执行 MultiLoader 的第一个 agent"就是这一行干的。③用 appName、agent、会话服务三件套创建 Runner。④外层循环等用户输入,每来一行就调一次 r.Run,range 逐个收事件打印。
从清单里去掉的,都是交互层面的杂务:专门开 goroutine 读标准输入、流式输出时的去重打印、审批问答的排队提示(第 3 篇在终端里答过的 yes)。这些与"怎么运行 agent"无关。
骨架已经给出了答案:编程方式运行 agent,就是"拿一个 agent,配一个会话服务,建一个 Runner,然后 range 它的 Run"。
5.1.3 runner_demo:复刻启动器的骨架
照着这个骨架写 demo,完整代码如代码清单 5-2 所示。
代码清单 5-2 runner_demo.go:三十行代码编程式运行 title agent
package main
import (
"context"
"fmt"
"log"
"opc-platform/copewriter"
"opc-platform/runconf"
"google.golang.org/adk/v2/agent"
"google.golang.org/adk/v2/runner"
"google.golang.org/adk/v2/session"
"google.golang.org/genai"
)
func main() {
ctx := context.Background()
titleAgent := copewriter.CreateTitleAgent() // ①
//创建 runner
sessionSvc := runconf.GetSessionService() // ②
//调用 runner 的 run 方法驱动内部的 agent,持续获取 agent 产生的 events
resp, err := sessionSvc.Create(ctx, &session.CreateRequest{
AppName: "runner-demo", // ③
UserID: "test-user",
})
if err != nil {
log.Fatal(err)
}
r, _ := runner.New(runner.Config{ // ④
AppName: "runner-demo",
Agent: titleAgent,
SessionService: sessionSvc,
})
var userMsg *genai.Content
userMsg = genai.NewContentFromText("hi", genai.RoleUser) // ⑤
for event, err := range r.Run(ctx, "test-user", resp.Session.ID(), userMsg, agent.RunConfig{
StreamingMode: agent.StreamingModeNone,
}) { // ⑥
if err != nil {
log.Fatal(err)
} else {
//获取事件中的 llm 响应内容
fmt.Println(event.Content.Parts[0].Text)
}
}
}
这六步把清单 5-1 的骨架展开成了可直接运行的代码。①复用第 2 篇的配置工厂创建 title agent。②复用 runconf 里的 GORM 会话服务,连的还是那四张表。③显式创建一个会话,拿到会话 ID——console 里这步由启动器代劳,自己写代码时得自己来。④创建 Runner,三件套与清单 5-1 的③完全同款。⑤把一句话包成用户消息,角色标为 user。⑥range r.Run:每转一圈收到一个事件,打印第一个文本片段。
**提示:根 agent 必须是 chat 模式。**Runner 明确拒绝非 chat 模式的根 agent,直接报错。第 3 篇给三个专业 agent 声明的都是 single_turn;本篇要让 title agent 单独当根直驱,所以把 CreateTitleAgent 改回了 chat。outline、article 仍是第 3 篇声明的 single_turn,继续蹲在产线节点上当一次性工位。模式跟着位置走:做根要 chat,做节点要 single_turn。
运行它。现在仓库根目录有四个入口文件,各带一个 main 函数,按文件名单文件运行:
go run runner_demo.go
运行效果如图 5-1 所示:几屏 [llm-raw] 原始报文日志(第 2 篇装的透传)之后,是 title agent 对 "hi" 的回复——它自报家门,向你要内容主题。回复之外,这轮调用还真实落了账:data/adk.db 的 sessions 表多了一条 app_name 为 runner-demo 的会话,"hi" 和回复作为两个事件进了 events 表。
图 5-1 runner_demo 的运行效果
**实战经验:多轮对话的钥匙是会话 ID。**一次 Run 只处理一条用户消息,跑完就返回。下一轮对话,拿同一个会话 ID 再调一次 Run,上下文自然接上——console 从启动到退出始终用同一个会话,交互感就来自这个循环。runner_demo 每跑一次建一个新会话,所以每次都是初次见面。想让对话连续下去,把会话创建放在循环外,循环里反复调 Run 就行。这件事此前四篇都由启动器代劳,这次换你亲手写。
5.2 Runner 的心脏:事件循环
5.2.1 一个协调器,一群执行者
清单 5-2 跑通了,但 r.Run 里面发生了什么?官方 Runtime 文档(Runtime: Event Loop 一节)把这套机制叫事件循环(event loop),并称它是运行时的心跳:Runner 与执行逻辑之间一来一回,每一下都推动 agent 前进。
分工很清楚。Runner 是协调器,负责管事件循环、把事件写进会话、转发给调用方,它自己不调 LLM、不算业务。执行逻辑是干活的,包括 agent、工具、回调。两边靠 Event 通信:Event 是自包含的信息包,带着内容(文本或函数调用)、动作(状态增量)、元数据(作者、时间戳)。各组件的职责见表 5-1。
表 5-1 Runtime 关键组件
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
执行逻辑与循环的交互只有四个动作:执行(基于调用上下文干活)、交出(把结果包成 Event 递出去,文档叫 yield)、暂停(交出后立即停下,等 Runner 处理)、恢复(Runner 处理完,从停下的地方继续)。记住这四个动作,下面的时序就是它们的一次次循环。
5.2.2 完整时序:一次带工具调用的事件循环
图 5-2 是官方文档给出的 Runner 内部完整时序:用户问"法国的首都是什么",一个挂着工具的 LLM agent 从收到问题到给出答案的全过程。整个时序可以分三幕看。
第一幕,Runner 接活。Run 被调用后,Runner 先通过会话服务加载 Session;再把用户这句话包装成第一个 Event(作者为 user)追加进事件历史;然后准备好本次调用的上下文,发一个 InvocationID——这次调用产生的所有事件都带这个 ID。
第二幕,来回让路。agent 去调 LLM,LLM 决定用工具,返回一个函数调用;agent 把它包成 Event 交出并暂停;Runner 接手,先把事件写进会话服务,再转发给调用方(网页、终端,或者你代码里的 for range);agent 恢复,执行工具拿到结果,把结果包成函数响应事件再次交出、再次暂停;Runner 再次落库、转发……每完成一次"交出、暂停、处理、恢复",events 表多一行,调用方的 range 循环转一圈。
第三幕,收口。工具结果回炉给 LLM,生成最终的自然语言回复,作为最后一个 Event 交出;agent 再无事件可交,这次调用结束。
图 5-2 Runner 内部的完整时序
这个循环解释了前几篇的几处"就这么发生了":
-
第 3 篇的审批:挂起时的事件是函数调用(adk_request_confirmation),你的答复是同一个 ID 的函数响应。两种事件都在这个循环里流动,第 3 篇图里的审批记录就是其中一次让路。 -
第 4 篇的产线中断:adk_request_input 函数调用挂起、同 ID 答复恢复,同一套协议。 -
第 4 篇的恢复现场:ReconstructRunState 从事件历史重建运行状态。事件历史因此有两重身份:它是账本,也是恢复暂停现场的唯一依据。
5.2.3 Go 的"暂停与恢复":迭代器当 yield
官方文档以 Python 为主语言:agent 是 async generator,yield 之后真正挂起,Runner 处理完再唤醒。各 SDK 的落地方式不同,文档专门列表对照:Java 用 RxJava 的 concatMap 建模"处理后才恢复",Go 用 range-over-func 迭代器。Runner.Run 的签名如代码清单 5-3 所示。
代码清单 5-3 Runner.Run 的签名(adk v2.6.0 runner/runner.go)
func (r *Runner) Run(ctx context.Context, userID, sessionID string,
msg *genai.Content, cfg agent.RunConfig, opts ...RunOption,
) iter.Seq2[*session.Event, error]
它的返回值是一整个事件序列(iter.Seq2:可 range 的"事件+错误"二元流),最终回复只是其中最后一个事件。这份序列是懒的:你不 range 它,机器不转;range 循环体每转一圈,恰好对应"Runner 处理完一个事件、agent 从暂停点恢复"的一个节拍。demo 清单 5-2 的⑥就是站在消费者侧参与这台循环。
暂停与恢复在 Go 里的实现很直白。执行逻辑内部调用 yield 函数把事件递出去,这个调用在消费方循环体结束之前不返回。不返回,就是暂停。循环体转完一圈后 yield 返回,执行从停下的那行继续,这就是恢复。没有语法级的生成器,一个普通的函数调用把语义补齐了。
**轻松一刻:等了多年的"for range 万物"。**range-over-func 在 2024 年的 Go 1.23 转正,配套的 iter 包进了标准库。为了"让 for range 能遍历任意序列",Go 团队讨论了两年多,Russ Cox 还专门写过系列文章论证为什么不引入协程、最终选了轻量的函数式迭代器。ADK 拿它复刻 Python 生成器的"暂停-恢复"语义,算是一次语言特性补齐另一个生态的运行时模型。
5.2.4 值得记住的四条运行时行为
事件循环的主干之外,官方文档标注了几条运行时行为,Go 源码都能对上。它们平时无感,排坑时是基础。
**1.流式事件只转发,不落库。**开启流式输出后,LLM 的一段回复会拆成多个带 Partial 标记的事件,边生成边转发给调用方,界面上就是逐字出现的效果。Runner 对这些部分事件只转发、不写会话服务;等最终事件到达才落库。这样事件上携带的动作只被应用一次,不会重复记账。console 的流式模式吃的就是这个。
**2.状态的生效时机是事件落库。**执行逻辑想改会话状态,自己不动手,只把增量挂在事件的动作上。Runner 把事件写进会话服务的那一刻,修改才生效。对照第 3 篇的 app_states、user_states 表:表里的值永远跟事件历史一致,因为它们就是从事件上的增量攒出来的。
**3.单次调用内的状态可见性有边界。**刚写进状态的值,什么时候自己读得到、什么时候要等下一轮?Go 版有两条硬边界,5.4 节拆开讲。
**4.LlmAgent 的根走同一个图引擎。**根 agent 是 LlmAgent 时,Runner 并不直接执行它,而是把它包成一个"单节点 workflow"(START 指向唯一节点),让所有 agent 都走统一的图引擎,事件路径与 Python 保持一致。第 4 篇 4.3 节说"agent 与 workflow 是一家人",这一处是它在 Runner 层的实锤。也因此,第 4 篇讲的中断恢复(从事件历史重建状态)对普通 agent 同样生效。
提示:写测试用 NewInMemory。
runner.NewInMemory(appName, agent)一步拿到"全内存会话 + 自动建会话"的 Runner,不用自己准备会话服务。官方注释明说它面向本地开发与测试,生产环境请用 New 自备服务。
5.3 Event:拆开一封信
5.3.1 信封上写了什么
5.2 的三幕里,Event 是唯一在场的东西:agent 交出的是它,Runner 记账记的是它,你的 for range 收到的也是它。要跟它打交道,先认字段,见表 5-2。
表 5-2 Event 的关键字段(Go session.Event)
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
还有几个字段在前几篇露过面:审批挂起的事件带 LongRunningToolIDs;产线节点事件带 NodeInfo 与 Output。认下这张表,events 表的主要列就都有了着落。
5.3.2 认信:五种信与消费循环
常见的信有五种,辨认从信封开始,三步走。(1)先看 Author,知道是谁写的。(2)Content 非空时,拆 Content.Parts 逐个看:part.FunctionCall 非空是工具调用请求,part.FunctionResponse 非空是工具结果,否则是文本,此时再看 Partial 判断是不是流式碎片。(3)Content 为空时看 Actions:StateDelta 或 ArtifactDelta 非空是副作用事件,TransferToAgent、Escalate 是控制信号。第 2 篇的 transfer_to_agent 委派,走的就是 TransferToAgent 这类控制信号。把这套辨认写进消费循环,如代码清单 5-4 所示。
代码清单 5-4 事件消费循环:按信的类型打印(模式片段,r 为已创建的 Runner)
for event, err := range r.Run(ctx, userID, sessionID, userMsg,
agent.RunConfig{StreamingMode: agent.StreamingModeNone}) {
if err != nil {
log.Fatal(err) // ①
}
fmt.Printf("[%s] ", event.Author) // ②
if event.Content == nil { // ③
fmt.Println("(无正文,只有副作用或节点输出)")
continue
}
for _, part := range event.Content.Parts { // ④
switch {
case part.FunctionCall != nil: // ⑤
fmt.Printf("调用 %s(%v)", part.FunctionCall.Name, part.FunctionCall.Args)
case part.FunctionResponse != nil: // ⑥
fmt.Printf("结果 %s -> %v", part.FunctionResponse.Name, part.FunctionResponse.Response)
default: // ⑦
fmt.Print(part.Text)
}
}
if event.IsFinalResponse() { // ⑧
fmt.Println(" <- 本条为最终回复")
}
fmt.Println()
}
①错误照旧先拦。②Author 打头,多 agent 参与时一眼看出谁在说话。③Content 为空的事件没有正文可读,副作用都在 Actions 里,别对它取 Parts。④⑤⑥⑦是辨认的固定套路:函数调用、函数响应、文本三类 part 各归各位。⑧最终回复的标记,下一小节讲它怎么判。把这套循环套在 runner_demo 上,title agent 的回复会被打成 [title-agent] hi!我是标题创作专家。…… <- 本条为最终回复;套在第 3 篇的 leader 上,子代理与文件工具的来往都会现形。
5.3.3 IsFinalResponse:哪条是最终回复
流式消费时最要紧的问题:这轮结束了吗?Event 给了现成的 IsFinalResponse(),判定规则三条。压缩记账事件(上下文过长时框架压缩历史的记账事件)不算。SkipSummarization 置位(告诉框架别再让模型总结工具结果)或 LongRunningToolIDs 非空,直接算——第 3 篇审批的挂起事件正是靠 LongRunningToolIDs 成为"最终回复",agent 循环才在那里收口,把回合停下来等人。其余情况要求同时满足:没有函数调用、没有函数响应、不是流式碎片、结尾不是代码执行结果。
两个使用要点。其一,别照着规则手写判断,直接调 IsFinalResponse(),console 流式模式的去重打印就建立在它上面。其二,一次调用里有多个 agent 参与时,每个参与 agent 都可能贡献一条最终回复,别假设全局只有一条。
5.3.4 账本的第二种用法
事件落库后就是会话的历史,会话对象上留着读历史的口子:Session.Events() 提供 All()、Len()、At() 三个方法,可以整段遍历,也可以按下标取第几条。第 3 篇的对账 SQL 查的是这份历史落库后的样子,这里拿到的是内存里的同一份。
调试时靠两样东西:拿 InvocationID 串起一次调用的全部事件,拿 ID 精确引用某一条。要留心的只有一件事——幂等。事件可能被重复处理(重试、重放、恢复现场都算),消费逻辑别假设每条事件只到达一次。
5.4 State:会话的草稿板
官方文档把 session.state 叫会话的草稿板:agent 工作时随手记键值对的地方,键是字符串,值要能序列化。典型用途是跨轮记住事实——用户偏好、任务进度、决策开关。第 3 篇结尾预告的 app_states 与 user_states,连同 sessions 表的 state 列,装的都是它。这一节把它的机制与用法补齐。
5.4.1 前缀定去处
草稿板是一整块,但写上去的每个键都按前缀路由到不同的仓库,见表 5-3。
表 5-3 状态键的前缀与作用域
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
三件事要知道。agent 代码里看到的是一张合并后的大字典,按前缀分流到三张表是 SessionService 的活。temp: 键在父 agent 和子 agent 之间共享(同一次调用共用一份调用上下文),适合当便签,调用一结束就被剥掉。前缀是单层的,不组合:app:temp:x 是个普通的 app 作用域键,temp 部分没有任何临时语义。
5.4.2 读状态:接口与模板
读有两个入口。程序侧,从会话服务 Get 回会话后 resp.Session.State().Get("key");工具与回调里,ctx.State().Get("key")。
更有意思的读法是把状态注入提示词。Instruction 支持 {key} 模板,框架在发给 LLM 前替换成状态值:
## 角色
本次创作主题:{theme}。目标平台:{platform?}。
theme 在状态里就会填入实际值;{platform?} 带问号是可选键,缺失不报错;{artifact.名} 还能注入工件文本。两个边界要记住。模板键只认裸标识符(字母、数字、下划线),带前缀的键比如 user:nickname 填不进去,会被当字面文本留在提示词里。提示词里要保留字面花括号(比如给 LLM 看的 JSON 示例),就换 InstructionProvider:它接管 Instruction 并关闭模板,花括号原样发出;需要注入状态时配合 util/instructionutil.InjectSessionState 手动注入。
5.4.3 写状态:三条正路
第一条,工具与回调里 ctx.State().Set,最常用。写法与读对称:
functiontool.New(functiontool.Config{Name: "note_style", Description: "记下用户偏好的文风"},
func(ctx agent.Context, args noteStyleArgs) (string, error) {
ctx.State().Set("user:style", args.Style) // 增量自动挂到当前事件
return "已记录", nil
})
第二条,OutputKey,一行配置换一个自动动作:agent 的最终回复文本写进指定的状态键。挂在我们熟悉的 Build 闭包里:
agents.Build(cfg, func(c *llmagent.Config) { c.OutputKey = "last_title" }) // 最终回复自动存入该键
第三条,系统事件,程序侧不经过 LLM 直接改状态的正路。清单 5-5 在本项目上实测过,连的仍是 runconf 的 GORM 会话服务。
代码清单 5-5 用系统事件写状态(state_probe,实测运行)
ev := session.NewEvent(ctx, "state-demo-inv-1") // ①
ev.Author = "state-demo"
ev.Actions.StateDelta["theme"] = "职场沟通" // ②
ev.Actions.StateDelta["user:nickname"] = "静远"
ev.Actions.StateDelta["temp:step"] = "draft"
sessionSvc.AppendEvent(ctx, resp.Session, ev) // ③
got, _ := sessionSvc.Get(ctx, &session.GetRequest{ // ④重读验证
AppName: "state-demo", UserID: "u1", SessionID: resp.Session.ID(),
})
for _, key := range []string{"theme", "user:nickname", "temp:step"} {
v, err := got.Session.State().Get(key)
if err != nil {
fmt.Printf("%s -> 读不到\n", key)
} else {
fmt.Printf("%s -> %v\n", key, v)
}
}
运行输出:
theme -> 职场沟通
user:nickname -> 静远
temp:step -> 读不到
①NewEvent 造一个新事件,ID、invocationID、时间一次填齐,增量字典也已备好,②直接挂键、不用判空。③经 AppendEvent 提交,这一步就是 5.2.4 第 2 条说的"落库生效"。④重读验证,三个键三种命运——各自落进了哪张表,下一小节打开数据库对账。
**提示:读随意,写必须走事件。**直接对 Get 回来的会话对象
Session.State().Set,是绕过事件历史的野路子:账上查不到、不可审计,进程重读会话后修改也跟着消失,还有并发风险。程序侧要写状态,只有本节三条路。读取没有这个限制,随便 Get。
5.4.4 状态住在哪张表
第 1 篇 1.4.5 查验过 data/adk.db 的四张表,当时只点了名字。状态住在其中三张,形状见表 5-4。
表 5-4 状态的三个存储去处(本项目 GORM 会话服务)
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
对照清单 5-5 的落库结果:sessions 表里该会话的 state 列存着 {"theme":"职场沟通"};user_states 表多了一行,state 列存着 {"nickname":"静远"}——user: 前缀入库时被剥掉了。app_states 这次没轮到,清单没写 app: 键。
看这三张表,有三处要紧。第一,前缀只是路由标签,表里存的是剥掉前缀的裸键。读的时候框架把三张表拼回一张大字典,给 user、app 键重新戴上前缀,所以取键永远用带前缀的全名。哪张表还没有行,就按空对象起步,这是正常路径——第 1 篇 runconf 里开启的 IgnoreRecordNotFoundError,挡的正是它触发的 GORM 红色日志。第二,一行一整包,不是一键一行。改一个键就是重写这一包,每行只有最后更新时间,没有键级历史。想看某个键一路怎么变,历史在 events 表的事件增量里。第三,写入发生在事件落库那一刻。服务把事件上的增量按前缀拆成三份,分头合进三行。temp: 键在这条路上被直接丢弃,连事件身上的增量都进不了库:清单 5-5 落库后,events 表里那行事件的增量只有前两个键。
5.4.5 提交时机:从草稿到账本
状态改动什么时候真正生效?分两种情况。
第一种:工具、回调里调 ctx.State().Set。这个写会同时进两个地方——当前事件的增量,和本次调用手里那份内存会话。所以同一次调用里,后面的工具能读到前面刚写入的值,官方文档不避讳这一点,管它叫脏读。但账还没记:增量还在事件上,落库那一刻才作数(5.2.4 第 2 条)。调用中途失败、事件没能落库,脏写的值就全丢了。
第二种:随事件 yield 出去再提交的增量,OutputKey 是典型。事件提交后,agent 手里那份内存上下文不刷新,同一次 Run 里读到的还是旧值;下一轮 Run 开始时重新加载会话,新值才可见。官方文档专门为 Go 标注了这一点:Python 的 agent 在提交后恢复,能立刻读到已提交的新值,Go 做不到。
5.2.4 第 3 条说的就是这两条边界。设计时的推论也简单:靠状态在同一次调用里传递信息是可行的(脏读就是机制);靠状态跨轮传递信息,写的一方别指望同轮读到回执。
使用上再记两条:
-
键名带前缀表意,看键名就知道作用域 -
只存真正要跨轮用的,草稿板不是杂物间
5.5 小结
本篇从"想把 agent 嵌进程序"出发,把陪我们四篇的 console 启动器拆开了。它的骨架只做四件事:配会话服务、取根 agent、建 Runner、循环等输入并 range Run。三十行的 runner_demo 复刻了这套骨架,在终端之外亲手转了一圈 agent,账目照常落进 SQLite。
下半篇借官方 Runtime 文档把 Runner 讲透:Runner 是协调器,agent、工具、回调是执行逻辑,Event 是两边的通信协议;一次调用由同一个 InvocationID 串起,"执行、交出、暂停、恢复"四个动作循环往复,直到 agent 再无事件可交。事件循环之外还有四条运行时行为:流式事件只转发、状态随事件落库、单次调用内状态可见性有边界、LlmAgent 的根走同一个图引擎。
最后两节拆开了循环里流动的两类数据。Event 是信:Content 装正文,Author 记写信人,Actions 携带副作用;辨认五种信走 Parts 结构,IsFinalResponse 是"这轮结束了吗"的官方答案,审批挂起正是靠 LongRunningToolIDs 把循环收口在人面前。State 是草稿板:前缀定去处,无前缀归会话、user 归用户、app 归全局、temp 单次调用即弃。三张表分头落库,读回时拼成一张大字典。写入只有三条正路——工具里的 Set、OutputKey、系统事件——生效时机统一是事件落库那一刻。
开头提到的代码包,扫码加我个人微信,备注"代码"即可直接发送。
欢迎点赞收藏转发关注。
你有没有一个想把 agent 嵌进去的程序?定时任务、队列消费者或者一个现成接口都行,说说看,我帮你看看接上 Runner 要几步。

