把 AI Agent 做成可治理的插件系统:能力清单、策略校验与模型适配层实践

简介: 本文提出一个TypeScript轻量框架,将Agent工具调用解耦为四层边界:插件清单(能力声明)、注册中心(生命周期)、策略层(权限控制)、模型适配层(协议隔离)。强调模型仅提供建议,执行权归属宿主,确保可枚举、可校验、可授权、可审计,兼顾安全与可维护性。(239字)

很多 Agent 项目从一个循环开始:把提示词发给模型,模型返回工具名和参数,程序执行工具,再把结果交还模型。这个原型可以快速验证想法,但随着工具增加,几个问题会同时出现:

  • 每新增一个工具,都要修改调度器中的条件分支;
  • 模型生成的参数未经验证便进入文件系统、数据库或命令行;
  • 工具能否执行取决于代码路径,而不是明确的权限策略;
  • 更换模型服务时,业务代码被请求格式、流式协议和错误码绑住;
  • 执行失败后只有零散日志,难以区分模型决策错误、参数错误和工具故障。

插件化的价值不只是“方便扩展”,而是把 Agent 的能力变成可枚举、可校验、可授权、可审计的对象。模型只能提出调用建议,真正的执行权仍属于宿主程序。

本文实现一个 TypeScript 最小框架,重点放在四条边界:插件清单描述能力,注册中心管理生命周期,策略层决定是否放行,模型适配层隔离外部 API。示例不绑定特定模型厂商,也不假设任意接口天然兼容某种工具调用协议。

一、先定义执行链路

一次受控工具调用应经过以下步骤:

  1. 宿主从注册中心导出允许公开的工具清单;
  2. 模型返回结构化的工具调用意图,而不是直接执行代码;
  3. 宿主解析响应,并验证工具名和参数;
  4. 策略层结合用户、会话和资源范围作出允许或拒绝决定;
  5. 插件在超时、取消信号和最小权限上下文中运行;
  6. 宿主记录调用结果,再决定是否把结果交还模型继续推理。

这里必须避免一个常见误区:JSON Schema 只能证明参数“形状基本正确”,不能证明操作“应该被允许”。例如 path 是字符串,并不代表插件可以读取任意路径。因此,参数校验和权限校验需要分层处理。

二、定义稳定的插件契约

先建立与模型供应方无关的内部类型:

// src/types.ts
export type JsonSchema = {
   
  type: "object";
  properties: Record<string, unknown>;
  required?: string[];
  additionalProperties?: boolean;
};

export interface ToolContext {
   
  requestId: string;
  actorId: string;
  signal: AbortSignal;
}

export interface ToolPlugin<TArgs = unknown, TResult = unknown> {
   
  manifest: {
   
    name: string;
    description: string;
    inputSchema: JsonSchema;
    risk: "read" | "write" | "external";
  };
  validate(args: unknown): TArgs;
  execute(args: TArgs, context: ToolContext): Promise<TResult>;
}

export interface ToolCall {
   
  id: string;
  name: string;
  arguments: unknown;
}

manifest 用于发现能力,validate 负责运行时校验,execute 只实现业务动作。risk 不能替代完整授权,但可以作为默认拒绝、人工确认或审计分级的输入。

生产项目可使用 Ajv、Zod 等成熟库完成 Schema 校验。下面采用显式检查,只为让示例可以独立阅读,不意味着手写校验适合复杂对象。

三、实现一个受限文件读取插件

文件工具尤其适合展示资源边界。仅检查 ../ 并不充分,因为绝对路径、符号链接和编码差异都可能绕过简单字符串判断。应先解析真实路径,再确认它仍位于允许目录内。

// src/plugins/readText.ts
import {
    readFile, realpath } from "node:fs/promises";
import {
    relative, resolve, sep } from "node:path";
import type {
    ToolPlugin } from "../types.js";

const workspace = resolve(process.env.AGENT_WORKSPACE ?? "./workspace");

type Args = {
    path: string };

