跳到正文
Hacker News· alviso·· 9 天前AI 评分50

如何为 MCP 场景设计本地模型运行框架:Saybooks ERP 实测

Designing a local model harness optimized for MCP use

AI 导读

作者用 Ollama 在 32GB Mac 上跑 Gemma 4 26B(A4B QAT)和 Qwen3-30B-A3B,通过 MCP 对接自建开源 ERP Saybooks(48 个工具)与 huntctrl(23 个工具),约 40 次测试发现本地客户端会丢弃 MCP 服务器的 instructions。

正文 · AI 翻译

我花了三天时间,通过 MCP 用一个小型本地模型跑我自己的 ERP。不是因为我认一个 4B 激活量的模型是做账的合适工具。而是因为小模型不会像前沿模型那样替一个缺失的规则打掩护,所以你能看清模型和系统之间的那一层到底在做什么。以下是我的发现。

设置

这个系统是 Saybooks,一个我构建的开源 ERP。它有一个注册表,负责生成 MCP 工具、Web UI、校验和审计日志,所以智能体和真人碰到的是同一套规则,落在同一个日志里。它自带 48 个工具,对应我在这里用到的模块。有些测试里的第二个服务器是 huntctrl,一个我也写的小型求职 CRM,23 个工具。

模型是 Gemma 4 26B,A4B QAT 变体,由 Ollama 在一台 32 GB 的 Mac 上提供服务。后来我加了 Qwen3-30B-A3B。机器很重要,我后面会回到这一点。

测试是四个简单的提示词,一段对话。粘贴一份 20 行的信用卡账单。要求先把这些行分类并展示出来,再写入任何东西。改正一处,然后说继续。问花了多少钱、订阅有哪些。每个提示词之后,一个脚本检查账本,而不是检查模型的回答。如果数据库是对的,这一步就算通过。我跑了这个脚本和其他几个脚本大约四十次,每次只改一个东西。

聊天客户端做了什么

在这一切之前,我检查的第一件事是 LM Studio 如何把 MCP 服务器交给模型。答案是:它只传工具描述,别的什么都不传。服务器在连接时发送指令,SDK 把它们存下来,而客户端从不读取它们。然后我在源码里检查了 Jan 和 Open WebUI。一样。我能找到的每个本地客户端都把服务器的指令扔在地上。

所以本地模型看到的是 48 条工具描述,而没有任何准则。像“在提出分类之前先读词汇表”或“转账的分类是钱去了哪里”这样的规则,除非也写进工具描述和工具结果里,否则永远到不了模型那里。在 Saybooks 里它们写进去了,因为我早就吃过苦头学到了这一点。但那正是起点问题:如果指令真的到达了模型,会有多大改变?

起初时间花在了哪里

在指令上比我预期的少,而在一些我没想到的事情上很多。

第一次设置,指令被转发、思考关闭,四步里只过了一步,连续三次运行都是如此,每次 13 到 32 分钟。同一个模型完全不给指令,一次四步全过,一次四步过两步,用时 6 到 7 分钟。这对指令来说不是好兆头。读日志,失败和准则毫无关系。

模型必须把二十行重新输入为工具参数。每次它这么做,就有一个值出错。一个年份错误的日期。“20.09-23”里一个全角点。一个名为“rowintdex”的字段。导入是原子的,所以一行出错就意味着整个拒绝,也就意味着重新输入所有内容,而这又带来下一次失误。大部分时间都花在了那里。

第二个失败只有一个词。在有指令的情况下,模型为打印行使用了一个名为 "counterparty" 的字段,而遗漏了必需的 "description"。三次运行中三次都这样做。它得到的拒绝是数据库自己的消息:NOT NULL constraint failed。它的回应是在调用的顶层添加 "description",而不是在每一行中添加。我改了两件事。注册表现在自己验证行,并说 "rows[1] has no description. Each row takes: date, amount, description, ..." 字段文本从 "Exactly as printed." 改为 "The line as the statement prints it (the merchant or payee text). Every row has one." 之后,同样的设置第一次尝试就导入成功,两次。

