一个 Agent 平台做到后面,真正的护城河不是"接了多少模型",而是能力单元的组织方式:知识库、工具、技能如何被独立维护、被任意 Agent 组合装配、被精确地按空间授权。这篇文章分享我们的内部 Agent 平台在这件事上的三层设计:三类工具形态的统一抽象、技能(Skill)作为可复用的 prompt 片段、以及工具可见性的多对多空间绑定模型。

1. 能力三极:KB、Tool、Skill

我们把 Agent 的能力拆成三种正交的单元:

  知识库 (KB)          工具 (Tool)           技能 (Skill)
  ──────────          ──────────           ──────────
  参考什么?           能做什么?             怎么做?
  "搜索文档获取信息"    "调用 API 执行操作"    "这套方法论教你写邮件"
  语义检索 → 上下文     function_call → 执行   提示词注入 → 指导行为
维度 知识库 工具 技能
形态 文档/向量 可执行代码/API 提示词模板(Markdown)
作用 提供参考信息 执行外部操作 教会 Agent 做事方法
注入方式 检索结果拼入 context function_call 动态调用 直接拼入 system prompt
复用范围 空间内共享 按空间绑定共享 跨 Agent 复用

关键认识:这三者协同而非互替。以"分析客户 ABC Corp 并写一封开发信"为例——技能提供方法论框架(怎么评估匹配度、邮件什么结构),工具和知识库提供数据原料(客户画像、行业报告),LLM 在技能框架下消化原料产出结果。

2. 工具:三种形态,一张表,一个网关

2.1 统一数据模型

所有工具收进一张 tool_definition 表:

CREATE TABLE tool_definition (
    tool_id      VARCHAR(64)  NOT NULL PRIMARY KEY,
    name         VARCHAR(128) NOT NULL COMMENT '程序标识,如 search_knowledge',
    label        VARCHAR(128) NOT NULL COMMENT '展示名称',
    description  TEXT         NOT NULL COMMENT '供 LLM 理解工具用途',
    input_schema JSON         NOT NULL COMMENT '输入参数 JSON Schema',
    type         VARCHAR(16)  NOT NULL DEFAULT 'CUSTOM' COMMENT 'BUILTIN / CUSTOM / MCP',
    endpoint     VARCHAR(512) COMMENT 'HTTP URL 或 MCP Server 地址',
    auth_config  JSON         COMMENT '认证配置 {type:api_key/header/oauth, ...}',
    mcp_server_id VARCHAR(64) COMMENT '所属 MCP Server (仅 MCP 类型)',
    status       VARCHAR(16)  NOT NULL DEFAULT 'ACTIVE',
    ...
);

ID 前缀即形态:bt_ 内置、ct_ 自定义、{serverId}__ 前缀的属于 MCP 发现工具。description 写给 LLM 看,label 写给人看——这两份文案的受众不同,不要合一。

2.2 三种形态的执行路径

类型 执行方式 适用场景 超时/重试
BUILTIN Java 代码直接执行 知识检索、计算器、当前时间、媒体生成 5s 超时
CUSTOM HTTP POST 转发(平台做代理) 外部 REST API 集成 10s 超时,重试 1 次
MCP JSON-RPC tools/call 业务方 MCP Server 暴露的工具 15s 超时,不重试

分派逻辑收在 ToolGateway.execute() 一个入口里:

ToolGateway.execute(spaceId, toolId, params)
    ├── BUILTIN → BuiltinExecutor
    │     ├── bt_search_knowledge → KnowledgeGateway.search()
    │     ├── bt_current_time     → LocalDateTime.now()
    │     └── bt_calculator       → 表达式引擎 eval()
    ├── CUSTOM → 读 endpoint + auth_config → WebClient POST
    └── MCP    → 读 mcp_server_config → McpHttpClient.callTool()

MCP 超时故意最长且不重试:跨网络的 JSON-RPC 调用方差大,而工具调用的结果会进 LLM 上下文,重复执行的副作用(写操作)不可控——宁可快速失败,把"要不要重试"交给 LLM 在下一轮决定

2.3 第四种形态:消息工具

除了上面三种"对外调用"的工具,还有一个常驻注入的特殊工具 bt_send_message(to, type, content)——它不调用任何外部系统,而是把消息投递到多 Agent 消息总线(Redis inbox),供主 Agent 与子 Agent 协作。把它做成工具而不是框架特性,是为了让"要不要协作、和谁协作"也成为 LLM 的决策,而不是硬编码的编排。这给我们的启发是:工具的抽象边界可以画在"LLM 可调用的一切能力"上,包括平台自身的内部原语

2.4 Agent 绑定与 function calling 序列化

Agent 通过 configJson.activatedTools 声明激活的工具 id 列表:

{
  "model": "gpt-4o-mini",
  "systemPrompt": "你是客户经营助手…",
  "mountedKbs": ["kb_abc123"],
  "activatedTools": ["bt_search_knowledge", "bt_current_time", "ct_get_weather"],
  "maxSteps": 5
}

