构建

把依据摆在答案旁边 — 接一个会去问设备的 LLM

站在机器前的人用话来问,LLM 就去调用设备的工具来答。要紧的不是答案,而是这个答案究竟查了什么才出来的,要和答案一起显示在画面上。一个工具都没调用的答案,会被标出来。mcp_client + mcp_llm 的全部接线、运行日志、4 张真实渲染截图。

作者: makemind · 2026年8月10日

站在工厂设备前的维修工想知道的通常就那么几件:这台机器过保养周期了没有?振动在限值内吗?动手之前要先确认什么?

这些信息其实早就都在厂里某处。在保养履历数据库里,在传感器采集系统里,在安全点检表文档里。只是站在机器前的人,没有办法去问。

于是「接个 LLM 不就行了」这个念头很自然地冒出来。而真正的问题从那里才开始。如果没法区分「说出『三号传送带过保养期了』的机器」和「这么说但什么都没查过的机器」,那么信了这句建议、把手伸进机器里的人就危险了。

这篇文章记录的,是把这个区分做到画面上为止。答案旁边,会一起出现造出这个答案的那次工具调用。 而一个工具都没调用的答案,会被标出来。

先看结果

问了机器的情况。答案下面附着 造出这个答案的那一行工具调用。

问 CONV-03 —— 答案下面用文字写着 "grounded in 1 call(s) to the plant",再下面原样附上那次调用。真实渲染截图
问 CONV-03 —— 答案下面用文字写着 "grounded in 1 call(s) to the plant",再下面原样附上那次调用。真实渲染截图

问了点检表。按工厂工程师写的顺序原样出来。

询问检查清单 —— 答案下面的调用记录里原样留着 checklist.get
询问检查清单 —— 答案下面的调用记录里原样留着 checklist.get

而这是本文最重要的一张画面。抛了一个工厂答不了的问题。

与设备无关的问题 —— 调用为 0,屏幕写着 "no tool call behind this answer — it stands on the model alone"
与设备无关的问题 —— 调用为 0,屏幕写着 "no tool call behind this answer — it stands on the model alone"

工具被调用了 0 次,画面就这么说。它没有摆出和前面两个答案一样的脸。

全景

plant_server (mcp_server) assistant (client + server) 平板
 equipment.list ◀──MCP── plant 的客户端 ──MCP──▶ ui://assistant
 equipment.read 画面的服务端 答案 + 调用记录
 checklist.get 中间是模型

先把中间那块的身份说明白。这个样例里模型的位置放的是一个确定性桩。 因为任何人都得能在没有 API key 的情况下跑起来验证。而这恰恰是本文最不重要的部分 —— 值得读的是 它两侧的接线,而那套接线不管中间是桩还是 Claude 都一样。替换点在下面原样可见。


① 设备服务端不做判断

先看工具这侧。这里有一件事是刻意不做的 —— 服务端不说「这台机器没问题/危险」。

handler: (args) async {
  final id = (args['id'] as String?)?.toUpperCase();
  final m = _machines[id];
  if (m == null) { /* ... */ }

  // The server states facts, and how those facts compare to their limits.
  // It does not say the machine is "fine" — that word belongs to the person
  // holding the checklist.
  final overdue = (m['runHours'] as int) > (m['serviceEveryHours'] as int);
  final vibrationOver =
      (m['vibrationMm'] as num) > (m['vibrationLimitMm'] as num);
  return _json({
    'id': id,
    ...m,
    'serviceOverdue': overdue,
    'vibrationOverLimit': vibrationOver,
  });
}

serviceOverdue: true 是事实。safe: false 是判断。服务端只出前者。

点检表也一样。工具的说明文里写了 「这些步骤由工厂工程师制定,不得改写」。工具说明文是模型真的会读的文本。

server.addTool(
 name: 'checklist.get',
 description:
 'Get the plant safety checklist for a machine type (press, conveyor, welder). '
 'These steps are set by the plant engineer and must not be paraphrased.',
 /* ... */
);

