站在工厂设备前的维修工想知道的通常就那么几件:这台机器过保养周期了没有?振动在限值内吗?动手之前要先确认什么?
这些信息其实早就都在厂里某处。在保养履历数据库里,在传感器采集系统里,在安全点检表文档里。只是站在机器前的人,没有办法去问。
于是「接个 LLM 不就行了」这个念头很自然地冒出来。而真正的问题从那里才开始。如果没法区分「说出『三号传送带过保养期了』的机器」和「这么说但什么都没查过的机器」,那么信了这句建议、把手伸进机器里的人就危险了。
这篇文章记录的,是把这个区分做到画面上为止。答案旁边,会一起出现造出这个答案的那次工具调用。 而一个工具都没调用的答案,会被标出来。
先看结果
问了机器的情况。答案下面附着 造出这个答案的那一行工具调用。

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

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

工具被调用了 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();