Tool Calling 与工程化

Agent 工具明明都在本地,为什么不能一次性全部暴露?——Tool Calling 的权限、路由与审计设计

围绕工具路由、白名单、最小权限与审计链设计可控的 Tool Calling。

Tool Calling路由审计

接入自动告警闭环后,我在 AIOps 诊断链路中遇到过一个看起来很反常的问题:

MCP 连接正常,工具也能被发现,但一个普通诊断任务拿到的工具列表却是空的。

第一反应很容易是“工具服务断了”,但真正的问题发生在更靠前的位置:系统把普通任务误判成了一个 Live Task,又因为它没有合法的 Live 证据范围,安全路由选择了 fail-closed,最终返回空工具集。

这个问题让我重新审视了 Tool Calling 的边界。工具能被发现,不代表模型应该看到它;模型能看到工具,也不代表它有权执行;即使允许执行,模型生成的参数也不能全部被信任。

本文的中心结论是:

LLM 负责在授权范围内选择和编排工具;系统负责决定它能看到什么、哪些参数可信、是否允许执行,以及如何审计和安全失败。

这不是为了限制 Agent 的能力,而是为了让工具调用从“模型输出一段 JSON”变成一条可控制、可复现、可追责的执行链。

一、一次工具列表变空,暴露了什么问题

当前项目既支持普通用户发起的诊断,也支持带有固定证据范围的自动 Live 任务。两者的工具权限不同:

任务类型工具来源是否需要 Live scope
普通诊断当前用户启用的 MCP 连接
自动 Live 诊断经过场景白名单过滤的日志与运行时工具

路由层有一条刻意严格的安全规则:如果一个任务不是受支持的自动 Live Task,却携带了 liveEvidenceScope,系统会把它视为可疑输入,进入受限模式并返回空工具集。

简化后的判断如下:

if not automatic_live_task:
    if "liveEvidenceScope" in input_payload:
        return scoped_route(allowed_tools=set())
    return ordinary_route()

这条规则本身没有问题。问题出在调用方:它曾经无条件构造下面的路由输入。

route_input = {
    "automaticClosureMode": state.get("automatic_closure_mode"),
    "benchmarkMode": state.get("benchmark_mode"),
    "benchmarkScenarioId": state.get("benchmark_scenario_id"),
    "liveEvidenceScope": state.get("live_evidence_scope"),
}

普通任务虽然没有真实的 Live scope,但状态中存在默认空对象。于是,“没有该字段”和“该字段为空”被错误地当成同一件事:

普通诊断任务
  → 路由输入出现 liveEvidenceScope={}
  → 不是受支持的自动 Live Task
  → 被视为可疑的越界输入
  → fail-closed
  → allowed_tools=[]

修复不是放宽路由,而是恢复输入语义:只有任务本身确实拥有本地 Live scope,或者已被确认是自动 Live Task 时,才把 liveEvidenceScope 传入路由。

if task_local_live_scope or automatic_live_task:
    route_input["liveEvidenceScope"] = state.get("live_evidence_scope")

修复后形成两条明确路径:

  • 普通任务不携带该字段,继续使用当前用户启用的 MCP 工具;
  • 真正的 Live Task 如果 scope 缺失、伪造或越界,仍然得到空工具集。

这里最重要的经验不是“空对象会导致 Bug”,而是:安全上下文中的字段存在性本身就是协议的一部分。 missingnull{} 不一定具有相同语义。

二、为什么不能把所有工具都交给模型

如果所有工具都运行在本地,为什么不在启动时全部注册给 LLM,让它自己选择?

因为“本地”只描述部署位置,不代表工具无副作用、无敏感数据,也不代表当前任务有权使用它。

一次完整的 Tool Calling 至少包含四种不同权限:

权限要回答的问题
发现权系统是否知道这个工具存在?
可见权当前 Agent 是否能在 Prompt/Schema 中看到它?
调用权当前任务是否允许实际调用它?
参数所有权哪些参数可由模型决定,哪些必须由运行时注入?

