把 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月前
|
人工智能 Kubernetes Cloud Native
从百炼到 FDE:六大门派里,工程师该选哪一段?
在百炼、Qwen 这类大模型平台上,企业已能很快拿到一个模型。但「拿到模型」和「用进业务」之间,还差一支能把 AI 部署落地的人——FDE(前沿部署工程师)。这篇按身份把约 95 家玩家切成 6 类,每类配技能栈地图,帮你在通义/百炼生态里找到切入角色。—— 模型厂(①,含通义/百炼背后能力)造模型,云厂(②)供算力,AI 原生(③)做产品,咨询(④)给方案,外包(⑤)做交付;第六类做新媒体热度,与前五类并列。
255 3
|
2月前
|
人工智能 数据可视化 开发工具
Qoder CN的RepoWiki是什么?能自动生成项目文档吗?
RepoWiki 是阿里云 Qoder CN 的智能项目知识生成工具,可自动分析 Git 代码库,生成带溯源(含行号)的结构化文档;支持交互式规划、进度可视化、双向同步及多格式导出。按任务复杂度消耗 Credits,个人版首注赠300点。阿里云Qoder CN官网:https://t.aliyun.com/U/hGZKNX
|
5天前
|
搜索推荐 算法 前端开发
SpringBoot+Vue 管理系统毕业设计|软件工程毕设开发实战
本系统基于SpringBoot+Vue开发,实现自习室预约管理,支持座位预约、签到、违规监管与数据看板。创新点在于引入协同过滤算法,根据用户历史行为智能推荐座位(如常去楼层、时段、座位类型),提升资源利用率。含完整数据库设计、核心代码及测试方案,适合作为计算机/软件工程专业本科毕设。(239字)
|
2月前
|
人工智能 Java Apache
Apache Jena 详解:Java 开发者如何真正搭起一套知识图谱与语义服务
Apache Jena 是 Java 生态中成熟的语义网开源框架,提供 RDF 存储、SPARQL 查询、TDB2 图数据库、Fuseki 服务端、本体建模、逻辑推理与 SHACL 验证等全栈能力,是构建企业级知识图谱与 AI 语义后端的理想基础设施。(239字)
435 1
Apache Jena 详解:Java 开发者如何真正搭起一套知识图谱与语义服务
|
2月前
|
JSON fastjson Java
阿里巴巴为什么不建议 boolean 类型变量用 isXXX
为什么不推荐使用 isXXX 来命名呢?到底是用基本类型的数据好呢还是用包装类好呢?
111 3
阿里巴巴为什么不建议 boolean 类型变量用 isXXX
|
1月前
|
前端开发 数据库
多端看课进度怎么同步才不打架:断点续播、进度合并与冲突处理
用户在手机、电脑、平板间切换看课时,学习进度常出现断点不准、进度回退、多端互相覆盖等问题。本文把进度同步拆成上报、合并、冲突三件事:用时间加事件双触发节流上报并在离开页面前补报,用取最远位置的归并规则替代按到达时间覆盖,再单独处理拖动复习、跳章和离线补传三类冲突,最后给出工程落地与上线自检清单。
|
2月前
|
存储 弹性计算 运维
混合云资产管控痛点多?首码科技联动阿里云,实现云上线下资产一体化管理
首码科技资产管理系统深度融合阿里云生态,打通线下机房(RFID/U位可视化)与云上资源(ECS/OSS等)管理,实现全生命周期统一纳管、自动盘点、分权分域与合规审计,助力企业降本增效。(239字)
|
2月前
|
缓存 前端开发 测试技术
通义千问Qwen3.7 Plus与Max实测对比:多模态能力、推理表现与性价比深度解析
在大模型应用落地的过程中,很多开发者会陷入选型困境,同样属于Qwen3.7系列的两款主力基座Qwen3.7‑Max与Qwen3.7‑Plus,都具备百万级超长上下文窗口,支持长周期Agent智能体运行,但二者在模态支持、推理侧重、计费成本、实际业务表现上存在明显分化。不少开发者只看到参数规格相近,直接盲目选用高价Max,造成业务调用成本成倍上涨;也有部分业务场景对纯文本硬核推理要求极高,选用Plus之后遇到复杂逻辑任务出现能力瓶颈。本文将从底层架构、多模态能力、基准实测数据、代码Agent表现、计费性价比、真实业务场景、API实操调用、选型避坑多个维度,完整拆解两款模型的差异,帮助个人开发者、
437 1
|
2月前
|
运维 安全 网络安全
UNC6671 团伙语音钓鱼攻击私募机构的攻击机理、风险传导与防御体系研究
本文聚焦UNC6671团伙针对私募股权行业的语音钓鱼(Vishing)勒索攻击,剖析其伪造号码、社工话术、云身份劫持与数据窃取全链路。指出传统技术防护对语音社工失效,提出融合身份认证加固、通信管控、制度优化、人员实训与应急处置的五层闭环防御体系。(239字)
110 2
|
4月前
|
存储 安全 Java
AgentScope Java 2.0:打造分布式、企业级智能体底座
AgentScope 2.0 面向分布式部署、稳定运行、权限安全等企业级需求全面升级,打造支持多租户隔离与长期稳定运行的企业级智能体底座。
1888 40

热门文章

最新文章