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

image

随着企业内部 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

整体架构

我们的网关采用三层模型,每一层职责清晰:

flowchart TD Client([Client]) -->|JWT| NGINX[NGINX<br/>单一入口 / API Gateway] NGINX --> Auth[Auth<br/>JWT 认证] NGINX --> Registry[Registry<br/>Agent 发现] NGINX --> Proxy[Proxy<br/>请求路由] NGINX --> Search[Search<br/>语义搜索] Auth --> PG[(PostgreSQL<br/>统一存储)] Registry --> PG Proxy --> PG Search --> PG Proxy -->|转发 + 后端 OAuth| Agents[Backend Agents<br/>Weather / Calculator] subgraph 管理层 Registry end subgraph 控制层 Auth end subgraph 执行层 Proxy end

管理层(Management Layer)——集中式的 Agent 注册表,支持发现与语义搜索。每个 Agent 部署后只需注册一次,就能被所有授权客户端发现。注册表还会缓存 Agent Card,客户端无需逐个访问后端获取能力描述。

控制层(Control Layer)——基于 JWT scope 的细粒度访问控制。客户端认证后拿到的 Token 中携带 scope(如 weather:readcalculator: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_idclient_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_idscope 放进响应头 x-client-id / x-scope 返回。这两个头就是后续 NGINX 会捕获、并注入给后端服务的"身份标签"——后端服务信任它们,正是因为它们经过了 /verify 的双重把关(签名 + 实时状态)。

值得强调的是 /verify职责边界:它只回答"这个 Token 有效吗、持有者是谁、有哪些 scope",并不判断"能不能访问某个具体 Agent"。后者是 Registry / Proxy 拿着 x-scopex-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 后,会把解析出的 scopeclient_id 放进响应头(X-ScopeX-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 的完整旅程是这样的:

sequenceDiagram participant C as Client participant N as NGINX participant A as Auth (/verify) participant P as Proxy / Registry C->>N: 请求 /agents/xxx (带 JWT) N->>A: auth_request 子请求 (转发 JWT) A-->>N: 200 + X-Scope / X-Client-Id Note over N: 鉴权通过,捕获身份变量 N->>P: 转发请求 + 注入 X-Scope / X-Client-Id P-->>N: 业务响应 N-->>C: 返回响应 Note over C,A: 若 JWT 无效, /verify 返回 401,<br/>请求在此终止, 永远到不了后端

这样设计带来的三个好处:

  • 后端服务彻底不涉及 JWT。 打开 Registry 或 Proxy 的代码你会发现,它们从不解析 Token、也不持有 JWT 密钥——只是读取请求头里的 X-ScopeX-Client-Id。认证的复杂度被完全封装在了 Auth 服务 + NGINX 这一层。前面数据模型和后续代理章节里,后端服务信任的都是这两个头,正是因为它们由 NGINX 在鉴权后注入,外部无法伪造(/_authinternal 的,客户端够不到)。
  • 路由与协议映射集中管理。 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(提供 tokenUrlclientId)和 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 网关——无论是成熟的托管服务、开源项目,还是你自己要搭建的方案——都不再是面对一个黑盒,而是能看透它每一层在做什么、为什么这么做、边界在哪里。

参考资料

posted @ 2026-07-04 02:37  zhaojie10  阅读(9)  评论(0)    收藏  举报