Stage 15(选修):本地 Demo 跑通以后,真正麻烦的才刚开始——把 Agent 变成 Production Service
Language: English | 简体中文
Stage 14 已完成核心 Agent 学习路径。本选修适合你准备把 Agent 面向多个用户作为长期服务提供时学习:生产服务还要接住很多请求、穿过重启、对不同租户公平地限制负载,并让用户在原始 HTTP 连接结束后仍能找到自己的工作。
本章用 SQLite 实现一个很小、与 Web 框架无关的 Service Core。它不会假装 python demo.py 就是已经部署好的网站;先把 HTTP、队列和 Worker 无论怎样实现都必须保持的服务语义建立起来。
1. 先分清身份和生命周期:Request、Run、Thread、User、Tenant
这些名字很容易混在一起,但它们回答的问题不同:
| 名称 | 表示什么 | 常见生命周期 |
|---|---|---|
| Request | 一次协议交互,例如 POST /runs |
毫秒到数秒 |
| Run | 一次可持久化的 Agent 执行 | 数秒、数分钟或更久 |
| Thread | 多个 Run 可以共用的一段会话或任务上下文 | 跨多个 Run |
| User | 已认证的最终用户 | 账户生命周期 |
| Tenant | 组织或客户的数据与资源隔离边界 | 组织生命周期 |
Request 只负责提交,Run 要在 Request 结束后继续承载这份工作:
POST /runs
→ 创建 durable run
→ 返回 accepted + run_id
GET /runs/{run_id}
→ queued / running / completed / failed
Run 的身份必须来自可信认证边界,不能来自普通请求数据。本章的 Service Core 用单独的值表达这一点:
from service import TrustedIdentity
identity = TrustedIdentity(user_id="alice", tenant_id="acme")
用户消息即使写着 {"tenant_id":"evil"},它仍然只是任务输入。真实 HTTP Adapter 应先验证 Session、Token 或 mTLS 身份,再把得到的 TrustedIdentity 传给 AgentService;绝不能把 JSON 中自称的 tenant_id 提升为归属事实。
2. 提交是持久化操作,不是等待很久的 HTTP 请求
Agent 可能检索资料、调用 MCP、等待人工审批或生成 Artifact。让最初的 HTTP 连接一直等着,会让重试、负载均衡和重启都变得困难。提交接口应创建一条可持久化的 queued Run 并返回 ID;Worker 再去领取它。
下面是完整的本地示例,同时展示了两条服务规则:
- Backpressure(背压):每个 Tenant 只能拥有有限数量的排队 Run。
- Idempotency(幂等):重试同一个提交会返回原来的 Run,不会多创建一条。
from pathlib import Path
import tempfile
from service import AgentService, RunStore, TrustedIdentity
with tempfile.TemporaryDirectory() as tmp:
service = AgentService(
RunStore(Path(tmp) / "runs.db"),
max_queued_per_tenant=2,
)
identity = TrustedIdentity(user_id="alice", tenant_id="acme")
first = service.submit(
identity=identity,
thread_id="support-42",
input_text="Summarize ORDER-42.",
idempotency_key="request-123",
)
retry = service.submit(
identity=identity,
thread_id="support-42",
input_text="Summarize ORDER-42.",
idempotency_key="request-123",
)
assert first.run_id == retry.run_id
print(first.status) # queued
Idempotency Key 表示“这是同一个业务操作的重试”,不是“随便复用一个方便的字符串”。教学 Store 按 (tenant_id, idempotency_key) 作用域保存它,因此不同 Tenant 都可以使用 request-123。同一个 Key 如果配上不同 User、Thread 或输入,会抛出 IdempotencyConflictError;生产系统也常保存请求体 Hash,而非保存完整输入来比较。
提交的幂等和 Run 内 Tool 的幂等不是同一层。Stage 09 中的支付或发邮件 Tool 仍需要自己的幂等键:前者防止重复创建 Run,后者防止同一个 Run 里的副作用重复发生。
3. Queue 需要归属、上限和 Durable State
提交和执行分开后,任务到达速度可能超过 Worker 消费速度。无限接收任务只会把失败从 HTTP Handler 推迟到内存、数据库、第三方限额和用户等待时间。
max_queued_per_tenant 把上限明确为每个 Tenant 的预算。Tenant 的队列满了,AgentService.submit() 会抛出 BackpressureError;另一个 Tenant 仍可以提交。这是走向公平调度的最小一步。真实调度器还可能增加全局容量、权重、优先级和限流,但每个预算都必须先说明作用域。
Run Record 也必须比 AgentService 这个 Python 对象活得久。本章通过 Python 标准库的 sqlite3 使用 SQLite,保存 Run ID、Thread、认证后的 User 和 Tenant、状态、输入/输出、幂等键和时间戳。SQLite 是紧凑的教学 Store;换成 Postgres 或其他 Durable Store 时,必须保留同样的语义。
两者的区别是:
内存字典会随进程退出而消失
Durable Run Record 在重新创建 Service 后仍能查询
它和 Stage 06 的 Checkpoint 有关联,但并不相同。Checkpoint 保存的是“怎样继续执行”所需的 Runtime State;Run Record 是服务可见的生命周期与归属记录。Stage 13 会把两者连到长任务中。
4. 领取 Run 既是状态迁移,也是并发控制
教学状态机故意保持很小:
queued → running → completed
Worker 不能随意先 SELECT 一条 queued 记录、做别的事、最后才更新。两个 Worker 可能同时选中同一条。RunStore.claim_next() 用 SQLite 的 BEGIN IMMEDIATE Transaction,选择一条 queued Run 后立刻把它更新为 running,再提交;在这个 SQLite 示例中,选择与领取因此成为一个受保护的操作。
完成也有前提:只有当前状态为 running 的 Run 才能变成 completed。对 queued 或已经 completed 的 Run 调用 complete() 会抛出 InvalidRunTransitionError,避免过期 Worker 悄悄覆盖生命周期。
BEGIN IMMEDIATE 是本地教学用的并发边界,不是分布式 Worker Lease 的完整方案。如果 Worker 领取后消失,记录仍会停在 running;Stage 13 会用 Lease、Heartbeat 和恢复机制处理这个问题。
5. HTTP Adapter 要薄,Health 要诚实
本章代码是 Service Core,因此没有伪造认证 Middleware,也不会写一个把未验证 Header 当作可信身份的玩具 FastAPI 服务。真实应用中 HTTP 层应该只做协议工作,然后调用 Core:
HTTP / API adapter
→ 验证凭证并创建 TrustedIdentity
→ 验证请求大小、结构和 deadline
→ AgentService.submit() 或 RunStore.get()
AgentService / RunStore
→ 持久化提交、归属检查、队列上限
Worker
→ 领取 Run、调用 bounded Agent runtime、保存结果
这种分层让同一个 Service Core 以后可以被 REST、Queue Consumer、CLI 或其他协议复用。它也让 Tenant Ownership 进入每次查询:RunStore.get() 同时以 run_id 和认证后的 tenant_id 查询。其他 Tenant 的调用者只会得到“not found”,不会借此知道那条 Run 是否存在。
Health Endpoint 也需要同样诚实。Liveness 问“进程还活着吗”;Readiness 问“它现在能接业务流量吗”。进程可能活着,但 Durable Store 已不可用。store.ready() 会执行一次很小的数据库查询,因此依赖故障可以让 Readiness 变成 false,而不是无条件返回 { "status": "ok" }。
6. “Async”、Shutdown、Configuration 与真实 Runtime
“异步”常被用来描述两件不同的事:
Python async def
→ 一种并发编程模型
Durable asynchronous job
→ 提交 Request 结束后,工作仍能继续
一个 async def Endpoint 仍可能让连接一直等待任务结束;一个同步 Python Worker 也可以处理 Durable Queue Job。本章真正关心的是 Durable Run 边界。
部署时,收到终止信号的进程应停止接收新任务、变为 not-ready、完成或安全暂停有限的工作、保存可恢复状态,然后退出。教学 Service 没有实现 Signal Handler,因为 Worker Loss 的恢复已在 Stage 13 讲解;但 Shutdown 必须被当作 Runtime Lifecycle 的一部分来设计。
配置和凭证同样来自部署边界。不要把它们写进 Prompt;每个 Secret 只交给真正需要它的组件。Stage 12 已经说明,子进程也不应该默认继承完整 Environment。
最后,run_one() 返回确定性字符串,并没有调用 DeepSeek。这是刻意的:本章需要稳定地测试服务归属、重试和持久化状态,不能让模型输出的不确定性掩盖服务语义。真实系统中,Worker 的这一步会调用前面章节建立的 bounded Agent Runtime,包括模型、工具、Guardrail 和 Workspace/Sandbox 边界。
7. 运行完整的 Service Core 示例
从仓库根目录运行:
python stages/15(optional)-production-deployment/code/demo.py
python stages/15(optional)-production-deployment/code/checks.py
Demo 会提交一条 Run、用相同幂等键再次提交、领取并完成该 Run、重新创建 Service,并从同一 SQLite 文件读取 completed record。检查覆盖可信身份、Tenant 作用域的查询和幂等、复用 Key 时的内容冲突、每个 Tenant 的背压、重启后的持久性、合法状态迁移和 Readiness。
8. 这门选修放在什么位置
Queue 已让 Run 脱离 HTTP Request,但还不能恢复“已被 Worker 领取、随后 Worker 消失”的任务。需要这部分机制时,回看 Stage 13:Long-Horizon Harness,其中会建立 Ledger、Lease、Heartbeat 和可恢复执行。
当你的 Agent 从本地或内部工具跨越到多用户生产服务时,再学习本选修即可;它很重要,但不是理解 Stage 00–14 Agent 核心机制的前置条件。