先说结论

把大模型接到内部系统上,真正危险的不是它答错,而是它做错。问答错了顶多重来一次;工具调用错了——建了单、发了通知、改了数据——是要人去收拾的。

所以我做这类工具时,不把精力放在”让模型更聪明”,而是放在让它出错时不至于造成破坏。落到代码上就是三层闸门:

  1. 权限(scope):每个工具声明它需要什么权限,调用方(人/角色)持有什么权限,不匹配就拒绝;
  2. 人工确认(confirmation):不可逆操作必须由人点头,而且这个点头不能由模型自己给;
  3. 审计(audit):每一次调用都留下可追溯的记录——谁、在什么角色下、调了什么、结果如何。

下面用一个最小的 MCP 风格服务(stdio + JSON-RPC,零依赖)说明怎么落。

一、校验与执行必须分离

工具调用只有两个结果:过闸门,或者被拦下。我的写法是先校验、后执行,校验层完全不碰业务:

1
2
3
4
5
6
7
8
9
def call_tool(self, name, args, confirmed: bool):
tool = TOOLS.get(name)
if tool is None: # 未知工具:拒绝(fail-closed)
return deny("unknown_tool", "该工具未注册,已拒绝执行")
if tool.scope and tool.scope not in self.scopes:
return deny("scope_missing", f"当前角色缺少 {tool.scope} 权限,请先申请")
if tool.requires_confirm and not confirmed:
return deny("confirm_required", "该操作不可逆,需要人工确认后重试")
return run(tool, args) # 到这里才是业务代码

这里有两个刻意的选择:

  • 未知工具默认拒绝(fail-closed)。新增工具忘了配权限,结果是”用不了”,而不是”谁都能用”。
  • **拒绝对不是抛异常,而是返回结构化的 rule + reason**。这样前端能给出可执行的提示(”请先申请 tickets.write 权限”),日志能按 rule 聚合,用户不用猜。

二、模型不能自己决定”已确认”

这是整套机制里最容易做错的一点。如果 confirmed 只是工具参数的一部分,那么模型完全可以自己填 confirmed: true —— 闸门就形同虚设。

确认信号必须来自闸门之外:由人在界面上点、由带外通道回填、或者由服务端根据会话状态注入。模型只能”请求确认”,不能”提供确认”。

配套的一条纪律:确认的粒度是操作,不是会话。一次点头只对那一次不可逆操作有效,不能”这次确认了,后面十次都算”。

三、审计用指纹,不落明文

审计日志要回答”谁在什么时候做了什么”,但不能变成新的泄密渠道。我的做法:

1
2
{"ts":"2026-10-01T22:41:03+08:00","role":"operator","tool":"create_ticket",
"decision":"deny","rule":"confirm_required","args_sha256":"9f2c…","duration_ms":0}

参数只记 sha256,需要复盘时拿手上的参数去比对;不把用户的原文(可能含手机号、金额、密钥)写进日志。审计文件用 JSONL 追加写,天然可 grep、可入库。

四、策略外置:改授权面不用改代码

权限关系写在一份 policy.json 里,服务启动时加载:

1
2
3
4
5
6
7
{
"roles": {
"reader": {"scopes": ["docs.read"]},
"operator": {"scopes": ["docs.read", "tickets.read", "tickets.write"]},
"admin": {"scopes": ["docs.read", "tickets.read", "tickets.write", "notify.send"]}
}
}

好处很实际:加一个角色、收窄一个权限,是配置变更而不是发版。生产里这份策略应该来自配置中心或数据库,并且策略本身的变更也要留审计——否则闸门自己就成了后门。

五、实测:把规则钉成断言

我给它写了 15 条端到端断言,跑一个文件就能全部验证,其中几条最关键:

断言 期望
reader 调 search_docs ✅ 允许
reader 调 create_ticket ❌ 拒绝,rule=scope_missing
operator 调 create_ticket 不带确认 ❌ 拒绝,rule=confirm_required
operator 调 create_ticket 带确认 ✅ 允许
admin 调不可逆工具,不带确认 ❌ 仍然拒绝(admin 不豁免确认)
调用未注册工具 ❌ 拒绝,rule=unknown_tool
审计文件行数 等于调用次数(含被拒的)

最后一条特别重要:**”被拒绝的调用也要留痕”**。只看成功记录,你永远不知道有人在敲门。

六、几个踩过的坑

  1. 我第一版把 whoami 工具忘写了处理函数——工具列表里有、调用就崩。是这个自测把它抓出来的,不是我的自信。能跑的测试比自信值钱。
  2. 别把权限写死在业务代码里。第一版我把 role 判断塞在工具实现里,加一个角色要改五处;拆成策略文件之后清爽了。
  3. 确认提示要写清后果。”确认执行?”是废话,”该操作会向 12 位同事发送通知,且不可撤回”才是信息。
  4. 平仓/撤销类操作要单独设计。风控的目标是防止乱开仓,不是阻止逃跑——只拦开仓,不拦平仓。这个原则在交易系统里是铁律,在工具闸门里同样成立。

小结

给 AI 工具装闸门,技术难度不高,难的是取舍:默认拒绝还是默认放行、确认由谁给、审计记多少。我的选择是——默认拒绝、由人确认、只记指纹。

这三条不一定适合你的场景,但至少让”模型乱来”这件事,从”可能发生”变成”必须绕过三道明确的门”。

文中的服务骨架与 15 条断言都是我自己写的,跑得起来;不涉及任何在职公司的代码与数据。