《互联网产品的 AI 架构·模型接入与能力边界》7.2 工具调用:谁执行、谁授权

从「回声」模型试图自带 tenantId 查询别家订单、以及直接提议退款两个场景出发,拆开工具调用中“谁提议、谁授权、谁执行”三段职责,给出工具白名单、角色、租户注入、参数校验、副作用分级与单轮次数上限的可测试实现,并说明确认为什么必须绑定到具体调用 id。

一次“帮我查订单”的越权提议

「回声」的客服助手接入了订单查询。用户说“帮我看看 E20261026001 到哪了”,模型返回一个工具提议:

{"id":"call-17","name":"orders.lookup","arguments":{"orderId":"E20261026001","tenantId":"t-other"}}

参数看起来齐全,订单号也对。但 tenantId 不是用户给的,也不是会话上下文里的,而是模型从更早的一段测试数据里“补”出来的。如果网关直接把参数透传给数据访问层,这次查询就会落到另一个租户。

同一天还有第二个提议。用户说“这么慢,退钱吧”,模型直接调用 refund.create,金额 100。坐席的实际角色是 operator,退款工具要求 admin,而且这个租户根本没有开通退款工具。

这两个例子说明,工具调用不是“模型调用了一个函数”,而是“模型提议了一次带副作用的业务动作”。提议可以来自模型,授权和执行不能。

模型只提议,网关才执行

把链路拆成三段,职责就清楚了。

第一段是提议。模型根据对话决定“要不要调用工具、调用哪个、参数是什么”。它输出的是结构化数据,不是执行结果。模型可以犯错、可以被提示注入、可以幻觉出一个不存在的工具,这些都在预期内。

第二段是授权。网关拿到提议后,依次检查工具是否注册、租户是否开通、角色是否足够、参数是否越界、用户是否确认、本轮次数是否超限。任何一项不通过,提议就停在网关,不产生业务副作用。

第三段是执行。只有授权通过的参数才会进入业务适配器。适配器从会话上下文拿租户身份,拼进查询条件,再调用领域服务。模型提供的参数只描述“查什么”,不描述“以谁的身份查”。

为什么不让模型直接调函数

最直接的做法是把函数签名塞进提示,让模型生成调用,再由运行时反射执行。这个做法省事,但把三个本该公司掌握的东西交给了模型:可调用集合、调用者身份、副作用边界。

模型不知道当前租户是谁,也不该知道;模型不知道坐席有没有退款权限,也不该知道;模型更不知道一次退款会不会触发财务对账。把这些判断留在模型外面,不是不信任模型,而是不让一个概率系统承担确定性职责。

工具契约与副作用分级

每个工具在注册表里有一份契约:名称、副作用等级、最低角色、参数 Schema、单轮最大调用次数。参数 Schema 直接复用 7.1 的校验器,工具边界和输出边界共享同一套路径与问题码。

副作用分四级:

等级含义例子是否需要确认
read只读,不改变任何状态查订单、查物流否
draft只生成草稿,不对外发送生成回复草稿否
write改变站内状态修改收货地址、关闭工单是
external站外或资金动作发起退款、调用第三方物流是

分级不是为了给工具“贴标签”,而是为了决定两件事:要不要打断用户确认,以及默认给多高的角色门槛。draft 之所以不需要确认,是因为它没有对外副作用;一旦坐席点了发送,动作就从 draft 变成了 write,应该走另一条授权路径。

六个检查点,顺序不能乱

授权器里的检查顺序是固定的,因为顺序本身会影响安全性和用户体验。

  1. 工具是否在全局注册表里。未注册的名字直接拒绝,不进入后续任何分支。
  2. 租户是否开通了这个工具。全局注册表是能力上限,租户白名单是实际开通范围。
  3. 当前角色是否达到工具的最低角色。角色来自会话,不来自请求参数。
  4. 参数里是否出现租户字段。模型不得自带 tenantId 或 tenant_id,嵌套在过滤条件里也要拦住。
  5. 参数是否符合 Schema。失败时返回具体路径,方便提示修复。
  6. 副作用是否需要确认,以及本轮调用次数是否超限。

