You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

16 KiB

Agent 工具调用框架设计(fetch 为首个工具)

概述

构建一个通用工具调用框架,作为 agent 系统的前置基础设施。fetch 工具是第一个内置工具,后续可扩展 search、code-exec 等。框架支持 LLM tool-calling 自主调用,也支持独立执行 API 用于调试。

架构

┌─────────────────────────────────────────────────┐
│  前端                                            │
│  ┌──────────────┐    ┌───────────────────────┐  │
│  │ 工具管理页面  │    │  Chat 页面(现有)     │  │
│  │ admin/       │    │  + 工具调用状态展示    │  │
│  │ agent-tools  │    │                       │  │
│  └──────┬───────┘    └───────────┬───────────┘  │
└─────────┼────────────────────────┼──────────────┘
          │                        │
┌─────────┼────────────────────────┼──────────────┐
│  服务端 │                        │              │
│  ┌──────▼────────┐    ┌──────────▼───────────┐  │
│  │ tools API     │    │ llm/chat API(扩展)  │  │
│  │ CRUD + execute│    │ tool-calling loop    │  │
│  └──────┬────────┘    └──────────┬───────────┘  │
│         │                        │              │
│  ┌──────▼────────────────────────▼───────────┐  │
│  │      Tool Registry(工具注册中心)         │  │
│  │  ┌─────────┐  ┌─────────┐  ┌─────────┐   │  │
│  │  │ fetch   │  │ (future)│  │ (future)│   │  │
│  │  │ executor│  │ executor│  │ executor│   │  │
│  │  └─────────┘  └─────────┘  └─────────┘   │  │
│  └───────────────────────────────────────────┘  │
│         │                                        │
│  ┌──────▼────────┐    ┌──────────────────────┐  │
│  │ agent_tools   │    │ agent_tool_logs      │  │
│  │ (DB)          │    │ (DB)                 │  │
│  └───────────────┘    └──────────────────────┘  │
└──────────────────────────────────────────────────┘

核心分层:

  • Tool Registry:type → executor 的映射,硬编码执行逻辑 + 根据 config 收窄 inputSchema
  • agent_tools 表:存工具实例(全局共享),含 type/config/enabled
  • agent_tool_logs 表:执行日志
  • tools API:CRUD + POST /tools/:id/execute(独立执行,调试用)
  • llm/chat 扩展:注入 enabled 工具到 ai-sdk streamText,自动 tool loop

数据库 Schema

agent_tools 表

export const agentTools = sqliteTable("agent_tools", {
  id: text("id").primaryKey(),                    // UUID
  name: text("name", { length: 50 }).notNull(),   // 展示名(给 admin 看)
  slug: text("slug", { length: 50 }).notNull().unique(),  // 唯一标识
  description: text("description").notNull(),     // 给 LLM 看的工具描述
  type: text("type", { length: 30 }).notNull(),   // "fetch" | 未来扩展
  config: text("config").notNull(),               // JSON string,按 type 解析
  enabled: integer("enabled").default(1).notNull(),  // 0/1
  sortOrder: integer("sort_order").default(0).notNull(),
  createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
  updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().$onUpdate(() => new Date()).notNull(),
});

agent_tool_logs 表

export const agentToolLogs = sqliteTable("agent_tool_logs", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  toolId: text("tool_id").notNull(),
  toolSlug: text("tool_slug", { length: 50 }).notNull(),  // 冗余存 slug,防 tool 删除后日志丢失上下文
  userId: integer("user_id"),                              // null = 系统调用
  input: text("input").notNull(),                          // JSON string
  output: text("output"),                                  // JSON string(可能被截断)
  status: text("status", { length: 20 }).notNull(),        // "success" | "error" | "timeout"
  errorMessage: text("error_message"),
  durationMs: integer("duration_ms").notNull(),
  createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
});

fetch 工具的 config 结构

interface FetchToolConfig {
  defaultMethod: "GET" | "POST";       // 默认 GET
  defaultHeaders: Record<string, string>;
  timeout: number;                      // ms,默认 10000
  maxResponseSize: number;              // bytes,默认 102400
  allowedDomains: string[];             // ["*"] | ["example.com", "*.api.com"]
  blockedDomains: string[];
  parseMode: "raw" | "markdown" | "json";  // 默认 markdown
}

Tool Registry

核心接口

// server/service/agent-tool/registry.ts

interface ToolExecutor<TConfig> {
  /** 根据 config 生成给 LLM 的 inputSchema(JSON Schema) */
  buildInputSchema(config: TConfig): JSONSchema7;
  /** 根据 config 生成给 LLM 的 description */
  buildDescription(config: TConfig): string;
  /** 执行工具 */
  execute(input: unknown, config: TConfig, ctx: ToolContext): Promise<ToolResult>;
}

interface ToolContext {
  toolId: string;
  toolSlug: string;
  userId: number | null;
}

interface ToolResult {
  success: boolean;
  data: unknown;          // 成功时的返回内容
  error?: string;         // 失败时的错误信息
  metadata?: {            // 执行元数据
    statusCode?: number;
    responseSize?: number;
    durationMs: number;
  };
}

