【google-adk专栏】03.从委派到编排:会交付成果的文案部
文末有我的微信,需要这个 google-adk 专栏同款代码包(本篇新增的单轮编排、文件读写工具与人工审批源码都在里面,跟着文章每一步都能对上号)的朋友们,加好友备注"代码"即可,打包直接发送。
第 2 篇结束时,文案部有了四位成员:一位总管、三个专业 agent,还有联网搜索的眼睛。但它还称不上一条产线——用户说"写一篇文章",总管把任务转给标题专家,转出去之后对话就归标题专家了,总管根本接不回话,更别说接着安排大纲、成文。本篇就来补齐这条流水线:先把子代理改成单轮模式,让总管真正"编排";再给平台配一个带围栏的文件读写工具,让成稿落进硬盘;然后给写入动作加上人工审批,写入前先过人这一关;最后看一眼会话库:这些协作与审批,是怎么一条条记进 SQLite 的。
本篇主要内容:
-
单轮模式:子代理从"聊天同伴"变成"流水线工人" -
文件读写工具:路径围栏与读取上限的设计 -
落盘与审批:五步编排流程的装配与完整演示 -
审批的实现原理:一次特殊的函数调用 -
会话落库:sessions 表与 events 表
版本约定:除特别指出,本手册的 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+。
3.1 单轮模式:从"委派"到"编排"
先回顾第 2 篇 2.5.2 节说清的一个语义:transfer 是控制权移交,不是函数调用。chat 模式的子代理接管会话后直接面对用户,干完活也握着话筒不撒手。对话式任务这样很自然;可一旦总管想把"写一篇文章"拆成三道工序串起来,就卡在这个语义上:第一道工序转出去,产线断在第一站。
3.1.1 一行配置,换一种协作关系
解法第 2 篇 2.5.3 节已经预告过:single_turn 模式。三个专业 agent 的 Create 函数各加一个 BuildOption,如代码清单 3-1 所示。
代码清单 3-1 copewriter/title-agent.go:子代理声明单轮模式
func CreateTitleAgent() agent.Agent {
// 从 agent.toml 构建 agent,模型、提示词、采样参数等全部定义来自配置文件
conf := config.GetConfig()
cfg, err := agents.Load(filepath.Join(conf.Cwd, "copewriter/title-agent.toml"))
if err != nil {
log.Fatal(err)
}
return agents.Build(cfg, func(c *llmagent.Config) {
c.Mode = llmagent.ModeSingleTurn ①
})
}
①处一行,协作关系整个变了。对照第 2 篇 2.5.3 节读过的 installTaskTools 分发逻辑:子代理声明 single_turn 后,ADK 不再把它当"聊天同伴"注册进 transfer_to_agent 的名单,而是在父 agent 身上安装一个以子代理命名的函数工具。总管调用三个子代理,跟调用 web_search 是同一件事——发一次函数调用,拿一个返回值。
这个变化直接体现在总管收到的工具清单上。chat 时代,系统提示词后面追加的是一长串子代理清单和路由规则(第 2 篇 2.5.2 节实录);改成单轮后,下面这段是总管运行时实际收到的 tools 定义(从原始 HTTP 日志 [llm-raw] 抓取,节选):
"tools": [
{"function": {"name": "write_file", "parameters": {...}}},
{"function": {"name": "title-agent",
"parameters": {"properties": {"request": {"type": "string"}},
"required": ["request"]}}},
{"function": {"name": "outline-agent", "parameters": {"properties": {"request": ...}}}},
{"function": {"name": "article-agent", "parameters": {"properties": {"request": ...}}}}
]
transfer_to_agent 消失了。三个子代理直接成为三个工具,各带一个 request 字符串参数——路由规则从一段自然语言提示词,变成了工具清单本身。总管调用时大概长这样(同样来自实录):
{"name": "title-agent",
"arguments": "{\"request\": \"为以下文章创作5个候选标题:……\"}"}
顺带回答一个读者可能冒出的疑问:子代理都不直接面对用户了,系统提示词里那段"你是爆款标题创作专家……始终用中文|极简风回复"的人设还有用吗?有用。single_turn 的返回值虽然交给总管,但产出物终究要进成稿、见读者,文风与专业性的约束依然在起作用——约束的对象从"对用户说话"变成了"对总管交活"。
3.1.2 实测:总管串起两道工序
实测运行,任务是一句"我要写一篇 AI 给我们的学习、工作、创业带来了哪些有利因素的文章"。总管先调 title-agent 拿到五个候选标题,从中选定一个,再把标题连同任务转给 outline-agent,最后自己整合出终版大纲。整个过程如图 3-1 所示。
图 3-1 单轮模式下的编排实录:总管调用子代理,结果自动回流
事件面板里看得清清楚楚:title-agent 和 outline-agent 的卡片都是"单轮调用"——参数进、结果出,对话主线始终握在总管手里。这跟图 2-5 那次 chat 模式的 transfer 形成鲜明对照:那次是控制权一去不回,这次是函数调用有去有回。
还有一个隐藏的好处:函数调用天然支持一次发多个。将来"三份稿件并行评审"这类需求,总管在一个回合里并发调三个审稿 agent 即可——这是 transfer 语义永远做不到的。
3.2 文件读写工具:带围栏的工作台
流水线有了编排,还缺最后一站:成果要落盘。文章光留在聊天记录里没用,得写成文件交给用户。这就需要一个文件读写工具。
把文件读写交给 LLM,第一反应应该是后背发凉:模型给出的路径是不可信输入。它可能出于好意写 ../../.ssh/authorized_keys,也可能读走 config.toml 里的密钥。所以这个工具的第一设计目标不是功能,而是围栏:
-
工具持有一个工作区根目录,所有读写都被强制限定在根目录之内; -
相对路径基于根目录解析,绝对路径必须本身落在根目录内; -
..逃逸——包括拼起来才逃逸的a/../../x——一律拒绝。
这样"任意文件读写"就被降级成了"工作区内读写",风险从系统级缩小到目录级。工具落在 tools/file 包,与第 2 篇的 tools/search 平级,结构也对齐:functiontool 包装层加独立可测的执行核心。读取工具的定义如代码清单 3-2 所示。
代码清单 3-2 tools/file/tool.go:read_file 工具定义(节选)
// NewReadTool 构造读取工作区内文本文件的 adk 工具
func NewReadTool(root string) (tool.Tool, error) {
r, err := absRoot(root) ①
if err != nil {
return nil, err
}
return functiontool.New(functiontool.Config{
Name: "read_file",
Description: fmt.Sprintf("读取工作区(%s)内指定文本文件的内容。", r),
}, func(_ agent.Context, args readFileArgs) (string, error) {
return runRead(r, args)
})
}
type readFileArgs struct {
// Path 文件路径
Path string `json:"path" jsonschema:"文件路径,相对工作区根目录;也可用工作区内的绝对路径"`
}
①处把根目录绝对化,后续所有判定都基于同一基准。jsonschema tag 是参数说明到达 LLM 的唯一通道——这条第 2 篇 2.4.4 节用实测换来的经验,这里直接沿用。注意 Description 里把根目录路径拼了进去:模型看得见围栏在哪,才不会反复撞墙。
围栏的执行者是 resolvePath,所有读写的必经之路,如代码清单 3-3 所示。
代码清单 3-3 tools/file/tool.go:resolvePath——路径围栏
// resolvePath 把 LLM 给的路径解析为根目录内的绝对路径。
// 相对路径基于 root 拼接;绝对路径原样保留。两者最终都必须落在 root 内,
// `..` 逃逸(含 Clean 后逃逸、绝对路径指向 root 外)一律拒绝。
func resolvePath(root, p string) (string, error) {
if strings.TrimSpace(p) == "" { ①
return "", fmt.Errorf("path 不能为空")
}
var target string
if filepath.IsAbs(p) { ②
target = filepath.Clean(p)
} else {
target = filepath.Join(root, p) ③
}
rel, err := filepath.Rel(root, target) ④
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("路径 %q 越出工作区 %s,只允许读写工作区内文件", p, root)
}
return target, nil
}
三个判定点值得逐一说明。②③处先分而治之:绝对路径清洗后原样保留,相对路径拼到根目录下——注意不能无脑 Join,因为 filepath.Join("/root", "/etc/passwd") 会得到 /root/etc/passwd,把绝对路径悄悄"驯化"成了相对路径,语义就错了。④处是围栏的最终裁定:算出目标相对根目录的路径,只要它以 .. 开头,就说明逃出了围栏,拒绝。错误信息里特意带上根目录路径——模型被拒一次后能自我纠正,靠的就是这句提示。
除了围栏,读取还有一个保护:1 MiB 上限。超限文件只返回前 1 MiB 并附截断说明,防止模型一时兴起读个百兆日志,把上下文窗口撑爆。
提示 resolvePath 的单测是一张八行的表:根内相对路径、嵌套相对路径、根内绝对路径、根外绝对路径、
..逃逸、清洗后逃逸、当前目录、空路径——放行与拒绝各半。围栏逻辑不复杂,但它是安全边界,值得把每个洞口都钉上测试。
3.3 落盘与审批:流水线的最后一道闸
工具有了,该装到总管手上了。但写入动作和搜索不一样:搜索错了浪费一次调用,写错了文件系统真的会变。所以装配的同时,我们给写入再上一道闸——人工审批。
3.3.1 写入工具:确认后再动手
写入工具的定义如代码清单 3-4 所示,与读取工具共用同一套围栏。
代码清单 3-4 tools/file/tool.go:write_file 工具定义(节选)
// NewWriteTool 构造向工作区内写文件的 adk 工具
func NewWriteTool(root string) (tool.Tool, error) {
r, err := absRoot(root)
if err != nil {
return nil, err
}
return functiontool.New(functiontool.Config{
Name: "write_file",
Description: fmt.Sprintf("把文本内容写入工作区(%s)内指定文件,整体覆盖原文件,"
+"父目录不存在时自动创建。每次写入前需用户人工确认,被拒绝则不会写入。", r),
// 写文件是有副作用的操作,走 ADK 原生 HITL:调用先挂起并返回
// ErrConfirmationRequired,客户端展示待写入参数,用户批准后框架
// 带确认重放调用,拒绝则返回 ErrConfirmationRejected 且不执行。
RequireConfirmation: true, ①
}, func(_ agent.Context, args writeFileArgs) (string, error) {
return runWrite(r, args)
})
}
①处就是那道闸:RequireConfirmation: true。这是 ADK 原生的 HITL(Human-in-the-Loop,人在环中)开关——开了之后,每次工具调用都会先挂起,等用户批准或拒绝再决定执不执行,机制细节 3.4 节专门拆。工具描述里"每次写入前需用户人工确认,被拒绝则不会写入"这句话同样重要:它让总管对审批有预期,被拒绝时不会一脸懵地反复重试。读取工具没有这个开关——读是无副作用操作,不值得为它打断用户。
写入的执行核心 runWrite 很朴素:围栏判定、MkdirAll 补父目录、WriteFile 覆盖写,回执带上绝对路径和字节数。三个数字都回给模型,它才有料向用户汇报。
3.3.2 装配:五步编排流程
工具挂载沿用第 2 篇 2.1.3 节的 BuildOption 通道,如代码清单 3-5 所示。
代码清单 3-5 copewriter/leader-agent.go:总管装配写入工具
func CreateLeaderAgent(subagents ...agent.Agent) agent.Agent {
conf := config.GetConfig()
cfg, err := agents.Load(filepath.Join(conf.Cwd, "copewriter/leader-agent.toml"))
if err != nil {
log.Fatal(err)
}
//成稿落盘工具,工作区限定在 copewriter/output,模型无法越界读写
writeTool, err := file.NewWriteTool(filepath.Join(conf.Cwd, "copewriter", "output")) ①
if err != nil {
log.Fatal(err)
}
leaderAgent := agents.Build(cfg,
agents.WithTools(writeTool), ②
func(c *llmagent.Config) {
c.SubAgents = subagents
})
return leaderAgent
}
①处工作区定在 copewriter/output——成果目录,围栏圈到这里刚好;②处一行挂载。光有工具还不够,总管还得知道什么顺序用它们。这就是 leader-agent.toml 里新的编排流程,如代码清单 3-6 所示。
代码清单 3-6 copewriter/leader-agent.toml:总管的编排流程指令(节选)
instruction='''
## 角色
你是文案类任务的总管,负责编排子代理完成创作,并用 write_file 工具把成稿落盘。
始终用中文|极简风回复。
## 标准编排流程
收到完整创作任务(如"写一篇文章")时按序编排,每步结果回到你手上后再进入下一步:
1. 委派 title-agent 创作候选标题,从结果中选定一个最佳标题
2. 把选定标题交给 outline-agent 规划大纲
3. 把标题和大纲交给 article-agent 写成文
4. 用 write_file 把成稿落盘。write_file 的工作区根目录就是 output 目录,
path 直接传"标题.md"即可,不要再加 output/ 前缀;文件名里不能出现
/ \ : * ? " < > | 等字符,空格用连字符替代
5. 向用户汇报:最终标题、文件路径、全文
## 约束
- 子代理均为单轮执行,不与用户交互,所有用户沟通由你完成
- 单步需求(只要标题、只要大纲、只要改写)直接委派对应子代理,不走全流程
- 成稿统一用 Markdown 格式,正文开头写明标题
'''
第 4 步里"不要再加 output/ 前缀"这句是踩坑换来的。工具的围栏根目录就是 output,但总管的常识是"要写进 output 目录",于是第一次实测它老老实实传了 path="output/第三杯才上线.md"——文件落进了 output/output/ 嵌套目录。工具结果回执里的完整路径让总管自己都察觉了不对。这次修复一行代码都没动,改的是提示词,把目录规则说透。给模型的指令要迁就它的常识,你的目录结构别指望它猜中。
提示 改这份 toml 时还踩了另一个小坑:TOML 的
'''...'''是多行字面串,反斜杠没有转义功能,写/ \\ :会原样出现两个反斜杠。想在提示词里表示单个反斜杠,就直接写单个\。另外再提醒一次第 2 篇的老坑:'''是字符串定界符不是注释,注释只能用#。
3.3.3 完整流程实测:拒绝与批准
万事俱备,实测走一遍。照第 2 篇 2.3.3 节的方式启动(go run main.go web webui api),在 Web UI 里选总管,任务还是那一句:"写一篇 AI 给我们的学习、工作、创业带来了哪些有利因素的文章"。总管依次完成标题、大纲、成文,走到落盘一步——工具挂起,Web UI 弹出审批卡片,把待写入的文件名和全文参数摆在用户面前,如图 3-2 所示。
图 3-2 写入前的审批卡片(Events 面板):用户选择 Reject
这一次用户点了 Reject。工具不执行,返回"call is rejected",总管的回应相当得体:"已取消写入,文章内容保留在对话中。如需继续,您可以……重新写入"——不重试、不纠缠,成稿仍在对话里,随时可以再来。再跑一次同样的任务,这回点 Submit 批准,文件真正落盘,总管汇报标题、路径与全文,如图 3-3 所示。
图 3-3 审批通过:文件写入 output 目录,总管汇报全文
跑完检查 copewriter/output/ 目录,AI不会淘汰你但会用AI的人会.md 已经写好——从一句自然语言到一篇 Markdown 文件,整条流水线通了。
图 3-4 审批卡片展开的参数:原始调用现场与确认状态
提示 console 启动器对审批的回答词表是:
y、yes、true、confirm(不分大小写)算批准,空行和其他任何输入都算拒绝。管道自动化测试时多输几个n无妨——多余的行只会被当成下一轮用户消息。
3.4 审批的实现原理:一次特殊的函数调用
演示看完,还有三个问题没回答:审批卡片是从哪冒出来的?总管为什么会乖乖停下?用户的点击是怎么变成"允许执行"的?
先把核心答案一句话放桌上:ADK 没有为审批发明任何新协议。它把"请求批准"建模成一次函数调用,把"人的答复"建模成这次调用的返回值。人工确认,相当于替框架手动补了一次工具调用的 FunctionResponse。整个机制在 functiontool 的执行入口一览无余,如代码清单 3-7 所示。
代码清单 3-7 adk v2.6.0 tool/functiontool/function.go:确认分支(节选)
if confirmation := ctx.ToolConfirmation(); confirmation != nil { ①
if !confirmation.Confirmed {
return nil, fmt.Errorf("error tool %q %w", f.Name(), tool.ErrConfirmationRejected)
}
} else {
if requireConfirmation { ②
err := ctx.RequestConfirmation(
fmt.Sprintf("Please approve or reject the tool call %s() ...", f.Name()), nil)
if err != nil {
return nil, err
}
ctx.Actions().SkipSummarization = true
return nil, fmt.Errorf("error tool %q %w", f.Name(), tool.ErrConfirmationRequired) ③
}
}
output, err := f.handler(ctx, input) ④
这段代码是一台两幕剧。第一幕在②③处:工具被调用时,如果声明了需要确认,处理函数根本不执行,只是登记一个确认请求,然后带着 ErrConfirmationRequired 返回——运行到此挂起。第二幕在重放时:框架再次调用这个工具,这次①处能取到上一步登记的确认结果,Confirmed 为真才走到④处真正执行;为假则返回 ErrConfirmationRejected,同样不碰处理函数。
两幕之间发生的事,才是"审批模拟为函数调用"的精髓,全部记录在会话库里。下面两段 JSON 是从 data/adk.db 的 events 表里原样取出的(content 字段截断)。
第一段,挂起时框架写入的确认请求事件——注意它的形态,是一次 functionCall:
{
"parts": [{
"functionCall": {
"id": "adk-22581b2a-ceff-4b9d-87ba-98a1c5fda447",
"name": "adk_request_confirmation",
"args": {
"originalFunctionCall": {
"name": "write_file",
"args": {"path": "AI不会淘汰你但会用AI的人会.md",
"content": "# AI不会淘汰你,但会用AI的人会\n……"}
},
"toolConfirmation": {"confirmed": false}
}
}
}],
"role": "model"
}
函数名叫 adk_request_confirmation,参数里裹着原始调用的完整现场(write_file 的名字和参数)和一个 confirmed: false。Web UI 把它渲染成审批卡片,展开能看到原始调用参数与确认状态两块内容,如图 3-4 所示。
第二段,用户点完 Submit 后,客户端写回的答复事件——作者已是 user,形态是同一次调用的 FunctionResponse:
{
"parts": [{
"functionResponse": {
"id": "adk-22581b2a-ceff-4b9d-87ba-98a1c5fda447",
"name": "adk_request_confirmation",
"response": {"confirmed": true, "payload": {"content": "# AI不会淘汰你,但会用AI的人会\n……"}}
}
}],
"role": "user"
}
id 与确认请求一一对应,confirmed: true 就是那一声"准了"。下一轮回合,框架带着这份答复重放 write_file,代码清单 3-7 的①处放行,④处执行——文件这才落盘。把五个阶段连成表 3-1。
表 3-1 工具审批协议的五个阶段
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
拒绝分支同样走这张表,只是第三阶段 confirmed: false,第四阶段返回 ErrConfirmationRejected,错误信息 call is rejected 作为工具结果回流给总管——实测里那句得体的"已取消写入",就是总管读到这条错误后的反应。挂起事件的 id 还会被记进事件的 long_running_tool_ids 字段,客户端靠它识别"有个工具在等人",这套标记与长运行工具共用同一机制,名字也由此而来。
现在可以回答"为什么这么设计"了:审批走的是 LLM 已经会说的话(函数调用与返回),人只需要用同样的语言回一句话。任何客户端只要会发 FunctionResponse,就免费获得了审批能力,不需要各自发明私有通道——console、Web UI、自己写的 API 服务都不例外。而且审批的请求、答复、执行全部是事件,天然落在会话库里,事后审计有据可查。3.5 节就去看它。
轻松一刻 老会计有个规矩:支票不论金额大小,必须双人复核——记账的盖章,复核的再盖一个,缺一章银行不收。章本身不判断对错,判断的是盖章的人;但没有那枚章,单据就寸步难行。RequireConfirmation 就是给 write_file 配的复核章:它不聪明,甚至有点笨——每次写文件都得停下来等;可正因为这一停,落笔之前必须先过人的眼睛。
实战经验 审批行为为什么没有单元测试、只做了两轮端到端验证(批准一次、拒绝一次)?因为确认标志是交给框架的声明式配置,工具的 Declaration 不对外暴露它,而 ADK 的工具上下文构造器藏在 internal 包里,外部测试代码根本造不出一个能骗过框架的假上下文。强行动手只会测到自己的假对象,测不到框架真行为。这类"配置驱动 + 框架执行"的能力,端到端验证比伪单测诚实得多——批准后文件真的落盘、拒绝后目录真的为空,这两条断言胜过千行 mock。
3.5 会话落库:sessions 表与 events 表
3.4 节那两段协议 JSON 都来自 SQLite 会话库——第 1 篇 1.4.3 节埋下的方案,到这里真正派上了用场。本节掀开盖子看两眼,只看结构,不深入机制。
3.5.1 两张表的分工
会话库 data/adk.db 里核心是两张表,schema 整理成表 3-2。
表 3-2 sessions 与 events 的关键列
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
分工一句话:sessions 一行是一个会话,events 一行是一个动作。从用户提需求、总管编排,到子代理交活、审批挂起,每件事都是 events 表里的一行,webui 左侧的 Events 面板,就是这张表按时间序的投影。
3.5.2 给一次"写文章"对账
拿 3.3 节那次实测会话来对账。先看 sessions:
sqlite3 data/adk.db "select id, app_name, user_id, create_time, update_time
from sessions where id='7918196b-…';"
7918196b-f74b-487f-ae08-03ad57f744c9|copewriter-leader-agent|user|
2026-10-29 09:57:45|2026-10-29 10:18:19
一行,app_name 是总管(MultiLoader 里它排在第一位,是统一入口)。再看这个会话攒下的动作:
sqlite3 data/adk.db "select count(*), author from events
where session_id='7918196b-…' group by author;"
1|article-agent
11|copewriter-leader-agent
1|outline-agent
1|title-agent
2|user
十六行账目:三个子代理各一条(各自的单轮交付),总管十一条(编排决策、汇报、write_file 的调用与回执、那次挂起的确认请求),用户两条(最初的需求、那条 confirmed: true 的批复)。一次"写文章"从需求到落盘,账面上一个动作不少,连用户那次批准也在内。把 events 表拉到数据库客户端里看,这个会话的事件链一行一行排得明明白白,如图 3-5 所示。
图 3-5 数据库客户端里的 events 表:一次"写文章"的完整事件链
实战经验 直接查会话库是排障利器:3.4 节整套审批协议,就是先在源码里读到线索、再用 sqlite3 查 events 表核实的,两相印证才敢写进正文。Web UI 的事件面板看"最近发生了什么"很方便,但要精确检索(比如"找出所有被拒绝的写入")就得靠 SQL。两点注意:开发期随手查没问题,生产环境建议用只读连接,避免和正在写入的 runner 抢锁;查大 content 字段时先截断,别把终端刷爆。
state 的合并时机、app_states 与 user_states 两张表的角色、runner 读写这些表的完整链路,属于 runner 篇的主题,届时专门分析。
3.6 小结
本篇让文案部从"会聊天"走到了"会交付"。子代理改为单轮模式后,总管与三个专业 agent 的关系从控制权移交变成了函数调用,结果回流、逐站接力,五步编排流程得以成立;通用文件读写工具用路径围栏和读取上限,把模型的文件操作圈在安全的工作区内;写入动作启用 RequireConfirmation 人工审批,批准与拒绝两条分支都实测验证。原理侧,我们对照源码与会话库拆解了审批协议:挂起即一次 adk_request_confirmation 函数调用,人的答复即它的 FunctionResponse,重放时按确认结果执行或拒绝——人机协作复用同一套工具调用语言。最后看了会话库的两张表:sessions 一行一会话、events 一行一动作,编排、审批、交付全程留痕。
开头提到的代码包,扫码加我个人微信,备注"代码"即可直接发送。
欢迎点赞收藏转发关注。
你有没有哪个交给 AI 的动作,是想让它先问你一声再执行的?说说看,我帮你看看怎么加这道闸。

