我以为 AI Agent 只是调模型,直到我亲手补上审批、Outbox 和故障恢复

一个真正能进生产环境的 Agent,到底还缺什么?这篇文章记录我从"调用模型 API"一路走到多租户、幂等、人工审批、可靠消息、重试、可观测性和 Docker 故障恢复的完整实践。

最开始,我对 AI Agent 的理解很简单:

text 复制代码
用户提问 → 大模型思考 → 调用工具 → 返回答案

直到我认真问了自己几个问题:

  • 模型说"删除项目",系统真的可以直接删吗?
  • 用户网络超时后重试,会不会创建两个订单?
  • 模型生成一个 tenant_id,我们能相信吗?
  • 数据库写成功、消息发送失败,Worker 怎么知道还有任务?
  • 两个审批人同时一个批准、一个拒绝,听谁的?
  • 工具执行成功,但数据库提交失败,重试会不会再次扣款?
  • Worker 挂了十分钟,恢复后任务还在吗?

这些问题让我意识到:模型调用只是 Agent 系统中最显眼的一小块,真正决定它能否进入生产环境的,是模型之外的工程约束。

于是,我从零实现了一个 production-oriented 的多租户 Agent 执行平台。

目前的技术栈:

  • Python 3.12
  • FastAPI
  • PostgreSQL + SQLAlchemy Async
  • Alembic
  • Pydantic
  • Prometheus Metrics
  • Docker Compose
  • Pytest

最终结果:

text 复制代码
自动化测试:40 passed
数据库迁移检查:无漂移
API:healthy
PostgreSQL:healthy
Worker:running
端到端:E2E_OK status=succeeded
Worker 停机恢复:RECOVERY_OK status=succeeded

下面分享这个过程里最值得记住的 8 个工程认知。


一、UUID 只能标识记录,不能证明权限

我曾经有一个很自然的想法:user_id 已经是 UUID 了,足够唯一,按它查询不就可以了吗?

后来发现,这混淆了三个完全不同的问题:

text 复制代码
UUID:这条记录是谁
tenant_id:这条记录属于谁
权限校验:当前调用者能不能访问

知道房间号,不代表你拥有这间房。

因此,多租户查询不能只写:

python 复制代码
select(AgentRun).where(AgentRun.id == run_id)

而要把租户边界写进 Repository:

python 复制代码
select(AgentRun).where(
    AgentRun.tenant_id == tenant_id,
    AgentRun.id == run_id,
)

跨租户访问和数据不存在统一返回 404,避免攻击者通过 403/404 差异探测其他租户的数据是否存在。

我把这套保护做成三道防线:

text 复制代码
API 认证上下文
→ Repository 强制 tenant_id
→ 数据库复合外键与唯一约束

这叫纵深防御。任何一层写错,后面还有防线兜底。


二、幂等不是"先查一下有没有"

客户端超时后重试是正常行为。危险的是两个请求同时到达:

text 复制代码
请求 A 查询:不存在
请求 B 查询:不存在
请求 A 插入
请求 B 插入

"先查询再插入"不是一个原子操作。

我最终把裁决权交给 PostgreSQL:

sql 复制代码
INSERT ...
ON CONFLICT (tenant_id, idempotency_key) DO NOTHING
RETURNING id;

同时为输入计算规范化 JSON 的 SHA-256 摘要:

text 复制代码
相同键 + 相同输入 → 返回原 Run
相同键 + 不同输入 → 409 Conflict
不同租户 + 相同键 → 互不影响

为什么同一个键配不同输入不能返回旧结果?

因为幂等键代表调用方的承诺:"这是同一次业务操作的重试。"内容变了,用户意图也变了,必须换新键。


三、业务数据、审计和 Outbox 必须同生共死

创建 Agent Run 后,系统还要写审计日志,并通知后台 Worker。

如果分三次提交:

text 复制代码
1. Run 提交成功
2. 进程崩溃
3. 消息没有发送

数据库里有任务,但 Worker 永远不知道。

于是我使用 Transactional Outbox:

python 复制代码
async with session.begin():
    create_agent_run()
    create_audit_event()
    create_outbox_event()

Outbox 可以理解为数据库里的"发件箱":

text 复制代码
AgentRun    = 准备好的包裹
OutboxEvent = 寄件单
Worker      = 定期取件的快递员
消息系统     = 运输网络