推理引擎构建请求时按 id 捞出 ToolDefinition,把 name + description + input_schema 序列化为 function calling 格式注入。工具调用的多步循环(LLM 返回 function_call → 执行 → 结果作为 tool 消息回填 → 继续推理)由 Spring AI 的 ToolCallingChatOptions 内部处理,应用层没有手写 ReAct 循环。每次调用落一条调用日志(toolId、agentId、耗时、成功与否),供事后审计与排障。

3. 技能:把方法论从角色提示词里拆出来

3.1 要解决的问题

没有 Skill 之前,一个业务 Agent 的 systemPrompt 长这样:

"你是客户经营专家。你的任务是帮助用户分析客户池、生成触达策略。

## 邮件写法
写邮件时,用 Dear 开头,主题要简洁有力,正文要包含:
1. 一句话建立联系
2. 产品匹配点
…(80 行)

## 营销策略
做营销策略时,先分析客户分层:
1. 高价值客户 → 个性化深度跟进
…(70 行)

## 产品匹配
评估产品匹配度时考虑:…(60 行)"

问题一目了然:角色定义和三套方法论搅成 200+ 行;另一个数据分析 Agent 想复用营销方法论只能复制粘贴;改邮件格式要同步改 N 个 Agent 的提示词;新人想沉淀经验只能去改线上 Agent 的 prompt,风险极高。

3.2 Skill 的形态

Skill 就是命名的、可复用的 prompt 片段,一张表存下:

CREATE TABLE skill_definition (
    skill_id    VARCHAR(64) PRIMARY KEY,      -- 内置 sk_ 前缀, 自定义 sku_ 前缀
    space_id    VARCHAR(64),                  -- NULL = 内置全局
    name        VARCHAR(128) NOT NULL,        -- email_draft
    label       VARCHAR(128) NOT NULL,        -- 外贸开发信写法
    description TEXT NOT NULL,                -- 给 Agent 创建者看(注意:不是给 LLM)
    prompt      TEXT NOT NULL,                -- ★ 核心:Markdown 提示词片段
    category    VARCHAR(64),                  -- writing / marketing / analysis / ...
    tags        JSON,
    type        VARCHAR(16) DEFAULT 'CUSTOM', -- BUILTIN / CUSTOM
    status      VARCHAR(16) DEFAULT 'ACTIVE',
    ...
);

tool_definition 对照着看最清楚:

tool_definition skill_definition
核心字段 input_schema, endpoint prompt, category
给 LLM 的方式 function_call 动态调用 拼入 system prompt 静态注入
是否执行 平台调用外部 API 不执行(纯文本)

Skill 内容是规范的 Markdown 片段,例如:

## 技能:外贸开发信写法

### 适用场景
向海外潜在客户发送首次开发信,目的是建立联系、介绍产品、争取回复。

### 邮件结构
1. **主题行**:简洁有力,5-8 个英文单词,突出价值主张
2. **开头问候**:Dear + 客户名称
3. **第一段(建立联系)**:一句话说明来意,提及与客户的相关性
…

### 注意事项
- 每段不超过 3 句话
- 避免 "we are a leading company" 等空洞表述

3.3 注入顺序是有讲究的

PromptBuilder 组装最终 system prompt 的顺序:

┌──────────────────────────┐
│ Agent.systemPrompt       │  ← 角色定义(精简后十几行)
├──────────────────────────┤
│ Skill_1.prompt           │  ← 方法论 1
│ Skill_2.prompt           │  ← 方法论 2
│ Skill_3.prompt           │  ← 方法论 3
├──────────────────────────┤
│ OUTPUT_FORMAT            │  ← 输出格式规范
├──────────────────────────┤
│ KB context (动态)        │  ← 知识库检索结果
├──────────────────────────┤
│ User profile (动态)      │  ← 用户画像
└──────────────────────────┘
  • Skill 紧跟角色定义:让 LLM 先在方法论框架下理解后续的动态信息;
  • 输出格式在 Skill 之后:格式规范的优先级压过方法论里可能夹带的格式描述;
  • 动态内容(KB、画像)放最后:超长时从尾部截断最安全。

实现就是几行:

private String buildSkillContext(List<String> skillIds) {
    if (skillIds == null || skillIds.isEmpty()) return "";
    var sb = new StringBuilder();
    for (var s : skillGateway.listByIds(skillIds)) {
        if ("ACTIVE".equals(s.getStatus())) {
            sb.append(s.getPrompt()).append("\n\n");
        }
    }
    return sb.toString();
}

注意 禁用/删除 Skill 不影响已绑定的 Agent——只是推理时不再注入。这让 Skill 可以安全地下线,不会产生悬空引用错误。

3.4 拆解效果

我们把旗舰业务 Agent 按这个方式重构后:

维度 拆解前 拆解后
Agent 提示词长度 ~200 行 ~15 行
邮件写法复用 贴在单个 Agent 里 任何 Agent 勾选 sk_email_draft 即用
方法论更新 改 Agent 配置,影响面大 改 Skill 本身,所有绑定者同时生效
新 Agent 上手 从 200 行提示词开始抄 写 15 行角色定位 + 勾选几个 Skill
团队协作 改提示词容易冲突 各自维护各自的 Skill

两个已知的坑与对策:prompt 超长——约定单个 Skill < 2000 字符,PromptBuilder 侧做 token 计数和截断告警;Skill 更新即生效、无版本——这是有意的取舍(方法论迭代要快),真有回滚需求再上 skill versioning。

4. 空间绑定:工具可见性的多对多模型

4.1 单值 space_id 走不远

最初工具隔离靠 tool_definition.space_id 一个字段,三种形态三种窘境:

工具类型 隔离方式 问题
BUILTIN space_id IS NULL → 全局可见 无法把某个内置工具只开放给特定空间
CUSTOM space_id = ? → 单空间 同一个自定义工具不能分享给多个空间
MCP 不过滤 业务方 MCP 工具对全部空间可见,且跨空间调不通(API Key 不匹配)

两个真实场景做不了:业务方的 MCP 工具本该是其空间专属,结果人人可见;想做一个"生产库只读"的内置工具只给有数据分析师的空间,也做不到。

4.2 关联表 + "无行即全局"

方案是废弃单值字段,改用两张关联表:

-- 工具-空间绑定(行不存在 = 全局可见)
CREATE TABLE tool_space_binding (
    binding_id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tool_id    VARCHAR(64) NOT NULL,
    space_id   VARCHAR(64) NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_tool_space (tool_id, space_id)
);

-- MCP Server-空间绑定(其下所有工具自动继承可见性)
CREATE TABLE mcp_server_space_binding (
    binding_id BIGINT AUTO_INCREMENT PRIMARY KEY,
    server_id  VARCHAR(64) NOT NULL,
    space_id   VARCHAR(64) NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_server_space (server_id, space_id)
);

核心规则一句话:无行 = 全局可见,有行 = 仅限行中空间。这个默认值选得很关键——它让存量数据(全局内置工具、未配置空间的 MCP server)零迁移成本地保持原行为,向后兼容是靠默认语义而不是靠刷数据拿到的。

空间 S 的可见性判定:

SELECT * FROM tool_definition
WHERE status = 'ACTIVE'
  AND (
    tool_id NOT IN (SELECT tool_id FROM tool_space_binding)                    -- 全局
    OR tool_id IN (SELECT tool_id FROM tool_space_binding WHERE space_id = ?)  -- 已绑定本空间
    OR (type = 'MCP' AND mcp_server_id NOT IN
          (SELECT server_id FROM mcp_server_space_binding))                    -- server 全局 → 工具全局
    OR (type = 'MCP' AND mcp_server_id IN
          (SELECT server_id FROM mcp_server_space_binding WHERE space_id = ?)) -- server 已绑 → 工具继承
  )

注意第 3、4 条:MCP 工具的可见性继承自 server 级绑定。一个 MCP Server 可能发现出 40 个工具,逐个绑空间是不可维护的;绑定挂在 server 上,工具自动同权。

4.3 各来源的默认行为

工具来源 默认可见性 控制方式
BUILTIN(存量) 全局 绑定表无行
BUILTIN(新增,受限) 仅系统空间 配置标 visibility: "restricted",启动时预插一行 (tool_id, 系统空间),再由管理员分配
CUSTOM 创建时所在空间 space_id 迁移为一行绑定
MCP(server 配了 spaceId) 指定空间 配置加载时写 server 级绑定
MCP(server 未配 spaceId) 全局 无绑定行

受限内置工具走 Config as Code:

{
  "toolId": "bt_db_readonly",
  "visibility": "restricted"
}

启动加载器把 restricted 翻译成一行绑定,后续分配动作全部收敛到管理后台(POST /api/admin/tools/{toolId}/bindings,批量替换式设置)。分组筛选(UI 视图)和空间绑定(访问控制)是两个正交维度,管理页同时支持。

5. 把它们装配起来

最终,一个 Agent 的能力声明就是一份 JSON:

{
  "model": "gpt-4o-mini",
  "systemPrompt": "你是客户经营专家。利用你掌握的技能完成客户分析与触达工作。",
  "mountedKbs": ["kb_industry_news"],
  "activatedTools": ["bt_search_knowledge", "<业务方 MCP 工具>"],
  "boundSkills": ["sk_email_draft", "sk_marketing_strategy", "sk_product_match"],
  "maxSteps": 5
}

运行时,知识库决定它参考什么,工具决定它能做什么,技能决定它怎么做,空间绑定决定这三者对谁可见。四类机制各自独立演化——换一批工具不动技能,改一套方法论不动 Agent——这正是"能力可组合"在工程上的兑现方式:没有编排引擎,没有场景 if-else,只有正交的能力单元和一份装配清单。