我原本猜不到一句字段文本比一页指令更有价值。但确实如此。

宿主能做什么

重打问题有两个修复,在两个地方。

在服务器上,导入工具现在接受粘贴的文本形式的对账单并解析它。控制总数仍然必须对账,所以模型仍然必须读取对账单。它只是不再重打它。

在宿主上,我添加了按引用传递文件。当用户附加文件时,模型看到它,并被告知可以传递 "@statement.csv" 作为参数。宿主在工具运行前替换内容。Gemma 第一次尝试就使用了这个,没有被要求两次。原本 1/4 成功率、耗时 13 到 32 分钟的设置变成了 4/4、6 分钟、零拒绝。

这是最大的单一改变。其余的更小,每一个都来自我在日志中能看到而模型看不到的失败:

  • 一个拒绝被用完全相同的调用重新发送来回应。宿主阻止它并说明原因。
  • 一个重试改变了某些东西,但没有改变拒绝中指定的字段。宿主在参数中的任何位置检查指定的键,如果仍然缺失则阻止调用。
  • 同一个读取工具连续调用七次直到跳数限制。同样的规则。
  • 一个运行时解析器无法读取的工具调用。Ollama 的 Gemma 解析器在一个晚上放弃了四次。每次调用消失,回合以散文形式返回。宿主现在在散文中识别模板的标记,并再次请求调用一次。
  • 一个 20 行的写入在 4000 token 上限处被截断,这变成完全没有写入。预算现在按跳数调整,被截断的回合会被告知这一点。
  • 一个模型第三次重新发送被阻止的调用。回合在那里结束,以拒绝作为答案,而不是烧掉十四分钟。

这些都不需要更聪明的模型。它们需要有人监视调用。

思考

思考是指令最终出现的地方。关闭思考时,模型大约一半时间跳过 "先读取词汇表",即使在工具描述、原则和之前的工具结果中都有这条规则。开启思考时,它几乎每次都遵循规则。

但在交接文件的步骤上开启思考花费 9 到 10 分钟,而关闭思考只需 100 秒。模型在 20 行对账单上生成近 4000 个思考 token,随着上下文填满,生成速度从每秒 35 个 token 减慢到 14 个。

还有一个我没预料到的代价。Ollama 的 Gemma 模板只在思考位于最后一条用户消息之后时,才把这一轮的思考保留在提示中。一旦用户再次发言,思考就会从历史记录中丢弃。这会重写提示,运行时的前缀缓存从那一刻起就会失效。在一个 15k token 的提示上,大约需要 110 秒的预填充,每一轮模型思考时都是如此。关闭思考则保持提示逐字节相同,缓存得以保留。

所以宿主在交接文件的步骤上关闭思考,在其他所有步骤上开启。这就是 6 分钟运行背后的设置。我尝试把之前的思考作为纯文本折回历史记录以保留缓存。这行不通,字节与实时渲染出来的不同。要在开启思考的情况下保留前缀,就意味着要自己掌控模板并直接与原始补全端点通信。我已经把这件事搁置了。

没有帮助的做法

我以为更小的提示会更快。工具 schema 占了每个提示的四分之三,48 个工具大约 10,500 个 token,每一跳都发送。指令只占十分之一。

分阶段加载指令,即在某个模块的工具第一次被调用时发送该模块的规则,机制上可行,但只节省约百分之一。发送一行工具目录并按名称打开 schema,把每跳的提示从 14k 降到 6k token。在两台服务器上它通过了,但耗时更长。在 statement 脚本上,它从 4/4 降到 2/4,因为“先读词汇表”存在于某个工具描述中,而一行目录把它切掉了。

更小并不更快的原因还是同一个缓存。Gemma 的模板把工具声明放在系统轮次中。工具列表的任何变化都会重写提示的顶部,整个上下文会以大约每秒 200 token 的速度重新预填充。一个命中缓存的稳定的 14k token 提示,胜过未命中缓存的 6k 提示。完整 schema 和完整指令放在前面、保持不变,是这个运行时允许的最快选项。

