08. Tools

别让模型「用嘴」干活:Custom Tool、MCP 与确定性的边界

前面「驾驭」板块里,我们把 Agent「想做什么、怎么做」拿捏住了,但那些都还停在模型脑子里。要真把想法落到一个实实在在的文件上、甚至推送到外部平台,得靠工具。这一篇聊下怎么给这位写作副驾配工具,以及关于边界的思考:自己写成工具 or 接外部的 MCP/CLI

👆你在这里
👆你在这里

🪧 一个反复在做的判断:这活,该让模型干,还是写成工具?

Markdown 格式大家应该都很熟悉。我几乎每天在用,因此常碰到烦人的场景:把一段内容在不同产品之间倒腾,它的格式会串,比如标题层级乱了、列表前少了空行,或者和上下文格式不统一。平常碰到这种情况,我一般都是让模型来处理:「帮我把这篇的 Markdown 格式整理规范」。它能做,也做得八九不离十

但在构建产品时我会多想一下:这其实是 「用嘴」在干活,靠语言理解去猜哪里该改、再一个字一个字重新吐出来。慢,费 token,偶尔还手滑把不该动的内容也改了。但这件事本质上是确定性的:什么样才算规范的 Markdown,规则很明确,一段几十行的代码就能精确搞定,根本不需要额外的「理解」

沿着这个思路往后想,为 Agent 配置工具的原则其实是相通的:

凡是确定性、可程序化的活,尽量别让模型「用嘴」去做,写成工具

这条原则,其实是前几篇表达的「确定性接管」思路的延续。第 6 篇我接管了「用哪个题材」,第 7 篇接管了「命令怎么触发」,到这一篇,我把执行本身也从模型嘴里接管过来。接下来,就展开讲下是怎么接管的,接管到什么程度

🧭 概念:工具,是把「确定性的活」从模型嘴里接管过来

先看下「工具」这个词

在 Agent 的语境里,一个工具就是一段确定性的代码。模型的角色变了:它不再亲自「用嘴」把活干完,而是只负责判断「现在该调这个工具了」,真正的执行,交给那段代码。模型出「决策」,代码出「执行」,各司其职

再往下钻,模型本身运行不了任何代码,它唯一能做的,是在自己的输出里吐出一句「我想调用工具 X,参数是这些」的请求(这在第 1 篇讲 Loop 时提过,就是一个 tool_use)。真正把这段代码跑起来的,是模型外面那套 Harness;跑完,Harness 再把结果回灌给模型,让它接着往下想。所以「给模型加一个工具」,本质上是在这套「模型请求 → Harness 执行 → 结果回灌」的循环里,多注册一个它可以点名调用的能力。模型负责点名,Harness 负责执行,这个分工,是所有工具运作的底层逻辑,不管是内置的读写工具,还是我们自己写的那些,都跑在这套机制上

为什么要这么分工?三个字,准、省、稳。,代码数字数不会数错,模型「用嘴」估字数可能差好几十;,一段代码几乎不烧 token,让模型逐字重吐一篇稿子却很贵;,代码的行为可预测、可测试,模型的自由发挥则时好时坏。把确定性的活交给代码,是把「不确定性」这个宝贵又危险的东西,省着用在真正需要它的地方,也就是创作本身

🔩 Claude Code:一套工具,加一套用工具的纪律

Claude Code 在这块是现成的老师,虽然它聚焦在编码场景,但设计思想是跨领域相通的。Learn Claude Code 的源码解读系列第二篇专门拆了 Claude Code 的工具使用机制,感兴趣的可以去翻一下

先看它给模型配了哪些工具,可以粗略分成几类:读与找Read(读文件)、Glob(按文件名模式找文件)、Grep(按内容搜);动手改Write(写文件)、Edit(改文件);通用执行Bash(跑任意系统命令,相当于一把万能钥匙);往外够WebSearch / WebFetch(联网搜与抓);还有派活的 Task(派一个 sub-agent 去干)、记事的 TodoWrite(管一份待办清单)。这一套工具集,基本就圈定了模型「够得着的世界」的全部边界