租户白名单放在角色之前,是因为“没开通”比“权限不够”更根本。一个租户根本没买退款能力,讨论坐席是不是管理员没有意义。租户字段检查放在参数校验之前,是因为它属于身份边界,不属于数据格式;即使 Schema 恰好允许这个字段,也应该先被身份规则拦下。

授权器实现

下面的实现把“授权”和“执行”分开。authorizeToolCall 是纯函数,不碰网络、不碰数据库,测试可以直接断言每一个拒绝原因;executeToolCall 只有在授权通过后才查找并调用执行器。

// file: src/tools/policy.ts
// tier: L1
// verify: node --test test/tools/policy.test.ts

import type { SchemaIssue, SchemaNode } from "../structured/schema.ts";
import { validate } from "../structured/schema.ts";

export type TenantRole = "viewer" | "member" | "operator" | "admin";

/** 副作用从只读、草稿、写入到站外操作逐级升高。 */
export type SideEffect = "read" | "draft" | "write" | "external";

const ROLE_RANK: Record<TenantRole, number> = {
  viewer: 0,
  member: 1,
  operator: 2,
  admin: 3,
};

const SIDE_EFFECT_RANK: Record<SideEffect, number> = {
  read: 0,
  draft: 1,
  write: 2,
  external: 3,
};

export interface ToolSpec {
  name: string;
  sideEffect: SideEffect;
  minRole: TenantRole;
  /** 参数契约复用 7.1 的校验器,模型自述的参数不算数。 */
  parameters: SchemaNode;
  /** 单轮对话允许的最大调用次数,防止模型循环调用。 */
  maxCallsPerTurn: number;
}

export interface ToolCall {
  /** 模型生成的调用 id,用于把用户确认绑定到具体一次调用。 */
  id: string;
  name: string;
  arguments: unknown;
}

export interface ToolContext {
  /** 租户身份只来自会话,不来自模型参数。 */
  tenantId: string;
  role: TenantRole;
  /** 租户开通的工具白名单,与全局注册表取交集。 */
  allowedTools: string[];
  /** 用户已经明确确认过的调用 id。 */
  confirmedCallIds: string[];
  /** 由网关维护的本轮调用计数,模型无法修改。 */
  callCounts: Record<string, number>;
}

export type DenyReason =
  | "unknown-tool"
  | "tool-not-allowed"
  | "role-denied"
  | "tenant-override"
  | "invalid-arguments"
  | "confirmation-required"
  | "call-limit";

export type ToolDecision =
  | { allowed: true; tool: ToolSpec; args: Record<string, unknown> }
  | { allowed: false; reason: DenyReason; detail: string; issues?: SchemaIssue[] };

export type ToolExecutor = (
  args: Record<string, unknown>,
  context: ToolContext,
) => unknown | Promise<unknown>;

export interface ToolExecution {
  status: "denied" | "executed" | "failed";
  decision: ToolDecision;
  output?: unknown;
  error?: string;
}

const FORBIDDEN_ARGUMENT_KEYS = new Set(["tenantId", "tenant_id"]);

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

/** 递归查找模型试图自带的租户字段,嵌套在过滤条件里也要拦住。 */
function findTenantKey(value: unknown, path: string): string | null {
  if (Array.isArray(value)) {
    for (let index = 0; index < value.length; index += 1) {
      const found = findTenantKey(value[index], `${path}[${index}]`);
      if (found !== null) return found;
    }
    return null;
  }
  if (!isRecord(value)) return null;
  for (const key of Object.keys(value)) {
    const child = /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)
      ? `${path}.${key}`
      : `${path}[${JSON.stringify(key)}]`;
    if (FORBIDDEN_ARGUMENT_KEYS.has(key)) return child;
    const found = findTenantKey(value[key], child);
    if (found !== null) return found;
  }
  return null;
}

