【google-adk专栏】04.从天马行空到流水线:确定性的文案工作流
文末有我的微信,需要这个 google-adk 专栏同款代码包(本篇新增的工作流产线与人工选标题节点源码都在里面,跟着文章每一步都能对上号)的朋友们,加好友备注"代码"即可,打包直接发送。
第 3 篇结束时,文案部已经能独立完成"标题→大纲→成文→落盘",成稿进了文件,账目进了 SQLite。但这份交付的稳定性是建立在沙子上的:五步流程住在总管的系统提示词里,这一轮它是否按部就班、产出什么格式、参数带没带全,全看 LLM 当轮的状态。本篇把路由从提示词里搬出来,钉进代码。先用一个三节点的小管道认识 ADK 的 workflow,顺带看清一个底层事实:在 ADK 眼里,LLM agent 和 workflow 是同一种东西,运行方式一样,记账方式也一样。然后复用第 3 篇的三个专业 agent,装配文案产线 2.0,在产线中间种下一个"人工选标题"工位,并把这道工序背后的中断与恢复机制拆开看明白。
本篇主要内容:
-
总管的烦恼:五步流程是提示词里读出来的 -
workflow 初试:三个节点,一条管道 -
agent 与 workflow 是一家人 -
文案产线:三个 agent 原样复用,加一个人工工位 -
中断与恢复:选标题节点的两幕戏
版本约定:除特别指出,本手册的 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+。
4.1 总管的烦恼:五步流程是提示词里读出来的
先回顾代码清单 3-6 里总管的编排指令:
## 标准编排流程
收到完整创作任务(如"写一篇文章")时按序编排,每步结果回到你手上后再进入下一步:
1. 委派 title-agent 创作候选标题,从结果中选定一个最佳标题
2. 把选定标题交给 outline-agent 规划大纲
3. ……
五步流程写在自然语言里,挂在系统提示词上。演示时看得过去,当工程方案用,有三桩烦恼。
第一桩,路由没有保证,因为 LLM 本身就是概率的。同一句"写一篇文章"发十遍,总管的十次决策不会完全一致:可能跳步(成文之后忘了落盘),可能重排(先要大纲再回头补标题),可能传话失真(选定的标题没进下一站的参数),也可能过度发挥(自作主张把一篇拆成两篇)。把温度调到 0 也只是收窄波动,改变不了本质:LLM 对提示词的态度是"尽量照办",动作是逐个 token 采样出来的。单看一步,出岔概率不高;可流程是串联的,可靠性要做乘法——假设每步 95% 听话,五步下来 0.95 的五次方约等于 77%,十次任务就有两三次跑偏。而在代码里,你找不到任何一行能保证"标题在前、大纲在后",唯一的保证住在提示词的"标准编排流程"里。提示词是请求,不是约束。
第二桩,想往流程里加一个无需 AI 的函数节点,加不进去。举个具体的例子。title-agent 的系统提示词里写着一条硬性约束:"每组标题的长度不超过 20 个字符"。规则清楚,执行全凭自觉:模型眼里只有 token,没有字符,"20 个字符"在它那儿是个模糊的量,超没超全靠感觉。这种检查交给代码最合适,一个 len() 的事,判定永不失手。可这个函数往哪放?agent 树里没有"函数节点"这个位置。做成工具挂给总管?工具调不调,由 LLM 自己决定:它可能查,也可能忘,确定性检查又变回了概率事件。做成子代理?等于把数字符这种事外包给概率模型,更不靠谱。总管自己数?还是 LLM。委派、transfer、工具调用,agent 树里每条边的通行证都握在 LLM 手里。想在标题站和大纲站之间插一段无需 AI 即可运行的 Go 代码,框架没有这个概念,硬约束只好继续住在提示词里。
第三桩,路由烧 token,还测不了。每走一站都要总管出面做一次 LLM 决策:系统提示词和工具清单重读一遍,模型再决定"下一站去哪",连把标题转交给下一站这样的搬运也在其列。与此同时,"断言总管先调 title-agent 再调 outline-agent"这样的用例写不出来——路由不在代码里,拿什么断言?
三桩烦恼指向同一件事:创作环节需要 LLM,路由环节不需要。路由要的是确定性,把路由交给代码、把生成留给模型,这就是 workflow。
4.2 workflow 初试:三个节点,一条管道
workflow 的构成一句话讲完:节点干活,边定顺序。边是 Go 代码里连的,引擎按边调度,路由从头到尾没有一个 LLM 参与决策。先看一个最小的管道,如代码清单 4-1 所示。
代码清单 4-1 workflow_demo.go:三个函数节点连成顺序管道(节选)
// Step 1: return a city name. The string is set as event.Output and
// becomes the typed input of the next node.
cityGeneratorNode := workflow.NewFunctionNode("city_generator_agent",
func(_ agent.Context, _ any) (string, error) {
return "Tokyo", nil ①
},
workflow.NodeConfig{})
// Step 2: receive the city name and return structured time data.
lookupTimeNode := workflow.NewFunctionNode("lookup_time_function",
func(_ agent.Context, city string) (cityTime, error) { ②
return cityTime{City: city, TimeInfo: "10:10 AM"}, nil
},
workflow.NodeConfig{})
// Step 3: receive the cityTime struct and produce the final report string.
cityReportNode := workflow.NewFunctionNode("city_report_agent",
func(_ agent.Context, ct cityTime) (string, error) {
return fmt.Sprintf("It is %s in %s right now.\nWORKFLOW COMPLETED.",
ct.TimeInfo, ct.City), nil
},
workflow.NodeConfig{})
demoAgent, _ := workflowagent.New(workflowagent.Config{ ③
Name: "workflow_agent",
Description: "Sequential workflow: generate city → look up time → report.",
Edges: workflow.Chain(workflow.Start,
cityGeneratorNode, lookupTimeNode, cityReportNode),
})
loader := agent.NewSingleLoader(demoAgent)
lc := &launcher.Config{ ④
AgentLoader: loader,
SessionService: runner.GetSessionService(),
}
l := full.NewLauncher()
var err error
if err = l.Execute(ctx, lc, os.Args[1:]); err != nil {
log.Fatalf("运行失败: %v\n\n%s", err, l.CommandLineSyntax())
}
逐个看圈号。①②处是 workflow 最迷人的地方:节点的输入输出是类型化的。第一个节点返回 string,第二个节点的参数就声明成 city string;哪天手滑改成 int,编译器当场拒绝。工位之间的契约从"提示词的承诺"升级成"函数签名"。数据也不绕道:上游的返回值被打进事件、交给下游节点(代码注释写得直白:set as event.Output, becomes the typed input of the next node),中间不需要共享状态。③处 workflow.Chain 把三个节点连成顺序管道,workflow.Start 是起点哨兵。顺带留意节点名——city_generator_agent 其实是个普通 Go 函数。在 workflow 里,"谁在干活"只是个节点:函数能当节点,LLM agent 也能当节点。4.1 里没处安放的标题长度检查,到这里就是再普通不过的一个函数节点;4.4 节再让真 agent 上场。
再看④处,这段尾巴值得盯着看十秒。把它和总管入口(仓库整理后的 agent_tree_demo.go)的启动尾巴并排放:从 launcher.Config 到 full.NewLauncher().Execute,逐行相同(差的只是 err 声明的位置),真正的区别只有一行——AgentLoader 里装的是谁。总管入口装的是 MultiLoader,里面是 leader;这里装的是 SingleLoader,里面是 workflow。
运行方式因此一个字都不用学:
go run workflow_demo.go web webui api
Web UI 的 agent 选择器里多了一个 workflow_agent,选中它,发一句 "hi",事件流如图 4-1 所示。
图 4-1 workflow_agent 的运行实录:三个节点依次出场,终点输出 "It is 10:10 AM in Tokyo"
一条用户事件,三条节点事件。再发一句 "hi2",输出与上一轮一模一样——events 表可以作证:两次运行的节点 output_json 逐字节相同。同样的输入,走同样的路,出同样的结果,这就是确定性。
轻松一刻 1909 年,福特对外界说:任何顾客都可以把车漆成他想要的颜色,只要那是黑色(Any customer can have a car painted any colour he wants, so long as it is black)。流水线不给选项,所以它快、便宜、可预期。一百多年后我们把 LLM 请上流水线,道理没变:确定性,恰恰来自不让每个工位即兴发挥。
4.3 agent 与 workflow 是一家人
清单 4-1 ④处那段"盯着看十秒"的启动尾巴,背后是 ADK 的一层窗户纸:workflow 进 launcher、进 webui、进 console,零适配。为什么能这样?因为在 ADK 的架构里,workflow 本身就是 agent。本节把这层纸捅破。
先看全家福的共同面孔——Agent 接口,如代码清单 4-2 所示。
代码清单 4-2 adk v2.6.0 agent/agent.go:Agent 接口(节选)
// Agent is the base interface which all agents must implement.
type Agent interface {
Name() string
Description() string
Run(InvocationContext) iter.Seq2[*session.Event, error] ①
SubAgents() []Agent
FindAgent(name string) Agent
FindSubAgent(name string) Agent
internal() *agent
}
接口不大,灵魂在①处:一个 agent 的全部行为,归结为一个 Run——吃进调用上下文,吐出一条事件流。用户消息、模型输出、工具调用、中断请求,都是这条流里的事件。
那"LLM agent"和"workflow agent"分别是怎么做成的?答案简单得出奇:都由同一个构造器 agent.New 造出来,差别只在塞进去的 Run 是什么。workflow 这一侧的做法如代码清单 4-3 所示。
代码清单 4-3 adk v2.6.0 agent/workflowagent/workflow.go:workflow 变身 agent(节选)
func New(cfg Config) (agent.Agent, error) {
w, err := workflow.New(cfg.Name, cfg.Edges) ①
...
wfAgent, err := agent.New(agent.Config{
Name: cfg.Name,
Description: cfg.Description,
SubAgents: cfg.SubAgents,
Run: wa.run, ②
})
...
state.AgentType = agentinternal.TypeWorkflowAgent ③
return wfAgent, nil
}
①处先把图本身建出来(节点加边);②处把图引擎塞进 Run 回调;③处打个类型标记,可观测层凭它把遥测 span 叫成 invoke_workflow 而不是 invoke_agent,除此之外没人需要知道内情。而 llmagent.New 的内部是同构的:同样经 agent.New 构造,塞进 Run 的是 LLM 循环——调模型、执行工具、带着结果回头再调,直到交出最终回复。
现在亲缘关系一目了然,如图 4-2 所示。
agent.Agent(接口)
│ 统一经 agent.New(Config{Run: …}) 构造
┌────────────────┼────────────────┐
llmagent.New workflowagent.New agent.New(自定义)
Run = LLM 循环 Run = 图引擎 Run = 你的函数
图 4-2 agent 一家亲:同一个接口、同一个构造器,只换 Run 回调
两种 agent 的差别,只是一个 Run 回调的内容。Run 之外的一切都共享:runner 驱动、事件流、launcher 全家桶、会话存储、可观测性,全是同一套。这是 ADK 架构一致性最核心的体现:LLM agent 与 workflow,是同一个运行时的两件衣服。
记账一侧最能说明问题。图 4-1 的三条节点事件,与总管的事件落进同一张 events 表:author 记 workflow 的名字,节点路径进 node_info_json(形如 workflow_agent@1/city_generator_agent@1),节点产出进 output_json。不止如此,workflow 跑到一半暂停后(怎么暂停是 4.5 节的正题),恢复所需的运行状态也从事件历史重建:停在哪个节点、哪些节点已完成,答案全在事件里,没有第二套存储。第 3 篇 3.5 节的对账 SQL,拿来查 workflow 一字不用改。
这份一致性还有个红利:workflow 也是 agent.Agent,于是凡是 agent 能出现的位置它都能出现——进 MultiLoader、挂上 leader 的子代理清单;反过来,任何 agent 也都能通过 NewAgentNode 进 workflow 当节点。LLM agent 与 workflow 可以互相嵌套,零额外胶水代码。
提示 想做自定义 agent?眼下同样走 agent.New,把逻辑塞进 Run,别自己实现 Agent 接口。接口的源码注释说得直白:直接实现接口要到将来的版本才开放(in future releases we will allow just implementing this interface),目前 agent.New 是官方正确答案——初始化和配置它都替你办了。
4.4 文案产线:三个 agent 原样复用,加一个人工工位
地基打好,把文案部的产线重建一遍:标题→选标题→大纲→成文,四站固定。其中三站复用第 3 篇的三个专业 agent,剩下一站留给人。装配如代码清单 4-4 所示。
代码清单 4-4 copewrite_workflow.go:文案管道装配(节选)
titleAgent := copewriter.CreateTitleAgent()
outlineAgent := copewriter.CreateOutlineAgent()
articleAgent := copewriter.CreateArticleAgent()
//将 llm agent转成工作流节点
titleNode, _ := workflow.NewAgentNode(titleAgent, workflow.NodeConfig{}) ①
outlineNode, _ := workflow.NewAgentNode(outlineAgent, workflow.NodeConfig{})
articleNode, _ := workflow.NewAgentNode(articleAgent, workflow.NodeConfig{})
copewritePiplineAgent, _ := workflowagent.New(workflowagent.Config{
Name: "copewrite-pipline",
Description: "文章创作工作流管道",
Edges: workflow.Chain(workflow.Start,
titleNode, humenSelectTitleNode, outlineNode, articleNode), ②
})
①处 NewAgentNode 把任意 agent.Agent 包成节点,三个 Create 函数一行没改、原样复用——这就是 4.3 节一致性的直接红利。②处四站连成一条线,其中 humenSelectTitleNode 是人工选标题工位,下一节专门拆。
提示 LLM agent 放进 workflow 节点,以单轮模式运行。源码注释说得很清楚:图节点是一次性工位(a graph node is a one-shot placement),没声明模式的 agent 到了节点上也会被按成单轮。我们的三个 agent 在第 3 篇已自己声明了 single_turn,到这里正好无缝衔接。
人工工位为什么必要?标题的选择没法交给代码,也不忍心交给掷骰子的路由。总管时代标题由总管定夺;workflow 里没有总管,而标题决定文章的生死,这道判断留给人才放心。ADK 的 workflow 原生支持中途暂停等人,做起来只要一个函数节点,完整代码在 4.5 节代码清单 4-5。
4.5 中断与恢复:选标题节点的两幕戏
产线上最特别的一站就是这个人工工位,完整代码如代码清单 4-5 所示。类型参数 [any, any] 声明的是工位的进出类型,这个工位两边都用不上,取 any 即可。
代码清单 4-5 copewrite_workflow.go:人工选标题节点
resume := true
humenSelectTitleNode := workflow.NewEmittingFunctionNode[any, any]("select_title", ①
func(ctx agent.Context, _ any, emit func(*session.Event) error) (any, error) {
//在首次执行时,workflow.ResumeOrRequestInput 发出一个 session.RequestInput 事件
//(以 Event.RequestedInput 的形式呈现)并返回 ErrNodeInterrupted,从而暂停工作流。
//在人工回复后,节点会从顶部重新调用(RerunOnResume: &true),
//ResumeOrRequestInput 返回回复内容,该内容通过 event.Output 作为类型化输入流向下一个节点。
reply, err := workflow.ResumeOrRequestInput(ctx, emit, session.RequestInput{ ②
InterruptID: "select_title-" + ctx.InvocationID(), //InterruptID前后要一致才能正常流转 ③
Message: "请选择标题",
})
if err != nil {
return nil, err
}
return reply, nil ④
},
workflow.NodeConfig{RerunOnResume: &resume}, ⑤
)
这个节点和第 3 篇 3.4 节的确认分支一样,是两幕戏。
第一幕,首次执行,暂停。 函数体走到②处。这是节点第一次执行,上下文里没有人工答复,ResumeOrRequestInput 便发出一个 RequestInput 事件,随即返回 ErrNodeInterrupted。事件经①处的 emit 回调发出去,用 NewEmittingFunctionNode 正是为此:它允许函数中途向事件流发事件,普通 FunctionNode 做不到。引擎收到这个错误,把节点转入等待态;这个事件自带最终回复标记,agent 循环就此收住,整轮运行干净地结束,等人类开口。
第二幕,答复到来,从头重跑。 用户在 Web UI 的表单里提交选择,客户端写回一条答复事件。下一轮,框架认出这条答复,从事件历史重建 workflow 的运行状态,倒回 select_title 节点、从函数第一行重新执行(⑤处的 RerunOnResume: &true 说的就是这件事)。函数体再次走到②处,这次 ResumeOrRequestInput 找到了与③处 InterruptID 匹配的答复,直接返回;④处把答复交出节点,引擎打包进事件、流向大纲站。同一段代码执行两遍,各走一个分支——workflow 的人在环中,全部机关就这一处。
中断和答复在账本里长什么样?两段 JSON 从 adk.db 原样取出,如代码清单 4-6 所示。
代码清单 4-6 events 表里的中断事件与答复事件(content 字段节选)
// 中断,author = copewrite-pipline
{"parts":[{"functionCall":{
"id": "select_title-e-c5234d35-…",
"name": "adk_request_input",
"args": {"interruptId": "select_title-e-c5234d35-…", "message": "请选择标题"}
}}],"role":"model"}
// 答复,author = user
{"parts":[{"functionResponse":{
"id": "select_title-e-c5234d35-…",
"name": "adk_request_input",
"response": {"result": "笑完才懂,原来最惨的是我自己"}
}}],"role":"user"}
眼熟吗。中断是一次名为 adk_request_input 的模拟函数调用,人的答复是同 id 的 FunctionResponse——与第 3 篇工具审批的 adk_request_confirmation 是同一套协议:把"等人"建模成函数调用,把"人的话"建模成它的返回值,框架按 id 路由。两套人在环中机制并排放在表 4-1 里。
表 4-1 ADK 两套人在环中机制对比
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
③处的 id 拼接值得单独一提。它用调用轮次 id(InvocationID)做后缀,workflow 每跑一轮 id 就换新,中断 id 自然每轮不同。若改成写死的固定字符串会踩坑:webui 按会话内 id 记录"哪些请求已答复过",已答复过的 id 不再弹表单——第二轮运行就会表现得像挂住了。
万事俱备,启动:
go run copewrite_workflow.go web webui api -include_debug_api --sse-write-timeout 100m
提示
--sse-write-timeout是必设项。webui 走 /run_sse,服务端给这类连接的默认写期限是 120 秒;产线一轮串三个 LLM 工位,本地 Qwen 写成文慢一些,很容易过两分钟。一旦超时,下一次事件写入报 i/o timeout,整轮运行会中途夭折,别误以为只是浏览器卡顿。该参数是子启动器 flag,必须放在 api 关键词之后(flag 分层规则:web 层 flag 在关键词前,子启动器 flag 在关键词后)。-include_debug_api则点亮 webui 的 Traces 与 agent 结构面板背后的调试端点,不设的话只有 Events 面板可看。
在 Web UI 里选 copewrite-pipline,发"写一篇段子"。标题站交出五个候选,产线随即停在 select_title——中断事件被 webui 渲染成一张表单卡片。
在表单里填"笑完才懂,原来最惨的是我自己",Submit,产线继续:大纲站、成文站依次跑完,整个事件链如图 4-3 所示。
图 4-3 文案产线的事件链:需求、五个候选标题、中断、答复、选定标题、大纲,链条继续延伸到成文站
一条链上七条事件:用户提需求一条,标题站交活一条,中断一条,人的答复一条,人工工位交出选定标题一条,大纲站一条,成文站一条。人和机器混编,全记在同一张 events 表里——这正是 4.3 节"记账一字不改"的意思。
实战经验 总管和 workflow 怎么选?判据就一条:路由是否固定。流程固定、内容活跃(如本篇的文章创作),用 workflow,路由零 token、可测试;路由依赖任务形态(咨询、问答、探索),留给总管;两者还能混搭——接口统一让 workflow 可以当子代理挂在总管手下,总管也能被包成节点进产线。这种零成本嵌套是同接口设计白送的,也是这套架构最值得榨取的能力。
4.6 小结
本篇把文案部的五步路由从总管的提示词里搬进了 Go 代码。先清点了总管编排的三桩烦恼:LLM 路由的概率性、无需 AI 的函数节点插不进流程、无法测试;再用三节点管道换来确定性:节点与边写在代码里,工位间数据走类型化的事件输出,同输入必同路由、同产出。架构上看清了一件事:LLM agent 与 workflow 都由 agent.New 构造,差别只在 Run 回调的内容;跑在同一个 launcher 上,记在同一张 events 表里,恢复状态也从事件历史重建。最后把三个专业 agent 原样复用装配成文案产线,加一个人工选标题工位:节点首次执行时 ResumeOrRequestInput 发出 adk_request_input 事件并暂停,人答复后节点从头重跑、答复变返回值流向下游,与第 3 篇的工具审批同一套"中断即函数调用"协议。workflow 管确定,LLM 管创作,人在关键工位上握着决定权。
开头提到的代码包,扫码加我个人微信,备注"代码"即可直接发送。
欢迎点赞收藏转发关注。
你手上有没有一条流程固定、每次都要人盯着的重复任务?说说看,我帮你看看能不能拆成一条带 AI 工位的流水线。