但 Claude Code 提供的,可不只是「有哪些工具」,更值钱的是那套「怎么用好工具」的纪律,比如:改一个文件前一定先读一遍、能小步 Edit 就别整篇重写、绝不许伪造一个工具的执行结果、没有依赖关系的工具可以并行调用。这套纪律在第 5 篇讲 System Prompt 时特意逐条借鉴了过来(因为一旦走 custom,preset 里这套说明就丢了,得自己补全)

「工具箱里有什么」和「拿到工具该怎么规矩地用」,是我从 Learn Claude Code 里学到最受用的两点,也是后面给写作 Agent 配工具、定规矩时的重要依据

🛠 SDK:两种工具,一个「进程内」的巧思,一个得留意的限制

有了 SDK 的封装,给模型加工具有两条路可选

第一条,自己写 custom tool。 用一个 @tool 装饰器定义好这段代码,再用一个方法把它包成一个「进程内的 MCP server」。这里的「进程内」是巧思:它不另起一个独立进程,就跑在后端自己的进程里,轻量、没有额外的部署负担。包好之后挂上去,把工具名列进允许清单,模型就能自动调用了

第二条,接外部 MCP。 如果某个能力是别人(第三方服务)以 MCP 的形式提供的,可以通过配置把它接进来(走标准输入输出或 http)。好处是接口有人替我维护,坏处是多了一层外部依赖

这两条路怎么选,没有绝对标准,要具体问题具体分析。下文以 SmartWriter 为例说下我当时的权衡

📐 深挖 · custom tool 回传不了「结构化数据」,怎么办

这里得澄清一下:不是 Python 函数本身「算不出」结构化数据,是协议里的通道被砍了一截。MCP 的工具结果本来留了三个口子:content(文本/图片等自由内容)、structuredContent(一个独立的、按 schema 校验的 JSON 字段)、isError。但 SDK 文档写得很直白:Python 的 @tool 装饰器只透传 contentis_error 这两样,structuredContent 会被原样丢弃,想要它就得换成独立进程的 MCP server,而不是我在用的这种进程内 server。也就是说,我当然可以把一个 JSON 对象序列化成字符串塞进 content 的文本块里,但那本质还是一段文本,模型和下游代码都得自己再解析一遍,没有 schema 帮我兜底校验,跟协议原生的结构化通道不是一回事。这不是「等版本更新就会解除」的临时限制,是进程内 server 这个巧思本身要付出的代价

关联第 1 篇提过的规范:面向用户的、自由的东西走一条路;供程序消费的、结构化的结论走另一条路。 所以但凡我需要一个「能被代码可靠解析的结构化结论」(比如一份终检报告),就不能再硬塞给 custom tool 了,需要换一种调用方式去拿。譬如,单独发起一次 query,在 output_format 里挂一份 JSON Schema,让模型的最终回复直接按这份 schema 校验、结构化地吐回来,跟工具调用完全是两条线

还有个小 tips:工具内部出错,要用那个「是否出错」的标志返回错误,别直接抛异常,因为抛异常会把整个任务给终止掉。让错误变成一条「工具没成功」的消息传回模型,它还能换个法子接着干

🧰 SmartWriter 的工具箱:把确定性的活,一件件固化下来

顺着前面说的原则,我把写作流程里能程序化的活,一件件挑出来固化成了确定性操作。它们有的做成了 Agent 工具循环里的 custom tool(模型可以自己调),有的就是后端的普通函数(譬如由发布流程程序化调用,模型用不着):

操作 它干的确定性的活 实现形式 在 Agent 工具循环里吗
normalize_markdown 校验并修复 Markdown 格式(那个开头的例子) 后端普通函数 否,发布流程调用
markdown_to_note_atoms 把 Markdown 转成墨问要的格式 后端普通函数 否,发布流程调用
compute_diff 算「AI 成稿版 vs 你改后版」的差异 后端普通函数 否,画像采集调用
profile_reader 按当前题材,组装该注入的那套画像 MCP custom tool@tool ,模型可调

这张表里重点说两个:

一个是 normalize_markdown,对应开头举的例子。同一件事,「让模型用嘴改」和「一个确定性函数改」,还是有差别的:前者慢、贵、偶尔改跑偏;后者快、几乎不花钱、每次结果都一样。注意它连 custom tool 都不必做成,因为格式修复发生在发布流程里,根本不在 Agent 的工具循环中,做成一个普通函数就够了

