Tool Calling 与工程化
Agent 工具明明都在本地,为什么不能一次性全部暴露?——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”,而是:安全上下文中的字段存在性本身就是协议的一部分。 missing、null 和 {} 不一定具有相同语义。
二、为什么不能把所有工具都交给模型
如果所有工具都运行在本地,为什么不在启动时全部注册给 LLM,让它自己选择?
因为“本地”只描述部署位置,不代表工具无副作用、无敏感数据,也不代表当前任务有权使用它。
一次完整的 Tool Calling 至少包含四种不同权限:
| 权限 | 要回答的问题 |
|---|---|
| 发现权 | 系统是否知道这个工具存在? |
| 可见权 | 当前 Agent 是否能在 Prompt/Schema 中看到它? |
| 调用权 | 当前任务是否允许实际调用它? |
| 参数所有权 | 哪些参数可由模型决定,哪些必须由运行时注入? |
把工具注册进 Tool Registry,只解决了发现权。直接把 Registry 全量转换成模型工具,相当于把后面三层权限一起跳过。
这会带来几类风险:
- 越权访问。 普通诊断可能看到只属于自动告警或特定租户的工具。
- 数据越界。 模型可能改写日志主题、时间窗口、资源 ID 或
run_id。 - 误执行。 一个本应只生成建议的任务,可能调用重启、终止连接等恢复工具。
- 选择退化。 工具越多、描述越相似,模型越容易选错工具或构造错误参数。
- 审计失真。 如果只记录模型最初生成的参数,就无法证明运行时最终执行了什么。
因此,正确的心智模型不是“给 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_client 和 mcp_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_tools 或 call_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;
- 工具名称;
- 最终有效参数;
started、completed或failed状态;- 安全的结果摘要或错误信息;
- 开始、结束时间和耗时。
审计不是保存模型的隐藏推理过程,也不应该原样持久化敏感日志。它记录的是外部可验证的执行事实:调用了什么、以什么参数调用、结果属于哪种状态、产生了哪些 Evidence。
十、fail-closed 不是“出错就把所有能力关闭”
fail-closed 经常被理解成:只要出现不确定性,就返回空工具集或转人工。这个理解过于机械。
更准确的原则是:
当系统无法证明某项受保护能力已被授权时,不执行那项能力。
它适合以下边界:
- Live scope 缺失或非法;
- 工具名称存在歧义;
- Trusted Arguments 无法生成;
- Recovery policy 不明确;
- 副作用调用的幂等状态未知;
- 旧 Checkpoint 中的权限无法被当前策略重新确认。
但它不应该误伤与该权限无关的普通路径。本文开头的 Bug 正是一个典型例子:Live scope 校验应该保护 Live 工具,却因为调用方无条件添加字段,错误关闭了普通用户工具。
因此,每条 fail-closed 规则都应该配套两类测试:
- 非法或越界输入确实被拒绝;
- 不需要该权限的正常请求不会被误判。
安全测试只覆盖第一类,很容易得到一个“很安全但不可用”的系统。
十一、把 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 与安全失败"]
可以把这套设计提炼成八条通用原则:
- Discovery 或本地注册不等于 Authorization。 Registry 只说明工具存在。
- 按任务暴露最小工具集合。 不把全量工具交给每个 Agent。
- 把数据 scope 当作后端签发的能力。 不相信 Prompt 自报身份。
- 区分模型参数与运行时参数。 模型决定查询意图,系统决定访问边界。
- 校验最终有效参数。 Schema、场景合同和审计都基于绑定后的参数。
- 按副作用分级。 Read-only、Proposal、Recovery 使用不同 Policy。
- 旧状态需要重新授权。 Checkpoint 兼容不能继承过期权限。
- 不同链路不要强行伪装成同一种协议。 Chat 本地工具、Webhook 和 AIOps MCP 各自保留清晰边界。
- fail-closed 要精确。 拒绝无法证明已授权的能力,同时保护正常路径可用。
做到这些以后,LLM 仍然可以灵活规划、选择工具和组织证据,但它的自由发生在一个可解释的安全边界内。
这也是生产级 Agent 与演示型 Agent 的关键区别:前者不仅要“会调用工具”,还必须能回答——为什么它能看到这个工具、参数从哪里来、谁允许它执行、失败后发生了什么,以及我们如何复现整条链路。