export const readText: ToolPlugin<Args, {
    content: string }> = {
   
  manifest: {
   
    name: "read_text",
    description: "读取工作区内的 UTF-8 文本文件",
    risk: "read",
    inputSchema: {
   
      type: "object",
      properties: {
    path: {
    type: "string", minLength: 1 } },
      required: ["path"],
      additionalProperties: false
    }
  },

  validate(value): Args {
   
    if (!value || typeof value !== "object") throw new Error("参数必须是对象");
    const path = (value as Record<string, unknown>).path;
    if (typeof path !== "string" || path.length === 0) {
   
      throw new Error("path 必须是非空字符串");
    }
    return {
    path };
  },

  async execute({
    path }, context) {
   
    context.signal.throwIfAborted();
    const root = await realpath(workspace);
    const target = await realpath(resolve(root, path));
    const rel = relative(root, target);

    if (rel === ".." || rel.startsWith(`..${
     sep}`) || resolve(rel) === target) {
   
      throw new Error("目标路径不在允许的工作区内");
    }

    const content = await readFile(target, "utf8");
    return {
    content };
  }
};

对于尚不存在的写入目标,不能直接调用 realpath(target),需要校验其最近的已存在父目录,并配合拒绝符号链接、原子写入和文件名规则。这也是读插件和写插件不应共用一段路径检查代码的原因。

四、注册、授权和执行

注册中心应拒绝重名插件,调度器则负责把校验、策略、超时和审计串起来:

// src/runtime.ts
import type {
    ToolCall, ToolContext, ToolPlugin } from "./types.js";

const plugins = new Map<string, ToolPlugin>();

export function register(plugin: ToolPlugin): void {
   
  const name = plugin.manifest.name;
  if (!/^[a-z][a-z0-9_]{1,63}$/.test(name)) throw new Error(`非法工具名: ${
     name}`);
  if (plugins.has(name)) throw new Error(`工具重复注册: ${
     name}`);
  plugins.set(name, plugin);
}

function authorize(plugin: ToolPlugin, actorId: string): void {
   
  const allowExternal = process.env.ALLOW_EXTERNAL_TOOLS === "true";
  if (plugin.manifest.risk === "external" && !allowExternal) {
   
    throw new Error(`用户 ${
     actorId} 无权调用外部工具`);
  }
}

export async function dispatch(call: ToolCall, actorId: string) {
   
  const plugin = plugins.get(call.name);
  if (!plugin) throw new Error(`未知工具: ${
     call.name}`);

  authorize(plugin, actorId);
  const args = plugin.validate(call.arguments);
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 8_000);
  const context: ToolContext = {
   
    requestId: call.id,
    actorId,
    signal: controller.signal
  };

  const startedAt = Date.now();
  try {
   
    const result = await plugin.execute(args, context);
    console.info(JSON.stringify({
   
      event: "tool_finished",
      requestId: call.id,
      tool: call.name,
      actorId,
      durationMs: Date.now() - startedAt,
      ok: true
    }));
    return result;
  } catch (error) {
   
    console.error(JSON.stringify({
   
      event: "tool_finished",
      requestId: call.id,
      tool: call.name,
      actorId,
      durationMs: Date.now() - startedAt,
      ok: false,
      error: error instanceof Error ? error.message : "unknown"
    }));
    throw error;
  } finally {
   
    clearTimeout(timer);
  }
}

示例中的环境变量开关只是最小策略。多用户系统应从可信身份上下文读取角色、租户和资源范围,不能接受模型生成的 actorId。对于转账、删除、发布等高影响操作,还应增加一次性确认令牌或人工审批。

此外,AbortSignal 只有在插件内部主动传递给网络客户端、数据库驱动或子进程时才能真正中止工作;超时后仅拒绝等待,并不会自动终止所有底层任务。

五、用模型适配层隔离接口差异

不同模型 API 对工具声明、流式事件、参数字段和错误响应的定义可能不同。业务调度器不应直接依赖外部响应,而应由适配器统一转换成内部的 ToolCall

// src/modelAdapter.ts
import type {
    ToolCall } from "./types.js";

const baseUrl = process.env.MODEL_BASE_URL;
const apiKey = process.env.MODEL_API_KEY;
const model = process.env.MODEL_NAME;

