《AI Agent 高级编程》11.1 平台化:Agent 注册、版本与配额

把单实例 ResearchCopilot 推进成多 Agent 平台:用一份可运行的注册表实现 Agent 的注册、发现与 semver 版本路由,再用配额账本在调用前完成扣减与拒绝。附真跑输出,并给出「平台替你做了什么、你又让渡了什么」的对照表。

11.1 平台化:Agent 注册、版本与配额

第 10 章把单个 ResearchCopilot 加固到了「能上生产」的程度:trace 归一、成本治理、安全纵深。但生产环境里很快会出现第二个问题——当你手里不止一个 Agent 时,谁来管它们。研究助理、摘要器、引用校验器、报告生成器,各自有版本、各有配额、还要能被别的服务发现。这就从「一个服务」变成了「一个平台」。

本节把「单实例服务」推进到「多 Agent 平台」:给出一份最小可运行的 Agent 注册表(注册 / 发现 / semver 版本路由)与配额账本(调用前扣减、超限拒绝),并真跑一段「注册 → 路由 → 扣减」的完整演示。全程用假模型,零网络、零凭据。

11.1.1 从「一个服务」到「一个平台」:变了什么

卷二把 ResearchCopilot 做成了一个服务:它内部有一个 minagent 引擎、一套工具、一条编排。到了卷三,同一件事被框架重写过很多遍,于是系统里同时存在多个 Agent 实现。此时「平台」要额外承担三件事,这三件事都不是「再多写一个 Agent」能解决的:

维度单服务(卷二)平台(本节)
Agent 从哪来代码里硬编码一个运行时注册、按名查找
版本怎么管改代码重新部署多版本共存、按约束路由
用量怎么控全服务一个限额按 Agent 独立配额、调用前扣减
谁能发现没人需要发现外部按 tag 发现「有哪些 Agent」
故障影响面全站单 Agent 可隔离、可降级

平台化不等于「上 K8s」或「接服务网格」。它的最小内核就是三样东西:注册表、版本、配额。把这三样做对,多 Agent 才谈得上治理。

11.1.2 注册表、版本路由与配额账本

先给结论式的设计:一个 AgentSpec 描述「一个 Agent 的某个版本」,一个 AgentRegistry 负责注册与解析,一个 QuotaLedger 负责扣减,一个 Platform 把三者串成一次调用。完整代码如下,可直接运行:

# platform_registry.py
from dataclasses import dataclass, field
from typing import Callable
from packaging.version import Version
from pydantic_ai import Agent
from pydantic_ai.models.function import FunctionModel
from pydantic_ai.messages import ModelResponse, TextPart


class QuotaExceeded(RuntimeError):
    pass


@dataclass
class AgentSpec:
    name: str
    version: str
    build: Callable[[], Agent]
    tags: tuple[str, ...] = ()


class AgentRegistry:
    def __init__(self) -> None:
        self._specs: dict[str, dict[str, AgentSpec]] = {}

    def register(self, spec: AgentSpec) -> None:
        self._specs.setdefault(spec.name, {})[spec.version] = spec

    def versions(self, name: str) -> list[str]:
        return sorted(self._specs.get(name, {}), key=Version)

    def resolve(self, name: str, constraint: str | None = None) -> AgentSpec:
        vs = self._specs.get(name)
        if not vs:
            raise KeyError(f"未注册的 Agent: {name}")
        keys = sorted(vs, key=Version)
        if constraint is None:
            return vs[keys[-1]]
        if constraint.startswith("^"):
            lo = Version(constraint[1:])
            ok = [v for v in keys if Version(v).major == lo.major and Version(v) >= lo]
        elif constraint.startswith(">="):
            lo = Version(constraint[2:])
            ok = [v for v in keys if Version(v) >= lo]
        elif constraint.startswith("=="):
            ok = [v for v in keys if v == constraint[2:]]
        else:
            ok = [v for v in keys if v == constraint]
        if not ok:
            raise LookupError(f"{name} 无匹配 {constraint!r} 的版本")
        return vs[ok[-1]]

    def discover(self, tag: str | None = None):
        out = []
        for name in sorted(self._specs):
            latest = self.resolve(name)
            if tag is None or tag in latest.tags:
                out.append((name, self.versions(name), latest.version))
        return out