把工具注册进 Tool Registry,只解决了发现权。直接把 Registry 全量转换成模型工具,相当于把后面三层权限一起跳过。

这会带来几类风险:

  1. 越权访问。 普通诊断可能看到只属于自动告警或特定租户的工具。
  2. 数据越界。 模型可能改写日志主题、时间窗口、资源 ID 或 run_id
  3. 误执行。 一个本应只生成建议的任务,可能调用重启、终止连接等恢复工具。
  4. 选择退化。 工具越多、描述越相似,模型越容易选错工具或构造错误参数。
  5. 审计失真。 如果只记录模型最初生成的参数,就无法证明运行时最终执行了什么。

因此,正确的心智模型不是“给 LLM 一个工具箱”,而是“给当前任务签发一张临时工具通行证”。

三、Tool Registry 只负责说明“有什么”

MCP 工具发现会返回名称、描述、输入 Schema 和服务来源。在项目中,它被归一化为类似下面的定义:

@dataclass(frozen=True, slots=True)
class McpToolDefinition:
    name: str
    description: str
    input_schema: dict[str, Any]
    server_name: str = "default"

这些信息适合构建 Tool Registry:

MCP Server
  → list_tools
  → name + description + JSON Schema + server_name
  → Tool Registry

Registry 的作用是建立统一目录,解决“有哪些工具、参数契约是什么、来自哪个服务”三个问题。它不应该自动做授权决策。

还要特别处理同名工具。两个 MCP Server 如果都声明 SearchLog,仅凭名称已经无法确定应该调用哪个服务。当前客户端会拒绝重复名称,而不是随机选择一个。这也是 fail-closed:歧义不是一个可以靠运气解决的错误。

所以,工具发现的输出只是路由的输入之一。真正暴露给 Agent 的集合还需要结合用户归属、任务类型、场景 scope 和工具策略计算。

四、先说清项目现状:工具并不都来自 MCP

前面以 MCP 工具发现解释了 Registry,但这不意味着当前项目的所有工具都由 MCP Server 动态提供。实际实现是三条不同链路:

链路工具或入口如何实现是否经过真实 MCP
Chat Agent后端本地注册 LangChain StructuredTool当前没有
Alertmanager 告警接收FastAPI HTTP Webhook否,这一步不是 Tool Calling
告警后的 AIOps 诊断CLS MCP、本地诊断 Client、内部 RAG 混合只有部分工具经过

1. Chat 使用本地 StructuredTool

当前 Chat Agent 会在后端构建一个请求级工具表,主要包括:

knowledge_retrieval
get_current_time
load_skill
list_active_incidents
get_incident
start_incident_diagnostic
get_diagnostic_status
get_diagnostic_report
get_diagnostic_evidence
create_recovery_approval_request

这些工具由 LangChain StructuredTool 包装,分别调用项目内部的 RAG、Skill Loader 和 AiopsBridgeService。Chat Intent Router 先识别意图,再由 allowed_tools_for(intent) 选择本轮可见工具:

用户消息
  → Chat Intent Router
  → allowed_tools_for(intent)
  → 本地 StructuredTool Registry
  → LangChain create_agent

虽然 LangChainChatAgentRunner 已经接收 mcp_clientmcp_client_provider,但当前 stream() 并没有调用 client_for_user()get_langchain_tools()discover_tools()。因此,用户启用的 MCP 工具目前尚未进入 Chat 的实际 tool_registry。依赖已经注入不等于功能已经接通。

2. Alertmanager Webhook 不是 MCP 工具

自动报警的入口是一条普通 HTTP 链路:

故障发生
  → Alertmanager 发送 firing 告警
  → FastAPI Webhook 校验、解析、过滤和去重
  → PostgreSQL 创建或更新 Incident
  → 后台任务调度 AIOps 诊断

这里没有 LLM 选择工具,也没有 MCP list_toolscall_tool。只有当 Incident 触发诊断任务后,系统才进入 Tool Calling 阶段。

3. AIOps 诊断采用混合工具链

报警后的 AIOps Agent 才会同时面对多种工具来源:

