这是「从零搭建 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 diagnosticsserver 启动失败和工具调用失败不会炸掉 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,防止并发任务带来的一系列问题。