@dataclass
class QuotaLedger:
    limits: dict[str, int] = field(default_factory=dict)
    used: dict[str, int] = field(default_factory=dict)

    def grant(self, name: str, limit: int) -> None:
        self.limits[name] = limit

    def charge(self, name: str, n: int = 1) -> int | None:
        limit = self.limits.get(name)
        used = self.used.get(name, 0)
        if limit is not None and used + n > limit:
            raise QuotaExceeded(f"{name}: 已用 {used} + 本次 {n} > 上限 {limit}")
        self.used[name] = used + n
        return None if limit is None else limit - self.used[name]


class Platform:
    def __init__(self, registry: AgentRegistry, ledger: QuotaLedger) -> None:
        self.registry = registry
        self.ledger = ledger
        self.trace: list[dict] = []

    def run(self, name: str, prompt: str, version: str | None = None) -> dict:
        spec = self.registry.resolve(name, version)
        remaining = self.ledger.charge(name)
        result = spec.build().run_sync(prompt)
        rec = {"agent": name, "version": spec.version, "remaining": remaining,
               "tokens": result.usage.total_tokens, "output": result.output}
        self.trace.append(rec)
        return rec

几个设计要点值得单独说:

  • build 是工厂不是实例。注册表存的是「怎么造出一个 Agent」,不是「一个已经造好的 Agent」。这样每个版本可以独立构造、独立替换依赖(模型、工具、prompt),也避免了共享可变状态。
  • 版本解析用真 semver。packaging.version.Version 能正确排序 2.0.0-rc1(预发布排在正式版之前),自己写字符串排序会把 10.0.0 排到 9.0.0 前面。
  • charge 返回剩余额度。返回值不是装饰,它让调用方能在同一次响应里带上「你还剩几次」,也方便测试直接断言。

11.1.3 版本路由:四种约束的语义

resolve 支持四种约束写法,语义如下表。注意 None(不传约束)表示「取该 Agent 的最高版本」,这和生产上「默认跑 latest」的习惯一致:

约束写法语义例子(已注册 1.0.0 / 1.1.0 / 2.0.0-rc1)
不传(None)取最高版本命中 2.0.0-rc1
^1.0同主版本内、不低于 1.0命中 1.1.0(不含 2.x)
>=1.0不低于 1.0,跨主版本命中 2.0.0-rc1
==1.0.0精确锁定命中 1.0.0

一个容易踩的坑:^1.0 不会命中 2.0.0-rc1。caret 约束按主版本划分兼容区间,主版本变了就意味着破坏性变更,必须显式升级。这正是平台化要版本路由的意义——你可以让「研究助理」的调用方钉死在 ^1.0,等 2.0 稳定后再改约束,而不用改一行调用代码。

11.1.4 真跑:注册 → 路由 → 扣减

把上面这份代码接到一段驱动脚本上,注册三个版本、给 2 次配额,然后依次触发路由与超限:

def build(marker: str):
    def _b() -> Agent:
        fn = lambda messages, info: ModelResponse(parts=[TextPart(f"[{marker}] 研究结论")])
        return Agent(FunctionModel(fn), instructions=f"research {marker}")
    return _b


reg = AgentRegistry()
reg.register(AgentSpec("research", "1.0.0", build("v1.0.0"), ("stable",)))
reg.register(AgentSpec("research", "1.1.0", build("v1.1.0"), ("stable",)))
reg.register(AgentSpec("research", "2.0.0-rc1", build("v2.0.0-rc1"), ("canary",)))

ledger = QuotaLedger()
ledger.grant("research", 2)
plat = Platform(reg, ledger)

print("discover():", reg.discover())
print("discover('canary'):", reg.discover("canary"))
print("resolve ^1.0 :", plat.run("research", "q", "^1.0")["version"])
print("resolve latest:", plat.run("research", "q")["version"])
try:
    plat.run("research", "q")
except QuotaExceeded as e:
    print("QuotaExceeded:", e)
print("trace:", [(r["agent"], r["version"], r["remaining"]) for r in plat.trace])

在本机 venv3 里真跑(PYDANTIC_AI_NO_BANNER=1 只是为了去掉 pydantic-ai 的启动横幅),输出逐字节如下:

discover(): [('research', ['1.0.0', '1.1.0', '2.0.0-rc1'], '2.0.0-rc1')]
discover('canary'): [('research', ['1.0.0', '1.1.0', '2.0.0-rc1'], '2.0.0-rc1')]
resolve ^1.0 : 1.1.0
resolve latest: 2.0.0-rc1
QuotaExceeded: research: 已用 2 + 本次 1 > 上限 2
trace: [('research', '1.1.0', 1), ('research', '2.0.0-rc1', 0)]

四行输出各自兑现了一件事:discover 列出了版本清单与 latest;^1.0 路由到 1.1.0 而非 2.0.0-rc1;不传约束取到 2.0.0-rc1;第三次调用被 QuotaExceeded 拦住,trace 里只留下两次成功记录。配额生效的关键在于扣减发生在 run_sync 之前——先扣再跑,超限的调用根本不会触达模型。

11.1.5 注册表与 A2A:发现能力的对外延伸

本节的 discover 是进程内的发现:它回答「我这个平台里有哪些 Agent」。而 8.1 A2A 协议:Agent Card/Task/消息,起两个本地 Agent 通信 里的 Agent Card 是跨进程的发现:它回答「网络对端那个 Agent 能做什么」。两者是同一件事的两个尺度,可以对接——把注册表里的 AgentSpec 投影成一张 Agent Card,平台里的 Agent 就能被外部协作方发现:

def to_agent_card(spec: AgentSpec, endpoint: str) -> dict:
    return {
        "name": spec.name,
        "version": spec.version,
        "url": endpoint,
        "capabilities": {"tags": list(spec.tags)},
    }

关键取舍是版本号要不要暴露给对端。A2A 的 Agent Card 里带 version,但协作方通常只关心「能力是否兼容」,不关心你的内部补丁号。一个务实的做法是:对外只暴露主版本,对内(注册表)保留完整 semver。这样对端钉在 ^1 时,你在内部从 1.1.0 升到 1.4.0 不需要重新协商,只有升到 2.0 才触发协议层的兼容检查。

11.1.6 配额为什么必须「调用前扣减」

配额扣减的时机是个容易做错的点,值得展开:

扣减时机后果
调用前扣减超限请求被挡在模型之外,零成本、零副作用
调用后扣减超限请求已经花掉额度,扣减变成「记账」而非「限流」
按 token 事后结算需要模型返回真实 usage,但假模型给不出真实 token

正确顺序是 resolve → charge → run。charge 抛异常时,run_sync 还没被调用,所以既没有网络开销,也不会有任何工具副作用。如果额度是按租户而非按 Agent 划分的(卷二的限流就是这样),把 name 换成 f"{tenant}:{agent}" 即可,QuotaLedger 本身不用改——它只认 key。

一个必须诚实交代的边界:本节演示的 charge 是单进程内存账本。真实平台要抗并发,charge 必须是原子的(Redis 的 INCR 加 Lua,或数据库行锁),否则两个并发请求会各自读到「还剩 1」然后都扣成功。这个原子性问题卷二第 6.3 节已经解决过,本节不重复,只标注「把 charge 换成原子实现」这一处即可。

11.1.7 这套注册表替你做了什么、你又让渡了什么

你得到的你让渡的
运行时可发现、可路由,不必改代码切版本引入了一层间接:调 Agent 要先过注册表
多版本共存,可灰度、可回滚要维护版本号与 tag 的纪律,否则注册表会烂
调用前限额,超限零成本配额状态本身要持久化、要原子,是新的运维面
统一的 trace 入口注册表成了单点,它挂了全平台挂

一句话:注册表把「有哪些 Agent、跑哪个版本、还剩多少额度」从散落的代码收敛成一份可查询的状态,代价是你多了一个必须高可用的核心组件。这和第 10 章「可观测/成本/安全」的加固是同一种思路——把散在各处的隐式规则,变成一处显式的、可测的机制。

下一节 11.2 真实 API 冒烟:假模型 vs 真模型量化差距 会把本节这套「用假模型跑得全绿」的演示,拿去和真模型对照,量一量假模型到底漏掉了什么;想回看生产化的另一半,见 10.3 安全纵深:注入/越权/供应链审计 。

阅读导航:上一节:10.3 安全纵深:注入/越权/供应链审计 · 下一节:11.2 真实 API 冒烟:假模型 vs 真模型量化差距 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距