SearchLog
  → 腾讯云官方 CLS MCP Server

InspectOrderPoolState / InspectOrderDatabaseSessions
  → 后端本地诊断 Client

knowledge_retrieval
  → 项目内部 RAG

SearchLog 是真实外部 MCP 调用。本地运行时探针提供与 MCP Client 相同的 discover_tools()call_tool() 接口,便于 Planner 和 Executor 统一处理,但它们并不是单独部署的 MCP Server。knowledge_retrieval 则由 Executor 直接调用内部 RAG Service,不经过 MCP。

因此,本文后面所说的 Tool Router,主要指 AIOps 任务级工具授权;Chat 使用的是另一套基于意图的本地工具白名单。两者目标相同——缩小模型可见能力——但实现路径不同。

五、任务级 Tool Router 决定“这一次能看到什么”

AIOps Tool Router 位于工具发现与 Agent 规划之间。它不判断根因,只根据可信任务上下文收缩工具集合。

flowchart LR
    D["MCP Tool Discovery"] --> R["Tool Registry"]
    T["Task Context"] --> P["Task-level Tool Router"]
    R --> P
    P --> A["Allowed Tool Definitions"]
    A --> L["LLM Planner / Specialist"]
    L --> V["Argument Binding & Validation"]
    V --> E["Executor"]
    E --> U["Tool Audit"]

路由输入不能只依赖用户 Prompt。诸如“这是自动闭环任务”“这是哪个场景”“允许查询哪个运行窗口”等信息,必须来自后端创建的任务记录或经过校验的状态,而不能由模型自行声明。

在当前自动 Live 场景中,scope 包含:

runId
scenarioId
incidentId
fromMs
toMs

解析器会校验字段集合、ID 格式、场景一致性、事件 ID 绑定、时间顺序和最大窗口。任何一项不成立,scope 都不会被“尽量修正”为一个可用值,而是直接判为无效。

这种严格性适用于边界清晰的自动任务,因为它处理的是跨数据源访问权限。相比之下,普通诊断不应该被强制要求提供一个它本来就不需要的 Live scope。fail-closed 的对象应该是受保护能力,而不是所有请求。

六、模型参数和 Trusted Arguments 属于不同所有者

Tool Calling 中最容易被忽略的问题是:工具的每一个参数都应该由模型生成吗?

答案是否定的。可以把参数分成两类:

参数类型示例所有者
诊断选择参数查询关键词、聚合维度、候选探针LLM 在契约内选择
运行时可信参数runId、日志主题、数据库名、时间窗口后端运行时注入

模型可以决定“查什么”,但不应该决定“查谁的数据”。

当前执行链会先绑定运行时拥有的参数,再做 Schema 和场景合同校验:

bound = bind_trusted_tool_arguments(plan, trusted_tool_arguments)

for step in bound:
    effective_arguments = normalize_tool_arguments(...)
    validate_json_schema(effective_arguments)
    validate_registered_call(effective_arguments)

这意味着审计中真正重要的是 effective_arguments,而不是模型最初建议的参数。

例如,模型生成:

{
  "tool": "SearchLog",
  "arguments": {
    "query": "pool timeout",
    "fromMs": 0,
    "toMs": 9999999999999
  }
}

如果时间窗口属于运行时所有,执行器不应直接接受这两个时间值。它应该用任务签发的可信窗口覆盖它们,然后校验最终参数。模型仍保留对查询意图的表达权,但不能借 Tool Calling 扩大数据范围。

这条边界也能减少重试噪声。重复指纹应基于绑定后的有效参数计算,否则模型每次轻微改写固定字段,都可能被误认为一次新的合法调用。

七、Single 与 Multi-Agent 都需要工具隔离

Multi-Agent 不是把同一批工具复制给多个模型。它更适合按数据源或能力域划分工具:

flowchart TB
    P["Main Planner"] --> R["Strategy Router"]
    R --> RT["Runtime Specialist"]
    R --> LG["Log Specialist"]
    RT -->|"运行时、数据库、业务探针"| ER["Evidence Aggregator"]
    LG -->|"CLS 日志查询"| ER
    ER --> F["Fact Adapter / Adjudicator / Decision"]

