一个真正能进生产环境的 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 安全执行底座。
下一步:给这套安全底座装上真正的"大脑"
后续我准备继续实现:
- Pydantic 约束的模型结构化输出
- 服务端 Tool Registry
- Policy Engine 风险分级
- 真实 Agent Loop
- Prompt Injection 对抗评测
- RAG 与知识库
下一阶段最重要的原则仍然不会变:
模型负责生成候选计划,服务器负责验证和授权,工具执行结果必须来自真实系统。
如果你也正在从"会调用模型 API"走向"能构建生产级 AI 应用",欢迎关注后续文章。我会继续把每一步的代码、踩坑、测试和面试要点拆开讲清楚。
项目关键词:FastAPI、PostgreSQL、Outbox、Idempotency、Human-in-the-loop、Agent Safety、Observability、Eval Gate。