export function needsConfirmation(sideEffect: SideEffect): boolean {
  return SIDE_EFFECT_RANK[sideEffect] >= SIDE_EFFECT_RANK.write;
}

/**
 * 模型只能提议,授权与执行都在网关侧完成。
 * 检查顺序固定:注册表 > 租户白名单 > 角色 > 租户注入 > 参数 > 确认 > 次数。
 */
export function authorizeToolCall(
  registry: Record<string, ToolSpec>,
  context: ToolContext,
  call: ToolCall,
): ToolDecision {
  const tool = registry[call.name];
  if (tool === undefined) {
    return { allowed: false, reason: "unknown-tool", detail: `tool ${call.name} is not registered` };
  }
  if (!context.allowedTools.includes(call.name)) {
    return {
      allowed: false,
      reason: "tool-not-allowed",
      detail: `tenant ${context.tenantId} has not enabled ${call.name}`,
    };
  }
  if (ROLE_RANK[context.role] < ROLE_RANK[tool.minRole]) {
    return {
      allowed: false,
      reason: "role-denied",
      detail: `role ${context.role} is below ${tool.minRole} for ${call.name}`,
    };
  }
  const tenantPath = findTenantKey(call.arguments, "$");
  if (tenantPath !== null) {
    return {
      allowed: false,
      reason: "tenant-override",
      detail: `tenant identity must come from context, not arguments (${tenantPath})`,
    };
  }

  const issues = validate(tool.parameters, call.arguments);
  if (issues.length > 0) {
    return {
      allowed: false,
      reason: "invalid-arguments",
      detail: `arguments for ${call.name} do not match the contract`,
      issues,
    };
  }

  if (needsConfirmation(tool.sideEffect) && !context.confirmedCallIds.includes(call.id)) {
    return {
      allowed: false,
      reason: "confirmation-required",
      detail: `${call.name} is a ${tool.sideEffect} operation and needs explicit confirmation`,
    };
  }

  const used = context.callCounts[call.name] ?? 0;
  if (used >= tool.maxCallsPerTurn) {
    return {
      allowed: false,
      reason: "call-limit",
      detail: `${call.name} already used ${used} times in this turn`,
    };
  }

  if (!isRecord(call.arguments)) {
    return { allowed: false, reason: "invalid-arguments", detail: "arguments must be an object" };
  }
  return { allowed: true, tool, args: call.arguments };
}

/** 只有授权通过才会触达执行器;被拒绝的调用不会产生任何业务副作用。 */
export async function executeToolCall(
  registry: Record<string, ToolSpec>,
  executors: Record<string, ToolExecutor>,
  context: ToolContext,
  call: ToolCall,
): Promise<ToolExecution> {
  const decision = authorizeToolCall(registry, context, call);
  if (!decision.allowed) {
    return { status: "denied", decision };
  }
  const executor = executors[call.name];
  if (executor === undefined) {
    return { status: "failed", decision, error: `no executor registered for ${call.name}` };
  }
  try {
    const output = await executor(decision.args, context);
    return { status: "executed", decision, output };
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    return { status: "failed", decision, error: message };
  }
}

有一个细节值得说明:findTenantKey 是递归的。只检查顶层 arguments.tenantId 不够,模型完全可能写成 {"filter":{"tenant_id":"t-other"}}。递归检查会带来一点遍历成本,但参数对象通常很小,这点成本远低于一次跨租户查询的代价。

另一个细节是拒绝原因的类型。DenyReason 是一个有限的字符串联合,不是自由文本。这样日志可以按原因聚合,前端也可以根据原因决定是提示“没有权限”还是“请先确认”。自由文本的拒绝消息看起来更友好,但无法统计。

测试与真实输出

测试覆盖十五个场景,重点不是“正常路径能跑通”,而是每个拒绝原因都能被单独触发,并且被拒绝的调用不会触达执行器。

