Agent 工程体系 · 第 93/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。
Agent 网关:认证、模型路由、配额、策略、审计和协议适配
Agent 网关位于客户端、业务系统与模型提供商之间。它不是“把请求转发到某个大模型”的反向代理,而是一个面向 Agent 运行时的控制平面与数据平面边界:
- 控制平面负责身份、租户、模型、工具、配额、策略、密钥和审计规则;
- 数据平面负责接收一次 Agent 请求,解析会话与消息,执行鉴权、路由、限流、策略检查和协议转换,然后调用模型或 Agent 运行时;
- 观测平面负责记录请求、模型调用、工具调用、策略决策、成本和最终结果。
Agent 本身通常需要规划、调用工具、协作多个专长 Agent,并保存足够的状态来完成多步任务。OpenAI 将 Agent 描述为能够规划、调用工具、协作并保留状态的应用;Anthropic 则区分了由固定代码路径编排的 workflow 与由模型动态决定过程和工具使用的 agent。(developers.openai.com)
因此,网关要处理的对象不只是一次 HTTP 请求,而是一个可能持续数分钟、产生多次模型调用和工具调用的 Agent Run。
一、先区分三种边界:API 网关、模型网关和 Agent 网关
1. API 网关解决“谁能访问哪个服务”
传统 API 网关主要关心:
客户端
↓
认证、限流、路由、日志
↓
订单服务、用户服务、支付服务
它通常以一次请求为基本计量单位:
request → response
2. 模型网关解决“这次模型调用发给谁”
模型网关位于应用与多个模型供应商之间:
Agent 应用
↓
模型网关
├── Provider A
├── Provider B
└── 私有部署模型
它主要处理:
- 模型名称映射;
- 供应商 API 差异;
- 重试与故障转移;
- 令牌统计;
- 供应商密钥管理;
- 价格和延迟选择。
3. Agent 网关解决“一个多步运行是否允许继续”
Agent 请求的实际结构可能是:
用户请求
↓
第 1 次模型调用:判断意图
↓
工具调用:查询订单
↓
第 2 次模型调用:解释结果
↓
工具调用:申请退款
↓
人工审批
↓
第 3 次模型调用:生成确认消息
如果网关只在第一次请求时检查配额和权限,就无法控制后续的模型调用与工具动作。
Agent 网关的基本计量对象至少包括:
tenant
principal
session
run
model_call
tool_call
policy_decision
audit_event
其中:
- tenant:租户,表示数据、模型、工具、记忆、配额和密钥的隔离边界;
- principal:主体,可以是用户、服务账号、Agent 或工作负载;
- session:会话,保存跨轮次上下文;
- run:一次 Agent 执行,从接收任务开始,到完成、失败、取消或等待审批结束;
- model_call:Run 内的一次模型调用;
- tool_call:Run 内的一次工具调用;
- policy_decision:一次策略判断及其结果;
- audit_event:不可抵赖地记录关键动作的审计事件。
二、总体架构:网关不应吞掉 Agent 运行时
一个可维护的部署关系如下:
flowchart LR
C[客户端或业务服务]
G[Agent 网关]
R[Agent Runtime]
M[模型适配层]
P1[模型供应商 A]
P2[模型供应商 B]
T[工具网关]
D[业务工具与数据系统]
S[(会话/状态存储)]
Q[(配额账本)]
A[(审计日志)]
O[观测系统]
K[密钥管理系统]
C --> G
G --> R
R --> M
M --> P1
M --> P2
R --> T
T --> D
G --> S
G --> Q
G --> A
G --> O
G --> K
这里有一个重要的职责边界:
- Agent Runtime 决定下一步做什么;
- 模型路由器 决定使用哪个模型或供应商;
- 工具网关 决定工具调用是否具备权限、是否需要审批;
- Agent 网关 决定这个租户、主体、会话和 Run 是否允许继续。
如果把规划、工具执行、配额、身份和审计全部塞进一个网关进程,网关将变成不可测试的“超级服务”。更合理的方式是让网关拥有强控制能力,但不拥有业务 Agent 的全部执行逻辑。
OpenAI 当前文档也区分了两类运行方式:Responses API 由应用自行控制模型交互、工具、状态和编排;Agents SDK 则由 SDK 管理 Agent loop、工具调用、handoff、guardrail 和可恢复审批。(developers.openai.com)
请求主流程
sequenceDiagram
participant U as 调用方
participant G as Agent 网关
participant I as 身份服务
participant P as 策略引擎
participant Q as 配额服务
participant R as Agent Runtime
participant M as 模型供应商
participant T as 工具服务
participant A as 审计系统
U->>G: POST /v1/runs + Idempotency-Key
G->>I: 验证令牌、主体、租户
I-->>G: principal + tenant + scopes
G->>P: 输入策略与能力策略检查
P-->>G: allow / deny / require_approval
G->>Q: 预留 Run、请求数和预算
Q-->>G: reservation_id
G->>A: 记录 request.accepted
G->>R: 启动或恢复 Run
loop Agent loop
R->>G: 请求模型能力
G->>Q: 预留本次模型调用预算
G->>M: 转换后的模型请求
M-->>G: 模型响应或工具调用
G->>A: 记录 model.call
G-->>R: 统一模型结果
alt 需要工具
R->>G: tool.call
G->>P: 工具策略检查
P-->>G: allow / deny / approval
G->>T: 执行工具
T-->>G: 工具结果
G->>A: 记录 tool.call
end
end
G->>Q: 结算实际用量
G->>A: 记录 run.completed
G-->>U: 统一响应或事件流
三、认证不是授权:先确定“谁”,再确定“能做什么”
1. 认证的定义
认证 Authentication 是验证调用方身份的过程。网关需要得到一个可信的主体:
{
"principal_id": "user_123",
"principal_type": "user",
"tenant_id": "tenant_acme",
"scopes": [
"agent:run",
"model:invoke"
],
"token_id": "tok_456"
}
常见凭证包括:
- OAuth 2.0 Bearer Token;
- OIDC 身份令牌;
- 服务账号令牌;
- mTLS 证书;
- 工作负载身份;
- 内部签发的短期 Agent Token。
网关不应直接相信客户端提交的 tenant_id、user_id 或 role。这些字段只能作为业务输入,不能作为身份来源。
错误做法:
POST /v1/runs
Authorization: Bearer user-token
X-Tenant-ID: tenant_other
如果网关直接使用 X-Tenant-ID 查询资源,就可能造成跨租户访问。正确做法是:
token → subject → identity mapping → tenant membership → effective permissions
2. 授权的定义
授权 Authorization 是判断已认证主体能否执行某个动作。
一次授权判断至少包含:
主体 + 租户 + 资源 + 动作 + 运行上下文
可以形式化为:
例如:
user_123
在 tenant_acme
对 run_789
执行 tool.refund
与普通 API 授权不同,Agent 授权还要考虑:
- 该工具是否允许此 Agent 使用;
- 工具参数中是否包含敏感数据;
- 是否超过金额阈值;
- 是否必须人工审批;
- 当前 Run 是否已经进入终态;
- 当前模型是否有权生成该类动作;
- 工具结果是否可以回流到当前租户的上下文。
3. 凭证传递
模型供应商密钥属于网关或模型适配层,不应下发给终端用户,也不应放进 Agent prompt。
工具凭证则应采用“按调用注入”:
用户令牌
↓ 认证
网关内部主体
↓ 授权
短期工具访问令牌
↓
工具服务
不要把长期数据库密码、云厂商密钥或第三方 OAuth refresh token 放入:
- 消息正文;
- 工具参数;
- tracing attributes;
- 普通应用日志;
- Agent 记忆。
四、模型路由:不是按模型名转发,而是约束下的选择问题
1. 模型路由的定义
模型路由 Model Routing 是根据请求属性、租户策略、模型能力、供应商状态和成本约束,选择具体模型部署的过程。
请求中的逻辑模型名不应等同于供应商模型名:
{
"model": "support.reasoning"
}
网关内部可以映射为:
{
"logical_model": "support.reasoning",
"candidates": [
{
"provider": "provider_a",
"model": "model-x",
"capabilities": ["tool_calling", "json_schema"],
"regions": ["cn-east", "us-west"],
"priority": 10
},
{
"provider": "provider_b",
"model": "model-y",
"capabilities": ["tool_calling"],
"regions": ["cn-east"],
"priority": 20
}
]
}
这样做的原因是:业务代码依赖逻辑能力,而不是依赖某个供应商的字符串。
2. 路由约束
设候选模型集合为 ,请求约束为 ,合法模型集合为:
如果 ,网关必须返回明确错误,而不是悄悄降级到不满足约束的模型。
例如请求要求:
{
"requires": {
"tool_calling": true,
"structured_output": true,
"data_region": "cn"
}
}
某模型支持工具调用,但不支持结构化输出,则不能因为“看起来差不多”而路由过去。
3. 路由评分
过滤出合法模型后,可以使用评分函数:
其中:
- :质量或评测分;
- :历史延迟;
- :估算成本;
- :当前健康度;
- :租户或产品配置的权重。
这不是说“质量最高的模型永远最好”。一个延迟高、成本高、且不支持目标区域的模型,评分前就应被过滤掉。
4. 失败转移必须考虑副作用
模型调用失败时可以重试,但不能把“所有错误都重试”当作可用性策略。
适合自动重试的情况:
- 连接超时,且请求尚未被供应商接受;
- 供应商明确返回临时过载;
- 读取流中断,且供应商支持安全续接;
- 请求使用了相同幂等键,供应商保证幂等。
不应盲目重试的情况:
- 已经产生工具调用;
- 模型输出要求执行外部副作用;
- 请求超过上下文限制;
- 参数校验失败;
- 内容策略拒绝;
- 供应商已经返回了完整结果但客户端断线。
尤其要避免下面的失败路径:
模型返回:请执行退款
网关超时
网关重试
模型再次返回:请执行退款
工具执行两次
模型重试与工具执行必须分层。对外部副作用工具,应使用独立的工具幂等键:
tool_idempotency_key =
hash(tenant_id, run_id, tool_call_id)
五、配额:限制的不只是请求数
1. 配额的定义
配额 Quota 是租户、用户、Agent 或项目在时间窗口、并发度、令牌量、金额和动作次数上的资源上限。
至少应区分:
| 配额维度 | 典型含义 |
|---|---|
| 请求数 | 每分钟或每天允许的 Run 数 |
| 并发数 | 同时运行的 Run 数 |
| 输入令牌 | 发送给模型的 token |
| 输出令牌 | 模型生成的 token |
| 总 token | 输入与输出之和 |
| 金额预算 | 账期内最大费用 |
| 工具次数 | 单个 Run 或租户的工具调用数 |
| 运行时长 | 单个 Run 的最长持续时间 |
| 记忆容量 | 可写入租户记忆的大小 |
仅使用 QPS 限流是不够的。一个请求可能触发几十次模型调用和工具调用。
2. 预留、使用和结算
配额应该采用三阶段模型:
reserve → consume → settle
- reserve:请求开始时预留可能需要的资源;
- consume:每次模型调用或工具调用实际消耗资源;
- settle:Run 结束后用实际值结算,释放未使用预留。
设租户余额为 ,本次预估成本为 ,安全余量为 :
模型调用结束后,实际成本为 :
如果只在结束时扣费,会出现并发超卖:
余额:100
请求 A 读取余额 100,可使用 80
请求 B 同时读取余额 100,也可使用 80
A 成功扣 80
B 成功扣 80
最终余额变成 -60
因此预留必须是原子操作:
UPDATE quota_bucket
SET reserved = reserved + :estimate,
version = version + 1
WHERE tenant_id = :tenant_id
AND reserved + consumed + :estimate <= hard_limit;
如果更新行数为 0,说明配额不足。生产实现还应使用数据库事务、乐观锁或原子脚本,不能依赖应用层“先读后写”。
3. 并发配额与令牌配额不同
并发配额限制的是正在运行的工作数量:
active_runs < max_concurrent_runs
令牌配额限制的是模型使用量:
used_input_tokens + used_output_tokens <= token_budget
两者不能互相替代:
- 一个长时间等待人工审批的 Run 会占用并发,但不消耗很多 token;
- 一个快速循环调用模型的 Run 会消耗大量 token,但可能只占用一个并发槽。
4. 配额不足时的行为
配额不足不是统一返回 429:
- 速率超限:
429 rate_limited; - 账期预算耗尽:
429 quota_exhausted; - 单 Run 预算不足:
409 run_budget_exhausted或业务定义的配额错误; - 模型供应商临时限流:可以返回
503 upstream_rate_limited; - 没有满足最低模型能力的降级候选:
503 no_eligible_model。
错误响应必须告诉客户端是否可以重试:
{
"error": {
"type": "quota_exhausted",
"code": "tenant_daily_budget_exhausted",
"message": "租户今日模型预算已用尽",
"retryable": false,
"request_id": "req_01"
}
}
六、策略:把“允许继续”变成可解释的决策
1. 策略的定义
策略 Policy 是对主体、资源、动作和上下文施加约束的规则集合。
策略至少分为五类:
- 访问策略:谁能调用哪个 Agent;
- 模型策略:允许哪些模型、区域和供应商;
- 工具策略:允许调用哪些工具;
- 数据策略:哪些数据可以发送给模型或写入记忆;
- 运行策略:最大迭代数、最长时间、审批节点和输出限制。
2. 策略检查点
策略不能只在入口检查一次。应在以下节点重复检查:
入口
→ 创建 Run
→ 每次模型调用
→ 每次工具调用
→ 工具结果回流
→ 写记忆
→ 输出给用户
原因是上下文会变化。初始请求可能只是“查询订单”,但模型在后续步骤中可能生成:
tool.refund(amount=9999)
此时需要重新进行工具授权、金额检查和审批判断。
3. Allow、Deny 与 Require Approval
策略结果最好不是二值,而是三值:
allow
deny
require_approval
allow:可以继续;deny:立即阻止;require_approval:暂停 Run,等待指定主体批准。
人工审批不是普通错误。它是一个合法的中间状态:
RUNNING
↓
WAITING_FOR_APPROVAL
↓ approve
RUNNING
↓ reject
REJECTED
OpenAI Agents SDK 的当前文档也将 guardrails、人工审批和可恢复的 approval flow 作为 Agent 运行生命周期的一部分。(developers.openai.com)
审批请求必须绑定不可变上下文:
{
"approval_id": "apr_123",
"run_id": "run_456",
"tool_call_id": "tc_789",
"tool": "refund",
"arguments_hash": "sha256:...",
"policy_version": "refund-v3",
"expires_at": "2026-09-01T12:00:00Z"
}
审批人批准的是这一次具体的工具调用,而不是模糊地批准“这个 Agent 可以退款”。
4. Prompt 不是安全策略
下面的做法不能代替网关策略:
系统提示词:不要给用户退款超过 100 元。
提示词属于模型可见上下文,模型可能误解、遗漏或受到不可信内容影响。金额上限必须在工具执行前由确定性代码检查:
def authorize_refund(ctx, args):
if ctx.tenant_id != args["tenant_id"]:
return "deny"
if args["amount"] > ctx.policy.max_refund_amount:
return "require_approval"
return "allow"
模型可以提出动作,但不能成为最终权限裁决者。
Anthropic 对 Agent 的描述强调了工具结果和环境反馈的重要性,并建议设置最大迭代次数、沙箱和适当的 guardrails,以控制自主运行的成本和错误累积。(anthropic.com)
七、审计:记录“发生了什么”,而不是只记录最终答案
1. 审计的定义
审计 Audit 是以可检索、可关联、尽量不可篡改的方式记录关键决策和动作,使系统能够回答:
- 谁发起了这个 Run?
- 使用了哪个租户和身份?
- 最终选择了哪个模型?
- 为什么选择它?
- 发生了哪些工具调用?
- 哪条策略允许或拒绝了动作?
- 谁批准了高风险操作?
- 实际消耗了多少 token 和金额?
- 失败发生在哪一步?
- 是否进行了重试或故障转移?
2. 事件模型
审计事件应带有统一关联字段:
{
"event_id": "evt_001",
"event_type": "tool.call",
"occurred_at": "2026-09-01T10:00:01.123Z",
"tenant_id": "tenant_acme",
"principal_id": "user_123",
"session_id": "sess_001",
"run_id": "run_001",
"model_call_id": "mc_001",
"tool_call_id": "tc_001",
"policy_version": "policy-2026-08-20",
"request_id": "req_001",
"idempotency_key": "idem_001",
"decision": "allow",
"input_hash": "sha256:...",
"output_hash": "sha256:..."
}
建议将原文和索引分离:
- 索引保存事件类型、租户、时间、Run ID、状态和哈希;
- 原文保存到有访问控制和保留期限的对象存储;
- 敏感字段默认脱敏或只保存哈希;
- 审计日志本身不能回流给模型。
3. 审计与普通日志的区别
普通日志用于调试:
model request failed
审计事件用于追责和重建:
model.call.failed
tenant=tenant_acme
run=run_001
provider=provider_a
logical_model=support.reasoning
attempt=2
error=upstream_timeout
retryable=true
fallback=provider_b
不要只记录最终响应。最终响应无法解释:
- 为什么没有调用某个工具;
- 为什么切换了模型;
- 为什么发生了两次工具调用;
- 为什么某次请求被配额拒绝;
- 为什么 Run 等待审批。
OpenAI Agents SDK 当前文档将 tracing 覆盖到模型调用、工具、Agent、guardrail 和 handoff 等层级,这体现了 Agent 运行必须具备步骤级可观测性,而不是只有 HTTP access log。(developers.openai.com)
八、协议适配:统一语义,不要强行统一所有能力
1. 协议适配的定义
协议适配 Protocol Adaptation 是将不同客户端、Agent Runtime、模型供应商和工具系统之间的请求与响应,转换为网关内部统一语义的过程。
适配分为两层:
语法适配:字段、路径、流格式、错误格式
语义适配:消息角色、工具调用、状态、幂等、取消、审批
只做字段重命名是不够的。
例如,供应商 A 可能返回:
{
"tool_call": {
"name": "search_order",
"arguments": "{\"id\":\"o-1\"}"
}
}
供应商 B 可能返回:
{
"content": [
{
"type": "tool_use",
"name": "search_order",
"input": {
"id": "o-1"
}
}
]
}
网关内部应统一成:
{
"type": "tool_call",
"id": "tc_001",
"name": "search_order",
"arguments": {
"id": "o-1"
}
}
2. 内部规范化对象
围绕关联主题,网关内部至少需要以下对象:
{
"run_id": "run_001",
"session_id": "sess_001",
"messages": [
{
"id": "msg_001",
"role": "user",
"content": [
{
"type": "text",
"text": "查询订单 o-1"
}
],
"attachments": []
}
],
"events": [],
"idempotency_key": "idem_001"
}
注意以下区别:
message是语义内容;event是运行过程中的状态变化;attachment是外部文件或媒体引用;error是机器可处理的失败对象;idempotency_key是客户端对同一意图的稳定标识。
3. 流式协议不能只转发文本
Agent 流中可能同时存在:
run.started
message.delta
model.call.started
tool.call.created
approval.required
tool.call.completed
message.completed
run.completed
如果网关把所有事件都拼成字符串,客户端将无法知道:
- 哪段内容是最终回答;
- 哪段内容是工具调用参数;
- Run 是否暂停;
- 是否可以安全重连;
- 当前事件是否已经被消费。
一个合理的 SSE 事件格式可以是:
event: tool.call.created
data: {"run_id":"run_001","tool_call_id":"tc_001","name":"search_order"}
event: approval.required
data: {"run_id":"run_001","approval_id":"apr_001"}
event: run.completed
data: {"run_id":"run_001","status":"completed"}
每个事件应有单调递增的 sequence:
{
"event_id": "evt_12",
"sequence": 12,
"type": "run.completed"
}
客户端断线后可以携带:
Last-Event-ID: evt_11
网关根据事件日志重放 evt_12 之后的事件,而不是重新执行 Agent。
4. 能力差异必须显式暴露
不同模型协议的能力并不相同。网关不能将缺失能力伪装成已支持。
例如:
{
"capabilities": {
"streaming": true,
"tool_calling": true,
"structured_output": false,
"vision": true
}
}
如果客户端要求:
{
"requires": ["structured_output"]
}
网关应在路由阶段拒绝不满足能力的候选,而不是调用后再尝试解析。
九、幂等、重试和状态机:Agent 网关的可靠性核心
1. 幂等键的语义
幂等键 Idempotency Key 表示客户端认为“这些重复提交属于同一个意图”。
它必须绑定请求摘要:
fingerprint =
hash(
tenant_id,
principal_id,
endpoint,
normalized_request_body
)
同一个幂等键再次出现时:
- 请求摘要相同:返回原结果或当前状态;
- 请求摘要不同:返回冲突;
- 原请求仍运行:返回
running; - 原请求等待审批:返回
waiting_for_approval; - 原请求已完成:返回已缓存结果。
错误做法是只用:
idempotency_key = "abc"
而不绑定租户和请求内容。这样可能造成不同租户或不同操作之间的错误复用。
2. Run 状态机
stateDiagram-v2
[*] --> ACCEPTED
ACCEPTED --> RUNNING
ACCEPTED --> REJECTED
RUNNING --> WAITING_FOR_APPROVAL
RUNNING --> COMPLETED
RUNNING --> FAILED
RUNNING --> CANCELLED
RUNNING --> TIMED_OUT
WAITING_FOR_APPROVAL --> RUNNING
WAITING_FOR_APPROVAL --> REJECTED
WAITING_FOR_APPROVAL --> TIMED_OUT
COMPLETED --> [*]
FAILED --> [*]
REJECTED --> [*]
CANCELLED --> [*]
TIMED_OUT --> [*]
每个状态转换都应该有前置条件:
RUNNING → COMPLETED
条件:Agent Runtime 已产生最终输出,所有必需工具调用已结束
RUNNING → WAITING_FOR_APPROVAL
条件:策略引擎返回 require_approval,且 approval 记录持久化成功
WAITING_FOR_APPROVAL → RUNNING
条件:审批主体有权限,审批内容哈希与原工具调用一致,审批未过期
如果审批后重新构造工具参数,而不是校验参数哈希,就可能发生“审批 A,执行 B”。
3. 故障路径
网关在调用供应商前崩溃
如果还未写入 model.call.started,可以安全重试。
网关在供应商已接受请求后崩溃
此时不能根据本地超时判断供应商没有执行。应使用:
- 供应商请求 ID 查询;
- 上游幂等键;
- 本地状态恢复;
- 或将 Run 标记为
unknown,进入人工或后台核查。
工具执行成功,但返回网关失败
工具必须使用幂等键。恢复时先查询工具执行记录:
if tool_call_id exists and status=completed:
reuse stored result
else:
execute with same idempotency key
审计写入失败
对高风险动作,审计写入失败应阻止工具执行;对低风险模型文本调用,可以采用异步审计,但必须明确“审计缺失窗口”。
十、一个最小可运行的策略与路由核心
下面的代码不调用真实模型,而是展示网关中最关键的确定性部分:租户授权、模型能力过滤、策略决策和幂等冲突。它可以直接用 Python 运行。
from dataclasses import dataclass
from hashlib import sha256
import json
@dataclass(frozen=True)
class Principal:
principal_id: str
tenant_id: str
scopes: frozenset[str]
@dataclass(frozen=True)
class Model:
logical_name: str
provider: str
model_name: str
capabilities: frozenset[str]
enabled: bool = True
@dataclass
class IdempotencyRecord:
fingerprint: str
status: str
result: dict | None = None
def fingerprint(tenant_id: str, endpoint: str, body: dict) -> str:
normalized = json.dumps(body, sort_keys=True, separators=(",", ":"))
raw = f"{tenant_id}\n{endpoint}\n{normalized}".encode()
return sha256(raw).hexdigest()
def authorize_run(principal: Principal, requested_tenant: str) -> None:
if principal.tenant_id != requested_tenant:
raise PermissionError("cross-tenant access denied")
if "agent:run" not in principal.scopes:
raise PermissionError("missing scope: agent:run")
def choose_model(
candidates: list[Model],
required_capabilities: set[str],
) -> Model:
eligible = [
m for m in candidates
if m.enabled and required_capabilities <= m.capabilities
]
if not eligible:
raise RuntimeError("no eligible model")
# 示例规则:先按供应商优先级,实际系统应从配置中心读取
priority = {"provider_a": 10, "provider_b": 20}
return min(eligible, key=lambda m: priority.get(m.provider, 100))
def decide_tool(tool_name: str, arguments: dict, max_refund: int) -> str:
if tool_name == "refund":
amount = arguments.get("amount")
if not isinstance(amount, int) or amount < 0:
return "deny"
if amount > max_refund:
return "require_approval"
return "allow"
def accept_idempotent_request(
store: dict[str, IdempotencyRecord],
key: str,
request_fingerprint: str,
) -> IdempotencyRecord:
old = store.get(key)
if old is not None:
if old.fingerprint != request_fingerprint:
raise ValueError("idempotency key reused with different request")
return old
record = IdempotencyRecord(
fingerprint=request_fingerprint,
status="accepted",
)
store[key] = record
return record
if __name__ == "__main__":
principal = Principal(
principal_id="user_123",
tenant_id="tenant_acme",
scopes=frozenset({"agent:run"}),
)
authorize_run(principal, "tenant_acme")
candidates = [
Model(
logical_name="support.reasoning",
provider="provider_a",
model_name="model-x",
capabilities=frozenset({"tool_calling"}),
),
Model(
logical_name="support.reasoning",
provider="provider_b",
model_name="model-y",
capabilities=frozenset({"tool_calling", "structured_output"}),
),
]
selected = choose_model(
candidates,
required_capabilities={"tool_calling", "structured_output"},
)
decision = decide_tool(
"refund",
{"amount": 500},
max_refund=100,
)
store = {}
body = {
"agent": "support",
"input": "查询订单 o-1",
"requires": ["structured_output"],
}
fp = fingerprint("tenant_acme", "/v1/runs", body)
record = accept_idempotent_request(store, "idem-001", fp)
print(selected.provider, selected.model_name)
print(decision)
print(record.status)
预期输出:
provider_b model-y
require_approval
accepted
这个示例有三个重要边界:
choose_model只负责能力约束,不负责真实健康度和成本;decide_tool只返回策略结果,不执行工具;accept_idempotent_request使用内存字典,只适合演示,生产中必须使用持久化存储和原子写入。
十一、生产诊断:从错误表象定位控制点
1. 请求频繁返回 401
检查顺序:
令牌是否存在
→ 签名是否有效
→ 是否过期
→ issuer/audience 是否匹配
→ token kid 是否能找到
→ 时钟是否漂移
401 表示身份凭证无效或缺失;如果身份有效但没有调用权限,通常应返回 403。
2. 请求返回 403,但用户认为“有权限”
检查:
主体属于哪个 tenant
→ scope 是否包含 agent:run
→ Agent 是否对该租户开放
→ 模型和工具是否被租户策略禁用
→ 是否命中了数据区域或敏感数据策略
不要只检查用户角色名称。最终权限应来自有效策略评估结果。
3. 请求返回 503,但供应商其实正常
可能不是供应商故障,而是:
- 所有候选模型都不满足能力;
- 租户区域策略排除了所有候选;
- 当前模型预算不能覆盖最小请求;
- 网关熔断器尚未恢复;
- 模型配置版本没有发布到当前实例。
错误中应区分:
no_eligible_model
upstream_unavailable
upstream_rate_limited
policy_blocked
quota_exhausted
4. 成本突然升高
需要沿着:
tenant → run → model_call → tool_call → retry
聚合,而不是只看供应商账单。
典型原因包括:
- Agent 迭代上限未设置;
- 工具错误导致模型循环重试;
- 上下文每轮完整复制;
- 路由降级到了高成本模型;
- 流断线后重复执行;
- 并行化任务数量未限制;
- 审批等待期间重复启动 Run。
Anthropic 指出,Agent 相比固定 workflow 往往以更高延迟和成本换取灵活性,并存在错误累积风险;因此 Agent 网关必须同时控制迭代、并发、预算和工具次数。(anthropic.com)
5. 审计日志无法重建一次运行
检查是否缺少:
run_id
model_call_id
tool_call_id
approval_id
policy_version
request_id
attempt
sequence
如果只有:
user asked something
assistant answered something
就无法解释模型路由、工具动作、审批和失败转移。审计字段应在请求进入时生成,并贯穿所有下游调用。
十二、常见错误设计及其反例
错误一:把网关做成“万能 Agent”
网关内部直接实现规划、记忆、工具执行、重试和业务逻辑,会导致:
- 权限边界模糊;
- 业务变更需要修改基础设施;
- 运行状态无法独立恢复;
- 测试需要启动整个系统。
改进方式是:
网关:身份、租户、策略、配额、审计、协议
Runtime:规划、上下文编排、Agent loop
工具服务:业务动作与数据访问
错误二:只限制入口请求
反例:
POST /runs 时检查一次 token 和预算
之后 Runtime 可以无限调用模型和工具
这相当于把一个长事务当作一个短请求处理。正确做法是每次模型调用、工具调用和敏感输出都重新经过相关策略与配额检查。
错误三:允许模型直接携带供应商密钥
模型输出:
{
"tool": "http_request",
"headers": {
"Authorization": "Bearer long-lived-secret"
}
}
这会把密钥暴露给上下文、日志、追踪和可能的外部模型。正确做法是工具服务根据主体和租户上下文,从密钥管理系统短期获取凭证。
错误四:用“降级模型”掩盖能力不兼容
如果业务要求结构化输出,降级模型不支持该能力,就不能仅因为它响应更快而使用。降级只能在满足最小能力集合的候选中进行。
错误五:把审批当作同步 HTTP 阻塞
人工审批可能持续数小时。保持 HTTP 连接会占用连接、线程、内存和网关实例。正确方式是:
返回 run.status = waiting_for_approval
客户端订阅事件或轮询
批准后恢复同一个 run
错误六:把原始 prompt 全量写入日志
原始消息可能包含个人信息、商业秘密、访问令牌或文件内容。审计需要可追溯性,但可追溯不等于无条件保存原文。应根据数据分类决定:
原文保存、字段脱敏、摘要保存、哈希保存或完全丢弃
十三、落地顺序:先建立不可绕过的控制点
一个可行的建设顺序是:
第一阶段:统一身份和租户上下文
所有请求在网关内生成不可伪造的:
principal_id
tenant_id
request_id
run_id
同时拒绝客户端直接覆盖这些字段。
第二阶段:建立统一 Run 和事件契约
先把会话、消息、附件、事件、错误、幂等键和 Run 状态定义清楚,再接入多个模型供应商。否则每个供应商都会把状态模型带进业务代码。
第三阶段:加入逻辑模型和能力路由
业务只请求:
support.fast
support.reasoning
embedding.search
网关负责映射供应商模型,并显式校验能力、区域和租户策略。
第四阶段:加入预留式配额
至少实现:
并发预留
模型调用预留
实际用量结算
Run 级预算
租户级预算
没有预留和结算,配额在并发下通常是不正确的。
第五阶段:将工具调用纳入策略和审计
工具调用是 Agent 从“生成文本”变成“执行动作”的边界。必须有:
tool_call_id
arguments_hash
policy_decision
approval_id
execution_status
tool_idempotency_key
第六阶段:实现可恢复事件流和故障处理
客户端断线、网关重启、上游超时和人工审批都不能导致重复副作用。事件重放、Run 恢复和工具幂等要在生产前验证。
Agent 网关的核心价值不是隐藏模型供应商,而是把 Agent 的不确定性限制在可管理边界内:
模型可以提出计划,
Runtime 可以推进步骤,
但网关决定身份、资源、权限、能力、数据和审计边界。
当认证、模型路由、配额、策略、审计和协议适配分别拥有清晰语义,并通过 tenant_id、run_id、model_call_id、tool_call_id 和 policy_version 贯通时,Agent 才能从一次可运行的演示,变成可以计量、诊断、恢复和追责的生产系统。
系列导航与关联阅读
- 系列入口:Agent 工程完整路线:从运行循环、记忆与协议到安全、评测和生产交付
- 上一篇:Agent API 契约:会话、消息、附件、事件、错误和幂等键
- 下一篇:Go 实现 Agent Runtime:状态、工具、流式、Context、并发和持久化
- 延伸:Agent 多租户系统:数据、模型、工具、记忆、配额和密钥隔离
官方资料
本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论