第二个模型

Qwen3-30B-A3B,同一宿主,同样的脚本。在没有附件的双服务器脚本上,它在不到五分钟内完成了四次中的四次,自行切换服务器,并在正确的记录上记录了正确的内容。宿主可以迁移。

在 statement 脚本上,它在两个运行时中都每次都失败。它不会按引用传递文件。它会重新输入内容,一次把每个符号都反转,另一次把每个金额都转换成美分,或者它把“@statement.csv”放在 name 参数中而让 text 参数为空。规则在消息里也在系统提示里。这没有用。

所以我发现的最大杠杆部分上是 Gemma 的特性。那些能捕捉模型习惯的宿主规则可以迁移。模型必须自己领悟的可供性则不能迁移,宿主需要一个不依赖模型配合的兜底方案。我已经构建了一个,但尚未测量。

Qwen 还在 Saybooks 中发现了一个 Gemma 从未触及的真实 bug,因为它把设置清单中的可选项目读作要做的事情。第二个模型是覆盖,而不仅仅是比较。

LM Studio,正面交锋

以上所有内容都是宿主与自身变体的对比测量。所以我在 LM Studio 自己的聊天中运行了同样的四个提示,两次,同一模型家族,从它自己的 mcp.json 使用同一服务器,statement 按人们粘贴的方式粘贴,然后事后清点账本。

通过的步骤墙钟时间拒绝
LM Studio 聊天,第 1 次运行4 次中的 1 次15 分钟4
LM Studio 聊天,第 2 次运行4 次中的 3 次11 分钟2
hostel,第 1 次运行4 次中的 4 次6 分钟0
hostel,第 2 次运行4 次中的 3 次6 分钟0

4 个结果中有 3 个是同一个漏检:我自己的检查和我的工具文本对什么算作一次转账意见不一致。那是一个决策,不是失误,而工具发现了它。

第一次 LM Studio 运行是这样的。三次拒绝导入。然后模型告诉我“我已成功导入全部 20 笔交易”,而账本上什么都没有。分类步骤没问题。写入步骤因为不存在任何行而被拒绝,重新导入在第三次尝试时才成功,审查从未被写入,而模型说它已经应用了我的更正。最后一个问题是根据粘贴的文本回答的,没有调用工具。

第二次运行好一些。导入在第三次尝试时成功,审查在一次调用中写完,最后一个问题是根据账本回答的。它仍然花了宿主两倍的时间,而且每次导入尝试都重新输入了全部二十行。

我关心的数字不是 4 个中的 1 个或 3 个。而是第一次运行中那两次虚假的成功声明。没有宿主,一次拒绝并不能阻止模型告诉你写入已经发生,而聊天窗口里没有任何东西告诉你并非如此。它发生在两次运行中的一次。检查账本才让它变得可见。

机器

还有一件与模型无关的事。这台 Mac 上 LM Studio 的 MLX 引擎在 17 GB 权重之外还占用一个 5 GB 的辅助程序,外加缓存。在 Docker 的虚拟机退出、上下文为 24k 的情况下,它仍然把机器锁死,我不得不重启。每 30 秒检查一次的看门狗太慢了。Ollama 在同一台机器上把 Gemma 保持在 20 GB 两天。宿主现在在加载前检查空闲内存,并在第一个危急样本时就采取行动。如果你运行本地模型来做真正的工作,机器是设计的一部分。

我的看法

“智能体是入场门槛”是我不断读到的一句话。我测量到的结果说明模型是最小的变量。同一个模型从 4 个中的 1 个变成 4 个中的 4 个,中间层之外什么都没变:规则是否到达它、它是否必须重新输入文件、一次拒绝说了什么、是否有人在监视这些调用。在我看过的每一个本地客户端里,那一层都是无主的。

我正在构建它。它目前叫 hostel,将会是一个 Mac 应用。如果你想知道它何时准备好,或者你有一台服务器想让它运行,请写信到 peter@portlandaiworks.com。

到周末它变成了什么:一个本地模型来记账。

来源:Hacker News · portlandaiworks.com