大数跨境

【google-adk专栏】04.从天马行空到流水线:确定性的文案工作流

【google-adk专栏】04.从天马行空到流水线:确定性的文案工作流 静远AI出海
2026-10-30
1
导读:LLM 编排跑十遍偏两三遍,workflow 把路由钉进代码:同输入必同产出,还能中途停下让人选标题。实战第 4 篇。

【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 所示。

workflow_demo 在 webui 中运行的事件流

图 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 两套人在环中机制对比


工具审批(第 3 篇)
workflow 输入(本篇)
场景
危险工具调用,如 write_file
工作流节点等人类决策
开关
RequireConfirmation: true
节点调用 ResumeOrRequestInput
中断事件
adk_request_confirmation
adk_request_input
人的答复
confirmed: true/false
自由内容(如选中的标题)
恢复方式
框架重放工具调用
节点从头重跑,答复作返回值

③处的 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 工位的流水线。

【声明】内容源于网络
0
0
静远AI出海
AI SaaS应用开发脚手架项目Fast SaaS的作者。致力于分享各类AI工具和大型模型的使用经验,探索高效的AI应用开发方法,专注AI应用的出海与变现。
内容 456
粉丝 0
静远AI出海 AI SaaS应用开发脚手架项目Fast SaaS的作者。致力于分享各类AI工具和大型模型的使用经验,探索高效的AI应用开发方法,专注AI应用的出海与变现。
总阅读3.9k
粉丝0
内容456