注册机制

const registry = new Map<string, ToolExecutor<any>>();

export function registerToolType(type: string, executor: ToolExecutor<any>) {
  registry.set(type, executor);
}

export function getExecutor(type: string): ToolExecutor<any> {
  const executor = registry.get(type);
  if (!executor) throw new Error(`Unknown tool type: ${type}`);
  return executor;
}

fetch executor

// server/service/agent-tool/executors/fetch.ts

export const fetchExecutor: ToolExecutor<FetchToolConfig> = {
  buildInputSchema(config) {
    return {
      type: "object",
      properties: {
        url: { type: "string", description: "要抓取的 URL(http/https)" },
        method: { type: "string", enum: ["GET", "POST"], default: config.defaultMethod },
        headers: { type: "object", description: "请求头" },
        body: { type: "string", description: "POST 请求体" },
      },
      required: ["url"],
    };
  },

  buildDescription(config) {
    const domains = config.allowedDomains.includes("*")
      ? "任意域名"
      : `仅限: ${config.allowedDomains.join(", ")}`;
    return `抓取网页或 API 内容。支持 ${config.parseMode} 模式。域名限制: ${domains}。超时 ${config.timeout}ms。`;
  },

  async execute(input, config, ctx) {
    // 1. zod 校验 input
    // 2. SSRF 检查(解析 IP,拒绝内网)
    // 3. 域名白/黑名单检查
    // 4. 发起 fetch(timeout via AbortController)
    // 5. 大小限制检查
    // 6. 按 parseMode 处理响应
    // 7. 写日志
    // 8. 返回 ToolResult
  },
};

SSRF 防护

// server/service/agent-tool/executors/fetch/security.ts

async function assertSafeUrl(url: string): Promise<void> {
  const parsed = new URL(url);
  if (!["http:", "https:"].includes(parsed.protocol)) {
    throw new Error("仅支持 http/https 协议");
  }
  // 解析主机名 → IP
  const addresses = await dns.lookup(parsed.hostname, { all: true });
  for (const addr of addresses) {
    if (isPrivateIp(addr.address)) {
      throw new Error(`禁止访问内网地址: ${addr.address}`);
    }
  }
}

function isPrivateIp(ip: string): boolean {
  // 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16,
  // 169.254.0.0/16, ::1, fc00::/7, fe80::/10
}

parseMode 处理

  • raw:直接返回原始响应体文本
  • markdown:用 marked(项目已有依赖)将 HTML 转为 markdown
  • jsonJSON.parse 后返回结构化对象;解析失败则降级为 raw + 错误提示

API 设计

工具管理 CRUD

方法 路径 说明
GET /api/agent-tools 列出所有工具(按 sortOrder 排序)
POST /api/agent-tools 创建工具
GET /api/agent-tools/:id 获取工具详情
PUT /api/agent-tools/:id 更新工具
DELETE /api/agent-tools/:id 删除工具
POST /api/agent-tools/:id/execute 独立执行工具(调试用)

独立执行接口

POST /api/agent-tools/:id/execute
Body: { input: { url: "https://...", method: "GET" } }
Response: { success: true, data: "...", metadata: { statusCode, responseSize, durationMs } }
  • 需要 admin 权限(复用现有 requireUser + admin 校验)
  • 执行前从 DB 加载 tool 配置,交给 registry 执行
  • 自动写 agent_tool_logs

chat 接口扩展

// server/api/llm/chat/index.post.ts
// 现有 body 新增可选字段
const { modelId, messages, enableThinking, enableTools } = body as {
  // ...
  enableTools?: boolean;  // 默认 false,显式开启
};

// 当 enableTools = true 时:
// 1. 查询所有 enabled = 1 的 agent_tools
// 2. 对每个 tool,用 registry.buildInputSchema + buildDescription 生成 ai-sdk tool 定义
// 3. 调用 streamText({ model, messages, tools, maxSteps: 5 })
//    - ai-sdk 自动处理 tool-calling loop(LLM 决定调用 → 执行 → 结果回填 → 继续)
//    - maxSteps = 5 防死循环
// 4. 工具执行时自动写日志

ai-sdk tool 定义生成

import { tool } from "ai";
import { z } from "zod";

function buildAiTool(agentTool: AgentToolRow) {
  const executor = getExecutor(agentTool.type);
  const config = JSON.parse(agentTool.config);
  const inputSchema = executor.buildInputSchema(config);

  // JSON Schema → zod schema(用于 ai-sdk 校验)
  // 使用 zod 的 z.object 手动构建,或引入 json-schema-to-zod 库
  const zodSchema = jsonSchemaToZod(inputSchema);

  return tool({
    description: executor.buildDescription(config),
    parameters: zodSchema,
    execute: async (input) => {
      const result = await executor.execute(input, config, ctx);
      return result.success ? result.data : { error: result.error };
    },
  });
}

关键决策

  • enableTools 默认 false,现有 chat 行为不变,向后兼容
  • maxSteps: 5 硬编码,后续可配置化
  • 工具执行失败不中断对话,错误信息作为 tool result 喂回 LLM,让 LLM 自行处理

