🔌 第6章 动手写一个MCP
📄 一句话概括

Client 要做五件事:连上、握手、要工具表、把表拼成一段话、按模型的话去调。 前三件和第五件是协议的活,第四件是纯文本拼接——而第四件才是模型能不能挑对工具的关键。 三个工具拼出来 288 字、约 189 token;二十个工具 1691 字、约 1020 token。

📝 本章和前面几章的关系

上一节写了 Server,这一节写连它的那一半。 教材用的是官方 Client SDK 加一个真模型。 这里用标准库加一个关键词替身,因为这台机器没有模型额度。 替身只替「模型决定调哪个工具」这一步,协议那四步全是真的。

一、Client 要做的五件事

连上、握手、要工具表、把表拼成一段话、按模型的话去调。 前三件和第五件都是协议动作,调 initialize、tools/list、tools/call,一个不多一个不少。 第四件不调协议,纯文本拼接。 它把 JSON 结构的工具表翻译成一段人话。

这一件事最容易被跳过,也最容易做砸。 因为模型读不了 JSON 结构,它只能读文字——拼得含糊,它就挑错工具。 所以第 7 章的 Inspector 才那么有用:它让你看见自己这段拼接到底长什么样。

二、工具表拼成一段话

演示里那段拼出来的文本,一共 288 字、约 189 个 token。 它的结构是固定的:一句总起、然后每个工具三行——名字、作用、参数。 参数那行带类型和必填标记,有默认值的写默认值。这一段就是模型看到的全部信息。

注意它把 description 原样搬了进来。 这正是第 3 章那个攻击面的来源:描述里夹的话,会沿着这条路进上下文。 所以「描述写得好不好」不是文风问题,是功能问题。 写 Server 的人和写 Client 的人,在这一段文本上碰头。

三、把回路闭上

演示里走了三步:记一条、搜一次、列一下。每一步都是「用户说一句、模型决定调什么、工具回结果」。 中间那一行「模型决定」,在真机上是模型输出的 tool_call; 这里换成关键词打分,格式一样,只是谁来产生。 [替身和真模型的分界线就在这一行。] 上一行和下一行都是真的。

三步走完,一次完整的「模型想、协议做、结果回」就闭环了。 这也是所有 Agent 框架的主循环,只是它们把它包得更复杂。 注意第三步「列一下最近记了什么」被正确判成了 note.list,而不是 note.add。 第一版替身判错了——它先命中了「记」字,于是去加笔记。 这在真机上叫「意图误判」,修法是让描述之间区别更明显。

四、模型那一段要配什么

五个配置项:base_url、api_key、model、max_tokens、tools。 前四行缺一样都跑不起来,这台机器上一样都没有——没有模型额度、没有 Key。 但第五行是真的:工具表怎么拼、怎么塞进请求,和有没有 Key 无关。这一段用标准库就能完整跑出来。

真机上的差别只在最后一步:把那段文本跟用户的问题一起发出去,等模型回一个 tool_call。 教材里这一段用的是官方 SDK 加一家模型服务。 写成代码大约二十行,换一家服务商主要改 base_url 和 model 两个字段。

五、工具一多,选择就难

三个工具时,拼出来 288 字、189 token;二十个工具时,1691 字、1020 token。 这 1020 个 token 每次对话都要带上。 不是第一次带一次,是每一轮都带。 而且工具越多,模型越容易挑错。它不是笨,是选项太多、描述又互相重叠。

所以「一个 Server 超过十个工具就该拆成两个」不是洁癖,是成本问题。 拆的依据不是「功能像不像」,是「这一句话里模型要同时考虑几个选项」。查天气和搜地点是两个选项, 天气和空气质量是一个选项的两个参数。

六、动手 Demo:写一个 Client,把回路闭上

用标准库写一个 Client:起进程、握手、要工具表、拼成一段话、按决定去调。 模型那一步用关键词打分替身,其余全真。最后量一下三个工具和二十个工具各占多少 token。 token 数是粗算的(中文按 1.6 字一个),只用来看量级,不当绝对值。

# 一、五件事里的前四件
c = Stdio('.../notes-mcp/src/server.py')
c.init()                     # 2 握手
tools = c.tools()            # 3 要工具表
prompt = tools_prompt(tools)  # 4 拼成一段话,不调协议
print(prompt)

# 二、模型那一步:真机上是模型输出 tool_call
for q in ['记一下 stdio 是两根管道', '搜一下管道', '列一下最近记了什么']:
    name, args = pick(q, tools)      # 替身:关键词打分
    print(q, '->', name, args)
    print('   工具回:', c.call(name, args)['result']['content'][0]['text'])

# 三、工具数一多,这段文本有多长
print(tool_chars(tools), tool_tokens(tools))

实跑输出:

--------------------------------------------------------------------
写一个 Client,把回路闭上
--------------------------------------------------------------------


  ① Client 要做的五件事
  --------------------------------------------------------------------