node --test test/tools/policy.test.ts

本机一次运行的摘要如下,耗时随机器变化:

ℹ tests 15
ℹ suites 0
ℹ pass 15
ℹ fail 0
ℹ duration_ms 172.733958

其中两个断言是这套设计的核心。第一个是“租户白名单先于角色检查”:operator 调用未开通的退款工具,返回的必须是 tool-not-allowed,而不是 role-denied。第二个是“被拒绝的调用不会触达执行器”:测试里放了一个会计数的执行器,拒绝后计数必须还是零。前者保证拒绝原因准确,后者保证副作用没有发生。

确认要绑定到具体调用

很多实现把确认做成“这个工具需要确认”,用户点过一次之后,本轮内所有同名调用都放行。这很危险:模型可以在用户确认“修改地址”之后,再提议一次“修改地址”但换成另一个订单。

正确做法是把确认绑定到调用 id。模型每次提议生成一个 id,前端展示的是这次调用的具体参数,用户确认后网关把 id 放进 confirmedCallIds。授权时比对的是 call.id,不是工具名。这样即使用户确认过同类操作,一次新的、参数不同的调用仍然需要重新确认。

调用次数上限也是同理。maxCallsPerTurn 由网关维护,模型无法通过参数修改。它防的不是恶意攻击,而是模型在失败后反复重试同一个工具,把一次对话变成几十次查询。

执行器与租户上下文

执行器的签名是 (args, context)。args 是模型提议且通过校验的参数,context 是网关从会话里取出的租户、角色和确认信息。业务适配器拼查询时,租户条件只能来自 context.tenantId,不能来自 args。

这条规则要在代码评审里反复强调,因为它的错误形式很隐蔽:where tenant_id = ? 的占位符填了 args.tenantId ?? context.tenantId,看起来做了兜底,实际上把模型参数放在了第一位。只要模型提供了一个值,上下文就被覆盖了。正确的写法是根本不读 args.tenantId,因为授权阶段已经保证它不存在。

执行器还要自己保证幂等。授权通过不等于只执行一次:网络超时、进程重启、上游重试都可能让同一个调用 id 到达执行器两次。修改地址、发起退款这类操作应该带业务幂等键,不能假设“网关只调用一次”。

小结

工具调用的边界可以压成一句话:模型只提议,网关做授权,业务做执行。提议是概率的,授权必须是确定的,执行必须带租户上下文和幂等保护。

白名单、角色、租户注入、参数校验、副作用分级、调用次数,这六个检查点缺一个都会留下不同的洞。顺序也不是随意的:身份和开通范围先于数据格式,确认绑定到具体调用而不是工具名。

下一节处理另一条失败路径:当结构化输出解析失败,而且一次修复也没救回来时,系统应该怎样体面地降级,而不是把空白页丢给用户。

阅读导航:上一节:7.1 结构化输出的契约与校验 · 下一节:7.3 解析失败的重试、修复与降级 。

常见错误与边界

  • 让模型直接调用函数。可调用集合、调用者身份和副作用边界都不该由模型决定。
  • 只校验参数,不校验租户。参数再合法,落到别的租户就是越权。
  • 只检查顶层 tenantId。嵌套在 filter、options 里的租户字段同样危险。
  • 把确认做成“工具级开关”。确认必须绑定到具体调用 id 和具体参数。
  • 先查角色再查白名单。未开通的能力应该报“没开通”,而不是“权限不够”。
  • 执行器从参数里读租户。租户只能来自会话上下文,参数里的值一律不可信。
  • 假设网关只调用一次。执行器要按调用 id 做幂等,重试和重启都可能导致重复执行。
  • 拒绝原因用自由文本。没有稳定的原因枚举,日志无法聚合,前端也无法分支。
  • 把调用次数限制交给提示。模型不会遵守“最多查三次”,计数器必须在网关。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查