Runtime Specialist 只看到运行时、数据库和业务探针工具;Log Specialist 只看到经过 scope 绑定的日志工具。这样做有三个好处:

  • 每个 Specialist 的职责和输入更清晰;
  • 工具描述之间的竞争更少;
  • 审计可以回答“哪一个角色基于哪一类数据得出了什么调查结果”。

多个 Specialist 可以共享任务级事实和 Evidence ID,但不应该通过共享可变字典互相覆盖状态。更稳妥的方式是让各自输出结构化 Investigation Result,再由 Evidence Aggregator 合并。

不过,工具隔离不会自动带来诊断提升。如果两个 Specialist 只是按固定计划调用工具、没有独立分析或差分假设,Multi-Agent 的效果可能与单 Agent 的串行收集接近,却增加了模型调用和等待时间。因此,路由应该只在跨数据源、候选根因较多或证据需要独立裁决时升级到 Multi-Agent。

八、只读、Proposal 与 Recovery 不是同一种工具

工具最好按副作用分级,而不是只按业务名称分类:

级别典型能力默认策略
Read-only查日志、查连接池、查数据库会话任务 scope 内可调用
Proposal记录缓解建议、生成待审批方案可记录,不产生恢复副作用
Recovery终止连接、重启服务、修改配置必须经过独立 Policy Gate

Proposal 工具的关键点是:它不是因为名字像“建议”就安全。 工具必须在当前请求的策略表中显式标记为 proposal_only,执行前还要重新校验。

这对旧 Checkpoint 尤其重要。旧状态中可能保存了历史工具定义,恢复执行时不能直接信任它。当前兼容逻辑允许读取旧 Checkpoint 的 proposal 定义,但只保留仍被当前 policy 标记为 proposal_only 的工具:

return tuple(
    definition
    for definition in checkpoint_tools
    if tool_policies.get(definition.name) == "proposal_only"
)

这是一种“兼容读取、重新授权”的策略:兼容的是数据格式,不是历史权限。

Recovery 的门槛更高。诊断结论、模型置信度或一句“建议自动修复”都不能直接形成执行权。真正的恢复至少还需要:

  • 根因与 Evidence 可验证;
  • 动作位于白名单;
  • 目标资源与当前任务 scope 一致;
  • 风险等级允许自动执行;
  • 具备幂等标识和恢复后验证;
  • Policy Gate 明确返回 execution_permitted=true

需要说明的是,项目中的 Docker Live Harness 可以在隔离场景和恢复合同内执行部分白名单动作,这不等于生产环境默认拥有相同权限。生产链路仍应优先使用 proposal_only、人工审批或外部策略授权。

九、Schema 校验、超时和审计如何组成执行闭环

一条可靠的 Tool Calling 链路,不应该从 Planner 直接连到 MCP Server、本地探针或内部服务。中间至少需要以下步骤:

模型生成计划
  → 检查工具是否在允许集合
  → 注入 Trusted Arguments
  → 参数归一化
  → JSON Schema 校验
  → 场景/任务合同校验
  → 计算调用指纹并去重
  → 创建 started 审计
  → 通过对应执行边界调用 MCP、本地探针或内部服务
  → 保存安全结果摘要或错误分类
  → 生成 Evidence

1. 为什么 Planner 和 Executor 都要校验

Planner 后校验可以尽早拒绝错误步骤,避免消耗工具预算。Executor 再校验一次,则是为了防御:

  • 旧 Checkpoint;
  • 外部构造的计划;
  • 绕过 Planner 的调用方;
  • 未来重构造成的契约遗漏。

非法参数必须在实际调用前被拒绝。执行器记录 invalid_arguments 和安全的合同错误码,但不会把非法请求发送给 MCP Server、本地探针或内部服务。

2. 超时和重试为什么不能只返回“调用失败”