② 接线 —— 工具够到模型的地方

这是本文的正题。把三块接起来。

// 1. Attach to the plant. Not a privileged channel — an ordinary MCP client.
final connected = await McpClient.createAndConnect(
 config: McpClient.simpleConfig(name: 'Plant Assistant', version: '1.0.0'),
 transportConfig: const TransportConfig.stdio(
 command: 'dart',
 arguments: ['run', 'bin/server.dart'],
 workingDirectory: '../plant_server',
 ),
);
final mcpClient = connected.get();

// 2. Register a provider and make the client that joins the two.
final bench = BenchProvider();
final llm = McpLlm()..registerProvider('bench', BenchProviderFactory(bench));

final client = await llm.createClient(
 providerName: 'bench',
 config: LlmConfiguration(model: 'bench-1'),
 mcpClient: mcpClient, // ← the tools go in here
 systemPrompt: assistantSystemPrompt,
);

// 3. Ask. Passing the tool list, executing the calls and feeding the results
// back all happens inside this one line.
final response = await client.chat(question, enableTools: true);

mcpClient: 这一行就是接线的全部。chat(enableTools: true) 把工具清单递给模型,模型调用工具时经 MCP 执行,再把结果附上去问一次拿到最终答案。

换成真实模型也是在这个位置。 样例里以注释留着。

// llm.registerProvider('claude', ClaudeProviderFactory());
// final client = await llm.createClient(
// providerName: 'claude',
// config: LlmConfiguration(apiKey: Platform.environment['ANTHROPIC_API_KEY'],
// model: 'claude-sonnet-5'),
// mcpClient: mcpClient,
// systemPrompt: systemPrompt,
// );
//
// Nothing below this point changes.

两行。它下面一个字都不变。

③ 系统提示词 —— 每一句都有理由

写得短。每一句之所以在那儿,是因为把它抽掉就会出现某种特定的坏答案。

You help a maintenance technician standing in front of a machine.

Rules:
- Every number you state must have come from a tool result in this conversation.
 If you do not have it, call the tool. Never estimate a reading.
- Safety checklist steps are the plant engineer's. Quote them in order and do
 not paraphrase, shorten or reorder them.
- You do not decide whether a machine is safe to work on. You report what the
 readings are, how they compare to their limits, and what the checklist says.
- If the plant has no tool that answers the question, say so.
  • 抽掉第一句,就会出现 编出来的数值。 听着合理的振动值和真实振动值分不出来。
  • 抽掉第二句,就会出现 被概括过的安全流程。 四步压成三步的点检表,就不是点检表了。
  • 抽掉第三句,就会出现 判断。「可以动手了」不是这个系统该说的话。
  • 抽掉第四句,它就会 不懂装懂。

不过提示词是请求,不是保证。所以才需要下一节。

④ 数依据的地方

在提示词里说「要用工具」,却不去看到底用没用,那么没用工具的答案和用了的在画面上长得一模一样。所以要把 一个问题所引发的工具调用 精确地切出来。

// Mark the current point in the plant's audit log, so only the calls this
// question triggered can be attributed to it — not everything since boot.
final before = await _auditCalls();
final response = await llm.chat(question, enableTools: true);
final after = await _auditCalls();

_answer = response.text.trim();
_toolCalls = after.sublist(before.length);
_notice = _toolCalls.isEmpty
 ? 'No tool was called. Treat this as the assistant talking about '
 'itself, not about the plant.'
 : '';

而数的那一侧要把自己的调用排除掉。

// audit.log is itself a tool call, but it is ours, not the assistant's —
// count it and the grounds under every answer inflate by one.
return calls.cast<String>().where((c) => !c.startsWith('audit.log')).toList();

此内容需要开发者或更高等级

登录并升级您的方案即可继续阅读。

查看方案
Twitter