1. 准备工作
大多数票务网站都会让买家排队,以防止服务器崩溃并应对高需求。您开始购买,却发现自己排在 14,000 人之后。队列移动了 40 分钟。当轮到您时,您需要在短时间内选择座位并付款。如果您错过了该时间段,您在队列中的排名将失效。
许多现实世界的任务都遵循这种模式:任务耗时远超单次对话,并且针对的是您无法控制的外部系统。例如,等待供应商补货、等待索赔审批或排队等待。
标准 AI 智能体在单个聊天回合中运行,持续时间只有几秒,并且完全是被动响应。它们无法等待 40 分钟或自主行动。为了解决这个问题,您需要一个长期运行的智能体:一个在长时间等待期间保持休眠状态、在进程重启期间保持上下文,并在被外部触发器唤醒时自动恢复的系统。
本讲座将引导您构建一个音乐会门票排队代理,涵盖在 Google Cloud 上运行长时间运行的代理所需的核心架构模式。
构建内容

在此研讨会中,您将构建一个长时间运行的机票预订代理,同时学习关键的代理软件模式:
- 进程暂停和恢复:在长时间等待期间暂停代理调用,并通过外部触发器恢复执行。
- 持久状态存储:使用 SQLite/Cloud SQL 和 Cloud Storage 在进程重启时保留对话状态和制品。
- 过时性防护和幂等性:在执行操作之前验证动态数据(例如座位空闲情况),并使用幂等性密钥来防止重复交易。
- ADK 工作流图:使用确定性代码节点(用于程序性规则)和代理节点(用于情境化决策)来构建执行结构。
- 人机协同审批:暂停执行,以收集并持久保存用户明确指定的支出限额。
- 云部署:使用 Cloud Scheduler 和 Pub/Sub 触发器将代理容器化并部署到 Cloud Run。
进行设置
首先,准备好您的环境,并确保您有权访问 Google Cloud。建议在 Cloud Shell 中运行此命令,因为 Cloud Shell 已包含您所需的所有工具。只需检查您是否有足够的空间,以及您的账号是否可以创建项目。
👉💻 克隆代码库并运行设置:
cd ~
git clone https://github.com/gca-americas/monstertix
cd ~/monstertix
./setup.sh
系统会询问您一次:
Which Google Cloud project should this workshop use?
Find yours at https://console.cloud.google.com (top-left picker)
Project id [YOUR_PROJECT_ID]:
然后,它会按顺序执行以下操作:
virtualenv | 创建 | |
登录 |
| |
项目 | 将 | |
API | 启用研讨会所需的 10 个。 | |
| 写入项目、区域和模型 ID,以便每个脚本和 | |
模型检查 | 对 Gemini 进行一次实际调用,因此如果项目或模型 ID 无效,则会在此处失败 | |
种子 | 构建一个已经存在两天的会话,第 3 步会打开该会话 | |
Cloud SQL | 在后台启动 Postgres 实例 | |
venue | 将您自己的模拟售票应用部署到 Cloud Run | |
👉💻 激活环境:
cd ~/monstertix
source .venv/bin/activate
您的提示现在以 (.venv) 开头。
方案概览
在此研讨会场景中,您的代理会预订两张音乐会(阿姆斯特丹的“午夜信号”)门票。存储在历史记忆中的用户偏好设置包括:
- 必须是周末的演出(Sam 无法在工作日晚上参加)。
- 避免在 Ziggo Dome 购买上层看台的座位(视野不佳)。
- 目标价格约为每张机票 200 美元,上限为 250 美元。

验证安装
👉💻 一条命令,八个勾号:
./verify.sh
Checking the rig
✓ adk installed adk, version 2.6.2
✓ adk 2 apis App + ResumabilityConfig
✓ workflow graphs Workflow + node
✓ async sqlite drivers sqlalchemy + aiosqlite + greenlet
✓ project your-project-id
✓ vertex ai no API keys anywhere
✓ seeded session 13 events, two days old
✓ venue https://venue-you-xxxx.us-central1.run.app
Ready. Cloud SQL is still building in the background and nothing
before step 10 needs it.
👉 打开第二个浏览器标签页:
地点 | 简介 |
| 控制面板,即按钮。 |
2. 通过提示实现自主化?
提示无法自行启动
提示只是在通话发生时传递给 AI 模型的文本。它无法自行运行、自行唤醒或发起新通话。
如果您在提示中告知智能体“10 分钟后再回来”,那么在当前回合结束后,实际上不会发生任何事情。该模型没有内部时钟或循环。若要使代理自主运行,必须有代理外部的某些事物唤醒并调用它。

