From 3c0cd86352718f86e2522506d3433a65583f2f74 Mon Sep 17 00:00:00 2001 From: clz Date: Tue, 16 Jun 2026 00:59:32 +0800 Subject: [PATCH] docs: add MCP, plugin/LSP tool, and RAG notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - notes/mcp-overview.md: MCP 协议概览,跟 tool use 的分层差异 - notes/mcp-client-internals.md: MCP client 内部实现(发现/连接/catalog) - notes/plugin-tools.md: plugin tool 与 native/MCP 的对比 - notes/lsp-tools.md: LSP 协议科普 + OpenClaw LSP tool 包装 - notes/rag-overview.md: RAG 与向量化(向量只用来检索,prompt 用原文) - notes/blog/mcp-101.md: 面向开发者的 MCP 科普,LSP 类比引入 - README 索引更新 --- .obsidian/core-plugins.json | 4 +- .obsidian/workspace.json | 18 +- README.md | 31 +++ blog/.obsidian/app.json | 1 + blog/.obsidian/appearance.json | 1 + blog/.obsidian/core-plugins.json | 33 +++ blog/.obsidian/workspace.json | 181 +++++++++++++++ blog/README.md | 6 + blog/mcp-101.md | 370 ++++++++++++++++++++++++++++++ lsp-tools.md | 224 ++++++++++++++++++ mcp-client-internals.md | 297 ++++++++++++++++++++++++ mcp-overview.md | 241 +++++++++++++++++++ plugin-tools.md | 226 ++++++++++++++++++ rag-overview.md | 382 +++++++++++++++++++++++++++++++ 14 files changed, 2009 insertions(+), 6 deletions(-) create mode 100644 blog/.obsidian/app.json create mode 100644 blog/.obsidian/appearance.json create mode 100644 blog/.obsidian/core-plugins.json create mode 100644 blog/.obsidian/workspace.json create mode 100644 blog/mcp-101.md create mode 100644 lsp-tools.md create mode 100644 mcp-client-internals.md create mode 100644 mcp-overview.md create mode 100644 plugin-tools.md create mode 100644 rag-overview.md diff --git a/.obsidian/core-plugins.json b/.obsidian/core-plugins.json index b977c25..1e23978 100644 --- a/.obsidian/core-plugins.json +++ b/.obsidian/core-plugins.json @@ -27,5 +27,7 @@ "file-recovery": true, "publish": false, "sync": true, - "webviewer": false + "webviewer": false, + "footnotes": false, + "bases": true } \ No newline at end of file diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json index c921f58..ff67042 100644 --- a/.obsidian/workspace.json +++ b/.obsidian/workspace.json @@ -13,12 +13,12 @@ "state": { "type": "markdown", "state": { - "file": "blog/function-call-101.md", + "file": "blog/mcp-101.md", "mode": "source", "source": false }, "icon": "lucide-file", - "title": "function-call-101" + "title": "mcp-101" } } ] @@ -142,13 +142,13 @@ "state": { "type": "outline", "state": { - "file": "blog/function-call-101.md", + "file": "blog/mcp-101.md", "followCursor": false, "showSearch": false, "searchQuery": "" }, "icon": "lucide-list", - "title": "function-call-101 的大纲" + "title": "mcp-101 的大纲" } } ], @@ -160,6 +160,7 @@ }, "left-ribbon": { "hiddenItems": { + "bases:新建数据库": false, "switcher:打开快速切换": false, "graph:查看关系图谱": false, "canvas:新建白板": false, @@ -170,9 +171,16 @@ }, "active": "d0cf7351ba09fde6", "lastOpenFiles": [ - "llm-autoregressive-kvcache.md", + "rag-overview.md", + "mcp-overview.md", + "mcp-client-internals.md", + "lsp-tools.md", + "blog/mcp-101.md", + "plugin-tools.md", "blog/function-call-101.md", "README.md", + "blog/README.md", + "llm-autoregressive-kvcache.md", "function-call-flow.md", "blog", "skills-management-design.md", diff --git a/README.md b/README.md index f0cee51..e81c268 100644 --- a/README.md +++ b/README.md @@ -37,4 +37,35 @@ LLM 自回归生成与 KV Cache:token 逐步生成的机制、prefill vs decode 阶段、 首 token 慢的原因,以及服务端 Prompt Cache 的命中条件与 OpenClaw 中保证顺序确定性的做法。 +- [mcp-overview.md](./mcp-overview.md) + MCP(Model Context Protocol)概览:协议定位、OpenClaw 作为 MCP server + 与 client 的两个方向、MCP 与 tool use 的分层差异(协议层 vs 实现层、 + 所有权与时机两根独立轴、plugin tool 反例)。 + +- [mcp-client-internals.md](./mcp-client-internals.md) + OpenClaw 作为 MCP client 的内部实现:server 发现(用户 config + plugin + manifest 合并)、连接时机(三种粒度的"启动"、attempt 引爆点)、 + 模型视角下的扁平 tool 列表、catalog 缓存与失效、JSON Schema 兼容/ + content block 收敛/stdio env 过滤/失败退避等实现细节。 + +- [plugin-tools.md](./plugin-tools.md) + Plugin tool:OpenClaw 插件给 agent 注册的工具——TypeScript 写、同进程跑、 + 归插件作者拥有。`defineToolPlugin` 的 execute / factory 两种声明、 + manifest-first 发现、Plugin SDK 边界、跟 channel/provider plugin 的关系、 + 跟 MCP tool 的关键对比,以及它在所有权 × 时机两根轴里"用户拥有 + 编译时确定"那一格的位置。 + +- [rag-overview.md](./rag-overview.md) + RAG(检索增强生成)概念整理:它解决什么问题、跟 fine-tuning/超长 context/ + tool use 的对比、典型离线索引 + 在线检索两阶段流程,重点澄清两个常被混淆的问题 + ——向量化的结果是什么、拼进 prompt 的到底是向量还是原文(答案:数据库同时存 + 向量和原文,向量只用来"找",原文才是发给 LLM 的内容);常见坑(检索失败、切块 + 策略、embedding ≠ 相关性、评估),以及跟 MCP 的关系。 + +- [lsp-tools.md](./lsp-tools.md) + LSP tool:先科普 Language Server Protocol 是什么(IDE 世界里 M × N 问题 + 的标准解、跟 MCP 同源同构的 stdio + JSON-RPC 协议),再讲 OpenClaw 怎么 + 把 hover/definition/references 三个 LSP 方法包装成 agent tool、跟 MCP 和 + plugin tool 平行的位置、以及当前只接受 plugin bundle 声明、用户 config + 还没接通的现状限制。 + 完整官方文档: diff --git a/blog/.obsidian/app.json b/blog/.obsidian/app.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/blog/.obsidian/app.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/blog/.obsidian/appearance.json b/blog/.obsidian/appearance.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/blog/.obsidian/appearance.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/blog/.obsidian/core-plugins.json b/blog/.obsidian/core-plugins.json new file mode 100644 index 0000000..639b90d --- /dev/null +++ b/blog/.obsidian/core-plugins.json @@ -0,0 +1,33 @@ +{ + "file-explorer": true, + "global-search": true, + "switcher": true, + "graph": true, + "backlink": true, + "canvas": true, + "outgoing-link": true, + "tag-pane": true, + "footnotes": false, + "properties": true, + "page-preview": true, + "daily-notes": true, + "templates": true, + "note-composer": true, + "command-palette": true, + "slash-command": false, + "editor-status": true, + "bookmarks": true, + "markdown-importer": false, + "zk-prefixer": false, + "random-note": false, + "outline": true, + "word-count": true, + "slides": false, + "audio-recorder": false, + "workspaces": false, + "file-recovery": true, + "publish": false, + "sync": true, + "bases": true, + "webviewer": false +} \ No newline at end of file diff --git a/blog/.obsidian/workspace.json b/blog/.obsidian/workspace.json new file mode 100644 index 0000000..054d373 --- /dev/null +++ b/blog/.obsidian/workspace.json @@ -0,0 +1,181 @@ +{ + "main": { + "id": "c5399a9acbb77689", + "type": "split", + "children": [ + { + "id": "983bb3f03ba0267b", + "type": "tabs", + "children": [ + { + "id": "d73d0bfd1d682067", + "type": "leaf", + "state": { + "type": "empty", + "state": {}, + "icon": "lucide-file", + "title": "新标签页" + } + } + ] + } + ], + "direction": "vertical" + }, + "left": { + "id": "65df11f83c8b6769", + "type": "split", + "children": [ + { + "id": "78cc3f055f0cbebc", + "type": "tabs", + "children": [ + { + "id": "77a689742a641c6d", + "type": "leaf", + "state": { + "type": "file-explorer", + "state": { + "sortOrder": "alphabetical", + "autoReveal": false + }, + "icon": "lucide-folder-closed", + "title": "文件列表" + } + }, + { + "id": "bfd0dd36c851aa00", + "type": "leaf", + "state": { + "type": "search", + "state": { + "query": "", + "matchingCase": false, + "explainSearch": false, + "collapseAll": false, + "extraContext": false, + "sortOrder": "alphabetical" + }, + "icon": "lucide-search", + "title": "搜索" + } + }, + { + "id": "368ed5ad7ec0d3ee", + "type": "leaf", + "state": { + "type": "bookmarks", + "state": {}, + "icon": "lucide-bookmark", + "title": "书签" + } + } + ] + } + ], + "direction": "horizontal", + "width": 300 + }, + "right": { + "id": "8b36ffcad1340b80", + "type": "split", + "children": [ + { + "id": "cb25124ebc9d68ef", + "type": "tabs", + "children": [ + { + "id": "d2956a3ad0a6ce8e", + "type": "leaf", + "state": { + "type": "backlink", + "state": { + "collapseAll": false, + "extraContext": false, + "sortOrder": "alphabetical", + "showSearch": false, + "searchQuery": "", + "backlinkCollapsed": false, + "unlinkedCollapsed": true + }, + "icon": "links-coming-in", + "title": "反向链接" + } + }, + { + "id": "8dcc2f42bc2674ca", + "type": "leaf", + "state": { + "type": "outgoing-link", + "state": { + "linksCollapsed": false, + "unlinkedCollapsed": true + }, + "icon": "links-going-out", + "title": "出链" + } + }, + { + "id": "91e1edfbd442848d", + "type": "leaf", + "state": { + "type": "tag", + "state": { + "sortOrder": "frequency", + "useHierarchy": true, + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-tags", + "title": "标签" + } + }, + { + "id": "33675859567823b1", + "type": "leaf", + "state": { + "type": "all-properties", + "state": { + "sortOrder": "frequency", + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-archive", + "title": "添加笔记属性" + } + }, + { + "id": "4d537f742f8042ed", + "type": "leaf", + "state": { + "type": "outline", + "state": { + "followCursor": false, + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-list", + "title": "大纲" + } + } + ] + } + ], + "direction": "horizontal", + "width": 300, + "collapsed": true + }, + "left-ribbon": { + "hiddenItems": { + "switcher:打开快速切换": false, + "graph:查看关系图谱": false, + "canvas:新建白板": false, + "daily-notes:打开/创建今天的日记": false, + "templates:插入模板": false, + "command-palette:打开命令面板": false, + "bases:新建数据库": false + } + }, + "active": "d73d0bfd1d682067", + "lastOpenFiles": [] +} \ No newline at end of file diff --git a/blog/README.md b/blog/README.md index 59a144d..cd6ec81 100644 --- a/blog/README.md +++ b/blog/README.md @@ -8,3 +8,9 @@ Function Call 是什么、LLM 如何学会使用工具:从一个问题入手, 解释模型不执行工具、只输出结构化调用请求的本质, 并以 OpenClaw 源码为例说明工具声明、调用、执行的完整链路。 + +- [mcp-101.md](./mcp-101.md) + MCP 是什么、为什么 Function Call 之后还需要它:用 LSP 解决编辑器 × 语言 + M × N 问题的类比引入,说清 MCP 跟 Function Call 是不同层(模型↔应用 + vs 应用↔工具),澄清三个常见误解,并用 OpenClaw 源码示意 MCP server + 怎么被发现、何时连接、模型如何看到扁平 tool 列表。 diff --git a/blog/mcp-101.md b/blog/mcp-101.md new file mode 100644 index 0000000..7e08fc5 --- /dev/null +++ b/blog/mcp-101.md @@ -0,0 +1,370 @@ +# MCP 是什么?为什么 Function Call 之后还需要它 + +> 本文以 [OpenClaw](https://github.com/openclaw/openclaw) 开源源码为例 +> (基于 2026.6.2 版本),结合实际代码说明 MCP 的工作原理和定位。 +> 涉及源码路径均可在仓库中直接查阅。 + +如果你看过上一篇 [Function Call 101](./function-call-101.md),应该已经 +知道 LLM 怎么"用工具"了:开发者把工具的 JSON Schema 塞进请求,模型决定 +调哪个、传什么参数,代码负责真正执行。 + +那为什么 2024 年 Anthropic 又搞了一个叫 **MCP(Model Context Protocol)** +的东西?它跟 Function Call 是同一个东西吗?还是说 Function Call 不够用? + +本文回答这两个问题。 + +--- + +## 从一个老问题说起 + +先看一个跟 AI 没关系的故事。 + +2016 年微软在做 VSCode 的时候,碰到一个头疼的事: + +``` + 编辑器(M 个) 语言(N 种) + ┌────────────┐ ┌──────────┐ + │ VSCode │ ↔↔↔↔ │ Python │ + │ Vim │ ↔↔↔↔ │ Go │ + │ Emacs │ ↔↔↔↔ │ TypeScript│ + │ Sublime │ ↔↔↔↔ │ Rust │ + │ JetBrains │ ↔↔↔↔ │ Java │ + └────────────┘ └──────────┘ +``` + +每个编辑器都要给每种语言写一遍"hover 显示类型"、"跳转到定义"、 +"查找所有引用"、"实时报错"...M 个编辑器 × N 种语言 = M × N 份 +重复实现。这显然不可持续。 + +微软的解法是 **LSP(Language Server Protocol)**:把语言能力抽出来 +变成一个独立的服务器进程,编辑器跟服务器用一套标准协议通信: + +``` + ┌────────┐ LSP ┌──────────────┐ + │ VSCode │ ←──JSON-RPC→│ tsserver │ ← TypeScript + └────────┘ └──────────────┘ + ┌────────┐ ┌──────────────┐ + │ Vim │ ←──────────│ rust-analyzer│ ← Rust + └────────┘ └──────────────┘ +``` + +现在变成 M + N:每个编辑器实现一次 LSP client,每种语言实现一次 +LSP server。**`rust-analyzer` 一份能给所有支持 LSP 的编辑器用。** + +记住这个故事。 + +--- + +## AI 工具集成的同样问题 + +现在回到 2024 年。当时已经有不少 AI 应用支持 Function Call,但每个 +应用都在重复造轮子: + +``` + AI 应用(M 个) 工具(N 种) + ┌────────────────┐ ┌──────────────┐ + │ Claude Desktop │ ↔↔↔│ 读文件 │ + │ ChatGPT App │ ↔↔↔│ 查数据库 │ + │ Cursor │ ↔↔↔│ 调 GitHub API │ + │ Codex │ ↔↔↔│ 操作 Notion │ + │ 你的 agent │ ↔↔↔│ 控制浏览器 │ + └────────────────┘ └──────────────┘ +``` + +每个 AI 应用都要给每种工具写一遍 Function Call 的接入代码: +读文件、调 API、解析响应、错误处理。M × N 又来了。 + +你想要的是这样:把"读文件"这个能力写一次,Claude Desktop、Cursor、 +你的 agent 都能用。 + +Anthropic 给的答案就是 MCP——**它对 AI 工具集成做的事,跟 LSP 对 +编辑器语言支持做的事是一模一样的**。 + +--- + +## MCP 是什么 + +一句话:**MCP 是一个开放协议,让任何 AI 应用都能用一套标准的方式 +接入任何外部工具。** + +具体来说,MCP 定义了两个角色之间怎么通信: + +- **Host**(主机):跑 LLM 的那一侧——Claude Desktop、Cursor、你写的 + agent 框架 +- **Server**(服务器):提供工具的那一侧——可以是本地子进程 + (读文件、控浏览器),也可以是远端 HTTP 服务(Notion 集成、 + GitHub 集成) + +通信用 JSON-RPC 2.0,跑在 stdio 或 HTTP 之上。MCP server 暴露三类能力: + +- **tools**:模型可以调的函数 +- **resources**:可读的数据源 +- **prompts**:可被引用的预制 prompt 模板 + +我们今天主要讲 tools。 + +跟 LSP 长得很像对吧?**stdio + JSON-RPC + 服务器自报能力**——某种意义上 +MCP 就是 LSP 思路在 AI 时代的延续。 + +--- + +## 但是……Function Call 不是已经能干这件事了吗? + +这是新手最容易卡住的地方。我们仔细看看。 + +### Function Call 是什么层面 + +Function Call 是**模型和应用之间的约定**:模型怎么"申请"调用一个 +函数,应用怎么"执行"它再把结果回传。 + +``` +┌─────────────────────────────┐ +│ LLM (Claude / GPT 等) │ +│ 输出 tool_use 请求 │ ← Function Call 层 +└──────────────┬──────────────┘ + │ + ▼ +┌─────────────────────────────┐ +│ 你的应用 │ +│ 接到请求,跑函数,回传结果 │ +└─────────────────────────────┘ +``` + +这一层只回答了"模型怎么表达调用意图"。**它没规定函数本身是怎么 +来的、是谁写的、怎么真正执行**。 + +### MCP 是什么层面 + +MCP 是**应用和工具实现之间的协议**:这个函数从哪儿来,代码在哪个 +进程里跑,参数怎么传过去。 + +``` +┌─────────────────────────────┐ +│ LLM │ +│ 输出 tool_use 请求 │ ← Function Call 层(没变) +└──────────────┬──────────────┘ + │ + ▼ +┌─────────────────────────────┐ +│ 你的应用 │ +│ - 收 tool_use │ +│ - 转成 MCP 调用 │ +└──────────────┬──────────────┘ + │ JSON-RPC over stdio/HTTP + ▼ +┌─────────────────────────────┐ +│ MCP server (子进程/远端) │ ← MCP 层 +│ 跑函数,回结果 │ +└─────────────────────────────┘ +``` + +注意:**模型完全不知道 MCP 的存在**。它看到的还是一个普通的 tool 定义, +还是按 Function Call 的方式输出 `tool_use`。MCP 是中间那一层应用代码 +的事——它把 tool 的"实现"从应用进程里挪到了独立的 MCP server 里。 + +### 一个类比 + +- Function Call ≈ "我要打个电话"——这是个抽象动作 +- MCP ≈ "电话怎么拨通对方公司的总机,问到分机号,再接到具体的人" + ——这是个具体协议 + +或者: + +- Function Call ≈ 函数调用这个概念本身 +- MCP ≈ gRPC / OpenAPI——让函数能跨进程跨语言被发现和调用的具体协议 + +**MCP 不替代 Function Call,它给 Function Call 加了一个"工具供应链"。** + +--- + +## MCP 实际带来了什么 + +讲了这么多概念,看看实际效果。 + +### 1. 工具一次写好,所有 AI 应用都能用 + +`@modelcontextprotocol/server-filesystem` 是社区的一个 MCP server, +让 AI 能读文件、列目录、搜文件。装一次: + +```bash +npx @modelcontextprotocol/server-filesystem ~/Documents +``` + +然后 Claude Desktop、Cursor、OpenClaw 都能直接用上它,不用各自实现 +一遍文件操作工具。**这就是 M + N 而不是 M × N。** + +### 2. 工具的提供者跟 AI 应用作者解耦 + +Notion 想给 AI 助手提供集成?以前要等每家 AI 应用主动接入,或者发 +SDK 给开发者集成。现在 Notion 自己跑一个 MCP server,任何支持 MCP +的应用都能用。 + +### 3. 工具能跨语言、跨进程 + +MCP server 可以是 Python、Go、Rust、任何能讲 JSON-RPC 的语言写的。 +你的 AI 应用是 Node.js,但工具实现可以是 Python——只要双方说 MCP 就行。 + +### 4. 故障隔离 + +MCP server 跑在独立进程里(或者远端),一个 server 崩了不会拖死整个 +AI 应用。可以单独重启、单独限流、单独配置超时。 + +--- + +## 看看 OpenClaw 怎么做的 + +OpenClaw(一个开源 AI agent 框架)的代码可以当作 MCP 的具体落地示例。 + +### MCP server 怎么"加进来" + +OpenClaw 不去网络扫描发现 MCP server,**全靠声明式 config**。两个来源: + +**1. 用户写在 `~/.openclaw/openclaw.json` 里:** + +```json +{ + "mcp": { + "servers": { + "filesystem": { + "command": "npx", + "args": ["@modelcontextprotocol/server-filesystem", "/home/user"] + }, + "github": { + "url": "https://mcp.github.com", + "transport": "streamable-http", + "auth": "oauth" + } + } + } +} +``` + +**2. 插件作者预置:** 装一个 OpenClaw plugin 时,它可能自带几个 +MCP server 配置(`src/plugins/bundle-mcp.ts`)。 + +两个来源在 `src/agents/bundle-mcp-config.ts` 里合并,**用户 config +能覆盖 plugin 默认**。 + +### 什么时候真的连接 MCP server? + +这是个有意思的设计选择。OpenClaw 的策略:**进程启动时不连接、每次 +模型轮次开始时连接**。 + +为什么?因为模型必须在请求里就看到全部 tool 才能决定调哪个,所以 +连接不能拖到模型真的调用某个 tool 时再做(那时候 tool 列表都没 +组装好)。但又没必要在 OpenClaw 启动时就把所有 MCP server 都拉起来 +(可能配了 20 个,这次对话其实只用 2 个)。 + +折衷点是 **agent 一轮模型请求开始时**:这时候必须把所有 MCP server +都 spawn、`tools/list`、拿到工具目录,然后把工具塞进发给模型的 +tool 列表里。 + +```ts +// 简化版,真实代码在 src/agents/embedded-agent-runner/run/attempt.ts +const mcpRuntime = await materializeBundleMcpToolsForRun({...}); +// 这里 MCP server 已经 spawn 完了,tool 也都拿到了 + +const tools = [...nativeTools, ...mcpRuntime.tools]; +// 发给模型 +sendRequest({ tools, ... }); +``` + +### 模型看到的是什么 + +**一个扁平的 tool 数组,跟 native tool 长得完全一样:** + +```json +[ + { "name": "Read", "description": "...", "input_schema": {...} }, + { "name": "Bash", "description": "...", "input_schema": {...} }, + { "name": "filesystem__read_file", "description": "...", "input_schema": {...} }, + { "name": "github__list_issues", "description": "...", "input_schema": {...} } +] +``` + +注意 `filesystem__read_file` 这种 `serverName__toolName` 的命名是为了 +防止重名。模型完全不知道哪些来自 MCP、哪些是 OpenClaw 写死的;它只 +根据 `description` 和 schema 做语义匹配,挑一个最合适的。 + +### 模型调用时发生什么 + +模型返回 `tool_use: filesystem__read_file`,OpenClaw 在 tool 列表里 +找到对应条目,它的 `execute` 函数会调 `client.callTool("read_file", input)`, +通过早就建立好的 stdio 连接发给 MCP server,server 跑完返回结果, +OpenClaw 再把结果回传给模型。 + +整个过程模型一无所知——它只觉得自己"调了个工具,得到了结果"。 + +--- + +## 关键澄清:几个常见误解 + +### 误解 1:"MCP 是给模型用的协议" + +**不是。MCP 是给应用用的协议。** 模型那一侧永远是 Function Call, +看到的就是普通 tool 定义。MCP 是应用怎么"获取这些 tool 定义、怎么 +真正执行 tool"的事。 + +### 误解 2:"用了 MCP 就不用 Function Call 了" + +**Function Call 永远在用。** MCP server 提供的 tool,最终还是要包装 +成 Function Call 的格式发给模型。两者不是 A 替代 B,而是 A 之上又加 +了一层 B。 + +### 误解 3:"native tool 落后了,MCP 才是未来" + +**不一定。** 跟应用深度耦合的工具(比如 OpenClaw 的 `Read`、`Bash`) +做成 native tool 启动快、故障少、能直接用 host 内部能力;MCP 的优势在 +**跨应用复用** 和 **可独立分发**。两者各有适用场景。 + +OpenClaw 自己就同时有 native tool、plugin tool、MCP tool,模型看见 +的是同一个扁平列表,不区分。 + +--- + +## 那 MCP 现在能用来干什么 + +一些实际的应用方向: + +- **本地工具**:文件系统、Git、Docker、SQLite——`@modelcontextprotocol/server-*` + 系列已经有不少现成的 +- **远端 SaaS 集成**:Notion、Linear、Slack、GitHub——越来越多 SaaS + 在做官方 MCP server +- **企业内部工具**:把内部数据库查询、运维脚本包装成 MCP server, + 团队所有 AI 助手都能用 +- **跨工具协作**:同一个 agent 同时接文件系统 + GitHub + Notion 的 MCP server, + 可以读本地代码、查 issue、写文档 + +--- + +## 总结 + +回到开头的问题:**MCP 跟 Function Call 是什么关系?** + +``` +Function Call:模型 ←→ 应用 之间怎么调函数 +MCP: 应用 ←→ 工具 之间怎么提供函数 +``` + +Function Call 解决"模型怎么用工具"。MCP 解决"工具从哪儿来、怎么跨应用 +复用"。两者各管一层,组合起来才是完整的故事。 + +LSP 当年解决了编辑器 × 语言的 M × N 问题,让 `rust-analyzer` 这种 +高质量语言服务器能服务所有编辑器。MCP 试图解决 AI 应用 × 工具的同样 +问题,让一个 MCP server 能服务所有 AI 助手。 + +它能不能像 LSP 那样成为事实标准,还要看生态——但思路是清晰的: +**把工具实现从单个应用里抽出来,变成可复用的独立组件**。 + +--- + +## 想深入了解? + +- MCP 规范: +- 看 OpenClaw 怎么实现 MCP client 的: + - 配置和发现机制 + - 何时连接、catalog 怎么暴露给模型 + - 缓存、失效、安全细节 + + 这部分在 OpenClaw 仓库的 `src/agents/agent-bundle-mcp-*.ts`, + 以及 `notes/mcp-client-internals.md`。 diff --git a/lsp-tools.md b/lsp-tools.md new file mode 100644 index 0000000..5e08057 --- /dev/null +++ b/lsp-tools.md @@ -0,0 +1,224 @@ +# LSP Tool + +基于 [OpenClaw](https://github.com/openclaw/openclaw) 2026.6.2 版本源码。 + +本文先讲 **LSP(Language Server Protocol)是什么**——它是 IDE 世界里 +一个很重要的协议,但跟 AI 领域关系不大,所以单独介绍——再讲 OpenClaw +怎么把它包装成 agent 的 tool。 + +## LSP 是什么 + +### 它解决什么问题 + +写编辑器的人原来面临一个 M × N 问题: + +``` + 编辑器 (M 个) 语言支持 (N 种) + ┌────────────┐ ┌──────────┐ + │ VSCode │ ↔↔↔↔ │ Python │ + │ Vim │ ↔↔↔↔ │ Go │ + │ Emacs │ ↔↔↔↔ │ TypeScript│ + │ Sublime │ ↔↔↔↔ │ Rust │ + │ JetBrains │ ↔↔↔↔ │ Java │ + └────────────┘ └──────────┘ +``` + +每个编辑器都得给每种语言写一遍 "hover 显示类型"、"跳转到定义"、 +"查找所有引用"、"实时报错"...这些功能。M 个编辑器 × N 种语言 = +M × N 份重复实现。 + +LSP 是微软 2016 年提出的解法:**把"语言能力"抽出来变成一个独立的 +服务器进程**,编辑器和服务器之间用一套标准协议通信。 + +``` + 编辑器 Language Server + ┌────────┐ LSP ┌──────────────┐ + │ VSCode │ ←─────JSON-RPC→│ tsserver │ ← TypeScript + └────────┘ └──────────────┘ + + ┌────────┐ ┌──────────────┐ + │ Vim │ ←──────────────│ gopls │ ← Go + └────────┘ └──────────────┘ + + ┌────────┐ ┌──────────────┐ + │ Emacs │ ←──────────────│ rust-analyzer│ ← Rust + └────────┘ └──────────────┘ +``` + +现在变成 M + N:每个编辑器实现一次 LSP client,每种语言实现一次 +LSP server。VSCode 装 Rust 插件 = 它在 VSCode 端走 LSP client, +背后跑一个 `rust-analyzer` 进程。 + +### 协议形态 + +LSP 用 JSON-RPC 2.0,跑在 stdio 之上(也支持其他 transport,但 stdio +是默认)。消息分帧用 HTTP 风格的 Content-Length header: + +``` +Content-Length: 213\r\n +\r\n +{"jsonrpc":"2.0","id":1,"method":"textDocument/hover","params":{...}} +``` + +典型方法名: + +- `initialize` — 握手,client 告诉 server 自己支持什么, + server 回 capabilities(支不支持 hover、definition 等) +- `textDocument/didOpen` / `didChange` / `didClose` — 通知文件状态 +- `textDocument/hover` — 给我光标位置的类型/文档 +- `textDocument/definition` — 跳转到定义 +- `textDocument/references` — 找所有引用 +- `textDocument/completion` — 自动补全 +- `textDocument/publishDiagnostics` — server 主动推:这里有错 + +每个方法的参数和返回值都在 LSP 规范里固定下来,所以一个 LSP server +可以同时被 VSCode、Vim、Emacs 用。 + +### 跟 MCP 的对照 + +| 维度 | LSP | MCP | +| ------------- | -------------------------------- | ------------------------------------ | +| 目标受众 | IDE / 编辑器 | LLM 应用 | +| 服务器干啥 | 提供代码分析(hover/跳转/补全) | 提供任意工具(读文件/查搜索/...) | +| 方法集合 | 固定一套 `textDocument/*` 等 | server 自己声明 `tools/list` | +| 协议层 | JSON-RPC 2.0 + Content-Length | JSON-RPC 2.0 | +| 提出者 | 微软 | Anthropic | +| 年份 | 2016 | 2024 | + +两个协议**长得很像**(stdio + JSON-RPC),解决的是不同领域的同一种 +M × N 问题——把可复用的能力(代码分析 / agent 工具)抽到独立进程。 +某种意义上 MCP 是 LSP 思路在 AI 时代的延续。 + +## OpenClaw 怎么用 LSP + +核心实现 `src/agents/agent-bundle-lsp-runtime.ts`,第一行注释: + +> Minimal LSP JSON-RPC framing over stdio (Content-Length header + JSON body). + +不是用现成的 LSP client 库,而是自己在 OpenClaw 里实现了 LSP 协议的 +最小子集(Content-Length 分帧 + JSON-RPC),spawn 语言服务器子进程, +跑 `initialize` 握手拿能力。 + +### 暴露的 tool + +根据语言服务器 `initialize` 返回的 capabilities 动态生成 +(`agent-bundle-lsp-runtime.ts:317` `buildLspTools`): + +| Tool 名 | LSP 方法 | 用途 | capability 门槛 | +| -------------------------- | ------------------------- | ----------------- | --------------------- | +| `lsp_hover_` | `textDocument/hover` | 看符号的类型/文档 | `hoverProvider` | +| `lsp_definition_` | `textDocument/definition` | 跳到定义 | `definitionProvider` | +| `lsp_references_` | `textDocument/references` | 找所有引用 | `referencesProvider` | + +如果语言服务器没声明对应 capability(比如 `hoverProvider: false`), +对应 tool 就不会被注册。 + +工具参数都长一样(`uri: string, line: number, character: number`), +因为 LSP 协议本身就是这套 `textDocument + position` 的形态。 + +**注意没有 completion**:agent 不需要交互式补全,它直接看完整文件就行。 +OpenClaw 只挑了"看完整代码不能直接得到的语义信息"做工具——hover 拿 +推断类型,definition/references 需要跨文件 AST 跳跃。 + +### 跟 MCP / plugin 平行 + +| 维度 | LSP tool | MCP tool | plugin tool | +| -------------- | ----------------------- | --------------------------- | --------------------------- | +| 协议 | LSP(`textDocument/*`) | MCP(`tools/call`) | OpenClaw Plugin SDK | +| 进程 | stdio 子进程 | stdio 子进程 / HTTP | 同进程 | +| Tool 名 | hardcode 三个 + server 后缀 | server 自报 `tools/list` | plugin 代码静态声明 | +| 配置来源 | bundle plugin manifest | bundle plugin + 用户 config | plugin manifest | +| 用户能加吗 | 现在不能 | 能(`openclaw mcp add`) | 装新 plugin 即可 | +| Catalog 动态性 | **静态**(三个固定 tool) | **动态**(server 决定) | 静态 | +| 模型视角 | 跟其他 tool 没区别 | 跟其他 tool 没区别 | 跟其他 tool 没区别 | + +跟 MCP 最大不同:**LSP 的 tool 集是 OpenClaw 写死的**(hover/definition +/references 三个),只是是否启用看 server capability。MCP 是 server +想暴露啥就 `tools/list` 出啥,完全运行时决定。 + +这是 LSP 协议本身决定的——LSP 的方法集就那么固定一套,不像 MCP +让 server 自由声明工具。OpenClaw 只是把 LSP 方法**翻译**成 agent +理解的 tool。 + +### 在 attempt 时序里 + +跟 MCP 完全平行(回看 [mcp-client-internals.md](./mcp-client-internals.md) +的 "Attempt 引爆点"): + +```ts +// src/agents/embedded-agent-runner/run/attempt.ts:1502-1549 +const bundleMcpRuntime = bundleMcpSessionRuntime + ? await materializeBundleMcpToolsForRun({...}) + : undefined; +bundleLspRuntime = bundleLspEnabled + ? await createBundleLspToolRuntime({...}) // ← LSP 在这里 spawn + initialize + : undefined; + +const allowedBundleMcpTools = applyEmbeddedAttemptToolsAllow(bundleMcpRuntime?.tools ?? [], ...); +const allowedBundleLspTools = applyEmbeddedAttemptToolsAllow(bundleLspRuntime?.tools ?? [], ...); +const allowedBundledTools = [...allowedBundleMcpTools, ...allowedBundleLspTools]; +``` + +每次 attempt 开始时,两条 runtime 各自独立 spawn(MCP / LSP), +各自维护 session,各自 dispose;最后合到同一个扁平 tool list 给模型。 + +## 典型用例 + +模型要回答 "把 `runEmbeddedAttempt` 的所有调用方都列出来"。可能的工具组合: + +1. `Grep "runEmbeddedAttempt"` ——快,但会被注释、字符串、相似名字误伤 +2. `lsp_references_typescript` ——准,基于 AST 分析,但要先定位符号的 + 精确 `(uri, line, character)` +3. 常见组合:先 `Grep` 找出大致位置 → `Read` 确认 → `lsp_references_typescript` + 拿精确引用列表 + +LSP tool 的价值在于 **精确度**——code intelligence 这件事 grep 替代不了: + +- `Grep` 找的是文本匹配,会把字符串字面量、注释里的同名词都算进来 +- `lsp_references` 走 AST + 类型系统,能区分 `foo.runEmbeddedAttempt` + 调的是哪个 `foo`,跨 import 跳得对 + +类似地 `lsp_hover` 拿 inferred type 是 grep / read 替代不了的—— +TypeScript 的 `const x = foo()` 你想知道 `x` 是啥类型,只有 tsserver +能告诉你。 + +## 配置怎么写 + +跟 MCP server 一样,LSP server 从 plugin bundle manifest 读 +(`src/plugins/bundle-lsp.ts`、`src/agents/embedded-agent-lsp.ts`), +plugin 在自己的 manifest 里声明 `lspServers`,字段形态跟 MCP 的 +stdio server 几乎一样(`command`、`args`、`env`、`cwd`)。 + +OpenClaw 实际上**复用了 MCP 的 stdio launch config 结构**—— +`agent-bundle-lsp-runtime.ts` 里直接 import 了 `resolveStdioMcpServerLaunchConfig` +和 `describeStdioMcpServerLaunchConfig`,因为两者都是 "spawn 一个本地 +stdio 子进程跑 JSON-RPC",launch 部分可以共享。 + +## 现状的限制 + +`src/agents/embedded-agent-lsp.ts:22` 有一条注释: + +> User-configured LSP servers could override bundle defaults here in the future. + +意思是**当前只接受 plugin bundle 声明的 LSP server**,用户 config 还 +没接通。MCP 已经走得更远(用户 config 可以加自己的 server),LSP 还 +停在 plugin 预置的阶段。 + +退避逻辑也比 MCP 简化——没有 "连续 3 次失败冷却 60s" 这种保护, +失败就直接报错给 agent。 + +## 心智模型一句话 + +LSP 是把 "编辑器智能" 抽出来的协议,跟 MCP 是把 "agent 工具" 抽出来的 +协议同源同构(stdio + JSON-RPC + 服务器自报能力)。OpenClaw 借了 LSP +这套现成的代码分析基础设施,把 hover/definition/references 三个最有 +agent 价值的能力包装成 tool,放进 agent 的扁平 tool 列表里。模型层面 +跟其他 tool 没任何区别。 + +## 相关 + +- [mcp-overview.md](./mcp-overview.md) — MCP 协议本身 +- [mcp-client-internals.md](./mcp-client-internals.md) — MCP client + 的内部实现,LSP 在 attempt 时序里跟它平行 +- [plugin-tools.md](./plugin-tools.md) — plugin tool,LSP server 的 + 配置来源就是 plugin bundle diff --git a/mcp-client-internals.md b/mcp-client-internals.md new file mode 100644 index 0000000..46c1203 --- /dev/null +++ b/mcp-client-internals.md @@ -0,0 +1,297 @@ +# OpenClaw 作为 MCP client:内部实现 + +基于 [OpenClaw](https://github.com/openclaw/openclaw) 2026.6.2 版本源码。 + +本文专讲 OpenClaw 作为 MCP client 那一侧的实现:server 怎么被发现、 +何时连接、tool catalog 怎么暴露给模型、怎么缓存与失效、有哪些值得注意的 +安全/兼容细节。 + +MCP 是什么、和 tool use 的关系、`openclaw mcp serve` 那一侧的实现见 +[mcp-overview.md](./mcp-overview.md)。 + +## 整体生命周期 + +``` +[发现] 读 config + plugin manifest → 一份 server map + (进程启动 / session 创建时纯 JSON 读,不 spawn) + ↓ +[连接] attempt 开始触发 getCatalog + → resolveMcpTransport + → connectWithTimeout (stdio spawn / http 握手) + → client.listTools() + ↓ +[暴露] buildBundleMcpToolsFromCatalog + → MCP tool 包装成 AnyAgentTool(name 加 server 前缀) + → 跟 native / plugin / LSP tool 合到同一个数组 + → 发给 LLM + ↓ +[调用] 模型返回 tool_use → 找到 AnyAgentTool + → execute → client.callTool() + → 走早就建立好的 transport + ↓ +[缓存与失效] 同 session 的后续 attempt 复用 catalog;失效条件: + idle TTL / listChanged / 连续失败冷却 +``` + +## MCP server 的发现 + +简短答案:**没有"扫描发现",全靠声明式 config**。OpenClaw 从两个来源 +合并出 MCP server 列表。 + +### 两个来源 + +**1. 用户 config:`openclaw.json` 的 `mcp.servers`** + +类型定义在 `src/config/types.mcp.ts`: + +```ts +export type McpConfig = { + servers?: Record; + sessionIdleTtlMs?: number; +}; +``` + +用户通过 CLI(`openclaw mcp add/set/configure/...`)或直接编辑文件, +往 `~/.openclaw/openclaw.json` 里写入条目。CLI 实现在 +`src/cli/mcp-cli.ts`,读写在 `src/config/mcp-config.ts`。 + +**2. 已启用的 bundled plugin 自带的 MCP 定义** + +每个 plugin 在自己的 manifest 里可以声明附带的 MCP server,加载逻辑 +在 `src/plugins/bundle-mcp.ts`: + +- `loadEnabledBundleMcpConfig` 遍历 `manifestRegistry` 里所有 + **enabled** 的 plugin +- 对每个 plugin 调 `loadBundleMcpConfig`: + 1. 读 plugin 的 manifest(`claude` / `codex` / `cursor` 三种格式之一) + 2. 解析其中 `mcpServers` 字段声明的相对路径列表 + 3. 如果 plugin 根目录存在 `.mcp.json` 也自动加入 + 4. 把这些文件里的 server 定义和 manifest 里的 inline `mcpServers` + merge 起来 +- 路径里的 `${CLAUDE_PLUGIN_ROOT}` 占位符会被替换成 plugin 根目录 + +### 合并优先级 + +两个来源在 `src/agents/bundle-mcp-config.ts` 的 `loadMergedBundleMcpConfig` +里合并: + +```ts +mcpServers: { + ...enabledBundleMcp, // plugin 自带的(底) + ...enabledConfiguredMcp, // 用户 config 的(覆盖在上) +} +``` + +规则: + +1. **同名 server**,用户 config 覆盖 plugin 默认(注释里明确说 + "OpenClaw config 是 owner-managed layer") +2. 用户 config 里 `enabled: false` 不仅排除自己,还能**屏蔽掉 + 同名的 bundled server**(`disabledConfiguredNames` 集合) +3. 一次性的 embedded agent run 通过 `loadEmbeddedAgentMcpConfig` + (`src/agents/embedded-agent-mcp.ts`)拿到最终 merged 结果 + +### 发现链路 + +``` +启动 / 每次 session + │ + ├─ getRuntimeConfig() ← 读 ~/.openclaw/openclaw.json + │ └─ cfg.mcp.servers ← 用户层 + │ + ├─ PluginManifestRegistry.plugins ← 已发现的 plugin manifest + │ └─ 每个 plugin 的 .mcp.json / + │ manifest mcpServers ← plugin 层 + │ + └─ loadMergedBundleMcpConfig({ cfg, manifestRegistry, workspaceDir }) + ↓ + { mcpServers: { name → config } } ← 最终 server 列表(还没 spawn) +``` + +### 发现性质 + +1. **完全声明式**:OpenClaw 不去网络扫描、不去 ServiceDiscovery、 + 不去 mDNS——所有 server 必须显式写在某个 config 文件里 +2. **plugin 是隐式来源**:用户装个 plugin,可能就"被"得到几个 MCP + server,这是 plugin 作者预置的 +3. **Config 是 process-stable**:根 `AGENTS.md` 的架构约定—— + MCP server 列表在进程启动后视为稳定,改了 config 要 + `openclaw mcp reload` 或重启 agent +4. **server 列表的"发现"和 tool 的"发现"是两层**: + - server 列表:**静态**(读 config + plugin manifest) + - 每个 server 的 tool:**动态**(运行时 `tools/list`,还支持 + `listChanged` 通知) + +所以严格说,OpenClaw 作为 MCP client 对 **server** 是静态发现, +对 **tool** 是动态发现。 + +## 何时连接 + +关键澄清:**"懒"是相对"OpenClaw 启动"懒,不是相对"模型调用"懒**。 +每次 agent attempt(模型一轮请求)开始时,MCP server 会**先全部 +spawn + listTools**,然后 tool catalog 才被塞进发给模型的 tool 列表里。 + +### 三种粒度的"启动" + +| 粒度 | 何时发生 | 状态 | +| ----------------------------- | ----------------------- | ----------------------------------------------------- | +| OpenClaw 进程启动 | `openclaw` 命令开始跑 | **不连接任何 MCP** | +| Session 创建 | 用户第一次说话 | **不连接**(只准备 runtime 壳子) | +| Attempt 开始(每轮模型请求) | 准备给模型发请求 | **必须连接 + listTools**(否则模型不知道有哪些 tool) | +| Tool 实际调用 | 模型返回 tool_use | **走已建立的连接** | + +"懒到模型调用时再连接"是不行的——模型必须在请求里就看到全部 tool +才能决定调哪个。OpenClaw 的折衷点在 "attempt 开始" 这一步:对每个会话, +只在它真有 attempt 时才连(避免无对话的会话开销),但一旦要 attempt, +必须先把 catalog 凑齐。 + +### Attempt 引爆点 + +`src/agents/embedded-agent-runner/run/attempt.ts:1502` 这段是关键—— +**模型还没被调用前**,这段已经把 MCP 全部 spawn 完了: + +```ts +const bundleMcpSessionRuntime = bundleMcpEnabled + ? await getOrCreateSessionMcpRuntime({...}) // 拿 runtime(可能复用) + : undefined; +bundleMcpRuntime = bundleMcpSessionRuntime + ? await materializeBundleMcpToolsForRun({ // ← 这里触发 getCatalog + runtime: bundleMcpSessionRuntime, + reservedToolNames: [...], + }) + : undefined; +``` + +紧接着 `attempt.ts:1535` 把 MCP tool 合到 `allowedBundledTools`,后面跟 +native tool 一起发给模型。 + +### 完整时间线 + +``` +进程启动 → 读 config,记下"理论上有哪些 server",不 spawn +session 创建 → runtime 壳子,sessions=空,catalog=null +attempt 开始 → getCatalog(): + ├─ for 每个 server: + │ - resolveMcpTransport + │ - connectWithTimeout (stdio spawn / http 握手) + │ - client.listTools() + └─ 拼成 catalog + buildBundleMcpToolsFromCatalog(): + 把每个 MCP tool 包装成 AnyAgentTool + (name = "serverName__toolName") +组装 LLM 请求 → tools = [...native, ...plugin, ...mcp] +LLM 收到请求 → 看到 N 个 function 定义,只看 name/description/schema +LLM 返回 → tool_use: "filesystem__read_file" +执行 → 在 tool 列表里找到对应 AnyAgentTool, + 调它的 execute → runtime.callTool() → 已建立的 transport +``` + +## 模型视角:一个扁平的 tool 列表 + +**模型不知道"MCP"这个概念**。它只看到一个 tool 数组,每个 tool 形如: + +```json +{ + "name": "filesystem__read_file", + "description": "Read contents of a file. Returns text content...", + "input_schema": { "type": "object", "properties": { "path": {...} } } +} +``` + +跟 `Read`、`Bash` 这种 native tool 在请求里**完全是同一形状**。区别仅在: + +- **来源不同**:native 是代码里写的,MCP 是从 `client.listTools()` 拉来的 +- **名字有前缀**:`buildSafeToolName` 给 MCP tool 加 `serverName__` 前缀 + 避免重名,native tool 没这个前缀 +- **execute 走向不同**:native 在本进程跑,MCP 走 `client.callTool()` + 到子进程/HTTP + +`attempt.ts:1535-1549` 那段把所有来源 concat 起来: + +```ts +const allowedBundleMcpTools = applyEmbeddedAttemptToolsAllow( + bundleMcpRuntime?.tools ?? [], ... +); +const allowedBundleLspTools = applyEmbeddedAttemptToolsAllow( + bundleLspRuntime?.tools ?? [], ... +); +const allowedBundledTools = [...allowedBundleMcpTools, ...allowedBundleLspTools]; +``` + +native tool、plugin tool、MCP tool、LSP tool 全部以 `AnyAgentTool[]` +形式 concat,经过 provider adapter(Anthropic / OpenAI / Google)翻译 +成各自的 tool schema,塞进同一个请求的 `tools` 字段。 + +模型挑哪个工具是**纯粹基于 `description` 和 schema 跟用户意图的语义 +匹配**——跟它怎么挑 `Read` 还是 `Bash` 同一个机制。 + +### 扁平化的几个微妙后果 + +1. **模型可能"搞错来源"**——装了一个 `memory__write` 的 MCP 工具, + 模型在该用 native `Write` 写文件时可能调了 `memory__write`,只因 + 后者 description 更像当下意图。这就是为啥 MCP server 的 `description` + 写得好不好直接影响 agent 效果。 + +2. **名字前缀是唯一的"出身标签"**——`buildSafeToolName` 加 `serverName__` + 前缀(`agent-bundle-mcp-materialize.ts`),既防重名,也无意中给 + 模型一点"分组信号"。但模型不会被告知 `__` 前面是 server 名,它只是 + 把整个名字当字符串看。 + +3. **工具排序影响行为**——`agent-bundle-mcp-materialize.ts` 显式 + `tools.sort(...)` 是为了让同一份 catalog 在不同 turn 里给模型呈现的 + 顺序一致(对 prompt cache 命中重要,见根 `AGENTS.md` 里 + "deterministic ordering" 那条)。不排序时顺序漂移会让 prompt + 字节级别变化,KV cache 失效。 + +4. **tool filter 是预算工具**——`toolFilter.include/exclude` 在 catalog + 阶段就过滤掉,模型完全感知不到"被屏蔽"的工具存在。这是抑制模型 + 乱调 MCP 工具的主要手段。 + +一句话:**对 LLM 来说,世界是扁平的一组 function;MCP 是 OpenClaw 这 +一层的实现细节,跨过 provider boundary 之后就消失了。** + +## Catalog 缓存与失效 + +`agent-bundle-mcp-runtime.ts` 的 `getCatalog` 有缓存——同一个 session 的 +后续 attempt **不再重新 listTools**,直接复用 catalog。 + +失效条件: + +- session 空闲超过 `mcp.sessionIdleTtlMs`(默认 10 分钟)被回收, + 下次 attempt 时 catalog 重新拼 +- MCP server 发了 `listChanged` 通知,`onChanged` callback 里 + `catalog = null` 强制下次刷新 +- 某个 server 连续 3 次失败,进入 60s 冷却期(后面 "Failure 退避" 段) + +这就是"server 列表静态发现 + tool 列表动态发现 + tool catalog 缓存" +三层的实际落地。 + +## 几个值得注意的实现细节 + +**JSON Schema 兼容**(`agent-bundle-mcp-runtime.ts` `createBundleMcpJsonSchemaValidator`): +MCP 用 JSON Schema draft 2020-12,而 TypeBox/Ajv 对一些 keyword 支持 +不一致,OpenClaw 用 `stripJsonSchemaFormats` + `normalizeJsonSchemaForTypeBox` +做 schema 归一化后再交给 TypeBox 编译。 + +**Content block 收敛**(`agent-bundle-mcp-materialize.ts` `mcpContentBlockToToolResult`): +MCP 的 `CallToolResult` 可以返回 text/image/audio/resource_link/resource 等 +多种 block,但 OpenClaw 的 `AgentToolResult` 只支持 text/image。多余类型 +被降级成 text,避免 provider(Anthropic)拒掉无效 image block 后整个 +session 历史被污染(注释里点了 issue #90710)。 + +**Stdio env 安全过滤**(`docs/cli/mcp.md` "Stdio env safety filter"): +`NODE_OPTIONS`、`PYTHONSTARTUP`、`LD_PRELOAD` 这类能在 RPC 之前改变 +解释器行为的 env 被禁止写入 stdio server 的 `env` 字段。 + +**Failure 退避**(`agent-bundle-mcp-runtime.ts` 常量 `BUNDLE_MCP_FAILURE_THRESHOLD`): +同一 server 连续 3 次失败会触发 60s 冷却,防止一个坏 server 阻塞整个 +agent turn。 + +**Tool filter**:每个 server 可配 `toolFilter.include/exclude`(支持 `*` glob), +catalog 阶段就把不想暴露给模型的工具过滤掉。 + +## 相关 + +- [mcp-overview.md](./mcp-overview.md) — MCP 协议本身、OpenClaw 的两个 + 方向、MCP 与 tool use 的对比 +- `docs/cli/mcp.md` — 官方面向用户的 CLI 文档 diff --git a/mcp-overview.md b/mcp-overview.md new file mode 100644 index 0000000..02bac9a --- /dev/null +++ b/mcp-overview.md @@ -0,0 +1,241 @@ +# MCP(Model Context Protocol)概览 + +基于 [OpenClaw](https://github.com/openclaw/openclaw) 2026.6.2 版本源码。 +本文讲 MCP 是什么、OpenClaw 怎么用它、它和 tool use 是什么关系。 + +OpenClaw 作为 MCP client 的内部实现(发现、连接、catalog、缓存等) +单独写在 [mcp-client-internals.md](./mcp-client-internals.md)。 + +## MCP 是什么 + +MCP 是一个开放协议,定义了 **LLM 应用(host)与外部工具/数据源(server) +之间的通信格式**。可以理解为"AI 应用的 USB-C 接口":一个 host 通过 MCP +可以同时接入文件系统、数据库、第三方 API 等任意 server,而不需要为每个 +工具单独写 adapter。 + +通信用 JSON-RPC 2.0,MCP server 暴露三类能力: + +- **tools** — 模型可以调用的函数(`tools/list`、`tools/call`) +- **resources** — 可读的数据源(`resources/list`、`resources/read`) +- **prompts** — 可被引用的预制 prompt 模板 + +## OpenClaw 里 MCP 的两个方向 + +OpenClaw 同时扮演 MCP server 和 MCP client(见 `docs/cli/mcp.md`): + +``` + ┌──────────────────────┐ + │ External MCP client │ + │ (Codex / Claude Code)│ + └─────────┬────────────┘ + │ stdio JSON-RPC + ▼ + ┌──────────────────────────────────────┐ + │ openclaw mcp serve │ + │ (src/mcp/channel-server.ts) │ ← OpenClaw as MCP server + │ ChannelBridge → Gateway WS │ + └──────────────────────────────────────┘ + + ┌──────────────────────────────────────┐ + │ OpenClaw agent runtime │ + │ (src/agents/agent-bundle-mcp-*.ts) │ ← OpenClaw as MCP client + └─────────┬────────────────────────────┘ + │ stdio / SSE / streamable-http + ┌─────────▼──────────┐ ┌──────────────┐ + │ third-party MCP │ │ remote HTTP │ + │ server (filesystem,│ │ MCP server │ + │ memory, ...) │ │ (OAuth) │ + └────────────────────┘ └──────────────┘ +``` + +### 方向 1:OpenClaw 当 server(`openclaw mcp serve`) + +把 OpenClaw 的 channel 会话(Telegram/Discord/iMessage 等)通过 MCP +暴露给外部 client(Codex、Claude Code 等)。 + +入口在 `src/cli/mcp-cli.ts`,核心装配在 `src/mcp/channel-server.ts`: + +``` +McpServer(@modelcontextprotocol/sdk) + + OpenClawChannelBridge (连到本地/远端 Gateway WebSocket) + + registerChannelMcpTools(server, bridge) +``` + +`src/mcp/channel-tools.ts` 注册的 tool: + +- `conversations_list` / `conversation_get` +- `messages_read` / `attachments_fetch` +- `events_poll` / `events_wait` +- `messages_send` +- `permissions_list_open` / `permissions_respond` + +传输是 stdio:外部 client 启动 `openclaw mcp serve` 子进程,两边通过 +stdin/stdout 跑 JSON-RPC。OpenClaw 内部再用 WebSocket 连到 Gateway 取 +真实会话。 + +### 方向 2:OpenClaw 当 client + +`openclaw mcp add/set/configure/probe/login/...` 把第三方 MCP server +保存到 `openclaw.json` 的 `mcp.servers` 下;OpenClaw 的 agent runtime +在跑的时候把这些 server spawn 起来,并把它们的 tool 合到模型可调用的 +工具列表里。 + +这一侧的详细实现(server 怎么被发现、何时连接、catalog 怎么暴露给模型、 +怎么缓存与失效、几个安全/兼容细节)单独写在 +[mcp-client-internals.md](./mcp-client-internals.md)。 + +## MCP 与 tool use 的区别 + +这两个经常被混在一起,其实是 **不同层的概念**。 + +一句话: + +- **Tool use(function calling)**:模型 ↔ 应用之间的**约定**—— + 模型怎么"申请调用一个函数" +- **MCP**:应用 ↔ 工具实现之间的**协议**—— + 这个函数从哪儿来、怎么真正执行 + +模型不知道 MCP 的存在,MCP server 也不知道是哪个模型在调它。 + +### 分层看 + +``` +┌─────────────────────────────────────────┐ +│ LLM (Claude / GPT / Gemini) │ +│ 只认 tool_use / function_call │ ← Tool Use 层 +└─────────────────────┬───────────────────┘ + │ provider API +┌─────────────────────▼───────────────────┐ +│ OpenClaw agent runtime │ +│ - 收集所有可用 tool │ +│ - 打平成 provider 期望的格式 │ +│ - 收到 tool_use 后分发执行 │ +└──┬───────────────┬──────────────┬───────┘ + │ │ │ +┌──▼──────┐ ┌─────▼─────┐ ┌─────▼──────┐ +│ native │ │ plugin │ │ MCP server │ ← Tool 来源 +│ Read/ │ │ tool │ │ (stdio/HTTP)│ +│ Bash... │ │ │ │ │ +└─────────┘ └───────────┘ └─────────────┘ +``` + +模型那一层永远是 tool use;MCP 只是 OpenClaw 拿到工具实现的一种方式。 + +所有 tool 最后都被归一化成同一个类型 `AnyAgentTool` +(`src/agents/tools/common.ts`): + +- **Native tool**:代码里直接定义,例如 `src/agents/tools/read.ts` +- **MCP tool**:`buildBundleMcpToolsFromCatalog` 把 `Client.listTools()` + 返回的远端 catalog 包装成 `AnyAgentTool`,`execute` 里调 + `client.callTool(...)` + +### 关键差异表 + +| 维度 | Tool use(原生 function calling) | MCP | +| ------------- | -------------------------------- | ---------------------------------------------- | +| 谁定义 | 应用代码内联定义 schema | 远端 MCP server 自己声明 | +| 谁执行 | 应用代码同进程里调用 | 远端 server(子进程 stdio,或 HTTP) | +| 发现方式 | 编译时静态注册 | 运行时 `tools/list` 动态拉取 | +| 跨语言 | 绑死宿主语言 | JSON-RPC 任何语言都能写 server | +| 跨应用复用 | 每个应用各写一遍 | 同一个 server 给 Claude Code、Codex、OpenClaw 都用 | +| 协议层 | 没有协议,就是个函数 | JSON-RPC 2.0 + 标准 schema | +| 能力 | 只有 function | tools / resources / prompts / notifications | +| 生命周期 | 跟着进程 | 独立进程,需要管 spawn/health/timeout/idle reclaim | + +### 类比 + +- **Tool use** ≈ 编程语言里"函数调用"这个概念本身 +- **MCP** ≈ gRPC / OpenAPI ——一种让函数能跨进程/跨语言被发现和调用的具体协议 + +或者: + +- Tool use ≈ "我要打个电话" +- MCP ≈ "电话怎么拨通对方公司的总机,问到分机号,再接到具体的人" + +### 实际后果 + +1. **不用 MCP 也能 tool use**:OpenClaw 的 `Read`、`Bash`、`Edit` 这些都是 + 原生 tool,模型直接调,没 MCP 什么事。 +2. **同一个 MCP server 给多个 host 复用**:`@modelcontextprotocol/server-filesystem` + 一次装好,Claude Code 和 OpenClaw 都能用;换成原生 tool 就得在每个 + host 里各实现一遍。 +3. **MCP server 挂掉不影响其他 tool**:远端调用有网络/进程/超时故障模式, + OpenClaw 用失败退避隔离(见 [mcp-client-internals.md](./mcp-client-internals.md) + 的 "几个实现细节")。 +4. **MCP tool 对模型完全透明**:模型看到的就是 `filesystem__read_file(path)`, + 跟 native tool 长得一样,`__` 前缀只是为了避免重名,不是给模型暗示 + 这是 MCP。 + +### 编译时 vs 运行时:典型用法不是协议本质 + +常见直觉是 "native tool use 是编译时静态、开发者固定;MCP 是运行时 +动态、用户自定义"。方向对,但这是 **典型用法**,不是 **协议本质**。 + +实践中的分布确实如此: + +| 维度 | Native tool use | MCP tool | +| ----------- | ------------------- | ---------------------------------- | +| 谁定义 | 开发者写在代码里 | 用户在 config 里加 | +| 何时确定 | 构建时 / 启动时 | 运行时 `tools/list` 拉来 | +| 变更成本 | 改代码、重发版 | 改 `openclaw.json` + `mcp reload` | +| 列表稳定性 | 一个版本里固定 | session 内可变(MCP `listChanged`)| + +但严格说要分两层: + +- **Tool use(协议层)** 没规定 tool 必须是静态的。应用完全可以每次请求 + 前动态生成 tool 列表(按用户权限、session 状态等)塞给模型。模型只看到 + 一个数组,不在乎是 hardcode 还是即时拼的。 +- **MCP** 协议本身**强制动态**:server 自己声明 `tools/list`,host 必须 + 运行时去问——因为 tool 在另一个进程里,编译时无从知晓。 + +所以更准的说法: + +- Tool use **允许**静态也**允许**动态,大多数应用选静态(因为简单) +- MCP **强制**动态(协议天然属性) + +### 更本质的轴:所有权 + +真正的分水岭是 **谁拥有 tool 实现**: + +- **Native tool** = 应用拥有 → 自然静态、自然由开发者掌控 +- **MCP tool** = 第三方/用户拥有 → 必须动态发现、自然由用户配置 + +"编译时 vs 运行时"是这个所有权差异的**自然后果**,不是原因。 + +### 一个反例:plugin tool + +OpenClaw 的 **plugin tool**(`extensions/*` 里那些)既不是 native 也不是 +MCP,正好用来校准这两根轴: + +- 像 native 一样:用 TypeScript 写,跟主进程同语言,**编译时**就在 +- 像 MCP 一样:不在 core 里,由"插件作者"(广义的用户/第三方)拥有 + +它们走 `src/plugin-sdk/*`,跟 native tool 一样进 `AnyAgentTool`,跟 MCP tool +一起被打平。 + +这说明 **"动态/静态" 和 "开发者/用户" 是两个独立轴**,plugin tool 占了 +"用户拥有 + 编译时确定" 那一格: + +``` + 开发者拥有 用户/第三方拥有 + ┌──────────────────┬──────────────────┐ + 编译时确定 │ native tool │ plugin tool │ + ├──────────────────┼──────────────────┤ + 运行时发现 │ (动态生成的 │ MCP tool │ + │ native tool) │ │ + └──────────────────┴──────────────────┘ +``` + +## 心智模型一句话 + +Tool use 是模型层的调用约定;native / plugin / MCP 是这个约定下 +**tool 实现的几种供应来源**,一部分由应用开发者编译时固化,一部分由 +用户或第三方运行时供应。MCP 不替代 tool use,它给 tool use 加了一个 +"工具供应链":模型继续按 tool use 协议出请求,应用按 MCP 协议去远端 +把工具 *供应* 回来,放到模型看到的 tool 列表里。 + +## 继续阅读 + +- [mcp-client-internals.md](./mcp-client-internals.md) — OpenClaw 作为 + MCP client 的内部实现:server 发现、连接时机、catalog 暴露、缓存与 + 失效、安全/兼容细节 diff --git a/plugin-tools.md b/plugin-tools.md new file mode 100644 index 0000000..9fb3d8e --- /dev/null +++ b/plugin-tools.md @@ -0,0 +1,226 @@ +# Plugin Tool + +基于 [OpenClaw](https://github.com/openclaw/openclaw) 2026.6.2 版本源码。 + +本文讲 OpenClaw 的 **plugin tool**:跟 native tool、MCP tool 同列, +但归插件作者拥有、用 TypeScript 写、跑在 OpenClaw 同进程里的那一类工具。 + +## 定位 + +OpenClaw agent 看见的工具一共有三种来源(详见 +[mcp-overview.md](./mcp-overview.md) 的 "MCP 与 tool use 的区别"): + +| | 拥有者 | 在哪儿 | 进程 | 协议 | +| ------------- | ----------------- | -------------------------------- | -------------- | -------------------------- | +| **native tool** | OpenClaw core | `src/agents/tools/*` | 同进程 | 无 | +| **plugin tool** | 插件作者 | `extensions//`(可第三方) | 同进程 | OpenClaw Plugin SDK | +| **MCP tool** | 第三方/用户 | 任何 MCP server | 子进程/远端 | MCP JSON-RPC | + +plugin tool 占的格子是:**用 TypeScript 写、走 OpenClaw 内部 SDK 接口、 +不需要跑子进程**。介于 "core 写死的 native" 和 "完全解耦的 MCP" 之间, +专门用来做"深度集成 OpenClaw 内部能力,但又不属于 core" 的工具。 + +## 怎么写一个 + +入口 `src/plugin-sdk/tool-plugin.ts` 的 `defineToolPlugin`。最小例子 +(`extensions/llm-task/index.ts`): + +```ts +import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin"; +import { Type } from "typebox"; + +export default defineToolPlugin({ + id: "llm-task", + name: "LLM Task", + description: "Generic JSON-only LLM tool for structured tasks.", + configSchema: Type.Object({ + defaultProvider: Type.Optional(Type.String()), + defaultModel: Type.Optional(Type.String()), + // ... + }), + tools: (tool) => [ + tool({ + name: "llm-task", + description: "...", + parameters: Type.Object({ // TypeBox schema + prompt: Type.String(), + input: Type.Optional(Type.Unknown()), + // ... + }), + execute: async (params, config, context) => { // 直接执行 + // 用 context.api 调用 host 服务(LLM 推理、存储等) + // 返回 text 或 JSON + }, + // 或者: + // factory: ({ api, config, toolContext }) => createMyTool(api), + }), + ], +}); +``` + +两种声明方式(`tool-plugin.ts` `ToolPluginToolDefinition` 的两个分支): + +- **`execute`**:声明完就是个完整的 tool,框架直接调 +- **`factory`**:返回一个或多个 `AnyAgentTool`,适合需要根据 + config / capability 动态构造工具的场景(`llm-task` 用的就是 factory) + +`defineToolPlugin` 内部把每个 tool 注册到 plugin entry,在 plugin +activation 时跑 `register(api)`,把 tool 加进 agent 可用 tool 池 +(`tool-plugin.ts:187-200`)。 + +## Manifest 让发现"不跑代码" + +每个 plugin 同时有一份 `openclaw.plugin.json` +(`extensions/llm-task/openclaw.plugin.json`): + +```json +{ + "id": "llm-task", + "activation": { "onStartup": true }, + "configSchema": { "..." }, + "contracts": { + "tools": ["llm-task"] + }, + "toolMetadata": { + "llm-task": { "optional": true } + } +} +``` + +`contracts.tools` 把 plugin 提供的工具名列出来。这样 OpenClaw 在 +**还没加载 plugin 代码**时就知道"装了这个 plugin 会多哪些工具"—— +`openclaw plugins list`、setup、文档生成都不用 import 真实运行时。 + +这条规则在 `src/plugins/CLAUDE.md` 里写明: + +> Preserve manifest-first behavior: discovery, config validation, and setup +> should work from metadata before plugin runtime executes. + +`defineToolPlugin` 输出的对象上挂了一个 `[toolPluginMetadataSymbol]`, +包含同样的 metadata,这样 manifest 落地后可以做 contract 校验, +保证 manifest 跟代码不漂移(`tool-plugin.ts:109-116`)。 + +## Plugin SDK 边界 + +来自根 `AGENTS.md` 的硬规则: + +> 插件 prod 代码:no core `src/**`, `src/plugin-sdk-internal/**`, other +> plugin `src/**`, or relative outside package. + +插件只能通过 `openclaw/plugin-sdk/*` 跟 core 交互。`defineToolPlugin` +的 `register(api)` 拿到的 `OpenClawPluginApi` 就是 plugin 看得见的所有 +host 能力(LLM 推理、存储、其他 plugin 句柄等),core 内部的具体实现 +对 plugin 透明。 + +这条边界让 plugin tool 既能用上 host 内部能力(不像 MCP server 那样 +完全隔离),又不会被 core 实现变更直接打死(只要 SDK 不破坏)。 + +## 仓库里有哪些 plugin tool + +`grep "contracts.*tools" extensions/*/openclaw.plugin.json` 找到的: + +| 插件 | 提供的能力 | +| ----------------- | ----------------------------------- | +| `browser` | 浏览器自动化 | +| `canvas` | 画图 | +| `diffs` | 大文件 diff | +| `llm-task` | "调一次 LLM 跑结构化任务" | +| `memory-core` | 知识/记忆存取 | +| `memory-lancedb` | 向量记忆 | +| `memory-wiki` | wiki 风格记忆 | +| `tavily` | Web 搜索 | +| `firecrawl` | Web 抓取 | +| `feishu` | 飞书集成 | +| `qqbot` | QQ 集成 | +| `google-meet` | Google Meet 集成 | +| `file-transfer` | 文件传输 | +| `codex-supervisor` | Codex 任务编排 | +| `lobster` | (内部任务) | + +`extensions/` 里**大多数其实不是 tool plugin**,而是另外两类——见下节。 + +## 它在更大的 plugin 生态里的位置 + +`extensions/` 里有三种主要 plugin,用不同的 `define*Plugin` 入口: + +| 入口 | 用途 | 例子 | +| ------------------------------- | ---------------------- | ------------------------------ | +| `defineToolPlugin` | 给 agent 注册工具 | `llm-task`, `tavily`, `browser` | +| `defineChannelPluginEntry` | 接入消息渠道 | `telegram`, `discord`, `imessage` | +| `defineSingleProviderPluginEntry` | 接入 LLM provider | `anthropic`, `openai`, `google` | +| `defineSetupPluginEntry` | 安装/onboarding 流程 | 各 plugin 的 setup 部分 | + +不同入口在 SDK 内部都收敛到 `definePluginEntry`(`src/plugin-sdk/plugin-entry.ts`) +拿到统一的 plugin lifecycle(activation、register、config)。 + +**channel / provider plugin 不直接给 agent 注册工具**——它们暴露的是 +消息渠道或模型推理能力。tool plugin 是这个生态里**专门给 agent 加 +function 的那一类**。 + +(理论上 channel plugin / provider plugin 也可以通过 `register(api)` 里 +顺手注册 tool,但很少这么干;一旦这么做就既是 channel/provider plugin +又有 tool plugin 的属性。) + +## 它怎么进到模型的 tool 列表 + +跟 MCP tool 走同一条路径(见 +[mcp-client-internals.md](./mcp-client-internals.md) 的 "Attempt 引爆点"): + +``` +plugin 启动时(activation.onStartup 或按需) + → tools 列表里每个 tool({...}) 被注册成 AnyAgentTool + → 加进 agent 的可用 tool 池(在 host 进程内存里) + +每次 attempt 开始 + → tools = [...native, ...plugin, ...bundleMcp, ...bundleLsp] + → 走 provider adapter + → 发给 LLM +``` + +模型看见的就是一个名字 `llm-task` 或者 `tavily_search`,没有任何 +"这是 plugin"的标识。execute 时框架找到对应 tool,调它的 `execute` +函数,**这一切都在 OpenClaw 同一个 Node 进程里**,跟 MCP 走 stdio +子进程或 HTTP 完全不同。 + +## 跟 MCP tool 的关键对比 + +| 维度 | plugin tool | MCP tool | +| -------------- | ---------------------------------- | ---------------------------------- | +| 语言 | TypeScript(锁死 Node) | 任意(JSON-RPC) | +| 进程 | 同进程 | 子进程 / 远端 | +| 启动开销 | 几乎 0(import 即可) | spawn + 握手 + listTools | +| 故障隔离 | 一个 plugin 崩了可能影响整个进程 | 子进程崩了不影响别人,有失败退避 | +| 访问 host 服务 | 通过 `OpenClawPluginApi` | 完全隔离,什么都拿不到 | +| 跨应用复用 | OpenClaw 专属 | 任何 MCP host 都能用 | +| 分发 | npm package(`@openclaw/plugin-*`) | 任何二进制 / 服务 | +| 适合做什么 | 深度集成 OpenClaw 内部能力的工具 | 通用、跨应用、可独立运行的工具 | + +例子:`llm-task` 必须是 plugin tool,因为它要复用 OpenClaw 的 +provider/auth/catalog 体系跑一次内嵌 LLM 推理; +`@modelcontextprotocol/server-filesystem` 适合做 MCP server,因为它 +跟 OpenClaw 没耦合,Claude Code 也能用。 + +## 在"两根轴"里的位置 + +回顾 [mcp-overview.md](./mcp-overview.md) 的所有权 × 时机两根轴, +**plugin tool 占了"用户/第三方拥有 + 编译时确定"那一格**: + +``` + 开发者拥有 用户/第三方拥有 + ┌──────────────────┬──────────────────┐ + 编译时确定 │ native tool │ plugin tool ★ │ + ├──────────────────┼──────────────────┤ + 运行时发现 │ (动态 native) │ MCP tool │ + └──────────────────┴──────────────────┘ +``` + +这个反例的价值在于:它证明了 "动态/静态" 跟 "开发者/用户" 是 +**两个独立的轴**——不能简单说 "用户拥有的就一定是运行时发现"。 +plugin tool 是用户拥有的(插件作者写、用户装),但因为还是 npm +package 同语言同进程,完全可以编译时确定。 + +## 相关 + +- [mcp-overview.md](./mcp-overview.md) — MCP 概览、与 tool use 的关系 +- [mcp-client-internals.md](./mcp-client-internals.md) — MCP client + 实现细节(plugin tool 跟 MCP tool 在 attempt 里的拼装) diff --git a/rag-overview.md b/rag-overview.md new file mode 100644 index 0000000..d7f1081 --- /dev/null +++ b/rag-overview.md @@ -0,0 +1,382 @@ +# RAG 与向量化 + +本文是 RAG(Retrieval-Augmented Generation)概念笔记,重点回答两个 +常被混淆的问题:**向量化的结果是什么**、**拼进 prompt 的到底是 +向量还是原文**。 + +不像本目录其他笔记基于 OpenClaw 源码,这一篇是通用概念整理。 + +## RAG 是什么 + +一句话:**让 LLM 在回答前先去外部知识库里捞一段相关资料,把资料 +拼进 prompt,再让模型基于资料回答。** + +它解决 LLM 的三个硬伤: + +1. **知识截止** — 训练完就定死了,新事件不知道 +2. **不知道私有数据** — 公司文档、产品手册、内部 wiki 它没见过 +3. **会编(幻觉)** — 不知道的事会一本正经胡说 + +RAG 的思路是:别让模型 "凭记忆答",让它 "看着资料答"。 + +## 跟其他方案的对比 + +| 方案 | 优势 | 劣势 | +| ------------------------------- | ----------------------------------- | ------------------------------------ | +| **RAG** | 知识可实时更新,可解释,便宜 | 检索质量决定上限,prompt 会变长 | +| **Fine-tuning** | 模型"内化"知识,不用每次塞资料 | 训练贵,数据更新要重训,容易遗忘 | +| **超长 context(塞全部文档)** | 不用检索,看全部信息 | 贵,"大海捞针"问题 | +| **Tool use / MCP**(让模型自己调 search) | 灵活,按需查 | 多一轮往返,延迟高,不一定查到 | + +实际系统经常组合:RAG 提供基础知识,fine-tuning 调风格,tool use +处理结构化查询。 + +## 最小工作流 + +``` +用户问:"我们公司的报销额度是多少?" + ↓ +[1] 检索(Retrieval) + 把问题向量化,在公司文档向量库里找最相关的几段 + → 拿到: "差旅报销单笔上限 2000 元,需要主管审批..." + ↓ +[2] 增强(Augmentation) + 把检索结果拼进 prompt: + + "根据以下公司文档回答问题: + 差旅报销单笔上限 2000 元,需要主管审批... + + 问题: 我们公司的报销额度是多少?" + ↓ +[3] 生成(Generation) + LLM 基于 prompt 里给的资料生成回答 + → "公司差旅报销单笔上限是 2000 元,需要主管审批。" +``` + +## 典型架构 + +**离线索引阶段**(只做一次,数据更新时跑): + +``` +文档 → 切块(chunking) → embedding → 向量数据库 +``` + +**在线检索阶段**(每次用户问问题时): + +``` +用户问题 → embedding → 向量数据库找 top-k 相似块 → 拼 prompt → LLM +``` + +常见 top-k = 3 ~ 10。常用向量数据库:Pinecone、Weaviate、Qdrant、 +LanceDB、pgvector(Postgres 扩展)。 + +## 向量化:这是什么、为什么需要 + +### 为什么需要向量化 + +用户问 **"怎么退货?"**,文档库里有: + +``` +A. "退款政策:商品 7 天内可申请退货退款..." +B. "如何使用购物车..." +C. "我们的隐私条款..." +``` + +朴素的关键词匹配有问题: + +- 用户问 "返货" 或 "怎么把货退回去" 时关键词对不上 +- 文档里写 "退款政策",没出现 "退货" 原词 +- 用户问 "我不想要这个东西了" 时,字面完全不含 "退货" + +**关键词匹配是字面的,问问题是语义的**。需要一种方法,让计算机 +能算 "这两段文字意思有多接近",哪怕字面完全不同。 + +### 向量是什么 + +向量就是 **一组数字**。比如: + +``` +"退货" → [0.21, -0.13, 0.88, 0.45, ..., 0.07] ← 1536 个数 +``` + +这 1536 个数字不是随便定的,它们是某种 "语义坐标"。 +**语义相近的词,向量在数学上也接近**: + +``` +"退货" → [0.21, -0.13, 0.88, ...] +"退款" → [0.23, -0.10, 0.85, ...] ← 跟 "退货" 很接近 +"返货" → [0.22, -0.12, 0.87, ...] ← 也接近 +"购物车" → [0.91, 0.40, -0.20, ...] ← 离 "退货" 远 +"隐私" → [-0.55, 0.70, -0.10, ...] ← 完全不同方向 +``` + +把它们想象成 1536 维空间里的点——意思相近的词,在这个空间里挨得近。 + +### "近" 怎么算 + +用 **余弦相似度(cosine similarity)**: + +``` +两向量方向越一致 → 越相似(值接近 1) +两向量方向越垂直 → 越无关(值接近 0) +两向量方向越相反 → 越相反(值接近 -1) +``` + +RAG 实际就是 "把用户问题向量和每段文档向量两两算 cosine,排序, +取 top-k"。 + +### 谁来 "算出" 这些向量 + +预训练好的 **embedding 模型**。常见: + +- OpenAI 的 `text-embedding-3-small` / `text-embedding-3-large` +- 开源 `bge-large` / `gte-large` / `m3e` +- 中文向量化常用 `bge-large-zh` / `m3e-base` +- 多语言 `bge-m3` / `multilingual-e5` + +训练目标大致是 "意思相近的句子输出向量接近,意思不同的句子输出向量 +远"。用的时候只需要: + +```python +embedding = embed_model("退货") +# → [0.21, -0.13, 0.88, ..., 0.07] (1536 个数) +``` + +做 RAG 时把它当**黑盒**:输入文本,输出向量,语义近的向量接近。 + +## 向量化的结果是什么、拼进 prompt 的是什么 + +**这是 RAG 最容易混淆的点。** + +答案:**向量化的结果是向量(一组数字),但拼到 prompt 里的是原文, +不是向量。向量只用来"找",找到之后用的是原文。** + +### 数据流 + +``` +[索引阶段] + + 原文 向量(用来查) 存储 + ┌──────────────────┐ + │ 块 #001: │ + │ "退款政策:商品 │ ─embedder→ [0.21,-0.13,..] ─┐ + │ 7 天内可申请退货"│ │ + └──────────────────┘ │ + ┌──────────────────┐ │ 存进数据库: + │ 块 #002: │ │ 每条记录有两份 + │ "购物车使用指南" │ ─embedder→ [0.91, 0.40,..] ─┤ 数据并存: + └──────────────────┘ │ ① 向量(索引用) + ┌──────────────────┐ │ ② 原文(召回用) + │ 块 #003: │ │ + │ "隐私条款..." │ ─embedder→ [-0.55,0.70,..] ─┘ + └──────────────────┘ + + 向量数据库里实际存的: + ┌─────────────────────────────────────────────────────────────┐ + │ id │ vector │ text │ + │ #001 │ [0.21,-0.13,0.88,..]│ "退款政策:商品 7 天内可..." │ + │ #002 │ [0.91, 0.40,-0.20,..]│ "购物车使用指南..." │ + │ #003 │ [-0.55,0.70,-0.10,..]│ "隐私条款..." │ + └─────────────────────────────────────────────────────────────┘ + ↑ ↑ + 用来 "找" 用来 "还原" +``` + +``` +[查询阶段] + + 用户问题 + ↓ + "怎么退货?" + ↓ + ┌─embedder→ [0.20,-0.14,0.86,...] ← 问题也变成向量 + │ + │ 跟数据库里所有向量算 cosine 相似度 + │ + │ Top-1:#001 相似度 0.89 + │ ↓ + │ 根据 id #001 取回原文: + │ "退款政策:商品 7 天内可申请退货退款..." + │ ↓ + └→ 把【原文】拼进 prompt: + + "根据以下文档回答: + 退款政策:商品 7 天内可申请退货退款... + + 问题:怎么退货?" + ↓ + 发给 LLM +``` + +### 关键澄清 + +**1. 向量只用来检索,不参与 prompt** + +LLM 完全看不到向量。它跟 embedding 模型是两个不同的模型: + +- embedding 模型:**文本 → 向量** +- LLM(GPT / Claude):**文本 → 文本** + +把向量塞进 LLM 的 prompt 没意义——LLM 不知道 `[0.21, -0.13, ...]` +是什么,它只懂自然语言 token。 + +**2. 向量数据库同时存向量和原文** + +每条记录通常长这样: + +```json +{ + "id": "doc-001-chunk-3", + "vector": [0.21, -0.13, 0.88, ...], // 索引结构,算相似度用 + "text": "退款政策:商品 7 天内可申请退货退款...", // 原文,召回后用 + "metadata": { + "source": "policies/refund.md", + "chunk_index": 3, + "updated_at": "2026-05-12" + } +} +``` + +- `vector` 字段:用 HNSW 之类的索引结构,给 "找最近邻" 用 +- `text` 字段:召回后真正塞进 prompt 的内容 +- `metadata`:过滤条件(按日期、按部门筛选) + +**3. 向量是有损的压缩** + +`text-embedding-3-small` 输出 1536 维向量,1536 个浮点数 ≈ 6 KB, +跟几百字原文容量差不多——但**你没法从向量还原出原文**。 + +向量是 "这段文字的语义指纹",指纹用来匹配,看真实内容要回到原文。 +数据库必须把原文也存下来。 + +### 类比 + +可以把这套机制类比图书馆: + +- **向量** ≈ 图书索引卡的 "主题分类号",用来快速定位 +- **原文** ≈ 书的内容,真正要读的东西 + +读者(LLM)读的是书,不是索引卡。索引卡只是帮你找到该读哪本书。 + +### 完整 prompt 长什么样 + +实际发给 LLM 的请求大概(简化): + +``` +System: You are a helpful assistant. Answer based on the provided context. + +Context: +--- +[Source: policies/refund.md] +退款政策:商品 7 天内可申请退货退款,需保持原包装完好,联系客服 400-xxx 办理。 +--- +[Source: faq/shipping.md] +订单状态查询:登录后在"我的订单"查看,异常订单 24 小时内会有客服回访。 +--- + +Question: 怎么退货? +``` + +从头到尾全是自然语言文本。**向量已经在检索阶段完成了它的使命, +不会出现在最终 prompt 里。** + +## 向量的几个有意思的性质 + +### 1. 不止短词,任意长度文本 + +embedding 模型能把一个 **短词** 变成向量,也能把一 **整段几百字** +变成同样维度的向量。所以 "问题向量" 和 "文档向量" 可以直接比较。 + +### 2. 跨语言也能用 + +好的多语言 embedding(`bge-m3`、`multilingual-e5`)让 "退货" 和 +"return goods" 的向量接近。RAG 可以做跨语言检索:中文问题查英文文档。 + +### 3. 多模态 + +CLIP 这类模型把 **图片和文字** 映射到同一个向量空间。可以用文字 +"穿红裙子的女孩" 检索图片库——文字向量和图片向量直接算 cosine。 + +### 4. 维度高就是为了 "细分" + +为什么要 1536 维而不是 10 维?因为人类语言的语义维度太多——词性、 +情感、领域、时态、正式程度...每一维捕捉一点点信息。低维度容易把 +"无关的事压扁到同一处"。 + +## RAG 的几个常见坑 + +### 1. 检索失败比幻觉更糟 + +如果检索没拉到相关文档,模型只能瞎编。要么加 "如果资料里没有就说 +不知道",要么在 retrieval 失败时直接拒绝回答。 + +### 2. 切块策略影响巨大 + +按固定长度切会切断句子,按段落切又长度不均。常见做法: + +- **语义切块**:用嵌入相似度决定切点 +- **重叠切块**:相邻块有 10% ~ 20% 重叠,避免边界丢上下文 +- **保留层级**:把 "标题 + 段落" 一起塞进块里,保留文档结构 + +### 3. embedding ≠ 相关性 + +embedding 抓的是 "语义相似",不一定是 "对这个问题有答案"。 + +例子:用户问 "明天天气怎么样",文档里有: + +- A. "天气预报通常由气象台发布..." +- B. "明天我们公司放假..." + +embedding 可能把 A 排在前面(都讨论天气),但 A 没回答用户的问题; +B 字面不相关但其实是用户该看的(他可能想问 "明天用不用上班")。 + +工程上常见加强: + +- **混合检索**:embedding(语义)+ BM25(关键词)双路召回合并 +- **Rerank**:第一轮拉 50 个,用专门的 reranker 模型 + (`cohere-rerank`、`bge-reranker`)精排到 top 5 +- **HyDE**:让 LLM "假想" 一个答案,用假答案去检索——比直接用 + 问题更接近文档表述 + +### 4. 评估很难 + +模型说错了,是检索没拉到?还是拉到了模型没用?还是用了但理解错了? +分阶段评估: + +- 检索准确率(召回的 top-k 里有没有正确答案) +- 端到端准确率(最终回答对不对) +- 引用一致性(回答有没有 hallucinate,引用的内容真的在检索结果里吗) + +## RAG 跟 MCP 的关系 + +可以把 RAG 视作 **"一类常见的 MCP server"**——很多 MCP server 干的 +事就是 "接到 query → 内部跑 RAG → 返回相关内容": + +- `mcp-server-notion`:用户问问题,server 在 Notion 里搜相关页面返回 +- `mcp-server-confluence`:同上,在内部 wiki 上跑 RAG +- 公司自建 MCP server:把产品文档喂进 LanceDB,模型问到产品时查它 + +但 RAG 也可以 **不通过 MCP**——直接在 agent 框架里写死 +(LlamaIndex、LangChain 那些),或者在每次 LLM 请求前直接拼 prompt。 +MCP 只是把 RAG 能力 "包装成可复用工具" 的一种方式。 + +详见 [mcp-overview.md](./mcp-overview.md)。 + +## 总结 + +``` +LLM 不知道答案? + + ├─ 是公开知识但过时了 → RAG(查最新资料)或 web search tool + ├─ 是你的私有数据 → RAG(查内部文档库) + ├─ 是结构化数据(SQL/API) → tool use(直接查) + ├─ 是需要算的(数学/代码) → tool use(让模型调 code interpreter) + └─ 是需要模型"内化"的风格/技能 → fine-tuning +``` + +RAG 是最常用的一格——便宜、可解释、知识能实时更新,适合 "知识问答" +和 "基于文档回答" 这类任务。 + +**心智模型一句话**:向量是 "导航地图"(找到正确目的地),原文是 +"目的地内容"(交给 LLM 读)。embedding 模型把文本变成向量是为了 +搜索;搜索完成后,LLM 看到的永远是原文。