另一个是 profile_reader,它是我工具箱里唯一一个真正的 MCP custom tool。第 4 篇讲画像注入时它就出场过:画像的裁决规则(全局禁忌盖过题材、题材盖过全局偏好)很容易拼错,我干脆把这套逻辑抽象出来、封装进一个确定性工具,让代码去精确组装该注入哪几层画像,而不是让模型「凭感觉」拼。它之所以做成 custom tool 而非普通函数,是因为它需要被 Agent 在工具循环中按需调用:compact 之后 Agent 可能想确认一下当前生效的画像,这时它自己调一下就行,把「确定性」的信息补充回上下文

还有那个看着不起眼的 compute_diff。它表面只是算差异,其实在悄悄干一件产品上的正事,采集「二次编辑字数占比」这个指标,也就是你在 AI 成稿基础上改动的字数,占成稿总字数的比例。这个比例越低,说明 AI 一次产出越贴合你、提效越明显,它是我用来衡量「这产品到底有没有用」的核心量化口径之一,也是那套「越写越懂你」画像闭环的一个输入信号(闭环怎么转,留到第 13 篇讲产品流程时细说)

🔀 以墨问发布为例,聊下工具形态的权衡选择

工具箱里这些,走的都是「自己写 custom tool」这条路。那另一条路,接外部 MCP/CLI,什么时候才该用?项目里并没有正向用例,但我可以用一键把作品发布到墨问为例,反向说下为什么不

最简单的方式,当然是接墨问的 MCP(现在它也提供 CLI 了),毕竟「发布到第三方平台」这种标准的外部集成,好像也没有必要自己单独写。但最后我还是改变了主意,后端直接调墨问的 REST API,没接 MCP,理由如下:

  1. 墨问发布,本质是一次「单步的 API 调用」,不是一套需要长期交互的复杂能力。为此专门引入一个 MCP server,有点杀鸡用牛刀,还凭空多一层外部依赖
  2. 这个产品最终要打包成桌面应用分发(用 Tauri)。桌面 app 里,依赖越少、链路越短,打包和分发就越省心。后端直调,正好最契合这个形态
  3. 每一次调 MCP/CLI,都会烧用户的 tokens。我觉得还是要把宝贵的额度留给写作本身,确定性的发布写入动作,就交给产品来兜底执行更好

以上仅仅是针对 SmartWriter 发布墨问笔记的场景生出的思考,并不代表墨问 CLI 不好用。恰恰相反,近期发布的墨问 CLI,除创建笔记以外,还提供了其它丰富的功能,可玩性很高,社区里也有不少有趣的案例

发布到墨问
发布到墨问

考虑到发布是数据外发,不可逆、还涉及隐私,因此在链路里加入「审批」节点,需要用户显式点头。至于真正对接墨问那一步,虽然 OpenAPI 已经很完善了,但过程中还是踩了不少的坑

🔧 避坑 · 「调个 API 而已」,结果踩了一路

「API 调用」这四个字,大概率没那么轻松,很多细节都得在实践里现摸现磨。这也是权衡的一部分:调 MCP/CLI 虽然偏黑盒,但确实能省不少事儿

1)契约得死磕。墨问只认一个有限的 Markdown 子集(这也是专门做了 markdown_to_note_atoms 的原因),发布接口、编辑接口、图片怎么传,字段和规则都得照着它的 OpenAPI 契约一丝不差地对,凭感觉写必挂。对接成功后,我还把它的契约单独整理成一份文档作为标准沉淀了

2)不只是「发」,还得能「改」。发出去之后用户又改了稿子,得能同步更新那篇已发布的笔记,而不是重发一篇。图片的嵌入,光 caption 的正常注入就来回调了 10 轮+

3)得给自己上限流。墨问对调用频率有管控,一不小心触发频控就可能被封。所以在客户端内置了一个限流器,把速率死死压在每秒一次以内,超了就自动排队,宁可慢一点,控好频次