| 步 | 做什么 | 调哪个方法 |
|---:|---|---|
| 1 | 把 Server 拉起来,连上 | 起进程或连地址 |
| 2 | 握手,问它会什么 | initialize |
| 3 | 把工具表要过来 | tools/list |
| 4 | 把表拼成模型能读的一段话 | 不调协议,纯文本 |
| 5 | 模型说要调哪个,就发 tools/call | tools/call |


  ② 表拼成话,就这几行
  --------------------------------------------------------------------
tools/list 回来的是 JSON。模型读不了 JSON 结构,得拼成一段文本:
  你可以使用下列工具。需要时调用,一次一个。
  1. note.add
     作用:新增一条笔记,返回它的编号
     参数 text(string,必填):笔记正文
     参数 tags(array,可选):标签,可空
  2. note.list
     作用:按时间倒序列出最近的笔记
     参数 limit(integer,可选):要几条,默认 10
  3. note.search
     作用:在笔记正文里搜关键词,返回命中的
     参数 keyword(string,必填):关键词
  这段文本一共 288 字、约 189 个 token。
  【工具越多,这段越长,模型越容易挑错】——下面第 5 节量化这件事。

  ③ 把回路闭上:问一句,自己走完
  --------------------------------------------------------------------
  用户:记一下 stdio 是两根管道
  模型(替身)决定:note.add,参数 {"text": "stdio 是两根管道"}
  工具回:{"id": 1, "total": 1}

  用户:搜一下管道
  模型(替身)决定:note.search,参数 {"keyword": "管道"}
  工具回:{"count": 1, "items": [{"id": 1, "text": "stdio 是两根管道"}…

  用户:列一下最近记了什么
  模型(替身)决定:note.list,参数 {"limit": 5}
  工具回:{"count": 1, "items": [{"id": 1, "text": "stdio 是两根管道"}…

  三步走完,一次完整的「模型想、协议做、结果回」就闭环了。
  【中间那一行「模型决定」,真机上是模型输出的 tool_call】,格式一样,只是谁来产生。

  ④ 模型那一段要配什么
  --------------------------------------------------------------------

| 配置项 | 填什么 | 本沙箱 |
|---|---|---|
| base_url | 模型服务的地址 | 没有 |
| api_key | 访问凭据 | 没有 |
| model | 模型名 | 没有 |
| max_tokens | 一次最多生成多少 | — |
| tools | 上面拼出来的那段表 | 有,自己拼 |

  前四行缺一样都跑不起来,所以这里用关键词替身顶住第四步。
  【但第 5 行是真的】——工具表怎么拼、怎么塞进请求,和有没有 Key 无关。

  ⑤ 二十个工具的时候
  --------------------------------------------------------------------
上面那个 Server 只有三个工具,模型挑得很准。工具一多,情况就变了。

| 工具数 | 拼出来多少字 | 约多少 token |
|---:|---:|---:|
| 3 | 288 | 189 |
| 20 | 1691 | 1020 |

  二十个工具就吃掉这么多上下文,而每次对话都要带上它。
  【一个 Server 超过十个工具,通常说明该拆成两个。】
  拆的依据不是「功能像不像」,是「这一句话里模型要同时考虑几个选项」。
  ! 第 7 章讲 Inspector,就是用来看「我这堆工具到底写了些什么」的工具。

①第一张表五行,第四行特别标了「不调协议,纯文本」。这一行是整节的重点,也是最容易被跳过的一行。 ②第二段那段拼出来的文本,结构和顺序都值得照抄:一句总起,每个工具三行。模型就是靠这段挑工具。 ③第三段三步都通了。 注意第三步判成了 note.list 而不是 note.add——替身第一版判错了,因为它先看到「记」字。 ④第四段那张表,前四行这台机器都没有。但第五行是真的,而第五行才决定模型能不能挑对工具。 ⑤最后那张两行的表说明成本:二十个工具 1020 token,每一轮都要带上。 这就是「超过十个就该拆」的算术依据。

30 秒实验

  1. 把 pick 的规则改一条,让它能分清「记一下」和「列一下」。想想靠什么区分最稳。
  2. 给工具表加第五条工具,看拼出来的文本涨了多少字。
  3. 把三个工具的 description 都改成「查数据」,再跑一遍 pick,看它还能不能挑对。

常见误区

  1. 「Client 就是把工具表转发给模型。」转发之前要拼成一段话,拼法直接决定模型能不能挑对。
  2. 「工具描述是给开发看的。」它是给模型看的,而且是模型唯一的依据。 写 Server 的人写它,写 Client 的人搬它。
  3. 「工具越多越方便。」每多一个,每轮对话都多几百 token,还多一个挑错的候选。
自测三问(点开对答案) 1. Client 要做的五件事是哪五件? —— 连上、握手、要工具表、把表拼成一段话、按模型的话去调。第四件不调协议。 2. 为什么工具描述写得好不好是功能问题? —— 描述会原样进模型上下文。模型只靠这段文字挑工具,写得含糊它就挑错。 3. 三个工具和二十个工具,token 差多少?说明了什么? —— 189 对 1020,差五倍多。而这段每轮对话都要带上,所以工具多就是持续的上下文成本。

Server 和 Client 都写出来了,回路闭合。下一章讲两件收尾的事:怎么调它、怎么把它搬到线上。

相关笔记