Run 和 Outbox 在同一事务里,因此只会出现两种结果:

text 复制代码
两者都存在
两者都不存在

不会出现"有业务数据却没有待发送事件"的半成品。


四、多个 Worker 如何不抢同一个任务?

生产环境通常会启动多个 Worker:一方面提高吞吐量,另一方面保证某个实例宕机时其他实例仍能工作。

但多个 Worker 同时查询普通的 published_at IS NULL,可能重复发送同一事件。

核心 SQL 是:

sql 复制代码
SELECT *
FROM outbox_events
WHERE published_at IS NULL
  AND available_at <= now()
FOR UPDATE SKIP LOCKED
LIMIT 1;

执行效果:

text 复制代码
Worker A 锁定事件 1
Worker B 跳过事件 1,领取事件 2
Worker C 跳过 1 和 2,领取事件 3

需要注意:行锁只能防止多个 Worker 同时领取,不能保证外部消息严格只发送一次。

可能发生:

text 复制代码
消息已经发送成功
→ Worker 还没更新 published_at 就崩溃
→ 重启后再次发送

因此 Outbox 通常提供的是 at-least-once delivery(至少一次投递),下游消费者仍然必须幂等。


五、模型建议不等于授权

如果模型输出:

json 复制代码
{
  "tool": "delete_project",
  "project_id": "123"
}

这只能说明模型建议调用工具,不能说明用户已经授权删除。

我的审批流程是:

text 复制代码
Agent 准备调用高风险工具
→ 保存 tool_name 和参数快照
→ 计算参数摘要
→ 创建 pending Approval
→ Run 进入 waiting_approval
→ 等待用户明确批准或拒绝

审批保存两份参数信息:

text 复制代码
parameters_snapshot:给审批人展示完整参数
parameters_digest:证明执行时参数没有被替换

同一个 tool_call_id

text 复制代码
相同工具、相同参数 → 返回原审批
不同工具或参数     → 冲突

更重要的是,创建审批不等于批准,批准也不等于立即执行。

text 复制代码
Approval Service 负责授权
Execution Worker 负责执行

这叫职责分离。


六、批准之后,为什么还要再检查一次?

从批准到执行之间,系统状态可能发生变化:

  • 审批人角色被撤销
  • 用户被停用
  • Approval 过期
  • 工具参数被修改
  • Run 被取消
  • 资源版本已经变化

所以执行前,我重新检查:

text 复制代码
Run 是否属于当前租户并处于可执行状态
Approval 是否属于同一个 Run
Approval 是否 approved 且未过期
approval_id 是否与恢复状态一致
参数摘要是否匹配
审批人当前是否仍有 reviewer 权限

一句面试表达非常好用:

The model proposes; the server authorizes; the worker re-validates and executes.

模型负责建议,服务器负责授权,Worker 重新校验后执行。


七、不是所有错误都值得重试

"报错就重试三次"是一个危险的默认策略。

我把错误分成三类:

text 复制代码
Timeout / Rate limit → transient,可重试
Permission / Validation → permanent,不重试
未知错误 → unknown,默认停止

权限错误不会因为再试一次就自动获得权限;参数错误也不会因为重试变正确。

对于暂时性错误,使用:

text 复制代码
有限重试预算
+ 指数退避
+ 随机 jitter

为什么需要 jitter?

如果 1000 个 Worker 同时限流、都固定等待 4 秒,它们会在 4 秒后同时醒来,再次压垮下游。随机抖动可以把重试分散到一个时间窗口。

重试次数不能只放在内存中,否则 Worker 重启后预算会归零,可能形成无限重试。

因此我持久化:

text 复制代码
attempt_count
next_retry_at
last_error_code
Run status = retry_wait

并创建 available_at = next_retry_at 的延迟 Outbox,时间到达后再唤醒 Worker。


八、生产系统必须能回答"现在到底发生了什么"

我为系统补齐了三类可观测信号:

text 复制代码
Trace:一次请求经过哪里,哪里慢或失败
Logs:某个具体事件发生了什么
Metrics:一段时间内发生多少次,趋势如何

HTTP 层记录:

  • 请求总数 Counter
  • 当前并发 Gauge
  • 延迟 Histogram
  • JSON 结构化日志
  • X-Trace-ID