4)错误必须脱敏。墨问返回的 4xx/5xx 原始错误,不能原样甩给用户看(又难懂又可能泄露技术细节)。于是把它们统一翻译成人话(像「发布失败,请稍后重试」这种),原始错误只落到后端日志里。还有一条红线:墨问的 API Key 绝不能进入模型的上下文、不进日志、也不进任何统计,它只在真正发请求的那一刻,从安全的地方取出来用一下

「对接一个外部平台」的复杂度,从来不在那个 API 调用本身,而在契约、增量同步、限流、凭证、脱敏这些「调用之外」的细节。 自己直调不走 MCP/CLI,说白了就是把这些都得自己扛下来,动手前最好想清楚

关于「图片的嵌入」,可以再展开讲讲,因为它正是「自己直调就得自己扛」的生动注脚

📐 深挖 · 「往笔记里塞一张图」事很小,却连撞三关

我一开始以为,正文都能发了,图片无非是「多传一个文件」。可真做起来,居然要连过三关,最后才成功把一张图正确地嵌进一篇墨问笔记里

第一关,一个默认请求头,把图片上传打成了 405。 图片得先传到对象存储(OSS)。可我那个 HTTP 客户端,为了平时发 JSON 方便,全局默认带了个「我发的是 JSON」的请求头,这个头被图片上传请求也一并继承了,结果对象存储把这次上传错当成了另一种操作,直接回我一个 405(方法不允许),图片就成了正文里的一行空白。修复其实很简单,把那个全局默认头去掉,让客户端按这次实际传的内容去自动判断类型

第二关,对方返回的地址里,混着一个反引号。 上传前要先跟墨问要一个「上传到哪」的地址。而它回给我的那个地址字段,字符串里夹着一个反引号`),直接拿去解析 URL 就崩。没别的办法,只能自己写一道清洗把它抹掉。其实接所有外部平台都是一样的,返回的真实数据才是标准,应用侧自己适配是硬道理

第三关,也最隐蔽,图片节点必须挂在文档的「最顶层」。 墨问的内容结构有个规矩:图片节点,必须是文档的顶层直接子节点。可我最初写的格式转换器,顺手把图片塞进了段落内部。诡异的是,上传明明成功了(存储那头返回 200),墨问 App 里却死活只显示一行空白。排查了好久才定位到,不是没传上去,是节点摆错了位置。修复是让转换器把图片从段落里「拎出来」、拍平到顶层

三关串起来,说的还是这一篇的老话:「对接一个平台」真正吃时间的,从来不是那句 API 调用,而是它周边这些没写进文档的细节,请求头、数据格式、节点位置等等。 没有免费的午餐,要想占 0 token 的便宜,就得多花一些功夫

⚖️ 垂直落点:通用是「万能 Bash + 自由发挥」,垂直是「一件件固化成工具」

回到那个贯穿全专栏的对照:

⚖️ 取舍现场 · 给 Agent 配工具,通用和垂直分别怎么做

通用 Agent SmartWriter 为什么这么选
确定性的活 常倾向「给个万能 Bash,让模型自由发挥」 一件件固化成确定性操作(custom tool 或后端函数) 更准、更省、更可控,也不用留 Bash
外部集成 提供 MCP/CLI 接入能力,利用外部资源 确定性场景优先直调 REST,复杂能力才考虑 MCP 桌面 app 依赖越少越好
结构化结论 塞进工具返回值 另走带 schema 的 query 守住「两条通路」的边界

在垂直业务场景里,自己配工具,还有个连带好处。因为「把确定性的活固化成一个个专用工具」后,其实就削掉了对「万能 Bash」的需求,而这大大缩小了整个 Agent 的攻击面。一个工具集只有几件、且明确狭窄(只读或只改自己文件)的 Agent,比一个手握万能 Shell 的 Agent,安全太多了

而「工具」和「安全」到底怎么勾连,正是下一篇的正题。给了 Agent 这么一箱工具,紧接着的问题就来了:这些工具,哪些能让它随便用,哪些必须经我点头?发布这种数据外发的动作,凭什么必须弹窗确认?

下一篇,我们就来拆权限,看看怎么用「六步评估」法则把每一次工具调用都框住,以及说下为什么要把「万能 Bash」整个给删了。咱们继续聊