一个 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,只有正交的能力单元和一份装配清单。