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

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

相关文章
人工智能 缓存 前端开发
6186 19
人工智能 JavaScript 开发工具
3084 4
缓存 JavaScript Shell
1462 1
|
12天前
|
存储 弹性计算 缓存
阿里云服务器租赁费用:新版租赁收费标准及活动报价参考
本文更新了2026年阿里云全系列云服务器租赁活动报价,所有特惠资源均可前往阿里云活动中心选购,整体覆盖从个人入门到企业级高性能场景的全梯度需求。其中轻量应用服务器主打极致性价比,2核2G峰值200M带宽配置每日10点、15点限时抢购价仅38元/年,2核4G配置379元/年起;高性价比的经济型e实例、通用算力型u2i实例覆盖2核4G至4核32G全档位,适配开发测试与中小型企业业务;搭载英特尔至强6处理器的第九代c9i企业级实例算力较上代提升20%,支撑高并发生产环境,不同实例规格价差清晰,用户可根据自身业务负载与预算灵活选型。
2078 121
阿里云服务器租赁费用:新版租赁收费标准及活动报价参考
|
13天前
|
人工智能 程序员 API
Codex 接入 DeepSeek-V4-Flash:还能补上识图,提供两套方案
Codex 接入 DeepSeek-V4-Flash 怎么配?本文覆盖 CLI 与桌面端,再用 qwen3-vl-flash 补识图,两套方案可直接照做
1675 13
缓存 人工智能 算法
655 1
|
10天前
|
编解码 弹性计算 云计算
MiniMax-H3 视频生成模型 — 一键部署与使用指南
MiniMax-H3是MiniMax开源的33B全模态视频生成模型,支持文生视频、图生视频、参考生视频三种模式,原生输出2K/15秒带立体声音频视频,已原生适配ComfyUI,并可通过阿里云计算巢一键部署。(239字)
|
11天前
|
人工智能 API 开发工具
2026 零基础本地 AI 漫剧完整实操教程(8G 笔记本显卡可用|附可直接复制命令与代码)
本方案提供完全离线、本地运行的漫剧全自动制作流程:RTX3060/4050 8G显卡即可驱动,涵盖Qwen写分镜→ComfyUI统一角色绘图→LTX2.3图生微动画→Qwen3-TTS本地配音→FFmpeg自动合成,全程无水印、免API、不限次。专为低显存优化,解决变脸、闪烁、爆内存三大痛点。(239字)
|
19天前
|
云安全 人工智能 运维
阿里云联动百位企业安全专家,共识Agent防御最佳实践
当Agent成为新员工,你的安全边界在哪里?
1984 10
阿里云联动百位企业安全专家,共识Agent防御最佳实践

热门文章

最新文章