自行实现 A2A 网关:从零理解 Agent 发现、路由与访问控制

随着企业内部 AI Agent 数量的快速增长,一个新的工程难题浮出水面:这些 Agent 之间该如何通信?当你只有两三个 Agent 时,点对点直连似乎还能接受。但当 Agent 扩展到几十个,每新增一个都要处理独立的连接、独立的凭证、独立的路由逻辑时,复杂度就会爆炸。此外,访问控制分散在各处,安全策略难以统一,运维成本随 Agent 数量呈平方级增长。
网关模式(Gateway Pattern)正是为解决这一问题而生。它在所有 Agent 前面放置一个统一入口,无论后端 Agent 运行在容器、函数计算、Kubernetes、其他云还是本地机房,客户端只需要对接一个域名。网关集中处理路由与细粒度权限,不把团队绑定在特定运行时或框架上。这一模式建立在 A2A(Agent-to-Agent)协议之上,一个标准化 Agent 间通信的开放协议。
托管型的 Agent 网关服务已经很成熟,但它们把所有复杂度都藏在了控制台背后。本文的目的是从零搓一个,把原理都拆开看清楚——认证怎么做、Agent 怎么注册与发现、请求怎么路由、后端凭证怎么隔离、限流怎么保证原子性。我们还会用 Strands Agents SDK 实现两个真实 Agent 并接入网关,跑通完整链路。
为什么要自建?
市面上已经有成熟的托管型 Agent 网关服务,直接用它们当然更省事。
托管服务把复杂度藏在了一个漂亮的控制台后面——你点几下鼠标,Agent 就注册好了,请求就路由通了,权限就生效了。但这背后到底发生了什么?JWT 里的 scope 是如何一步步变成"这个客户端能不能访问那个 Agent"的判断的?网关是怎么替客户端向后端换取 OAuth token、又不让客户端碰到后端凭证的?限流的计数器在高并发下如何保证原子性?流式响应又是怎么透传的?
这些问题,只有当你把每一行代码写出来、每一个组件跑起来、每一个坑踩一遍之后,才会真正内化成自己的理解。
完整源码见 GitHub:zhaojiew10/a2a-gateway。
整体架构
我们的网关采用三层模型,每一层职责清晰:
管理层(Management Layer)——集中式的 Agent 注册表,支持发现与语义搜索。每个 Agent 部署后只需注册一次,就能被所有授权客户端发现。注册表还会缓存 Agent Card,客户端无需逐个访问后端获取能力描述。
控制层(Control Layer)——基于 JWT scope 的细粒度访问控制。客户端认证后拿到的 Token 中携带 scope(如 weather:read、calculator:write),网关据此判断该客户端能访问哪些 Agent。同时在代理层实施按客户端、按 Agent 的限流。
执行层(Execution Layer)——把请求路由到实际的后端 Agent。客户端连接单一域名,网关按路径 /agents/{agentId} 转发到对应后端,并代为处理后端的 OAuth 认证。
组件与技术选型
| 职责 | 组件 | 说明 |
|---|---|---|
| 单一入口 | NGINX | 反向代理,auth_request 做前置鉴权 |
| 认证 | 自签 JWT 服务 (FastAPI) | 客户端凭证流,bcrypt 存密码 |
| Agent 注册/发现 | Registry 服务 (FastAPI) | 管理 Agent 生命周期,缓存 Agent Card |
| 请求代理 | Proxy 服务 (FastAPI) | 路由 + 后端 OAuth + 限流 + 审计 |
| 语义搜索 | Search 服务 (FastAPI) | 用自建模型接口做 embedding |
| 存储 | PostgreSQL | 一个库搞定注册表、权限、限流、审计 |
| 密钥 | PostgreSQL 加密字段 | AES 加密,不落明文 |
整个项目,用一个 PostgreSQL 实例替代多个专用服务。托管方案里往往用 NoSQL 存注册表、用独立的密钥管理服务存凭证、用向量数据库存 embedding。而在自托管场景,PostgreSQL 一个库就够了——JSONB 处理灵活的 Agent Card,加密字段存凭证,配合 pgvector 扩展还能做向量检索。
数据模型设计
整套系统的核心是六张表:
-- 客户端表(替代托管方案的用户池)
CREATE TABLE clients (
client_id VARCHAR(255) UNIQUE NOT NULL,
client_secret_hash VARCHAR(255) NOT NULL, -- bcrypt
scopes JSONB DEFAULT '[]', -- ["weather:read", ...]
rate_limit INT DEFAULT 100,
active BOOLEAN DEFAULT true
);
-- Agent 注册表
CREATE TABLE agents (
id VARCHAR(255) PRIMARY KEY,
backend_url VARCHAR(500) NOT NULL,
agent_card JSONB, -- 缓存的 A2A Agent Card
auth_config JSONB, -- 后端 OAuth 配置
status VARCHAR(50) DEFAULT 'active'
);
-- 权限表(客户端 × Agent 的授权关系)
CREATE TABLE permissions (
client_id VARCHAR(255) REFERENCES clients(client_id),
agent_id VARCHAR(255) REFERENCES agents(id),
scope VARCHAR(255) NOT NULL,
rate_limit_per_minute INT DEFAULT 100,
UNIQUE(client_id, agent_id, scope)
);
-- 限流计数器(按分钟窗口,原子递增)
CREATE TABLE rate_limits (
client_id VARCHAR(255) NOT NULL,
agent_id VARCHAR(255) NOT NULL,
window_start TIMESTAMP NOT NULL,
request_count INT DEFAULT 0,
PRIMARY KEY (client_id, agent_id, window_start)
);
-- 后端凭证(Fernet 加密存储)
CREATE TABLE credentials (
agent_id VARCHAR(255) UNIQUE REFERENCES agents(id),
credential_type VARCHAR(50) NOT NULL,
encrypted_data TEXT NOT NULL
);
-- 审计日志
CREATE TABLE audit_logs (
client_id VARCHAR(255),
agent_id VARCHAR(255),
action VARCHAR(100),
status_code INT,
duration_ms INT,
created_at TIMESTAMP DEFAULT NOW()
);
权限管理是集中的。要授予或撤销某客户端对某 Agent 的访问,只需增删 permissions 表里的一行,不用改动任何 Agent 代码。限流配额则设计成两层:clients.rate_limit 是客户端的全局默认,permissions.rate_limit_per_minute 是针对特定 Agent 的覆盖值——精确到某客户端访问某 Agent的粒度。后面代理章节会看到这两层是如何解析生效的。
认证:自签 JWT 替代托管用户池
托管方案通常用云厂商的身份服务处理 OAuth 2.0 客户端凭证流。我们用一个约 150 行的 FastAPI 服务自己实现,核心就三个端点:/token(签发)、/verify(验证,供 NGINX 调用)、/admin/clients(客户端管理)。
凭证从哪来:先有鸡还是先有蛋
在"客户端拿凭证换 Token"之前,得先回答一个更基础的问题:client_id 和 client_secret 这对凭证,最初是怎么来的?
关键原则是——client_secret 由服务端生成,客户端说了不算。管理员通过 /admin/clients 派生一个新客户端时,只提供 client_id(名字)和想要的 scope,secret 是服务端用密码学安全随机数现场生成的,而且只在创建那一刻返回一次,之后只存哈希、不可找回:
@app.post("/admin/clients")
async def create_client(reg: ClientRegistration):
raw_secret = secrets.token_urlsafe(32) # ① 服务端生成随机 secret
secret_hash = hash_secret(raw_secret) # ② 只把 bcrypt 哈希入库
await conn.execute(
"INSERT INTO clients (client_id, client_secret_hash, scopes, ...) VALUES (...)",
reg.client_id, secret_hash, reg.scopes, ...)
return ClientResponse(client_id=reg.client_id,
client_secret=raw_secret, # ③ 明文仅此一次返回
scopes=reg.scopes)
数据库里永远只有 client_secret_hash,连 GET /admin/clients/{id} 都不会吐出明文。这和 GitHub 的 Personal Access Token、云厂商的 AccessKey 是同一套逻辑:生成时给你看一眼,此后丢了只能重置、无法找回。这样即便数据库泄露,攻击者拿到的也只是 bcrypt 哈希,无法逆推出原始 secret。
那接踵而来的问题是:/admin/clients 本身需要 admin scope 才能调用——第一个 admin 客户端又是谁创建的? 这就是所谓的"信任引导"(bootstrap)。我们的做法是在数据库初始化脚本 init.sql 里预置一条种子记录:
INSERT INTO clients (client_id, client_secret_hash, scopes) VALUES
('admin-client', '<预先算好的 bcrypt 哈希>', '["admin"]');
有了这个初始 admin,整条信任链就能自举了:种子 admin → 派生业务客户端 → 各自换取 Token。博客后面示例用的 demo-client/demo-secret 同样是种子数据,纯粹为了开箱即测。生产环境里,这个引导 secret 应该在首次部署后立即轮换。
理清了凭证来源,我们就能顺着信任链往下看——客户端如何用这对凭证换取 Token。
签发 Token 的核心逻辑:
@app.post("/token")
async def get_token(req: TokenRequest):
async with pool.acquire() as conn:
client = await conn.fetchrow(
"SELECT client_secret_hash, scopes, active FROM clients WHERE client_id = $1",
req.client_id
)
if not client or not client['active']:
raise HTTPException(401, "Invalid credentials")
if not bcrypt.checkpw(req.client_secret.encode(), client['client_secret_hash'].encode()):
raise HTTPException(401, "Invalid credentials")
scopes = client['scopes'] or []
now = datetime.utcnow()
token = jwt.encode({
"sub": req.client_id,
"iss": JWT_ISSUER,
"iat": now,
"exp": now + timedelta(minutes=TOKEN_EXPIRE_MINUTES),
"scope": " ".join(scopes),
}, JWT_SECRET, algorithm="HS256")
return {"access_token": token, "token_type": "Bearer", "scope": " ".join(scopes)}
密码用 bcrypt 哈希存储,Token 用 HS256 对称签名。签发之后,还需要一个配套的验证端点 /verify。
客户端拿 Token 就是标准的客户端凭证流:
curl -X POST http://localhost:8080/token \
-H "Content-Type: application/json" \
-d '{"client_id":"demo-client","client_secret":"demo-secret"}'
# → {"access_token":"eyJhbG...", "token_type":"Bearer", "scope":"weather:read calculator:write"}
/verify:验证 Token 并交出身份
签发只是一半,另一半是验证。/verify 是 Auth 服务被调用得最频繁的端点——每一个受保护的请求经过 NGINX 时,都会先打一次 /verify。它做三件事:
@app.post("/verify")
async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
try:
# ① 验签 + 验有效期 + 验签发者(纯密码学,不查库)
payload = verify_jwt(credentials.credentials)
# ② 二次查库:确认这个客户端此刻仍然存在且启用
async with pool.acquire() as conn:
client = await conn.fetchrow(
"SELECT client_id FROM clients WHERE client_id=$1 AND active=true",
payload["sub"])
if not client:
raise HTTPException(401, "Client revoked or not found")
# ③ 把身份信息放进响应头,交给 NGINX
return {"valid": True,
"x-client-id": payload["sub"],
"x-scope": payload["scope"]}
except jwt.ExpiredSignatureError:
raise HTTPException(401, "Token expired")
except jwt.InvalidTokenError as e:
raise HTTPException(401, f"Invalid token: {str(e)}")
jwt.decode用同一把JWT_SECRET校验签名(防篡改)、检查exp(防过期)、核对iss签发者。这一步纯计算、不碰数据库——只要签名对、没过期,就能确信这个 Token 确实是我们签发的、且内容没被改动。- JWT 是自包含的:签发那一刻的 scope、client_id 都被"冻结"进了 Token,签名保证它们没被篡改。但问题恰恰在这里——如果一个客户端在 Token 有效期内被管理员禁用或删除了呢? 光验签名是发现不了的,因为签名依然合法、Token 也还没过期。所以
/verify多做一次SELECT ... WHERE active=true的查库,确认这个客户端"此刻"仍然有效。这就是无状态 JWT 与实时吊销之间的经典权衡:JWT 快在无需查库验签,但要支持"立即吊销",就得在验证时补一次轻量查询。我们选择了后者,因为"禁用客户端能立刻生效"对一个网关来说值这点开销。 - 验证通过后,
/verify把从 Token 里解出的client_id和scope放进响应头x-client-id/x-scope返回。这两个头就是后续 NGINX 会捕获、并注入给后端服务的"身份标签"——后端服务信任它们,正是因为它们经过了/verify的双重把关(签名 + 实时状态)。
值得强调的是 /verify 的职责边界:它只回答"这个 Token 有效吗、持有者是谁、有哪些 scope",并不判断"能不能访问某个具体 Agent"。后者是 Registry / Proxy 拿着 x-scope、x-client-id 去查 permissions 表才做的决策。认证(你是谁)与授权(你能干什么)在这里被清晰地分开了。
那么,/verify 是被谁、在什么时机调用的?答案是 NGINX——这就引出了下一节。
NGINX:单一入口与集中鉴权
现在有了签发 Token 的 Auth 服务,也有了即将实现的 Registry、Proxy、Search。但这里藏着一个容易被忽视、却决定整个网关成败的问题:JWT 验证这件事,到底应该由谁来做?
一个做法是让每个后端服务自己验证 JWT——Registry 验一遍、Proxy 验一遍、Search 再验一遍。这会导致 JWT 密钥散落在每个服务里、验证逻辑重复四份、任何一处漏验都是安全缺口。这恰恰是网关模式要消灭的"分散"。
所以,把认证收敛到入口的 NGINX,后端服务一概不碰 JWT。 NGINX 靠 auth_request 指令实现这一点——这是它做 API 网关时最强大的机制。
auth_request 的工作原理
auth_request 让 NGINX 在把请求转发给后端之前,先向一个内部认证端点发起子请求。子请求返回 2xx 就放行,返回 401/403 就直接拒绝,请求根本到不了后端。
我们先定义这个内部认证端点,它转发到 Auth 服务的 /verify:
# 内部认证端点(外部无法直接访问)
location = /_auth {
internal;
proxy_pass http://auth_service/verify;
proxy_pass_request_body off; # 只验 header,不传 body
proxy_set_header Content-Length "";
proxy_set_header Authorization $http_authorization;
# 关键:把 /verify 响应头里的 scope 和 client_id 捕获成变量
auth_request_set $auth_client_id $upstream_http_x_client_id;
auth_request_set $auth_scope $upstream_http_x_scope;
}
Auth 服务的 /verify 端点验证 JWT 后,会把解析出的 scope 和 client_id 放进响应头(X-Scope、X-Client-Id)返回。NGINX 通过 auth_request_set 把这些值捕获成变量,再注入到转发给后端的请求头里。
于是每个受保护的路由就变成了这个模式:
location ~ ^/agents/([^/]+)/?$ {
auth_request /_auth; # ① 先鉴权,不过就 401
auth_request_set $scope $auth_scope; # ② 取出鉴权结果
proxy_pass http://proxy_service/agents/$1; # ③ 路由到 Proxy
proxy_set_header X-Scope $scope; # ④ 把身份信息注入后端
proxy_set_header X-Client-Id $auth_client_id;
# A2A 流式响应支持:关掉缓冲,拉长超时
proxy_buffering off;
proxy_read_timeout 86400s;
}
一次请求经过 NGINX 的完整旅程是这样的:
这样设计带来的三个好处:
- 后端服务彻底不涉及 JWT。 打开 Registry 或 Proxy 的代码你会发现,它们从不解析 Token、也不持有 JWT 密钥——只是读取请求头里的
X-Scope和X-Client-Id。认证的复杂度被完全封装在了 Auth 服务 + NGINX 这一层。前面数据模型和后续代理章节里,后端服务信任的都是这两个头,正是因为它们由 NGINX 在鉴权后注入,外部无法伪造(/_auth是internal的,客户端够不到)。 - 路由与协议映射集中管理。 NGINX 的
location规则同时承担了 A2A 协议的路径路由:/token是公开端点(不加auth_request),/agents/{id}/.well-known/agent-card.json走 Registry/Proxy,/agents/{id}的消息端点走 Proxy 并开启 SSE 透传,/search走 Search。客户端只认一个域名,路径背后转发到哪个服务对它完全透明。 - 流式响应开箱即用。 A2A 的
SendStreamingMessage依赖 SSE。NGINX 默认会缓冲响应,这会破坏流式效果——所以在 Agent 消息路由上关掉proxy_buffering、拉长proxy_read_timeout,增量数据就能实时透传给客户端。
一句话总结 NGINX 的角色:它是网关的"前台"——统一接客、验明身份、按路径引路,把干净、可信、带好身份标签的请求交给后端,让后端专注做业务。
Agent 注册与发现
Registry 服务负责 Agent 的整个生命周期。管理员注册一个新 Agent 时,提供它的后端地址、Agent Card URL,以及——如果后端需要认证——一份 auth_config:
curl -X POST http://localhost:8080/admin/agents/register \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{
"agent_id": "weather-agent",
"name": "Weather Agent",
"backend_url": "http://weather-agent:8081",
"agent_card_url": "http://weather-agent:8081/.well-known/agent-card.json",
"auth_config": {
"type": "oauth2_client_credentials",
"tokenUrl": "https://weather-agent/oauth/token",
"clientId": "gateway-client",
"scopes": ["agent:invoke"]
}
}'
Registry 会自动去后端拉取 Agent Card 并缓存到数据库,同时把 auth_config 明文存进 agents 表。
这里要留意两点,它们正好呼应后面「代理」章节讲的凭证分离:
auth_config是可选的。 如果后端 Agent 不需要认证(比如内网里完全信任的服务),注册时把它省掉即可——本文示例的 Weather / Calculator Agent 就是这种「无认证」的最简情况。它一旦提供,描述的也只是「怎么认证」,不含密钥。client_secret不在这里给。 注册接口刻意不接受密钥字段——真正的后端密钥要走一个独立的加密端点POST /admin/agents/{id}/credentials单独写入(下一章节详述)。这样「注册 Agent」和「灌入密钥」是两个分开的动作,密钥永远不会出现在这个明文的注册请求里。
发现接口的做法是直接查权限表:
@app.get("/agents")
async def list_agents(x_client_id: str = Header(...), x_scope: str = Header(...)):
is_admin = x_scope and "admin" in x_scope.split()
async with pool.acquire() as conn:
if is_admin:
rows = await conn.fetch("SELECT id, name, description FROM agents WHERE status='active'")
else:
# 通过 permissions 表 JOIN,只返回该客户端被授权的 Agent
rows = await conn.fetch("""
SELECT a.id, a.name, a.description FROM agents a
JOIN permissions p ON a.id = p.agent_id
WHERE a.status='active' AND p.client_id = $1
""", x_client_id)
return {"agents": [{"id": r['id'], "name": r['name'],
"url": f"/agents/{r['id']}"} for r in rows]}
管理员改一行 SQL 就能调整授权,Token 无需重签。
代理:路由、后端认证与限流
Proxy 服务是网关最有分量的组件。一个请求进来,它要依次完成:限流检查 → 查后端地址 → 代理后端 OAuth → 转发请求 → 记录审计。
限流用了 PostgreSQL 的原子 UPSERT,按分钟窗口计数。这里有一个值得细说的设计:配额分两层解析
- 先看这个客户端对该 Agent 有没有专门的配额(
permissions.rate_limit_per_minute) - 没有就回退到客户端的全局默认配额(
clients.rate_limit),再没有才用硬编码兜底值。
这样既能「给某客户端访问某个 Agent 单独放宽/收紧」,又有一个客户端级的默认值托底:
DEFAULT_RATE_LIMIT = 100
async def resolve_rate_limit(conn, client_id: str, agent_id: str) -> int:
# 第一层:客户端 × Agent 的精细配额
perm = await conn.fetchrow(
"SELECT rate_limit_per_minute FROM permissions WHERE client_id=$1 AND agent_id=$2",
client_id, agent_id)
if not perm: # 退而求其次:通配权限
perm = await conn.fetchrow(
"SELECT rate_limit_per_minute FROM permissions WHERE client_id=$1 AND agent_id='*'",
client_id)
if perm and perm['rate_limit_per_minute'] is not None:
return perm['rate_limit_per_minute']
# 第二层:客户端级全局默认配额
client = await conn.fetchrow(
"SELECT rate_limit FROM clients WHERE client_id=$1", client_id)
if client and client['rate_limit'] is not None:
return client['rate_limit']
return DEFAULT_RATE_LIMIT # 兜底
async def check_rate_limit(client_id: str, agent_id: str) -> bool:
async with pool.acquire() as conn:
limit = await resolve_rate_limit(conn, client_id, agent_id)
window = datetime.utcnow().replace(second=0, microsecond=0)
result = await conn.fetchrow("""
INSERT INTO rate_limits (client_id, agent_id, window_start, request_count)
VALUES ($1, $2, $3, 1)
ON CONFLICT (client_id, agent_id, window_start)
DO UPDATE SET request_count = rate_limits.request_count + 1
RETURNING request_count
""", client_id, agent_id, window)
return result['request_count'] <= limit
ON CONFLICT ... DO UPDATE 保证了并发下计数的原子性,超限就返回 429。这点可以实测:把某客户端配额设成 7,并发打 10 个请求,会精确看到 7 个 200 + 3 个 429。计数器还能配合定时任务清理过期窗口,无需额外的缓存中间件。
后端认证是「代理式」的:客户端不需要知道后端 Agent 的任何凭证。Proxy 替客户端向后端换取 access token 并缓存,再透明地附加到转发请求上:
async def get_backend_token(agent_id: str, auth_config: dict) -> str:
# 先查缓存,未过期直接用
if agent_id in token_cache:
token, expires_at = token_cache[agent_id]
if datetime.utcnow() < expires_at - timedelta(minutes=5):
return token
# 用后端 OAuth 配置换 token
creds = await get_backend_credentials(agent_id) # 从加密表解密取出
async with httpx.AsyncClient() as client:
resp = await client.post(auth_config["tokenUrl"], data={
"grant_type": "client_credentials",
"client_id": auth_config["clientId"],
"client_secret": creds["data"],
"scope": " ".join(auth_config.get("scopes", [])),
})
token_data = resp.json()
token_cache[agent_id] = (token_data["access_token"],
datetime.utcnow() + timedelta(seconds=token_data["expires_in"]))
return token_data["access_token"]
这样一来,后端 Agent 的凭证被完全隔离在网关内部,客户端只持有网关签发的 JWT。对于流式场景(A2A 的 SendStreamingMessage),Proxy 用 SSE 透传后端的增量响应。
后端凭证从哪来:敏感与非敏感分开存
上面 get_backend_token 里出现了两个来源不同的东西——auth_config(提供 tokenUrl、clientId)和 creds(提供 client_secret)。它们不是存在一起的,而是被刻意拆成两半,这正是延续了前面「客户端 secret 只存哈希」的安全思路:把秘密和非秘密分开存,秘密再加密。
非敏感的那半——auth_config,注册 Agent 时明文存。 它描述「怎么认证」但不含密钥:认证类型、token 端点、clientId、scope。管理员注册 Agent 时随请求体一起提供,Registry 直接明文存进 agents.auth_config:
{
"type": "oauth2_client_credentials",
"tokenUrl": "https://backend/oauth/token",
"clientId": "gateway-client",
"scopes": ["agent:invoke"]
}
敏感的那半——client_secret,走独立的加密端点存。 真正的密钥不放进 auth_config,而是通过一个专门的 admin 端点 POST /admin/agents/{id}/credentials 写入,用对称加密(这里用 Python cryptography 的 Fernet)落进 credentials 表:
@app.post("/admin/agents/{agent_id}/credentials")
async def set_agent_credentials(agent_id: str, request: Request, x_scope=Header(...)):
if "admin" not in (x_scope or "").split():
raise HTTPException(403, "Admin scope required") # 仅 admin 可写
body = await request.json()
encrypted = cipher_suite.encrypt(body["data"].encode()).decode() # Fernet 加密
await conn.execute("""
INSERT INTO credentials (agent_id, credential_type, encrypted_data)
VALUES ($1, $2, $3)
ON CONFLICT (agent_id) DO UPDATE SET encrypted_data=$3, updated_at=NOW()
""", agent_id, body["type"], encrypted)
运行时换 token 那一刻,Proxy 再把两半拼起来:auth_config 提供「往哪打、用哪个 clientId」,credentials 解密出 client_secret 填进去。这样分离的收益很直接——即使 agents 表整个泄露,攻击者也只看到「往哪认证、用哪个 clientId」,真正的密钥在另一张加密表里,拿不到。
agents.auth_config |
credentials.encrypted_data |
|
|---|---|---|
| 内容 | tokenUrl / clientId / scopes(怎么认证) | client_secret(密钥本身) |
| 存储 | 明文 JSONB | Fernet 加密 |
| 写入路径 | 注册 Agent 时一并提供 | 独立的 admin 加密端点 |
| 能否读回明文 | GET /admin/agents/{id} 可见 |
永不明文返回 |
客户端 secret 只存哈希、后端 secret 加密存且与配置分离、运行时才在内存里拼合使用——秘密在任何静态存储里都不以可用形态出现。
用 Strands SDK 构建真实 Agent
有了网关,我们需要真实的 Agent 来接入验证。这里用 Strands Agents SDK 实现两个 Agent:一个查天气,一个做数学计算。Strands 的模型驱动设计让 Agent 定义极其简洁——用 @tool 装饰器暴露能力,用 A2AServer 一键起 A2A 协议服务。
天气 Agent 的核心:
from strands import Agent, tool
from strands.models.openai import OpenAIModel
from strands.multiagent.a2a import A2AServer
@tool
def get_weather(location: str) -> str:
"""Get current weather information for a location."""
# ... 实际实现调用天气数据源
return f"Weather in {location}: Sunny, 22°C, Humidity: 45%"
@tool
def get_forecast(location: str, days: int = 3) -> str:
"""Get weather forecast for a location."""
# ...
def create_weather_agent(context_id: str) -> Agent:
# 每个 A2A 上下文创建独立 Agent,保证调用方隔离
model = OpenAIModel(
client_args={"api_key": LLM_API_KEY, "base_url": LLM_API_URL},
model_id=MODEL_NAME,
)
return Agent(
name="Weather Agent",
description="Provides weather information and forecasts.",
tools=[get_weather, get_forecast],
model=model,
)
if __name__ == "__main__":
server = A2AServer(agent_factory=create_weather_agent, port=8081, host="0.0.0.0")
server.serve() # Agent Card 自动挂在 /.well-known/agent-card.json
Strands 会根据 @tool 的函数签名和 docstring 自动生成符合 A2A 规范的 Agent Card:
{
"name": "Weather Agent",
"description": "Provides weather information and forecasts.",
"capabilities": {"streaming": true},
"skills": [
{"id": "get_weather", "name": "get_weather",
"description": "Get current weather information for a location."},
{"id": "get_forecast", "name": "get_forecast",
"description": "Get weather forecast for a location."}
],
"url": "http://127.0.0.1:8081/"
}
部署与联调
整套系统用 Docker Compose 编排。为了适配没有 buildx 的环境,Dockerfile 直接用 pip 配合国内镜像源安装依赖,基础镜像走 Public ECR:
FROM public.ecr.aws/docker/library/python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev
COPY main.py .
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ \
fastapi uvicorn asyncpg pyjwt bcrypt httpx
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
启动全部服务后,注册 Agent 并授权,就可以跑通完整链路了。
一切就绪,我们从头验证一遍。
1. 拿 Token
TOKEN=$(curl -s -X POST http://localhost:8080/token \
-d '{"client_id":"demo-client","client_secret":"demo-secret"}' \
| jq -r .access_token)
2. 发现可访问的 Agent
curl http://localhost:8080/agents -H "Authorization: Bearer $TOKEN"
# → {"agents":[{"id":"calculator-agent",...},{"id":"weather-agent",...}]}
3. 调用天气 Agent
curl -X POST http://localhost:8080/agents/weather-agent \
-H "Authorization: Bearer $TOKEN" \
-d '{"jsonrpc":"2.0","id":"1","method":"message/send",
"params":{"message":{"messageId":"m1","role":"user",
"parts":[{"kind":"text","text":"What is the weather in Beijing?"}]}}}'
响应:
The current weather in Beijing is sunny with a temperature of 22°C and humidity at 45%.
4. 调用计算 Agent
curl -X POST http://localhost:8080/agents/calculator-agent \
-H "Authorization: Bearer $TOKEN" \
-d '{...,"parts":[{"kind":"text","text":"What is 25 * 48 + sqrt(144)?"}]}}'
响应:
The answer is 1212. Here's the breakdown: 25 × 48 = 1,200, √144 = 12, 1,200 + 12 = 1,212.
限流计数器在每次调用后原子递增,审计日志逐条落库:
client_id | agent_id | action | status_code | duration_ms
-------------+------------------+--------+-------------+-------------
demo-client | calculator-agent | proxy | 200 | 4432
demo-client | weather-agent | proxy | 200 | 8299
结语
从几个 Agent 走向几十上百个的过程中,工程挑战会从如何构建单个 Agent转向如何管理它们之间的连接。点对点集成不可扩展——不该需要知道每个 Agent 部署在哪、不该为每个 Agent 单独管理凭证、更不该从零造一套发现和访问控制。网关模式就是这个难题的答案。
但比起"用哪个网关",我们需要看懂了一个 Agent 网关内部到底在发生什么:
- 一个 JWT 是如何被签发、验证,再一步步变成访问控制决策的;
- Agent Card 是如何被拉取、缓存、并在发现接口里按权限过滤的;
- 请求进入代理层后,限流、后端 OAuth 换取、透明转发、审计落库是如何依次完成的;
- 为什么权限的"真相来源"应该是数据库表而不是 Token 字符串;
- 以及那些只有亲手跑过才会遇到的坑——模型 Provider 连错端点、
0.0.0.0绑定、scope 匹配逻辑写反。
这些原理一旦内化,你面对任何 Agent 网关——无论是成熟的托管服务、开源项目,还是你自己要搭建的方案——都不再是面对一个黑盒,而是能看透它每一层在做什么、为什么这么做、边界在哪里。
参考资料

浙公网安备 33010602011771号