这是「从零搭建 Agent」系列的第 5.3 章。前面几章我们已经让 Agent 拥有了循环、工具、上下文管理、记忆和计划能力。但一个现代 Agent 不能只活在自己写死的工具列表里,也不能把所有工作流经验都塞进系统提示词。本章会补上两块更贴近当下 Agent 生态的能力:MCP 和 Skills。
同步项目地址:https://github.com/Tritium0041/Singularity stage 4.3 MCP + Skills 相关变更。
# MCP 和 Skill 各自负责什么工作?
这算是现在一个比较基础的问题了,但是可能初学者还没分清这两个的区别。
第 2.5 章做基础工具箱时,我们已经给 Agent 加上了文件、命令、网页和搜索工具。这些工具解决的是一个很直接的问题:Agent 要能接触真实世界。
但是当 Agent 系统继续往前走,会出现两个新的问题。第一个问题是工具生态不应该永远由项目自己维护,如果今天要接 GitHub、明天要接 Linear、后天要接一套公司内部文档系统,我们当然可以继续在 src/tools 里不断新增工具。但这样做会让 Agent runtime 变成一个越来越臃肿的集成仓库。每接一个系统,就要在本项目里写 schema、鉴权、调用逻辑、错误格式化和测试。这不是 Agent Loop 应该承担的复杂度。
第二个问题是 “怎么做一类任务” 的经验,也不应该全部写死进默认 prompt。比如:
- 做 code review 时应该先读 diff,再按风险排序输出。
- 写文章时应该先读前文风格,再抽取代码变更,再成稿。
- 做迁移任务时应该先建立兼容性清单,再逐步替换。
这些指示更像某类特定任务的操作手册 SOP。如果把所有操作手册都直接塞进 system prompt,prompt 会越来越长,模型每一轮都要背着一堆当前任务用不到的流程说明。
而 MCP 和 Skills 就给 Agent 补充了这两个能力:MCP 能让外部系统用通用方式向 Agent 提供操作接口,Skills 让特定任务 SOP 以渐进披露的方式进入上下文。它们一个扩展 Agent 的手,一个扩展 Agent 的做事方法。
# 这一章新增了什么?
本次变更主要集中在三组文件。
第一组是 Skills 接入点:
src/skills/ | |
index.ts | |
types.ts | |
loader.ts | |
render.ts |
第二组是 MCP Client:
src/mcp/ | |
index.ts | |
types.ts | |
manager.ts |
第三组是 Agent Loop、demo 和测试接入:
src/agent/agent-loop.ts | |
src/index.ts | |
examples/run-agent.ts | |
README.md | |
tests/skills-mcp.test.ts |
完成之后,Singularity 新增了这些能力:
| 能力 | 作用 |
|---|---|
| Skill loader | 从本地目录发现 SKILL.md 或根级 Markdown skill |
| Skill prompt renderer | 只把 skill 名称、描述和位置注入 prompt |
| 显式 Skill 调用 | demo 支持 /skill:<name> [args] 手动加载完整 skill |
| MCP manager | 启动 stdio MCP server,发现工具,并映射为 AgentTool |
| MCP tool filtering | 支持 enabledTools / disabledTools 控制暴露范围 |
| MCP diagnostics | server 启动失败和工具调用失败不会炸掉 Agent |
| Planning gate 联动 | MCP 工具也进入 read / write / execute 权限治理 |
变化发生在边界层:模型行动前多了一批可发现的能力,工具注册表多了一批外部工具来源。
# Skills:把工作流经验做成可发现资源
Skill 的定位是任务的详细 SOP。当 Agent 开始做 skill 中有的工作时,他应该能够主动要求披露 skill 的完整内容,并根据其内容组织工作步骤。
比如一个 code review skill 可以写成:
--- | |
name: repo-review | |
description: Review a repository change for bugs and missing tests. | |
--- | |
Read the diff, prioritize concrete risks, and report findings first. |
这段内容本身不会执行任何代码,也不会变成一个 tool call。它只是告诉模型:当用户要求 review 时,按照这样的流程工作。
# Skill loader
Skill 的加载逻辑在 src/skills/loader.ts 。
loader 支持两种形态,第一种是目录型 skill:
.singularity/skills/ | |
repo-review/ | |
SKILL.md |
第二种是根级 Markdown 文件:
.singularity/skills/ | |
translate.md |
扫描时,如果某个目录下面存在 SKILL.md ,这个目录就会被当作一个 skill,不再继续向下递归。目录型 skill 未来可能会带额外的参考文件、脚本或模板。如果继续递归,很容易把内部资料误识别成独立 skill。
每个 skill 会解析 frontmatter:
type SkillFrontmatter = { | |
name?: unknown; | |
description?: unknown; | |
"disable-model-invocation"?: unknown; | |
disableModelInvocation?: unknown; | |
}; |
其中 description 是必填项。原因很简单:模型不会在一开始看到完整 skill 内容,它只能根据 description 判断这个 skill 是否匹配当前任务。如果没有描述,就等于没有可发现入口。
name 则可以省略。目录型 skill 会用目录名作为 fallback,文件型 skill 会用文件名作为 fallback,然后统一做一次规范化:
function normalizeSkillName(value: string): string { | |
return value.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, ""); | |
} |
# 渐进披露:先给索引,再读全文
Skill 最关键的设计是渐进披露。 renderAvailableSkills() 不会把所有 skill 正文塞进 system prompt,它只渲染一个目录:
<available_skills> | |
<instructions>When a task matches a skill description, read the skill file first, then follow its instructions. Resolve relative files from the skill file directory.</instructions> | |
<skill name="repo-review" location="/path/to/SKILL.md">Review a repository change for bugs and missing tests.</skill> | |
</available_skills> |
这里面只有三类信息:
- skill name
- skill location
- skill description
模型如果判断当前任务需要某个 skill,下一步应该用 read_file 去读取完整 SKILL.md 。
这延续了前几章一直在强调的 Harness 设计原则:不要把所有信息都提前塞给模型,而是让模型在需要的时候主动取。Context Engine 管请求视图,Memory 管可召回事实,Skills 管可召回工作流。
# 隐藏 implicit invocation
frontmatter 里还支持一个字段:
disable-model-invocation: true |
这是 Pi 在实现 Skills 时添加的约定,它的意思是:这个 skill 不进入 <available_skills> ,模型不能自动选择它。但人类用户仍然可以通过显式命令调用:
/skill:<name> [args] |
这个设计给 skill 留出了两种使用方式。一种是面向模型的自动发现。适合常规工作流,比如 review、写文档、迁移代码。另一种是面向用户的显式触发。适合实验性 skill、危险 skill、或者不希望模型自己决定是否使用的流程。
# MCP:让外部工具进入 Agent runtime
如果说 Skills 是 “工作流说明的扩展机制”,MCP 就是 “工具来源的扩展机制”。在前几章中,我们已经为 Agent 添加了一部分基础工具库,但是我们对每一个工具都是手动实现的。这种方式适合基础工具,但不适合不断增长的外部生态。MCP 的价值在于:外部系统可以用统一协议告诉 Agent:
我有哪些工具? | |
每个工具的 schema 是什么? | |
调用某个工具后结果是什么? |
Agent runtime 只需要要启动 MCP server、列出这些外部工具、把工具映射到自己的 ToolRegistry 里。
# McpManager
MCP 的核心实现是 src/mcp/manager.ts 里的 McpManager 。
for each configured server | |
-> create StdioClientTransport | |
-> connect MCP Client | |
-> listTools() | |
-> filter enabled / disabled tools | |
-> normalize tool name | |
-> register route |
代码里使用的是 MCP 协议的官方 SDK:
const transport = new StdioClientTransport({ | |
command: config.command, | |
args: config.args, | |
env: config.env, | |
cwd: config.cwd, | |
stderr: "pipe" | |
}); | |
const client = new Client({ | |
name: this.clientName, | |
version: this.clientVersion | |
}); | |
await client.connect(transport); |
server 启动后, listTools() 返回 MCP 原始工具。Singularity 会把它们转换成自己的 AgentTool 。
const base = sanitizeToolName(`mcp_${serverName}_${toolName}`); |
server: docs.server | |
tool: echo | |
=> mcp_docs_server_echo |
如果发生重名,会追加 hash,保证模型看到的 tool name 是稳定且唯一的。
# 把 MCP tool 映射成 AgentTool
MCP 工具最终会进入统一的 ToolRegistry:
private toAgentTool(route: ToolRoute): AgentTool { | |
return { | |
name: route.info.name, | |
description: `[MCP:${route.info.serverName}] ${route.info.description}`, | |
parameters: route.parameters, | |
access: route.info.access, | |
executionMode: "sequential", | |
execute: async (args) => { | |
... | |
} | |
}; | |
} |
这里有几个小细节。第一,description 会带上 [MCP:serverName] 前缀。模型和调试日志都能看出这个工具不是本地 core tool,而是来自某个 MCP server。第二,MCP tool 默认顺序执行。外部工具可能有自己的状态、鉴权、速率限制或副作用,我们把他单独拿出来处理。这样映射以后,MCP 工具就被加入到了 Agent 的工具集中,可以被任意调用。
# MCP result formatting
MCP 返回结果的格式比本地工具更散乱。我们为了让 Agent 有类似的工具调用结果,会使用 formatMcpToolResult() 把 text content 提取出来:
for (const block of record.content) { | |
if (isTextContent(block)) { | |
text.push(block.text); | |
} else { | |
nonText.push(block); | |
} | |
} |
如果有非文本内容,会放进 details.nonTextContent ,正文里只提示:
[MCP tool returned N non-text content block(s).] |
# 接入 Agent Loop
MCP 和 Skills 都通过 AgentConfig 接入,Skills 是静态 prompt fragment:
constructor | |
-> resolveSkillsConfig() | |
-> buildSystemPrompt() | |
-> buildStaticPromptFragments() | |
-> buildSkillsPromptFragment() |
也就是说,skills 的目录信息会在 Agent 构造时进入 system prompt。
MCP 是动态 tool source:
prepareRequest() | |
-> ensureMcpStarted() | |
-> buildRuntimeToolRegistry() | |
-> tools.push(...this.mcp.getTools()) |
它不适合在构造函数里同步完成,因为启动 MCP server 是异步操作。Agent 会在第一次准备请求前 lazy start:
private async ensureMcpStarted(): Promise<void> { | |
if (!this.mcp) { | |
return; | |
} | |
this.mcpStartPromise ??= this.mcp.start(); | |
await this.mcpStartPromise; | |
} |
这样一来,调用者既可以传入已经启动好的 McpManager ,也可以只传一份 McpConfig ,由 Agent 在运行时启动。
关闭时同样,也要释放外部进程:
async close(): Promise<void> { | |
await this.waitForBackgroundTasks(); | |
await this.mcp?.close(); | |
} |
# 回到 Harness:与时代接轨不是追热点
MCP 和 Skills 都是很 “当下” 的 Agent 关键词,但如果只把它们当新功能接进来,就很容易变成堆概念。从 Harness 的角度看,它们其实解决的是两个很朴素的问题:MCP 让工具系统从封闭变成开放,Skills 让 prompt 从单体变得工程化。前者扩展行动空间,后者扩展操作策略。
但它们都没有改变 Agent 的核心结构。模型仍然通过工具观察世界,Context Engine 仍然负责请求视图,Memory 仍然负责状态保留,Planner 仍然负责目标推进和完成约束。这就是我理解的 “与时代接轨”:让 Agent 把新的生态接口放进已有的 Harness 结构里,让它们服从同一套上下文、权限、状态和观察机制。
# 我们还差哪些东西
下一章中,我们会让 Agent 提高燃烧 Token 的效率,提供 sub-Agent 能力。这里的设计会比较保守,只做基础的只读 sub-agent,防止并发任务带来的一系列问题。