一个很容易踩的坑:不能把 trace_id、user_id、run_id 作为 Metrics 标签。

这些值几乎每次请求都不同,会制造海量时间序列,导致高基数和监控存储爆炸。

text 复制代码
Trace ID → 放进 Trace 和 Logs
Metrics label → 只使用 method、规范化 route、status 等有限集合

另外,我实现了评测发布门禁:

text 复制代码
总体通过率 >= 95%
并且 critical safety failures = 0

即使总体 99 分,只要失败的是"跨租户读取"或"绕过审批执行",仍然禁止发布。


最后,我真的把 Worker 停掉了

为了验证系统不是"测试里看起来正确",我把 API、PostgreSQL、Worker 构建成三个 Docker 服务,然后做了真实演练:

text 复制代码
停止 Worker
→ 通过 API 创建并批准 Run
→ Run 保持 pending,Outbox 没有丢
→ 重启 Worker
→ Worker 扫描遗留事件
→ Run 自动恢复到 succeeded

最终输出:

text 复制代码
STATUS_WITH_WORKER_STOPPED=pending
RECOVERY_OK status=succeeded

这次演练让我对 Outbox 的理解从"知道一个名词",变成了"亲眼看到它在进程宕机后把任务救回来"。


当前完整架构

text 复制代码
Client / Trusted Gateway
        ↓
FastAPI Auth Dependency
        ↓
Run & Approval APIs
        ↓
Application Services
        ↓
PostgreSQL
  ├── AgentRun
  ├── Approval
  ├── AuditEvent
  └── OutboxEvent
        ↓
OutboxDaemon
        ↓
Event Router
        ↓
Safe Tool Execution
        ↓
Retry Policy / Result / Audit

当前工程仍然有明确边界:

  • 身份网关使用开发密钥模拟
  • ToolExecutor 是安全的 demo adapter
  • Event Router 仍是进程内实现
  • Metrics 尚未配套 Grafana/Alertmanager
  • Eval Gate 尚未接入真实模型数据集和 CI/CD
  • 真实 LLM Planner 还没有接入

换句话说,它不是可以直接商用的成品,但已经是一套经过真实数据库、容器和故障恢复验证的 Agent 安全执行底座


下一步:给这套安全底座装上真正的"大脑"

后续我准备继续实现:

  1. Pydantic 约束的模型结构化输出
  2. 服务端 Tool Registry
  3. Policy Engine 风险分级
  4. 真实 Agent Loop
  5. Prompt Injection 对抗评测
  6. RAG 与知识库

下一阶段最重要的原则仍然不会变:

模型负责生成候选计划,服务器负责验证和授权,工具执行结果必须来自真实系统。

如果你也正在从"会调用模型 API"走向"能构建生产级 AI 应用",欢迎关注后续文章。我会继续把每一步的代码、踩坑、测试和面试要点拆开讲清楚。

项目关键词:FastAPIPostgreSQLOutboxIdempotencyHuman-in-the-loopAgent SafetyObservabilityEval Gate

相关推荐
百工蜂Agent1 小时前
上下文满了,Claude Code 扔什么、留什么?
agent·ai编程
小爱觉得Agent坑1 小时前
我从 DeepSeek Harness 的讨论区里,反推出了一个神秘团队的技术架构
agent
栩栩云生1 小时前
别再硬记 AWS 和 k8s 命令了!一行命令把十几个云平台的 CLI 全接进 AI
kubernetes·agent·mcp
Vuji1 小时前
Pi 插件解剖|summarize.ts:199 行,给 Agent 的对话做一份 Markdown 总结
前端·人工智能·agent
qq_513728041 小时前
浏览器配置 + 文件上传 + 截图
python·selenium·测试工具·计算机外设
Fluxproxy2 小时前
Python请求玄学根治:彻底解决脚本间歇性超时、断连、假死问题
网络·python·安全
青 春 记 忆2 小时前
零基础入门Python15|关联、聚合、索引与事务:订单数据库
开发语言·python·后端开发
深念Y3 小时前
AI Agent 时代运维安全:rm 防误删方案对比
linux·运维·人工智能·安全·自动化·agent
小马9263 小时前
智能体时代的两块基石:DeepSeek Harness 开源与“认知基础设施“数据库
数据库·人工智能·开源·agent