export async function requestToolCall(
  prompt: string,
  tools: unknown[]
): Promise<ToolCall | null> {
   
  if (!baseUrl || !apiKey || !model) throw new Error("模型 API 环境变量不完整");

  const response = await fetch(`${
     baseUrl}/chat/completions`, {
   
    method: "POST",
    headers: {
   
      "content-type": "application/json",
      authorization: `Bearer ${
     apiKey}`
    },
    body: JSON.stringify({
    model, messages: [{
    role: "user", content: prompt }], tools })
  });

  if (!response.ok) {
   
    const text = await response.text();
    throw new Error(`模型请求失败: HTTP ${
     response.status}, ${
     text.slice(0, 300)}`);
  }

  const payload: unknown = await response.json();
  return parseProviderResponse(payload);
}

function parseProviderResponse(payload: unknown): ToolCall | null {
   
  // 此处按实际供应方文档解析,并对名称、ID、arguments 做严格校验。
  // 不要对未知响应直接使用类型断言后进入 dispatch。
  void payload;
  return null;
}

MODEL_BASE_URL 可以指向自建网关或经过评估的中转接口。评估 HaerAPI(https://www.haerapi.com)之类的模型接入服务时,应先核对当前接口文档是否支持所需协议,再验证数据保留、日志、地域、模型标识、限流和错误语义;不能仅因 URL 或字段相似就认定完全兼容。

密钥只从环境变量读取。本地可以使用未纳入版本控制的 .env,部署环境则应使用云密钥管理服务、容器 Secret 或 CI/CD 的受保护变量:

export MODEL_BASE_URL="https://example.invalid/v1"
export MODEL_API_KEY="由部署环境注入"
export MODEL_NAME="按供应方文档填写"
export AGENT_WORKSPACE="/srv/agent/workspace"
export ALLOW_EXTERNAL_TOOLS="false"

example.invalid 是保留的无效示例域名,运行前必须替换。不要把密钥记录进异常信息、审计日志或模型上下文。

六、组装最小运行入口

// src/index.ts
import {
    readText } from "./plugins/readText.js";
import {
    dispatch, register } from "./runtime.js";

register(readText);

const result = await dispatch({
   
  id: crypto.randomUUID(),
  name: "read_text",
  arguments: {
    path: "notes/today.txt" }
}, "user-42");

console.log(result);

在接入真实模型前,先用固定 ToolCall 测试调度器更可靠。至少应覆盖:未知工具、重复注册、缺失参数、目录越界、符号链接越界、超时、未授权外部调用、插件异常以及日志脱敏。模型端到端测试应另行进行,因为它验证的是提示词和模型行为,不能替代宿主的确定性安全测试。

常见问题

1. 有了工具 Schema,为什么还要 validate

发给模型的 Schema 是能力提示,不是可信输入边界。模型可能返回缺失字段、错误类型或非 JSON 内容;攻击者也可能绕过模型直接请求调度接口。宿主必须再次验证。

2. 插件超时是否等于进程已停止?

不一定。普通 Promise 无法被宿主强制取消。网络请求要接收 signal,子进程需要显式终止,CPU 密集任务则应放入 Worker 或隔离进程,并设置资源上限。

3. 能否让插件直接读取完整对话?

技术上可以,但会扩大数据暴露范围。更稳妥的做法是由宿主提取插件必需字段,只传最小上下文。审计日志也应记录元数据和结果摘要,避免默认保存完整提示词、文件内容或凭据。

4. 动态安装第三方插件有什么风险?

插件代码与宿主同进程运行时,通常拥有相近的文件、网络和环境变量访问能力。来源不可信的插件应经过签名校验、依赖审查,并放入容器或独立进程;仅靠 TypeScript 接口无法形成安全隔离。

5. 更换模型端点真的不需要改业务代码吗?

只有外部协议能被适配器准确转换为内部契约时才成立。工具调用格式、流式增量合并、错误码、重试条件和 token 统计都可能不同,因此切换前应运行契约测试,不能把“可配置 URL”理解成无条件兼容。

总结

可维护的 Agent 插件系统应把模型视为建议生成器,而不是权限主体。插件清单解决能力发现,运行时校验守住输入边界,策略层控制谁能操作什么资源,调度器统一超时和审计,模型适配层则隔离外部协议变化。

落地时应先用少量只读插件打通确定性执行链路,再增加写操作、外部访问和动态加载。每扩大一种能力,都同步补上资源范围、取消机制、审计字段和失败测试。这样得到的系统不仅能够增加工具,也能解释每次调用为什么发生、由谁授权、访问了什么,以及失败后如何定位。

相关文章
|
2月前
|
人工智能 自然语言处理 算法
2026年企业建设智能客服系统要多少钱?费用、选型、落地
2026年全球智能客服市场爆发,AI准确率达93%,但传统“按坐席付费”模式失效。本文拆解“基础软件+AI算力+集成实施”三层成本结构,结合瓴羊Quick Service等标杆案例与星巴克、申通实战数据,揭示数十万至百万级投入的ROI逻辑,助企业避开隐性成本陷阱,精准锚定投入产出比。(239字)
|
2月前
|
SQL 人工智能 关系型数据库
AI Agent 混合检索选型:阿里云 AnalyticDB MySQL 向量+全文一站式方案
阿里云AnalyticDB MySQL版是面向AI Agent/RAG场景的一站式混合检索数据库,原生支持向量检索+全文搜索+结构化查询,单SQL实现三合一。延迟<10ms,成本降60%+,开发提效3倍,显著优于Milvus+Elasticsearch多组件架构。
382 6
存储 弹性计算 运维
40 1
运维 安全 网络安全
44 2
人工智能 Kubernetes Cloud Native
60 3
|
1月前
|
传感器 边缘计算 文字识别
车位与车牌目标检测数据集:4类别 | 目标检测
本数据集含5000张真实停车场图像,标注4类目标(空位、已占用、违规停车、车牌),支持YOLO等主流模型训练,适用于智慧停车、违停检测等场景,助力无人值守停车场落地。(239字)
199 6
车位与车牌目标检测数据集:4类别 | 目标检测
|
23天前
|
数据采集 人工智能 安全
自主多智能体邮件安全对抗 AI 生成钓鱼攻击的架构与防御体系研究
生成式AI使钓鱼攻击门槛骤降,传统邮件网关超50%场景被绕过。AegisAI推出Vanguard多智能体防御平台,首创全交互网页仿真、语义检测与伪CAPTCHA识别技术,误报率降90%,零日检出率近100%,提供可落地的Python代码与五层闭环防御体系。(239字)
111 1
|
1月前
|
人工智能 缓存 JavaScript
Reasonix的使用方法
Reasonix 是一款专为 DeepSeek 模型设计的开源终端 AI 编程助手,支持在终端和桌面客户端中使用
|
1月前
|
边缘计算 5G 定位技术
专访|GEO落地工程师罗长才:解析GEO与低时延通信、算网基础设施的协同赋能逻辑
本专访聚焦地理空间优化(GEO)技术的工程落地,深度解析其如何作为算网空间一体化的底层调度中枢,赋能低时延网络、5G/5G-A、6G、边缘计算与算力网络。资深工程师罗长才系统拆解GEO在空间测距、节点调度、流量闭环、时空协同等核心环节的技术机理与现实挑战,强调“位置驱动决策”替代传统“位置标注”,为元宇宙等沉浸式业务提供可落地的空间智能底座。(239字)
203 4
|
1月前
|
人工智能 JSON 数据可视化
4A企业架构+TOGAF如何指导Agent Skill设计
引言:AI Skill设计的"巴别塔"困局 当下的AI Agent生态,正陷入一种似曾相识的混乱。 去年帮一家保险公司梳理Agent技能库,发现100多个Skill横七竖八地堆在一起——有的直接调API,有的内嵌业务逻辑,有的把数据获取和分析揉成一团。问架构师这些Skill怎么分类,回答是"按安装顺序排的"。再问两个Skill之间数据怎么流转,回答是"各写各的"。一个股票监控Skill自己爬数据、自己做分析、自己发消息,三件事耦合在同一个脚本里。换一个场景想复用其中的分析逻辑?做不到,只能重写。 这不是个

热门文章

最新文章