1 changed files with 422 additions and 0 deletions
@ -0,0 +1,422 @@ |
|||||
|
# 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 表 |
||||
|
|
||||
|
```typescript |
||||
|
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 表 |
||||
|
|
||||
|
```typescript |
||||
|
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 结构 |
||||
|
|
||||
|
```typescript |
||||
|
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 |
||||
|
|
||||
|
### 核心接口 |
||||
|
|
||||
|
```typescript |
||||
|
// 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; |
||||
|
}; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 注册机制 |
||||
|
|
||||
|
```typescript |
||||
|
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 |
||||
|
|
||||
|
```typescript |
||||
|
// 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 防护 |
||||
|
|
||||
|
```typescript |
||||
|
// 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 |
||||
|
- `json`:`JSON.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 接口扩展 |
||||
|
|
||||
|
```typescript |
||||
|
// 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 定义生成 |
||||
|
|
||||
|
```typescript |
||||
|
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 |
||||
|
|
||||
|
```typescript |
||||
|
// 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 |
||||
|
|
||||
|
```typescript |
||||
|
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.1`、`10.x`、`192.168.x`、`169.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 次防死循环 |
||||
|
- 前端只看到最终流式回复,中间工具调用过程仅显示状态提示 |
||||
Loading…
Reference in new issue