Service 分层

server/service/agent-tool/
├── index.ts              # 对外导出(listTools, getTool, createTool, executeTool 等)
├── registry.ts           # ToolExecutor 接口 + 注册机制
├── log.ts                # 执行日志写入
└── executors/
    ├── fetch.ts          # fetch 执行器
    └── fetch/
        ├── security.ts   # SSRF 防护 + 域名检查
        ├── parse.ts      # parseMode 处理(raw/markdown/json)
        └── config.ts     # FetchToolConfig 类型 + 默认值 + zod 校验

index.ts 对外 API

// CRUD(与现有 service/tool 模式一致)
export async function listAgentTools(): Promise<AgentToolRow[]>
export async function getAgentToolById(id: string): Promise<AgentToolRow | null>
export async function createAgentTool(input: CreateAgentToolInput): Promise<AgentToolRow>
export async function updateAgentTool(id: string, input: UpdateAgentToolInput): Promise<AgentToolRow | null>
export async function deleteAgentTool(id: string): Promise<void>

// 执行
export async function executeAgentTool(
  id: string,
  input: unknown,
  userId: number | null
): Promise<ToolResult>

// 给 chat 集成用
export async function getEnabledToolsForLlm(): Promise<Record<string, AiTool>>

log.ts

export async function writeToolLog(entry: {
  toolId: string;
  toolSlug: string;
  userId: number | null;
  input: unknown;
  result: ToolResult;
}): Promise<void>

与现有代码的关系

  • 现有 server/service/tool/tools完全不动,它们服务于 cards/articles 的分类标签
  • 新增 server/service/agent-tool/ 独立模块,职责清晰
  • server/api/agent-tools/ 新增 API 目录,与 server/api/tools/ 并行

前端

工具管理页面

app/pages/admin/agent-tools.vue

  • 表格列出所有工具:name、slug、type、enabled(开关)、sortOrder、操作(编辑/删除/执行测试)
  • 新建/编辑表单:name、slug、description、type(下拉,目前只有 fetch)、enabled、sortOrder、config(JSON 编辑器,按 type 显示对应 schema 提示)
  • 执行测试面板:选择工具 → 填入 input JSON → 点"执行" → 显示返回结果和耗时

chat 页面改动

  • 对话输入框旁加一个"工具"开关(默认关),开启后 enableTools: true 发给后端
  • 流式回复中,工具调用过程以轻量提示展示(如"正在调用 fetch..."),不展开详情

组件复用

  • 表格、表单、开关等基础组件优先从 bolt-ui 复用
  • JSON 编辑器:评估 bolt-ui 是否有,没有则用简单 <textarea> + JSON 校验,不引入重量级编辑器

路由

  • /admin/agent-tools — 工具列表 + 管理
  • 现有 /admin 布局下新增菜单项

错误处理

场景 处理方式
URL 协议非 http/https 返回 error,不发起请求
SSRF(内网 IP) 返回 error,记录日志
域名不在白名单 / 在黑名单 返回 error,不发起请求
请求超时 AbortController 中止,status="timeout",记录日志
响应超过 maxResponseSize 截断并标记,返回截断后的内容 + warning
DNS 解析失败 返回 error,记录日志
HTTP 状态码非 2xx 仍返回响应体,但在 metadata 中标记 statusCode,让 LLM 自行判断
JSON parse 失败(parseMode=json) 降级返回 raw 文本 + error 提示
工具 type 未知 registry 抛错,API 返回 500
tool-calling 超过 maxSteps ai-sdk 自动停止,LLM 收到"达到最大调用次数"提示

测试策略

单元测试(server/service/agent-tool/

  • fetch/security.ts:SSRF 检测(各种内网 IP 格式)、域名匹配(通配符)
  • fetch/parse.ts:三种 parseMode 转换
  • fetch/config.ts:config 默认值、zod 校验
  • registry.ts:注册/获取 executor

集成测试(API 层)

  • CRUD 接口
  • POST /agent-tools/:id/execute 端到端(mock 外部 HTTP)

chat tool-calling

  • 手动验证,暂不自动化

安全边界

  • 域名控制allowedDomains / blockedDomains,支持通配符 *.example.com
  • 内网防护:默认禁止访问 127.0.0.110.x192.168.x169.254.x 等私有/链路本地地址(SSRF 防护)
  • 超时与大小限制:可配置 timeout(默认 10s)、maxResponseSize(默认 100KB)
  • 执行日志:每次工具调用记录到 DB(toolId、userId、input、output 摘要、耗时、状态)
  • 请求频率限制:暂缓,后续按需加

工具启用粒度

  • 全局启用:agent_tools.enabled = true 的工具全部注入到 LLM 的 tool 列表,所有用户共享同一套工具配置
  • 工具是平台级基础设施,不是用户私有资源

chat 集成交互模式

  • 全自动 tool loop:LLM 决定调用工具 → 服务端自动执行 → 结果喂回 LLM → 继续生成,直到 LLM 给出最终回答
  • 最多循环 5 次防死循环
  • 前端只看到最终流式回复,中间工具调用过程仅显示状态提示