很多 Agent 项目从一个循环开始:把提示词发给模型,模型返回工具名和参数,程序执行工具,再把结果交还模型。这个原型可以快速验证想法,但随着工具增加,几个问题会同时出现:
- 每新增一个工具,都要修改调度器中的条件分支;
- 模型生成的参数未经验证便进入文件系统、数据库或命令行;
- 工具能否执行取决于代码路径,而不是明确的权限策略;
- 更换模型服务时,业务代码被请求格式、流式协议和错误码绑住;
- 执行失败后只有零散日志,难以区分模型决策错误、参数错误和工具故障。
插件化的价值不只是“方便扩展”,而是把 Agent 的能力变成可枚举、可校验、可授权、可审计的对象。模型只能提出调用建议,真正的执行权仍属于宿主程序。
本文实现一个 TypeScript 最小框架,重点放在四条边界:插件清单描述能力,注册中心管理生命周期,策略层决定是否放行,模型适配层隔离外部 API。示例不绑定特定模型厂商,也不假设任意接口天然兼容某种工具调用协议。
一、先定义执行链路
一次受控工具调用应经过以下步骤:
- 宿主从注册中心导出允许公开的工具清单;
- 模型返回结构化的工具调用意图,而不是直接执行代码;
- 宿主解析响应,并验证工具名和参数;
- 策略层结合用户、会话和资源范围作出允许或拒绝决定;
- 插件在超时、取消信号和最小权限上下文中运行;
- 宿主记录调用结果,再决定是否把结果交还模型继续推理。
这里必须避免一个常见误区: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 插件系统应把模型视为建议生成器,而不是权限主体。插件清单解决能力发现,运行时校验守住输入边界,策略层控制谁能操作什么资源,调度器统一超时和审计,模型适配层则隔离外部协议变化。
落地时应先用少量只读插件打通确定性执行链路,再增加写操作、外部访问和动态加载。每扩大一种能力,都同步补上资源范围、取消机制、审计字段和失败测试。这样得到的系统不仅能够增加工具,也能解释每次调用为什么发生、由谁授权、访问了什么,以及失败后如何定位。