工具失败至少应区分:

  • 参数无效;
  • 工具不存在或同名歧义;
  • 连接失败;
  • 调用超时;
  • MCP 或本地工具返回业务错误;
  • 重试耗尽。

这些分类决定下一步是 Replan、重试、降级还是人工介入。如果所有失败都压成一个字符串,Agent 可能把基础设施故障误判成业务证据。

重试也必须受限。只读工具通常可以进行少量重试;具有副作用的工具则必须依赖稳定的幂等键,状态不确定时不能盲目补执行。

3. 审计到底应该记录什么

一次工具审计至少需要:

  • 当前用户和诊断任务;
  • 稳定的 tool call ID;
  • 工具名称;
  • 最终有效参数;
  • startedcompletedfailed 状态;
  • 安全的结果摘要或错误信息;
  • 开始、结束时间和耗时。

审计不是保存模型的隐藏推理过程,也不应该原样持久化敏感日志。它记录的是外部可验证的执行事实:调用了什么、以什么参数调用、结果属于哪种状态、产生了哪些 Evidence。

十、fail-closed 不是“出错就把所有能力关闭”

fail-closed 经常被理解成:只要出现不确定性,就返回空工具集或转人工。这个理解过于机械。

更准确的原则是:

当系统无法证明某项受保护能力已被授权时,不执行那项能力。

它适合以下边界:

  • Live scope 缺失或非法;
  • 工具名称存在歧义;
  • Trusted Arguments 无法生成;
  • Recovery policy 不明确;
  • 副作用调用的幂等状态未知;
  • 旧 Checkpoint 中的权限无法被当前策略重新确认。

但它不应该误伤与该权限无关的普通路径。本文开头的 Bug 正是一个典型例子:Live scope 校验应该保护 Live 工具,却因为调用方无条件添加字段,错误关闭了普通用户工具。

因此,每条 fail-closed 规则都应该配套两类测试:

  1. 非法或越界输入确实被拒绝;
  2. 不需要该权限的正常请求不会被误判。

安全测试只覆盖第一类,很容易得到一个“很安全但不可用”的系统。

十一、把 Tool Calling 设计成一条权限流水线

回到最初的问题:工具明明都在本地,为什么不能一次性全部暴露?

因为 Tool Calling 不是单纯的函数选择,而是一条权限与证据流水线:

flowchart LR
    A["发现或注册工具"] --> B["按用户、意图与任务路由"]
    B --> C["向 Agent 暴露最小集合"]
    C --> D["模型选择工具与诊断参数"]
    D --> E["运行时绑定可信参数"]
    E --> F["Schema / Contract 校验"]
    F --> G["Policy Gate"]
    G --> H["执行与有限重试"]
    H --> I["审计、Evidence 与安全失败"]

可以把这套设计提炼成八条通用原则:

  1. Discovery 或本地注册不等于 Authorization。 Registry 只说明工具存在。
  2. 按任务暴露最小工具集合。 不把全量工具交给每个 Agent。
  3. 把数据 scope 当作后端签发的能力。 不相信 Prompt 自报身份。
  4. 区分模型参数与运行时参数。 模型决定查询意图,系统决定访问边界。
  5. 校验最终有效参数。 Schema、场景合同和审计都基于绑定后的参数。
  6. 按副作用分级。 Read-only、Proposal、Recovery 使用不同 Policy。
  7. 旧状态需要重新授权。 Checkpoint 兼容不能继承过期权限。
  8. 不同链路不要强行伪装成同一种协议。 Chat 本地工具、Webhook 和 AIOps MCP 各自保留清晰边界。
  9. fail-closed 要精确。 拒绝无法证明已授权的能力,同时保护正常路径可用。

做到这些以后,LLM 仍然可以灵活规划、选择工具和组织证据,但它的自由发生在一个可解释的安全边界内。

这也是生产级 Agent 与演示型 Agent 的关键区别:前者不仅要“会调用工具”,还必须能回答——为什么它能看到这个工具、参数从哪里来、谁允许它执行、失败后发生了什么,以及我们如何复现整条链路。

陈涛 · Agent Application Developer

杭州 · 2026