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 秒实验
- 把 pick 的规则改一条,让它能分清「记一下」和「列一下」。想想靠什么区分最稳。
- 给工具表加第五条工具,看拼出来的文本涨了多少字。
- 把三个工具的 description 都改成「查数据」,再跑一遍 pick,看它还能不能挑对。
常见误区
- 「Client 就是把工具表转发给模型。」转发之前要拼成一段话,拼法直接决定模型能不能挑对。
- 「工具描述是给开发看的。」它是给模型看的,而且是模型唯一的依据。 写 Server 的人写它,写 Client 的人搬它。
- 「工具越多越方便。」每多一个,每轮对话都多几百 token,还多一个挑错的候选。
自测三问(点开对答案)
1. Client 要做的五件事是哪五件? —— 连上、握手、要工具表、把表拼成一段话、按模型的话去调。第四件不调协议。 2. 为什么工具描述写得好不好是功能问题? —— 描述会原样进模型上下文。模型只靠这段文字挑工具,写得含糊它就挑错。 3. 三个工具和二十个工具,token 差多少?说明了什么? —— 189 对 1020,差五倍多。而这段每轮对话都要带上,所以工具多就是持续的上下文成本。Server 和 Client 都写出来了,回路闭合。下一章讲两件收尾的事:怎么调它、怎么把它搬到线上。
- ☐ (本篇完,下一篇 7.1 MCP Server 调试工具)