启动设备
我们先来测试开发者工具。adk web 会启动 ADK Web 界面,即随 ADK 提供的开发界面。借助它,您无需先编写前端,即可与正在构建的智能体对话,并且它会显示聊天窗口无法显示的内容,例如每个工具调用及其实参、会话状态的变化、智能体保存的制品以及底层的原始事件。您不会在生产环境中公开它。在学习过程中,它是了解正在运行的代理的最佳窗口。在第 3 步中,您将构建实际公开的前端,并在最后一步中部署该前端。
👉🔴 在场地面板上,按 RESET THE VENUE。每个步骤都从同一世界开始:8 个演出,所有座位均可预订,时钟速度为 1 倍。
👉💻 终端 1 - 加载此步骤的代码并启动 ADK 网页界面:
cd ~/monstertix
. ./set_env.sh
./use-solution.sh 2 --force
adk web agent --port 8000 --allow_origins "*"
use-solution.sh 将某个步骤的已完成代码复制到 agent/ 中,您可以在其中进行操作。每个步骤都从它开始,并且它会打印出更改了哪些文件,以便您知道要阅读哪些内容。
初始状态:
文件 | 简介 |
| 智能体 - 名称、模型、指令、工具列表 |
|
|
| 营业地点的 HTTP 客户端。没有有趣的内容 |
| 使用哪个模型,以及检查您的凭据是否有效 |
. ./set_env.sh 会激活 virtualenv,加载您的项目和模型,并导出 $WORKSHOP(即 ~/monstertix 的绝对路径)。在您打开的每个终端中运行该命令。它会输出设置的内容:
folder /home/you/monstertix
project your project id
model gemini-2.5-flash (We'll avoid using 3.x for now to avoid high usage demands)
venue https://venue-yourname-xxxx.run.app
👉 打开第一个浏览器标签页:
地点 | 简介 |
ADK 网页界面 |
|
与它对话
👉✨ 在 ADK 网页界面中,输入:
I want to see The Midnight Signal. I'm in Amsterdam, going with Sam, budget around $200 each.
它会搜索巡回演出信息、读取座位图,并根据您的预算和公司信息进行推理。这是一个真正有能力的助理。不过,当预售于 10:00 开始时,由于没有进程或后台触发器在运行,因此代理不会自动执行。
阅读刚刚发生的事情
屏幕中间不仅是聊天记录。对话轮次的每个步骤都已编号,包括工具调用:
▸ I want to see The Midnight Signal. I'm in Amsterdam... ← user input
⚡ search_events({"city": "Amsterdam"}) ← tool execution
✓ search_events ← venue API response
Two Amsterdam shows: Tuesday 10 Nov and Saturday 14 ... ← model response
👉 点击带有 ⚡ 的行。左侧面板显示了模型选择的实参。点击返回的 JSON 下方的 ✓。
导览信息未出现在模型的训练数据或提示中。智能体通过执行 search_events 工具(该工具会查询场地 API)检索了此数据。
打开您刚刚运行的代码
您已看到它在运行并看到了轨迹。现在,我们来看一下代码本身。
agent/concert/
├── agent.py the agent — about twenty lines
├── tools.py what it can do
├── venue.py an httpx wrapper, nothing interesting
└── config.py which model, and a credentials check
👉💻 打开 agent/concert/agent.py。整个代理位于文件底部:
INSTRUCTION = """
You help someone plan a trip to see a band on tour.
You can search tour dates and read seat maps. Be concrete: name the show, the
city, the section, and the price...
"""
root_agent = Agent(
name="concert",
model=MODEL,
instruction=INSTRUCTION,
tools=[search_events, get_seatmap],
)
名称、模型、有关智能体应如何运行的说明以及 Python 函数列表。这就是 Agent 的全部内容。adk web 找到它是因为文件名为 agent.py,而变量名为 root_agent。
👉💻 现在 agent/concert/tools.py:
def search_events(city: str = "", weekday: str = "") -> dict:
"""List tour dates. Call with no arguments to see the whole tour.
This venue sells one artist and one tour, so there is nothing to search by
name — never ask the user which artist they mean.
Args:
city: Optional. Filter to one city, e.g. "Amsterdam".
weekday: Optional. Filter to one weekday, e.g. "Saturday".
"""
return venue.get("/events", city=city, weekday=weekday)
普通函数。没有装饰器、没有注册、没有要写入的架构。ADK 会读取签名和 docstring,并构建模型看到的工具定义,这就是为什么该 docstring 非常重要,以及为什么删除一个实参会改变模型的行为。
模型绝不会运行您的代码。当您看到 search_events({"city": "Amsterdam"}) 时,模型正在请求进行该调用。ADK 运行了该函数,将结果返回,然后模型写了一句关于该结果的话。循环为:
👉 最左侧边缘的图标用于切换面板显示的内容。第二个是代理图 - 尝试一下,您应该会看到两个工具:

因此,请告知设备不要进入休眠状态
您会采取什么措施来解决此问题?尝试大多数人首先会选择的选项。回忆一下第 1 步中的 INSTRUCTION,即每次轮到模型时提供给模型的散文,然后重写它。
👉💻 打开 agent/concert/agent.py。在 INSTRUCTION 下面,还有第二个,已经为您写好了。您可以自行尝试更改:
PROACTIVE_INSTRUCTION = """
You are a proactive ticket-buying assistant.
Monitor the presale for The Midnight Signal. The moment it opens at 10:00 on
Tuesday, buy two tickets for the Amsterdam Saturday show — do not wait to be
asked, and do not wait for me to say anything. Act on your own.
Keep checking until the tickets are bought.
"""
# agent/concert/agent.py
root_agent = Agent(
name="concert",
model=MODEL,
instruction=PROACTIVE_INSTRUCTION, # ← was INSTRUCTION
tools=[search_events, get_seatmap],
)
👉💻 在终端 1 中,重新启动:
cd ~/monstertix
adk web agent --port 8000 --allow_origins "*"
👉✨ 像告诉真人一样,准确地告诉它计划:
I want to see The Midnight Signal in Amsterdam with Sam. The presale opens at 10:00 on Tuesday. Grab us two tickets the moment it opens.
它会同意,而且听起来像是真心同意。
👉 现在,请等待。请勿输入任何其他内容。观看面板。
请稍等片刻。毫无反应!
为什么该提示永远不会奏效
重新阅读,或许会有新的发现。监控。在它打开的那一刻。请持续关注。这些指标都描述了随时间变化而发生的情况。
现在,我们来回顾一下 instruction= 的实际含义。它是一个字符串,在用户发送消息时作为请求的一部分传递给模型。在回合之间,没有任何内容会被读出。
# agent/concert/agent.py
root_agent = Agent(
instruction=PROACTIVE_INSTRUCTION, # read on invocation
... # ...and there was no invocation
)
指令无法调用其内部编写的函数。
失败的提示变体
尝试通过提示指令强制执行自主执行操作失败,无论措辞如何。常见的提示尝试,例如:
- “将计时器设置为周二上午 10:00,届时买票。”
- “每 5 分钟检查一次预售是否已开始。”
- “自主行动,无需等待用户输入。”
所有失败的原因都相同:调用发生时,提示处于只读状态。如果没有外部调用方,则在回合之间不会执行任何代码。
3. 事件驱动型休眠
代理不会永远保持唤醒状态
让进程持续运行轮询会浪费内存、消耗资金,并且如果笔记本电脑进入休眠状态或重启,还会导致崩溃。
长期运行的智能体应完全处于休眠状态,而不是在等待时保持运行。只有在有实际工作要完成时,外部事件(例如计时器、Webhook 或消息)才会唤醒代理。
👉🔴 在场地面板上,按 RESET THE VENUE。每个步骤都从同一世界开始:8 个演出,所有座位均可预订,时钟速度为 1 倍。
👉💻 加载此步骤的代码:
cd ~/monstertix
. ./set_env.sh
./use-solution.sh 3 --force
请注意安装的内容:monstertix/,包含服务器、时钟和您尚未见过的其他内容。检查它是否未更改 agent/,因为在此步骤中,智能体的任何方面都不会发生变化。请记住这一点,这才是重点。
文件 | 简介 |
| new。一个 Web 服务器,后面有一个 Runner |
| new。了解时间并调用服务器的实体 |
它分为两半,这两半会一直保留在两个文件中,直到上传到云端:
在第 10 步中变为 | |||
触发器 | 可运行代理的端点 | Cloud Run | |
触发者 | 带有时钟的设备,会调用该服务 | Cloud Scheduler | |
触发器 - 您自己的 Web 服务器
没错,是网络服务器。必须先有东西在监听,然后才能有东西调用它。上一步中的 ADK 网页界面是一个开发界面,恰好能够运行智能体。
👉💻 打开 monstertix/server.py:
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from concert.agent import root_agent
session_service = InMemorySessionService()
runner = Runner(
agent=root_agent,
app_name="concert",
session_service=session_service,
)
@app.post("/wake")
async def wake(payload: dict = Body(default={})):
session_id = f"wake-{uuid.uuid4().hex[:8]}" # brand new, every time
await session_service.create_session(...)
async for event in runner.run_async(
user_id=USER_ID, session_id=session_id,
new_message=types.Content(role="user",
parts=[types.Part(text=payload["message"])]),
):
...
Agent 本身不会运行。它是一个描述:名称、模型、指令和工具列表。必须有某种机制来接收传入的消息、找到正确的会话、将目前的对话交给模型、执行模型请求的任何工具、将结果反馈回去、循环执行直到模型停止调用工具,并将每一步都写入存储空间。这个“东西”就是 Runner。

ADK 网页界面会为您提供一个默认的。在这里,您需要自行构建,而这正是 ADK Web 界面与您可以部署的内容之间的全部区别。
👉💻 终端 2 - 启动它:
启动跑步机,
cd ~/monstertix
. ./set_env.sh
python -m monstertix.server
[server] agent concert · gemini-x-flash
[server] listening on http://xxxx_URL/wake
[server] nothing will happen until something calls it. Ctrl-C to stop.
读出最后一行。触发器正在运行,代理已加载,但没有任何反应。将“可调用”和“被调用”这两个概念分开。
还有前门,我们在这里
尽管 adk web 是一款出色的开发工具,但您不应将其纳入最终交付的产品中。在部署到生产环境时,您不应启用此功能,即使启用了,您也不会向客户推荐此功能。因此,同一服务器也会提供网页。
👉 在浏览器中打开 http://127.0.0.1:8090,即相同的地址。
认识 MonsterTix。一个聊天窗口、一个位于角落的名称、两个按钮。它会向 /wake 发布内容并呈现返回的内容,并且可以针对以相同方式响应的任何端点正常运行。
在网页上 | 底层是什么 |
对话 |
|
+ 新会话 | 新的会话 ID - 新对话,以及后续的新预算 |
重置记忆 | 删除所有会话和记忆文件。它会先询问,然后向您确切显示该状态的存储位置 |
👉✨ 对它说点什么(What Amsterdam dates are there?),然后观看终端 2。相同的 Runner、相同的代理、相同的日志行。只有来电者发生了变化。
触发器 - 知道时间的事物
👉💻 打开 monstertix/clock.py。查看代码。
time.sleep(args.delay) # wait
while True:
client.post(f"{TRIGGER_URL}/wake", # knock
json={"message": args.message})
if not args.every:
return # once, unless told otherwise
time.sleep(args.every) # or again, forever
这是一个非常简单的机制:休眠、发布、可能重复。您使用过的每个调度器都具有相同的功能,此外还支持重试、时区,并保证在重启后仍能正常运行。
👉💻 终端 3 - 立即触发,10 秒后:
cd ~/monstertix
. ./set_env.sh
python -m monstertix.clock --in 10
[clock] target http://127.0.0.1:8090/wake
[clock] first fire in 60s, once only
[clock] go and watch the other terminal — do not touch the keyboard
👉 现在停止输入。毫无疑问。观看终端 2。
一分钟后,在键盘上无人操作的情况下:
[clock] firing → POST /wake
[server] woken · session wake-23930983
[server] called search_events
[server] called purchase
[server] said I've purchased 2 tickets for The Midnight Signal in
Amsterdam on Saturday, section A. Your total is 420.
[clock] done. the agent ran and you were not involved.
这是第 2 步的答案。代理之外的某个实体调用了它,该实体是 sleep 和 HTTP 请求。
👉💻 Ctrl-C 两次观看后。如果您想让视频重复播放,请先使用 --every 30。
现在,请阅读它所说的内容
“我买了 2 张阿姆斯特丹午夜信号的门票,A 区,420 美元。”
👉🔴 查看活动场地面板,您应该会看到门票已购买。
那么,还有哪些问题
它购买了某件商品。看看它必须猜测的内容。它之所以选择阿姆斯特丹和 A 区,是因为这是它找到的第一项内容,而您让代理直接购买,但代理并不知道 Sam 无法在工作日晚上进行预订。在凌晨 3 点根据猜测采取行动的代理比什么都不做的代理更糟糕。
起床是简单的一半。如果代理在醒来时一无所有(没有历史记录、没有偏好设置、没有人监听),那么它还不值得唤醒。
4. 管理上下文生命周期
信息在不同时间位于不同位置
智能体将事实存储在多个位置:聊天对话中、会话状态中、文件(制品)中或长期用户记忆中。每个地点持续的时间各不相同。
如果您将信息放置在错误的位置:
- 消失得太快:聊天结束时,重要细节会被清除。
- 保留时间过长:临时事实会污染长期记忆,导致未来的对话出现混淆。
您需要将每条信息与正确的存储位置和生命周期相匹配。
加载代码
👉🔴 在场地面板上,按 RESET THE VENUE。
👉💻 在终端 1 中,运行:
cd ~/monstertix
. ./set_env.sh
./use-solution.sh 4 --force
自上一步以来发生了哪些变化:
文件 | 有何变化 |
| 添加了 |
| new。我们自己的记忆存储空间 |
| 列表中的另外三种工具,并且指令会告知智能体使用这些工具 |
👉💻 在终端 1 中重启代理:
cd ~/monstertix
. ./set_env.sh
adk web agent --port 8000 --allow_origins "*" \
--session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
--artifact_service_uri="file://$WORKSHOP/artifacts"
您之前未使用过的两个标志:
标志 | 简介 |
| ADK 会将每条消息、工具调用和状态更改作为行写入其中(SQLite 数据库)。 |
| 代理保存到的文件。(文件) |
如果没有它们,ADK 会将所有内容都保存在内存中,并在您停止进程时将其丢弃。正是这两项设置让它能够保持不变。
数据库 sessions.db 包含一个已植入的过往对话 (two-days-ago),其中包含 13 个已记录的事件:
you We're thinking about seeing The Midnight Signal. Me, Sam, and maybe Priya.
you Sam can't do weeknights though, bailed on every single one.
you Priya's out, she's away that weekend. Just me and Sam.
agent Two it is. Still Saturday the 14th, still Amsterdam.
you You said the upper bowl at Ziggo was bad last time?
包含过去三次预订的记忆文件 memory/userx.md,您可以在编辑器中打开该文件:
- Sam bails on weeknights. Every weeknight show we booked, Sam cancelled.
- Hates the upper bowl at Ziggo Dome, "couldn't see a thing" from section B.
- Comfortable spend is around $200 per ticket. $250 is the hard ceiling.
- Always books two seats, always together.
这就是为什么琥珀色夜间行车模式很重要。虽然周中晚间的演出每张票便宜 10 美元,还有更好的座位可选,并且位于列表顶部,但对于此人来说,这并不是正确的答案。智能体之所以能知道这一点,是因为该文件是它在上次预订之后写入的。第 4 步和第 5 步是确定该事实是否仍然存在。
我们添加的三种工具

note_companion 记录了谁会来以及他们的限制:
# agent/concert/tools.py
def note_companion(name: str, constraint: str, tool_context: ToolContext) -> dict:
prefs = dict(tool_context.state.get("user:prefs", {}))
prefs[name] = constraint
tool_context.state["user:prefs"] = prefs
return {"companions": prefs}
添加该规则,因为“Sam 无法在工作日晚上参加活动”这一条件在下周仍然适用。将其写入 user:prefs 以实现此目的,并观察名称的 user: 部分是否正常工作。我们将在下文中再次介绍这一点。
recall
和
remember 读取和写入文本文件,memory/userx.md。保持这些姿势片刻。
👉 现在,我们来看看代理存储上下文的两个位置:
地理位置 | 包含的内容 |
状态 | 此会话的内存,以键值对的形式呈现 - 您将在第 4 步中看到此内容 |
制品 | 智能体保存的文件,这些文件永远不会进入提示 |
查看 中的更改
get_seatmap
。以前会返回整个座位图。现在,它会将座位图保存到文件中,并返回简短的摘要:
# agent/concert/tools.py
async def get_seatmap(event_id: str, tool_context: ToolContext) -> dict:
seatmap = venue.get(f"/events/{event_id}/seatmap") # about 6 KB of JSON
part = types.Part(inline_data=types.Blob(
mime_type="application/json",
data=json.dumps(seatmap, indent=2).encode()))
await tool_context.save_artifact(f"seatmap_{event_id}.json", part)
summary = {"event_id": ..., "sections": [...], "captured_at": ...}
tool_context.state["temp:seatmap"] = summary
return summary
以下是此变更的重要性。
每个工具返回载荷都会附加到对话记录中,并在后续所有对话轮次中重新发送给模型。返回大型 JSON 对象(例如 6 KB 的座位图)会增加后续每次对话的令牌消耗量。
改为将其保存到文件中,并支付一个文件名的费用。如果代理需要详细信息,可使用 load_artifact 重新请求,但预计大多数情况下不会需要。
文件去向:制品服务
再次查看 agent/concert/tools.py 中 get_seatmap 的这一行:
await tool_context.save_artifact(f"seatmap_{event_id}.json", part)
tool_context 来自 ADK,ADK 会将其作为实参传递到您的工具中,而 save_artifact 是其上的一个方法。该行是您的工具将字节传递给 ADK 的位置。
然后,ADK 会使用启动时所用的任何 制品服务来写入这些文件。您选择的商品带有以下标志:
--artifact_service_uri="file://$WORKSHOP/artifacts"
ADK 有三个位置可用于存储这些文件:
URI | 字节的去向 |
| RAM。在您停止该进程时消失。如果您未传递任何标志,则这是默认值 |
| 磁盘上的文件夹。您目前使用的服务 |
| Cloud Storage。部署的代理在最后一步中使用的内容 |
👉💻 在这里选择文件:
ls -R ~/monstertix/artifacts
如需从笔记本电脑移至 Cloud Storage,请将 file:// 更改为 gs://。(我们将在第 10 步中看到这一点)
我们的记忆存储

ADK 为您提供了一个名为 BaseMemoryService 的长期记忆接口。它有两种方法:
# google.adk.memory.BaseMemoryService — ADK's interface, not your file
async def add_session_to_memory(session) # take a conversation in
async def search_memory(*, app_name, user_id, query) # hand memories back
ADK 随附了该接口的三种实现:
类 | URI | 所需条件 |
|
| 无。停止进程时会丢失所有内容 |
|
| Vertex AI RAG 语料库 |
|
| Agent Engine 资源 |
在本研讨会中,我们将编写自己的提示。排除 InMemoryMemoryService,它会在您停止进程时忘记所有内容;排除另外两个,它们需要花费数分钟才能创建的云资源。请改为选择文件,因为您可以提前为文件添加初始数据,然后在编辑器中读取该文件。以 memory.py 为例,我们将所有内容都存储在一个 Markdown 文件中:
class MarkdownMemoryService(BaseMemoryService):
async def search_memory(self, *, app_name, user_id, query) -> SearchMemoryResponse:
path = self._path(user_id) # memory/userx.md
return SearchMemoryResponse(memories=[
MemoryEntry(content=types.Content(
role="user", parts=[types.Part(text=path.read_text())]))])
SearchMemoryResponse 和 MemoryEntry 是 ADK 的类型,记忆库返回相同的对象。一个具有托管式服务。我们的有文件。
选择文件的原因之一:您可以打开并读取文件。智能体的整个长期记忆是一页文本。
那么,您应该指向哪里呢?
在此研讨会的实现中,我们在命令行中选择了三个服务中的两个。第三个是我们出于上述原因自行实现的:
服务 | 设置位置 | 本研讨会 |
session | 在 |
|
工件 | 在 |
|
记忆 | 命令行中任何位置 |
|
因此,memory.py 会根据环境变量构建服务本身:
# agent/concert/memory.py
MEMORY_DIR = pathlib.Path(os.environ.get("MEMORY_DIR", "./memory"))
MEMORY_USER = os.environ.get("MEMORY_USER", "userx")
memory_service = MarkdownMemoryService(MEMORY_DIR)
. ./set_env.sh 会同时导出这两个变量。获取 MEMORY_DIR 设置代理读取内存的位置,MEMORY_USER 确定文件。
前往相应位置查找物品
现在,您有 4 个可以存放事实的位置。查看所有四种。
首先,您自己的会话
👉 点击顶部栏中的新建会话。
👉✨ 依次说出三件事:
Sam can't do weeknights, by the way.

Show me the seat map for the Amsterdam Saturday show.

Actually, hold that thought — what else is on in Amsterdam?
第三条消息很重要。它会结束读取座位图的回合,而结束回合正是导致 temp: 消失的原因。
有三件物品被存放在三个不同的位置:
存储的内容 | 哪个工具执行了此操作 | 资金去向 | |
1 | Sam 无法在工作日晚上进行活动 |
|
|
2 | 完整座位图,6 KB |
| 制品服务 |
3 | 单行座位图摘要 |
| nowhere |
👉 立即检查。状态标签页和制品标签页位于 adk web 的左侧。


在状态标签页中,请注意 temp:seatmap 已省略。带有 temp: 前缀的状态键仅在有效调用期间存在,并且在将会话状态持久保存到数据库之前,会被 ADK 自动剥离。
然后是较早的对话
👉 在顶部的会话下拉菜单中切换到 two-days-ago。
此会话包含数据库中预先填充的对话记录。

👉 查看事件流(中间列)。请注意,较早的对话轮次已替换为总结性事件块。

👉 查看状态标签页。三种类型的密钥同时存在:
target_event_id ms-ams-01 ← plain: this booking only
party_size 2 ← plain: this booking only
user:prefs {"excluded_weekdays": [...]} ← the person, every session
user: 前缀(例如 user:prefs)可将状态范围限定为特定用户的所有会话。相比之下,不带前缀的键 (
target_event_id
、
party_size
) 表示纯会话状态,其范围严格限定为活跃会话,并在会话结束时删除。
👉 现在,比较这两个“状态”标签页。虽然 user:prefs 和存储的制品会保留,但 temp:seatmap 在两个会话中均不存在。带有 temp: 前缀的状态键严格来说是临时性的,永远不会持久保存到数据库中。
然后,文件
👉💻 在编辑器中打开该文件:
cloudshell edit ~/monstertix/memory/userx.md
三笔预订,最早可追溯到 1 月。代理在调用 recall() 时会读取该数据,即使我们丢失了会话数据,该数据仍会存在。
四处地点
Where it lives | 在下一条消息中存活? | 在重启后是否仍然存在? | 在删除会话后是否仍然存在? |
| 否 | 否 | 否 |
| 是 | 是 | 否 |
| 是 | 是 | 是 |
| 是 | 是 | 是,并且在删除数据库后仍存在 |
制品 | 是 | 是 | 取决于服务 |
请将该表视为与容器有关,而不是与容器中的内容有关。memory/userx.md 可以承受一切,并且其中只包含 remember() 选择放入的内容。没有任何内容会写入当前对话。因此,文件在会话结束后仍然存在,并不等同于智能体记住您刚才谈论的内容,而第 5 步正是体现这种区别的地方。

What state is
您可以将状态视为随会话一起传递的字典。通过 tool_context.state 从工具中读取和写入该数据,并让 ADK 将其与对话一起保存。
ADK 会分配前缀的含义。您未在任何位置定义它们。ADK 会读取键名称的第一部分,并根据该部分决定将值存储到何处以及存储时长:
名为 | ADK 会存储该信息 | 持续时间 |
| 会话 | 直到会话被删除 |
| 针对用户的 | 在用户的所有会话中 |
| 针对应用 | 所有用户 |
| 没有在任何国家/地区 | 一次调用 |
调用是指单个请求-响应周期,包括所有模型推理、工具执行和最终响应。
当调用结束时,ADK 会将新状态写入数据库,并在此过程中剥离每个 temp: 密钥。
一个未解之谜
在 two-days-ago 中,您会看到摘要位于用户前几个回合的位置。当对话足够长时,ADK 会自动撰写摘要。
所有事实都有热度周期
5. 上下文降级和压缩
摘要:忘记细节
当对话过长时,系统会总结旧消息,以节省空间和 token。
摘要有助于了解大致内容,从而节省一些令牌用量。

👉🔴 在场地面板上,按 RESET THE VENUE。每个步骤都从同一世界开始:8 个演出,所有座位均可预订,时钟速度为 1 倍。
👉💻 前往此步骤的代码:
cd ~/monstertix
. ./set_env.sh
./use-solution.sh 5 --force
自上一步以来发生了哪些变化:
文件 | 有何变化 |
| 代理封装在 |
其他所有内容 | 未更改 |
agent.py 不再以 root_agent 结尾。它以以下内容结尾:
app = App(
name="concert",
root_agent=root_agent,
events_compaction_config=EventsCompactionConfig(
compaction_interval=3, # summarise after every 3 turns
overlap_size=1, # re-read 1 turn either side when summarising
),
)
请注意,adk web 会先查找名为 app 的变量,然后再回退到 root_agent。每当需要将某些内容应用于整个应用(而非单个代理)时,请将代理封装在 App 中:此处是压缩,后续步骤中是可恢复性。
compaction_interval=3 表示 ADK 每三轮对话后进行总结。一个回合是指您说一句话,然后智能体完成回答,无论在此过程中调用了多少工具。3 较小,因此会在研讨会期间触发。实际应用会使用更大的数字。
overlap_size=1 表示每个摘要的开始时间都比严格需要的开始时间早一个回合。
ADK 总结了第 1、2 和 3 轮对话。在三个回合后,它会总结下一批次。使用 overlap_size=1,第二个摘要会再次从第 3 轮开始,而不是第 4 轮:

请注意,第 3 轮对话会被总结两次。这是有意为之。如果您在第 3 轮中提问,而代理在第 4 轮中回答,那么干净的分隔会将问题放在一个摘要中,将答案放在另一个摘要中,从而导致两者都令人困惑。重复一次发言是防止思路中断的低成本保险。
设置这两个字段。ADK 对这两者均没有默认值。
压缩和内存服务永远不会相遇
值得明确说明,因为这两个词听起来相关,但系统并不相关。
EventsCompactionConfig 位于 agent.py 的 App 上,紧邻 root_agent。它是应用的属性,而不是任何服务的属性:
# agent/concert/agent.py
app = App(
name="concert",
root_agent=root_agent,
events_compaction_config=EventsCompactionConfig(
compaction_interval=3,
overlap_size=1,
),
)
摘要会重写对话内容。它不会重写文件。
这就是 remember() 作为有意调用的全部原因。转写内容中剩余的任何内容最终都会被读作他人撰写的释义;写入文件的任何内容都会在明天和下周被完全按照存储时的内容读出。此步骤的最后一步是观看该差异的落地。
摘要的保存位置
在 sessions.db 中,在 events 表格中,代理会向事件行写入压缩记录,并带有与其他轮次事件相同的时间戳。
普通事件包含 content 字段,摘要位于 actions.compaction 下。
👉💻 在您自己的数据库中查看:
cd ~/monstertix
. ./set_env.sh
sqlite3 sessions.db \
"select json_extract(event_data,'\$.actions.compaction.compacted_content.parts[0].text')
from events where event_data like '%compaction%';"
事件的形状如下所示:
events row
└── event_data (JSON)
├── id, author, timestamp, invocation_id ← like every other event
├── content ← ABSENT on this one
└── actions
└── compaction
├── start_timestamp ← the window it replaced
├── end_timestamp
└── compacted_content.parts[0].text ← the summary itself
活动流会从内容中提取每个活动。数据库中的摘要,并在每次对话轮次中发送给模型。
第二个代理
budget_split 是一个完全独立的代理,而不是工具:
# agent/concert/agent.py
budget_split = Agent(
name="budget_split",
model=MODEL,
include_contents="none", # sees none of the conversation
instruction="You do ticket arithmetic and nothing else...", #just the budget
output_key="budget_plan", # its answer goes into state
)
root_agent = Agent(
...,
tools=[..., AgentTool(agent=budget_split)],
)
为什么使用智能体,而不是普通函数?因为该工作需要模型。将“以下哪些部分适合 4 人 200 美元的预算,总共是多少”视为包含判断的算术题,答案必须是人能够读懂的。让 Python 函数执行乘法运算,而不要让它决定哪些选项值得提及。
那么,为什么不让主智能体来做呢?下一行中的 include_contents="none" 表示此代理未收到任何对话。没有群聊,没有偏好,没有 40 个巡回演出日期。它会获取请求,但不会获取其他任何内容。该标志仅存在于 Agent 上,这才是此实体是代理而非函数的真正原因。

首选简短提示:更便宜、更快,而且更难跑题。依赖于算术不受 20 回合前说的话的影响,因为它永远不会看到这些话。
为什么选择
AgentTool
? 使用它封装智能体,以便主智能体可以像调用任何其他工具一样调用该智能体。从模型的角度来看,budget_split 与 search_events 完全相同。
以及
output_key="budget_plan" 会将答案放入该名称下的会话状态中,而不是将其随意放置在对话中。观看视频,了解为何这很重要。
运行应用
👉💻 在终端 1 中,按 Ctrl-C 并使用相同的标志重新启动:
cd ~/monstertix
adk web agent --port 8000 --allow_origins "*" \
--session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
--artifact_service_uri="file://$WORKSHOP/artifacts"
👉 开始新会话,进行真实的对话。五到六圈。以下内容有效:
hello
what's on in Amsterdam?
Sam is coming with us, and he can't do weeknights
there are 4 of us and I can spend about $200 a head. What fits?
👉 打开状态标签页。
找到 budget_plan,其中包含算术代理的完整回答。

👉✨ 现在,更改其中的数字:
what if 10 people come and I can only do $120 each?
👉 再次查看 budget_plan。它已被覆盖,而不是附加到末尾。这是因为 output_key 存储的是最新值。

您无法查看的内容:摘要
在这些对话轮次中的某个位置,ADK 总结了之前的对话。
👉💻 在新的终端中,运行以下命令以直接读取该文件:
cd ~/monstertix
. ./set_env.sh
./show-compaction.sh
阅读其撰写的内容
以下是一封真实的电子邮件(已缩减):
── summary 1 ──────────────────────────────────────────────────────
Conversation Language: English
**User Request:**
The user initially wanted to know what concerts are playing in Amsterdam.
Subsequently, the user provided a constraint regarding a companion, Sam.
**Context Summary:**
The user is planning a concert trip. The agent identified two events in
Amsterdam: "The Midnight Signal" at the Ziggo Dome on Tuesday, November
10 and Saturday, November 14, 2026. The user then informed the agent that a
companion named "Sam" will be joining, but "he can't do weeknights." The
agent has successfully noted this constraint for Sam.
**Key Decisions/Information Obtained:**
* **City of interest:** Amsterdam
* **Events found:** "The Midnight Signal" at the Ziggo Dome, Tuesday
10 November and Saturday 14 November 2026.
* **Companion:** Sam
* **Companion constraint:** Sam cannot attend concerts on weeknights.
**Unresolved Questions/Tasks:**
* The agent has not explicitly confirmed if the found event (which is
on a Saturday) is suitable given Sam's constraint, nor has it asked the
user for the next steps regarding this event or further event searches.
**Tools Used:**
* `recall`
* `search_events`
* `note_companion`
它保留了价格、版块、城市和偏好设置,并正确指出尚未做出任何决定。
由此可得出以下三点:
这是释义。此处的内容并非任何人的发言。它是一种对所说内容的记录,由与偶尔导致模型出错的流程相同的流程生成。
注意:摘要字符串存储在内部会话事件记录中,不会直接显示为单独的界面标签页。
每三轮对话,对话内容现在部分由之前的摘要组成。
尝试预订,但不要重复自己说的话
在新会话中:不要开始新会话 - 您刚刚阅读的摘要位于此会话的事件中,这也是代理仍然了解阿姆斯特丹的唯一原因。
👉✨ 尝试一些新事物:
Book us something.
观看客服人员询问观看的节目、观看的场次以及观看人数。将此标记为正确:摘要本身就说明了用户从未确认任何内容,因此智能体提出了询问。将压缩归功于压缩。
即使压缩成功,总结之前的对话轮次也可能会引入歧义,从而需要智能体与用户重新确认限制条件。
👉✨ 回答此问题,让预约顺利完成:
Amsterdam Saturday, section C, 8 people
在花费任何资金之前,它会读回订单:
“您想预订 11 月 14 日(星期六)在阿姆斯特丹举行的 The Midnight Signal 演出 C 区的 8 张门票。总费用为 760 美元。要继续购买这些机票吗?"
👉✨ 确认:
yes
现在,系统会告知您操作成功:
“您已成功购买 8 张 11 月 14 日(星期六)在阿姆斯特丹举行的 The Midnight Signal 演出 C 区门票,总价为 760 美元。您的订单 ID 为 ord_227877fe。”
前往检查是否已完成
👉 切换到商家面板。不要相信代理的说法。

在面板上 | 您应该看到的内容 |
已购买的门票 |
|
下方的订单 |
|
游览,阿姆斯特丹周六 | C 部分下降了 8 |
Activity |
|
查看面板,了解门票销售情况。
为什么选择 user:prefs 是正确的
回想一下,在user:prefs最后一步中,我们编写了 Sam 的限制条件note_companion,但有一个明显的异议:如果智能体可以记住这些限制条件,为什么还要使用工具呢?
原因如下。使状态与对话无关,并且摘要不会触及状态。检查 user:prefs 和 budget_plan 与它们进入时的状态是否一致,发现它们未发生变化。然后检查转写内容。
摘要会保留大致形状,但会丢失细节。请将您无法承受丢失的任何内容存储在对话之外的其他位置。
6. 流程恢复
等待 40 分钟而不保持在线状态
如果客服人员必须在 40 分钟的工单队列中等待,那么保持 Python 进程处于活动状态并等待是脆弱且昂贵的。
为妥善处理长时间等待的情况,请执行以下操作:
- 将状态保存到数据库:存储代理暂停的位置,以便进程可以安全关闭。
- 按需唤醒:等待结束后,外部信号会重新启动代理,并从上次停止的位置继续执行。
👉🔴 在场地面板上,按 RESET THE VENUE。每个步骤都从同一世界开始:8 个演出,所有座位均可预订,时钟速度为 1 倍。
👉💻 前往此步骤的代码:
cd ~/monstertix
. ./set_env.sh
./use-solution.sh 6 --force
运行应用
自上一步以来发生了哪些变化:
文件 | 有何变化 |
| 添加了 |
|
|
join_queue 会立即返回一个凭据,而不是阻塞,而 ResumabilityConfig 会让运行暂停,并在之后继续运行。

👉💻 终端 1 - 代理,保持不变:
cd ~/monstertix
. ./set_env.sh
adk web agent --port 8000 --allow_origins "*" \
--session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
--artifact_service_uri="file://$WORKSHOP/artifacts"
👉✨ 让智能体购买:
Get us two tickets to the Amsterdam show.
它会调用 recall,然后调用 search_events,再调用 join_queue,并回答类似以下内容:
You are in the queue for The Midnight Signal at Ziggo Dome, Amsterdam, on Saturday, November 14. Your position is 14203.
请注意,系统在未征求您的同意的情况下选择了星期六。阿姆斯特丹有两场演出,17 日是周二,而上一步中的记忆文件显示 Sam 不在工作日演出。这是根据之前预订中的事实来决定本次预订中的问题。
另请注意,它在查看单个座位或价格之前就加入了队列,并且没有提及任何座位或价格信息。这是场地的规定,您需要一直购买门票,直到到达前排,因此唯一值得提前购买的是排队位置。现在起 40 分钟后,该选择哪些座位了。
👉 查看控制面板。横幅内容如下:

Agent is waiting in line — #14,203
About 2400s at 1×. Safe to kill the agent right now — it will still be here.
队列确实有 40 分钟长,并且在您工作时不会消失。
👉💻 终止代理。:
Ctrl-C
👉💻 在终端 1 中再次启动:
cd ~/monstertix
. ./set_env.sh
adk web agent --port 8000 --allow_origins "*" \
--session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
--artifact_service_uri="file://$WORKSHOP/artifacts"
👉✨ 在同一会话中,询问智能体您在队列中的位置:
Where am I in line?
You are currently at position 12982 in the queue. It is not your turn yet.

比较工单,并观察到在没有任何内容运行的情况下,位置不断下降。请注意,代理不知道自己已被终止。它使用重启之前获得的票证调用了 check_queue,因为该票证位于磁盘上。
👉💻 了解原因:
cd ~/monstertix
sqlite3 sessions.db "select name from pragma_table_info('events');"
id
app_name
user_id
session_id
invocation_id
timestamp
event_data
请注意,每个对话的每个轮次都对应一行,而轮次本身、是谁说的、调用了哪个工具、返回了什么以及备注(JSON 格式)都位于 event_data 中。
👉💻 在其中找到您的排队票:
sqlite3 sessions.db "select event_data from events;" \
| grep -o '"ticket": "q_[a-z0-9]*", "position": [0-9]*' | tail -1
"ticket": "q_8cc2c6f9", "position": 14203
当 join_queue 返回时,其票券会直接保存到数据库中。重新启动后,ADK 会从磁盘重新加载之前的对话轮次和工具结果,从而使代理能够从上次中断的地方继续运行。
并注意它没有重新开始。智能体直接恢复,而不是从头开始重放工作流。由于 LongRunningFunctionTool 和 ResumabilityConfig 会将执行状态保留到磁盘,因此 ADK 知道代理已在排队,从而防止代理重新搜索巡回演出日期或请求重复的门票。

为什么选择 join_queue,而不是 check_queue
这两个函数均以毫秒为单位返回。区别在于函数返回时底层作业是否已完成。
返回结果 | 工作是否已完成? | |
| 队列工单和初始位置 | 否。外部系统会继续异步处理。 |
| 当前队列状态 | 是。返回即时的时间点答案。 |
LongRunningFunctionTool 专为返回待处理工作句柄的操作而设计。封装 join_queue 可让代理在外部队列推进时暂停执行。
相比之下,check_queue 是同步且完整的。如果将其封装起来,代理每次检查状态时都会错误地暂停执行,即使它排在队伍最前面,需要立即完成购买也是如此。

是什么让它奏效
以下三个组件可实现执行暂停和恢复:
# agent/concert/agent.py
LongRunningFunctionTool(func=join_queue) # the tool returns without finishing
ResumabilityConfig(is_resumable=True) # the run may pause and be picked up
--session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db"
LongRunningFunctionTool:将工具标记为非阻塞,返回一个有效句柄,以便代理可以等待,而不会挂起线程。ResumabilityConfig:指示运行时暂停未完成任务的执行生命周期,并从上次记录的状态恢复。Session Storage URI (--session_service_uri):将状态持久性定向到外部存储空间(例如 SQLite、Cloud SQL),而不是进程内存,从而防止在重启时丢失数据。
7. 针对过时数据采取行动
等待期间信息过时
持久保存的对话记录不是实时状态。在长时间的等待间隔期间,外部数据(例如机票供应情况或价格)可能会在客服人员不知情的情况下发生变化。
如果智能体缺少信息,会暂停并要求澄清;但如果智能体的数据过时,则会立即根据过时的假设采取行动。为防止无效操作,必须在执行关键操作之前立即重新验证时间敏感型数据。
暂时不加载新代码。停留在上一步的代理上(它可以购买机票,但目前没有针对过时或幂等性的安全措施)。
👉🔴 按 RESET THE VENUE,然后在 adk web 中开始新会话 - 即聊天上方的 + 新建会话按钮。
开始新会话,以清除之前的排队票证和待处理的购买状态。此步骤需要干净的执行上下文,才能正确演示过时处理。
快照位于
👉✨ 要求播放与之前相同的节目:
Get us two tickets to the Amsterdam show.
👉✨ 现在,在等待排队时,询问座位:
How much are the seats, and what's still available?
Section A: $210 per ticket, with 400 seats available.
Section B: $145 per ticket, with 900 seats available.
Section C: $95 per ticket, with 1200 seats available.
👉🔴 按 SELL THE GOOD SEATS。A 部分降至零。
👉🔴 按 SKIP THE WAIT(跳过等待)可让智能体排在队列的最前面。

👉✨ 让它购买:
Buy the two section A seats.
它会执行 purchase(section="A"),而不会重新验证座位供应情况,从而导致影院 API 返回错误响应:
{"error": "sold_out", "available": 0,
"message": "those seats are gone — re-fetch the seatmap before buying"}
Oh no! It looks like the Section A tickets just sold out. Let me get an
updated seat map to see what's still available.

请注意,智能体仅在收到 API 错误响应后才重新查询座位图。根本原因是上下文过时:在会话的早期,智能体记录了“A 部分:400 个可用”,而该轮对话仍保留在传递给模型的对话历史记录中。
明确旧信息的存储位置:它会保留在对话历史记录中,而 temp: 等状态前缀无法覆盖该信息。

解决方法
👉🔴 在场地面板上,按 RESET THE VENUE。每个步骤都从同一世界开始:8 个演出,所有座位均可预订,时钟速度为 1 倍。
👉💻 现在,加载此步骤的代码:
cd ~/monstertix
. ./set_env.sh
./use-solution.sh 7 --force
👉💻 在终端 1 中,按 Ctrl-C 并重新启动:
cd ~/monstertix
. ./set_env.sh
adk web agent --port 8000 --allow_origins "*" \
--session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
--artifact_service_uri="file://$WORKSHOP/artifacts"
变化:
文件 | 有何变化 |
| new。在购买交易之前立即运行,并且可以阻止购买交易 |
|
|
|
|
fence.py 是 ADK 在任何工具调用之前的时刻运行的回调:
root_agent = Agent(
...,
before_tool_callback=refresh_before_purchase, # re-read inventory, then decide
)
证明给我看
👉🔴 重置场地。排队,在等待期间按出售优质座位,然后让它购买 A 区的座位。
这次它自行停止了。观看智能体说出类似以下内容:
“看来 A 区刚刚售罄。我需要重新查看座位图。”
然后,观看它在未收到提示的情况下再次调用 get_seatmap,并返回提供部分 C。
👉🔴 仔细阅读场馆的活动动态,因为其中会显示上下半场:
error purchase failed: Section A has 0 seats left, 2 requested. Re-read the
seat map and pick again.
agent calling purchase

在信息中心内,您会看到通话购买交易,但支持请求计数器仍为零。出现这种情况是因为 fence.py 在工具运行之前充当保护机制:
- 智能体尝试调用购买工具。
fence.py首先捕获请求并检查直播座位是否可用。- 看到座位已售完,
fence.py直接返回错误,在调用 API 之前取消购买。
提示指令无法保证 LLM 不会尝试购买已售完的座位。代码防护措施 (before_tool_callback) 可确保不良购买交易永远不会影响到现实世界。
👉🔴 按 RESET THE VENUE,然后按 BREAK THE NEXT PURCHASE
👉✨ 让它购买,并让它根据需要重试任意次数。

订单数量仍为 1,因为商家在重试时收到了相同的幂等键,并返回了原始确认信息,而不是创建重复订单。
8. 自主工作流
大语言模型不擅长每次都严格遵守规则
LLM 非常擅长做出决策(例如根据用户偏好选择节目)。但如果依赖 LLM 来遵循严格的逐步规则(例如“始终先加入队列,然后再查看座位”),它有时会跳过步骤或弄乱顺序。
如要解决此问题,请将工作流拆分为两种类型的步骤:
- 固定规则(代码函数):必须每次都以完全相同的方式执行的步骤(例如取排队票、查看位置)会编写为普通代码节点。
- 决策(代理节点):需要判断的步骤(选择座位区域、检查用户偏好设置)交给 AI 代理。
查看此流程中实际包含的内容。有些内容完全没有判断:
步骤 | 判决? |
恰好取一张排队票 | 否。始终为 1,始终在购买前 |
检查是否轮到您 | 否。提问、查看回答 |
为买方撰写简报 | 否。每次都使用相同的句子和形状 |
显示哪个节目、哪个版块 | 可以。价格、日期、谁会来、他们讨厌什么 |
用户偏好设置和限制 | 可以。 |
严格的程序不应依赖 LLM,因为概率模型在不受监控的情况下最终会无法遵循严格的序列。
ADK 的 Workflow 将确定性执行与 AI 推理分离开来:
- 函数节点每次都会始终如一地强制执行固定规则(例如加入队列或检查位置)。
- 代理节点处理情境决策(例如根据用户偏好选择座位)。
- 等待节点会挂起执行,而不会使进程在内存中保持活跃状态。
原始代理不会被替换。它会成为工作流图中的最终节点,保留其工具、过时检查和幂等性保障。图表控制执行顺序,而代理执行购买决策。
👉🔴 在场地面板上,按 RESET THE VENUE。
👉💻 前往此步骤的代码:
cd ~/monstertix
. ./set_env.sh
./use-solution.sh 8 --force
运行应用
👉💻 在终端 1 中,按 Ctrl-C 并重新启动。与往常一样,使用以下命令:
cd ~/monstertix
. ./set_env.sh
adk web agent --port 8000 --allow_origins "*" \
--session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
--artifact_service_uri="file://$WORKSHOP/artifacts"
自上一步以来发生了哪些变化:
文件 | 有何变化 |
| new。以 ADK 2 |
|
|
| 它还丢失了 |
运行图表 - 三条消息,按顺序
在 nightly python 中运行以下所有操作(这是图表所在的位置)。在每个回合中发送相同的输入,以观察工作流图在各个状态之间的转换。
1. 注意您现在与谁对话
👉 重新加载 ADK 网页界面。
# concert/agent.py, at the bottom
buyer_agent = Agent(name="concert", ...) # ← everything you built, renamed
from .nightly import nightly
root_agent = nightly # ← what adk web actually runs
请注意,adk web 会执行分配给 root_agent 的任何对象。在此示例中,root_agent 指向 nightly 工作流图,其中 buyer_agent 配置为图中的最终节点。
2. 开始
👉✨ 输入以下内容并按 Enter 键:
go
注意:在自动化工作流图中,初始执行无需等待用户输入即可开始。在 adk web 中发送消息会触发初始运行。
👉 现在,请查看网络浏览器。左侧面板会绘制图表,并在节点运行时为其着色。右侧面板是事件流,用于观察决策的制定过程:

观看 queue_up 将 queue_ticket 和 queue_event_id 写入会话状态。

您应该会看到一个名为 adk_request_input 的函数调用,正在等待回答。然后,设备会静音,跑步结束。图表达到 check_front,找到您在第 14,203 位,然后停止。
👉 检查场地面板:一个排队票,没有正在运行的作业。

3. 唤醒它 - 并注意您在哪个框中输入内容
在事件流中滚动到 adk_request_input 事件,然后找到与该特定事件关联的输入框:

现在,您可以在两个位置输入内容,这两个位置的作用各不相同。
输入位置 | 具体变化 |
| 回答中断。已暂停的跑步活动从 |
底部的聊天主框 | 开始新调用。图表从 |
👉✨ 在 Enter your response... 框中输入任意内容,然后按箭头键。
👉 1 号航站楼:
[check_front] #14,197 — not yet. Pausing.
一行。它没有选择第二场演出,也没有领取第二张排队票,否则您将排在 14,203 人组成的队伍的末尾。
如果您看到 [open] 和 [pick_show] 再次运行,则说明您在聊天框中输入了内容。按 RESET THE VENUE,然后重新开始该步骤。
👉 再次查看场地面板:仍然是一张门票,但位置略有下降,因为场地的时钟一直在运行,而您的时钟没有运行。

4. 移至最前,然后完成
👉🔴 在活动场所面板中按跳过等待。

👉✨ 最后一次回答最新的 adk_request_input 活动,同样是在其框中,而不是聊天框中。
这次,手表check_front会找到您,然后从前面掉落。观看左侧面板上最后两个节点 brief 和 concert 亮起。

👉 然后查看浏览器。这是跑步结束时的画面,其中有四项值得阅读的内容。

图表完全为绿色,且边缘已纳入统计。brief 和 concert 已亮起,指向 pick_show、queue_up 和 check_front 的边现在带有徽章。
2x
是图表遍历该边的次数,每次唤醒遍历一次。顶部的 2 是恢复的价格:调度程序每次都从头开始重新进入图表。这不意味着工作发生了三次,场地面板就是证明:只有一个排队票,而不是 2 个。@node(rerun_on_resume=False)。
图表

# agent/concert/nightly.py
nightly = Workflow(
name="concert_nightly",
edges=[(START, open_the_night, pick_show, queue_up,
check_front, brief, buyer_agent)],
)
请注意,两个节点是代理,两个是函数,将哪个是哪个视为整个设计问题。
节点 | 为什么是这种形状 |
| agent. 从某人的历史记录中选择节目是一种判断。它会调用 |
| 函数。加入队列是一条规则。规则不应是凌晨 3 点的抛硬币 |
| 函数。“我在前面吗”有一个正确答案,并且活动场地有这个答案 |
| agent. 它是 |
查看最后一个节点,找到您构建的代理。不是复制,也不是重写:
# agent/concert/nightly.py
from .agent import buyer_agent
所有这些都随之而来:它拥有的每种工具、fence.py 中的座位图重新读取、purchase 中的幂等性密钥。只有一个。
为什么停止了,而不是等待
以下是任何人都不应编写的版本:
# what NOT to write — this is nobody's file
while not ready: # never do this
time.sleep(1)
它会使进程保持运行 40 分钟,但什么也不做,会随着终端的关闭而终止,并且无法恢复。check_front 的用途如下:
# agent/concert/nightly.py — check_front
if not status["ready"]:
return RequestInput(message=f"Still at #{position}. Wake me and I'll check.")
RequestInput 是一个中断。图表停止在当前位置,调用进入会话存储区,进程可以自由退出。
在等待期间,运行不会产生任何费用,并且运行会在一个地方结束:购买的彩票。不过,您可能需要熬夜并多次醒来才能实现这一目标。
9. 与人工客服人员沟通
未经他人仔细检查,请勿花钱
夜间运行的自主代理不应根据聊天记录中猜测或模糊的偏好做出昂贵的财务选择。
在执行高风险操作(例如花钱购买门票)之前,智能体必须暂停,大声询问用户以确认其预算,并在进行任何购买之前保存该明确的确认信息。
👉🔴 在场地面板上,按 RESET THE VENUE。每个步骤都从同一世界开始:8 个演出,所有座位均可预订,时钟速度为 1 倍。
👉💻 前往此步骤的代码:
cd ~/monstertix
. ./set_env.sh
./use-solution.sh 9 --force
运行应用
👉💻 在终端 1 中,按 Ctrl-C 并重新启动。与之前相同的命令:
cd ~/monstertix
. ./set_env.sh
adk web agent --port 8000 --allow_origins "*" \
--session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
--artifact_service_uri="file://$WORKSHOP/artifacts"
自上一步以来发生了哪些变化:
文件 | 有何变化 |
| 保留用户同意的内容,即用户所说的句子。 |
| 添加了 |
| 一个新的第一个节点 |
请勿对其进行建模。存储句子。
打开 agent/concert/budget.py 并阅读全部内容:
def load(state) -> str:
"""What they agreed, verbatim. Empty string means nobody has said."""
return str(state.get("budget") or "")
state["budget"] = "up to $100 for the upper bowl or general admission, up to
$250 for the lower bowl, and up to $300 for a Saturday show"
节点会检查两件事:当事人是否说过,以及当事人是否确认过。
然后,它会故意将句子保留在会话状态下,而不是在 user: 下。为一次预订协商的预算。“如果他们是好乐队,就值 250 美元”这句话是当晚针对该乐队说的,与门票预订无关。
他们说的其他一切也都是如此
不要止步于预算。用户在做决定时可能会改变主意,因此请在此处记录他们提出的所有要求,而不要依赖客服人员的记忆。
"I live in NYC, I can only do weekends, I have 10 people coming with me"
# agent/concert/nightly.py
def what_they_asked_for(ctx) -> str:
"""Everything this person typed, joined, in their own words."""
said = []
for event in (ctx.session.events or []):
if event.author != "user":
continue
for part in (event.content.parts if event.content else []) or []:
text = (getattr(part, "text", None) or "").strip()
if text and text not in said:
said.append(text)
return "\n".join(said)
读取用户撰写的事件,它只查看
text
function_response中的 部分,因此预算问题和回答不会在此处回显。无重复项。
请注意,agree_budget 会在每次运行中调用它,而不仅仅是第一次,因此在代理排队时输入的更正也会生效。然后,open_the_night 将这两个句子(问题和预算)交给选择器,并让选择器读取它们。
图,长一个节点
我们先从一段对话开始:
# agent/concert/nightly.py
nightly = Workflow(
name="concert_nightly",
edges=[(START, agree_budget, open_the_night, pick_show, queue_up,
check_front, brief, buyer_agent)],
)

两个节点停止并等待,它们采用的是相同的机制。check_front 等待场地,并由时钟回答。agree_budget 等待人工接听,只能由人工接听。这一差值就是第 10 步的全部内容。
高效运转 — 分步进行
1. 开始
👉 重新加载 ADK 网页界面,然后选择 concert 代理。
👉✨ 提出您真正想要的东西:
Get us two tickets to the Amsterdam show.

在第 8 步中,图表中没有用户,因此无论您输入什么,系统都会舍弃,而 go 的效果与任何其他输入一样。在此示例中,agree_budget 在第一行调用 what_they_asked_for(ctx),后者会收集您输入的所有内容并将其放入 state["request"] 中。然后,open_the_night 将其与预算一起交给拣货员。
您未指定日期,而阿姆斯特丹有两场演出。因此,请观看选择器回退到 recall(),找到 "Sam bails on weeknights",然后选择周六。
在浏览器中,运行会在 adk_request_input 事件发生时停止:
图表刚刚向您提出了一个问题。与第 8 步中的队列等待中断相同,但现在指向了回答问题的人。
2. 以用户实际回答的方式回答
👉✨ 在 Enter your response... 框中(而不是底部的聊天框中):
About a 100 for the cheap seats. 250 if they're the good ones.
观看它停止并确认:
[agree_budget] reading back: About a hundred for the cheap seats. Two-fifty
if they're the good ones.
⚡ adk_request_input
So: About a hundred for the cheap seats. Two-fifty if they're the good
ones. Have I got that right?

3. 确认
👉✨ 同一盒子:
Yes, that's right.
[agree_budget] agreed: About a hundred for the cheap seats. Two-fifty if
they're the good ones.
[open] Budget: About a hundred for the cheap seats. Two-fifty if
they're the good ones.
[pick_show] → {"section": "A", "reason": "..."}
[queue_up] ms-ams-01 section A — ticket q_5924edc2 at #14,203
[check_front] #14,203 — not yet. Pausing.
四个节点都只运行了一个字。👉 打开状态标签页,然后找到 budget - 您原封不动的那句话。

无前缀。这就是会话状态,如果您开始新会话,预算将用完,因此系统会再次询问您。
4. 到达前方并完成
👉🔴 在活动场所面板中按跳过等待。
👉✨ 回答最新的 adk_request_input - 同样在其框中:
go

👉 查看场地面板:一笔订单,价格在您为该类座位商定的价格范围内。

图表如何向用户提问
打开 nightly.py 并阅读 agree_budget。同时满足三个条件,如果缺少任何一个条件,则会发生相同的故障:永远询问。
@node(rerun_on_resume=True) # 1. run again when woken
def agree_budget(node_input, ctx):
answers = ctx.resume_inputs or {} # 3. where the answer arrives
said = answers.get(ASK_BUDGET)
if not said:
return RequestInput(
interrupt_id=ASK_BUDGET, # 2. STABLE id, not the default
message="What are you willing to spend?",
)
...
| 如果没有,则将该节点视为已完成并跳过 | |
稳定 的 | 默认情况下,每次运行都会生成新的 UUID,因此无法通过任何内容来查找答案 | |
| 回答的最终位置。不是 | |
图表
这是第 8 步的图,前面多了一个节点。
用户确认的限制是会话级限制,会在对话结束时失效。在各个会话中保留临时限额可能会导致一次性决策变成意外的永久默认设置。
10. 云堆栈
笔记本电脑进入休眠状态后本地文件消失
在笔记本电脑上,代理依赖于电脑保持唤醒状态、本地 SQLite 数据库和本地文件。如果您合上笔记本电脑的机盖或部署到会重启的云容器,本地文件和内存会消失。
为了在生产环境中可靠运行,请执行以下操作:
- 云计算:在始终保持在线状态的容器(例如 Cloud Run)中运行代理。
- 持久的云数据库:使用云服务(Cloud SQL 用于会话数据,Cloud Storage 用于文件)替换本地文件和 SQLite。
- 云端计时器:使用托管式调度器(例如 Cloud Scheduler 和 Pub/Sub),以便代理即使在笔记本电脑关闭时也能唤醒。
👉🔴 在场地面板上,按 RESET THE VENUE。每个步骤都从同一世界开始:8 个演出,所有座位均可预订,时钟速度为 1 倍。
👉💻 前往最终代码:
cd ~/monstertix
. ./set_env.sh
./use-solution.sh 10 --force
文件 | 有何变化 |
| 新。一个 FastAPI,用于提供聊天页面和调度器调用的端点 |
| new。普通 python-slim 和 uvicorn。它没有任何特定于 ADK 的内容 |
| 复制到容器中,以便网页与代理在同一容器中交付 |
| 未更改。您在完成第 9 步时使用的文件夹 |
您已构建的网页
** ADK Web 界面** 严格来说是一个本地调试界面,用于在开发期间检查状态、事件流和工作流图。它未打包以用于生产部署。
对于生产环境,请使用在第 3 步中构建的独立聊天前端。它通过 /wake 端点处理用户消息和互动中断,而不会公开内部调试控件。
将本地状态迁移到云服务
停止服务器并查看其写入的内容:
sessions.db a SQLite file next to the code
memory/userx.md a markdown file you can open in an editor
artifacts/ seat maps written to a folder
虽然本地文件非常适合开发,但在生产环境中却会失败,因为 Cloud Run 容器使用的是临时的实例本地存储空间。
在 Cloud Run 中运行时:
- 实例缩放到零:当流量停止时,容器会终止,并且其本地文件系统会被删除。
- 实例可独立横向扩缩:多个并发实例不共享磁盘空间,每个实例都使用自己的隔离文件系统运行。
如果代理将队列工单或会话状态保存到本地 SQLite 文件 (sessions.db),则后续路由到新容器实例或不同容器实例的请求会发现一个空数据库。为了在扩缩和重启期间保持持久状态,必须将本地文件替换为受管理的外部服务。
您真正需要的是能够比任何单个容器存活更长时间的状态。三个服务,每个文件对应一个服务:
在笔记本电脑上 | 在云端 | 简介 |
| Cloud SQL | 托管式 Postgres 数据库。无论是否正在运行任何内容,它都存在 |
| Cloud Storage | 存储分区。文件作为对象,可从每个实例同时访问 |
| Cloud Storage | 同一存储分区 |
| Cloud Scheduler | Google 运行的 cron。您的终端不是闹钟 |
创建它们
预配 PostgreSQL 数据库、数据库用户和 Cloud Storage 存储分区。请注意,Cloud SQL 实例创建需要几分钟时间,并且已在 setup.sh 期间在后台启动。
👉💻 收集数据,然后完成其余步骤:
cd ~/monstertix
./setup-cloud-state.sh
→ instance RUNNABLE
→ database adk created
→ user adk created
→ bucket created
→ memory memory/userx.md → gs://your-project-agent-you/memory/
✓ durable state ready. Written to .env
该脚本创建了四项内容(上表中的每一行表示一项内容),并将两个 URI 写入 .env:
CLOUD_SESSION_SERVICE_URI=postgresql+asyncpg://adk:...@/adk?host=/cloudsql/proj:region:instance
CLOUD_ARTIFACT_SERVICE_URI=gs://your-project-agent-you/artifacts
部署具有受管理的定时触发器的代理
服务准备就绪后,为代理指定其永久主页。
👉💻 三到四分钟。启动它并继续阅读:
cd ~/monstertix
. ./set_env.sh
./deploy-agent.sh
该脚本会从 agent/ 构建容器映像,将其部署到 Cloud Run,并预配支持事件驱动的基础设施。
完整部署包含五个不同的组件:
1. gcloud run deploy the container: your agent, the graph, and the page
2. a Pub/Sub topic a queue for messages
3. a service account an identity, so the message is allowed in
4. a push subscription topic ──► /trigger/wake
5. a Cloud Scheduler job the alarm clock, on a cron
将 Cloud Scheduler 用作触发源
Cloud Scheduler 使用托管式 cron 服务取代了本地执行计时器 (clock.py),该服务独立于本地终端会话持久存在,可处理自动重试,并支持时区调度。
将 Pub/Sub 用作消息解耦层
在 Cloud Scheduler 和 Cloud Run 之间放置 Pub/Sub 主题可带来以下三项架构优势:
- 消息持久性:如果 Cloud Run 服务正在冷启动、重新部署或暂时不可用,则将传入的触发事件加入队列。
- 内置重试政策:自动重试暂时性传送失败,无需自定义应用级重试逻辑。
- 扇出可伸缩性:使多个下游服务能够订阅同一调度事件,而无需重新配置调度程序。

配置 trigger_sources=["pubsub"] 会注册与标准 Pub/Sub 信封载荷兼容的推送端点,从而将传入的消息直接转换为代理调用事件。
→ agent https://concert-you-xxxx.run.app
→ topic presale-you created
→ iam concert-trigger@your-project... may invoke concert-you
→ push → https://concert-you-xxxx.run.app/trigger/wake
→ schedule 0 3 * * *
与它对话
👉 在浏览器中打开代理的网址。👉✨ 让它预订:
Book me 3 tickets for The Midnight Signal in Tokyo. Best seats in the house,
I don't want the cheap ones.
它应该会询问您愿意花费多少钱
👉✨ 像大声回答一样回答问题,然后确认:
up to 250 a seat for the good ones
跑步活动目前已暂停并加入队列。
👉🔴 在场地面板上按跳过等待,以便在收到消息时队列已准备就绪。
如需按实际时间表运行作业,必须启用该作业。如果作业处于暂停状态,jobs run 会拒绝执行该作业,并显示 Job.state must be ENABLED:
gcloud scheduler jobs resume presale-$(gcloud config get-value account | cut -d@ -f1) \
--location=$GOOGLE_CLOUD_REGION
👉 在几秒钟内,智能体会在确认的预算限额内执行购买交易,并自动返回订单确认信息。
👉🔴 查看场地面板。一个订单。
您可以按以下方式让该脚本每 5 分钟运行一次:
gcloud scheduler jobs update pubsub presale-$(gcloud config get-value account | cut -d@ -f1) \
--location=$GOOGLE_CLOUD_REGION --schedule="*/5 * * * *"
清理
部署了两个事物:场地和旁边的代理。两者均可缩减至零,因此空闲时无需支付任何费用。仍要删除。
./destroy-agent.sh # service, topic, subscription, scheduler job
./destroy-venue.sh
请注意,这些命令会故意保留 Cloud SQL 和存储分区,因为它们包含您的数据,并且两者都会继续产生费用。确定已完成所有操作后:
./destroy-agent.sh --all
其他所有内容都是本地内容。完成后,您可以删除该文件夹,也可以保留该文件夹,并根据自己的项目运行该文件夹中的文件,时间长短由您自行决定。
代理、其工具、其回调和预算均未发生变化。两个 URI 和一个环境变量。
11. 系统概览和问题排查指南
环境差异:本地架构与云架构
笔记本电脑 | 在云端 | 有何变化 |
| Cloud SQL | 连接字符串 |
|
| URI |
|
| 路径 |
| 一项 Cloud Run 服务 |
|
| Cloud Scheduler → Pub/Sub |
|
您(使用键盘) |
| 默认值,可按请求替换 |
核心代理实现(包括 root_agent、工具定义、回调防护和预算强制执行)完全保持不变。将 solutions/step10_deploy/concert/ 与第 9 步进行比较,会发现两者完全一致,这验证了 ADK 的服务抽象如何将基础架构配置与代理逻辑分离。
如果您遇到困难
在任何步骤中获取完整的有效代码:
./use-solution.sh N --force # any step. resets memory + sessions + artifacts
./use-solution.sh N --force --keep # code only, leave state alone
当出现问题时
问题 | 修复 |
| ID 错误。 |
模型返回 404 |
|
| 已有其他进程占用端口 8000 或 8090。 |
代理无法到达场地 |
|
队列永远不会前进 | 按跳过等待。在 1 倍速下,队列不会自行到达前面 |
面板上会显示第二张排队票 | 您在聊天框中回答了问题,而不是在 |
下拉菜单为空,或者应用无法加载 |
|
| 预期。 |
一切都很奇怪 | 按重置场地,然后按 |
内存文件包含大量重复预订 |
|
|
|
Cloud Shell 超时 | 重新开始 |