上一篇讨论了商城 Agent 的 Java 路由治理:模型先识别候选 Intent,Java 再通过置信度、能力白名单和资源契约,把请求路由到指定知识库、MCP Tool 或系统处理器。
路由完成以后,问题并没有结束。
假设系统已经判断用户要查询保修状态,并选中了 getWarrantyStatus。这个工具需要一个设备 SN。此时模型完全可能:
-
没有从问题里找到 SN;
-
把设备名称误当成 SN;
-
根据上下文"猜"出一个不存在的 SN;
-
把用户输入的其他设备 SN 原样传给后端;
-
自己生成一个
userId,试图查询不属于当前登录用户的数据。
因此,在真实业务系统中,"选对工具"只解决了第一步。执行之前还要继续回答三个问题:
-
这个工具需要哪些参数?
-
当前参数是否完整、合法而且来源可信?
-
当前登录用户是否有权用这些参数执行该工具?
这就是参数与权限治理要解决的问题。
本文沿着上一章的终点继续,从已经确定的 mcpToolId 出发,一直讲到最终的 tools/call:
确定MCP Tool → 读取Tool Schema → 模型抽取普通参数 → 参数过滤与格式校验 → 上游工具依赖 → 实体解析与消歧 → 必填参数门禁与缺参处理 → 私有工具注入服务端身份和HMAC凭证 → tools/call → MCP Server验签与数据归属校验
一、为什么不能相信模型生成的参数
大模型擅长把自然语言转换成结构化信息。例如用户说:
帮我看看上个月买的那块手表还在不在保。
模型可以比较好地提取:
{ "productType": "wearable", "purchaseTime": "last_month" }
但模型并不知道业务数据库中真实存在的订单号和设备 SN。即使它输出了一个格式正确的 SN,也无法证明这个 SN:
-
在数据库中真实存在;
-
属于当前登录用户;
-
对应用户所说的那块手表;
-
仍然处于当前任务允许访问的范围内。
所以,参数治理的核心并不是"把模型输出转成 JSON",而是区分哪些内容可以让模型理解和抽取,哪些内容必须由可信业务数据绑定。
二、先给参数划分可信等级
项目中不同参数的风险并不相同。可以按来源和用途分成五类。
| 参数类型 | 示例 | 模型的角色 | 最终可信来源 |
|---|---|---|---|
| 普通语义参数 | 商品类别、时间范围、故障描述 | 可以抽取 | 用户表达 + 格式校验 |
| 格式受限标识 | orderNo、serviceNo、手机号 |
可以抽取 | 用户表达 + 格式过滤 + 服务端业务校验 |
| 关键实体标识 | sn、couponId、productId |
模型值会被删除 | 实体解析或上游工具结果 |
| 身份参数 | userId、principal |
不接受模型填写 | Sa-Token 登录上下文 |
| 上游派生参数 | 订单查询得到的 SN | 不从自然语言猜测 | 上游工具结构化结果 |
这里最关键的一点是:模型抽取出来的值只是候选参数,不是天然可信参数。
"手表""上个月""黑色"这类自然语言条件可以由模型提取,再参与后续查询。订单号、维修单号等值经过格式过滤后,还要由 MCP Server 校验业务合法性和数据归属;SN、couponId、productId 等关键实体则会删除模型值,从可信候选或上游工具结果中重新绑定。用户身份始终只能来自服务端登录上下文。
三、完整的工具执行治理链路
路由层生成 AllowedRoute 后,如果资源类型是 MCP,RetrievalEngine 会根据其中固定的 mcpToolId 从 McpToolRegistry 精确取得工具。
后续链路可以拆成几层:
AllowedRoute(MCP, getWarrantyStatus)
↓
McpToolRegistry取得工具描述和输入Schema
↓
模型只针对当前Schema抽取普通参数
↓
ExactBusinessParameterFilter
→ 去除指代词和格式不合法的精确业务参数
↓
DependencyExecutionCoordinator
→ 优先从上游工具结果绑定依赖参数
↓
EntityResolver
→ 删除模型给出的关键实体ID,从可信候选重新绑定
↓
MissingParameterGate
→ 按requiredAll和requiredAny检查参数是否齐全
↓
仍然缺失或存在多个候选
→ 保存Pending状态并向用户追问
↓
私有工具进入HuaweiMallRuntimeArguments
→ 删除模型身份字段,注入服务端身份
→ 生成时间戳和HMAC运行时凭证
↓
MCP Client调用tools/call
↓
MCP Server验签,将principal单独传入业务服务并检查数据归属
它不是一条"模型生成参数,程序照着调用"的直线,而是一条逐步提高参数可信度的流水线。
四、Tool Schema 只是输入规范,不是安全证明
MCP Tool 会提供名称、描述和输入 Schema。例如保修工具的 Schema 可能要求:
{
"type": "object",
"properties": {
"sn": {
"type": "string",
"description": "设备序列号"
}
},
"required": ["sn"]
}
Java 已经通过路由选定 getWarrantyStatus 后,才会把这个特定工具的 Schema 提供给模型,让模型从用户表达中抽取普通业务参数。
这种方式比一次性把全部工具交给模型更受控,但 Schema 只能说明:
-
需要哪些字段;
-
字段是什么类型;
-
哪些字段必填;
-
字段在语义上代表什么。
它不能证明某个值真实存在,更不能证明当前用户有权访问。所以 Schema 校验是必要条件,却不是充分条件。
一个字符串满足 SN 的格式,并不意味着它就是可信设备 SN。
五、ExactBusinessParameterFilter:先做格式过滤
模型很容易根据语言模式生成"看起来合理"的订单号、SN 或优惠券编号。这类参数如果直接进入工具,就可能造成错误查询甚至越权风险。
ExactBusinessParameterFilter 的职责比"来源授权"更具体:它先去掉明显不能作为业务主键的指代词,再检查若干关键字符串是否符合项目约定的格式。
例如"这个""那个""我的""这张优惠券"等表达不能被当作真实 ID;订单号、商品 ID、维修单号和手机号则分别按照确定的格式校验。类似下面的模型输出:
{
"couponId": "这张优惠券",
"orderNo": "123",
"phone": "13800138000"
}
经过过滤后,只会保留格式有效的手机号。
需要注意,这一层主要做指代词与关键字符串格式过滤,不是完整的 JSON Schema 校验,也不负责最终证明参数来源可信。枚举范围、字段冲突和业务合法性仍需后续门禁以及 MCP Server 检查。
参数来源的真正治理发生在后续几层:
普通参数 → 经过格式过滤后可以继续参与执行 sn / couponId / productId等关键实体 → EntityResolver会删除模型值 → 再从可信候选中重新绑定 依赖参数 → DependencyExecutionCoordinator会删除模型值 → 再从上游工具结果绑定 身份字段 → 直接丢弃模型值,由服务端重新注入
因此,格式过滤解决"像不像一个有效值",实体解析和依赖绑定解决"这个值从哪里来、能不能信任"。两者不能混为一谈。
六、缺参补问:先确定工具,再询问缺少的信息
缺参和意图不明确看起来都需要向用户提问,但两者不是同一个问题。
用户说:
帮我查一下。
系统连用户想查订单、物流还是保修都不知道,这属于意图澄清,发生在路由阶段。
用户说:
帮我查维修进度。
系统已经确定 Intent 是 REPAIR_PROGRESS,工具是 getRepairProgress,只是缺少维修单号。这才属于参数补问。
项目不会只读取 JSON Schema 中简单的 required。ToolParameterContractRegistry 会把 Schema 必填项转换为 requiredAll,同时补充业务上的 requiredAny 规则:
requiredAll → 列出的字段必须全部存在 requiredAny → 一组候选字段中至少存在一个
例如:
getRepairProgress → serviceNo或phone至少提供一个 getCouponEligibility → couponId必须有 → orderNo或productIds至少提供一个 getSparePartPrice → part必须有 → productId或model至少提供一个
实际主链会先尝试工具依赖和实体解析,再由 MissingParameterGate 根据 requiredAll + requiredAny 做最终检查。此时仍不完整,系统才会进入补参闭环:
已确定Tool → 先尝试依赖绑定和实体解析 → 检查requiredAll和requiredAny → 发现参数缺失 → 保存当前任务上下文 → 向用户提出针对性问题 → 用户补充 → 恢复原任务 → 重新执行参数与权限门禁
例如:
请提供需要查询的维修单号。
这里最好不要泛泛地问"请补充更多信息",因为系统已经知道缺的是哪个字段,问题应该尽可能具体。
七、Pending State:让下一轮回复回到原任务
补问以后,用户下一轮可能只回复:
WX202609001
这句话单独看不出任何业务 Intent。如果重新走普通意图识别,它很可能被判成无法识别。
因此,系统会把待补充任务写入 Redis。Key 使用用户和会话共同隔离:
ragent:pending:{userId}:{conversationId}
Pending 状态中保存当前工具、缺失字段、已有参数和必要的恢复上下文,并设置 30 分钟 TTL,避免陈旧任务长期占用状态。
用户下一轮输入到达时,Pipeline 会在正常路由之前尝试恢复 Pending。恢复时不会重新执行完整 Intent 路由,而是根据缺失字段构造一个缩小版 Tool Schema,只从新输入中抽取当前缺少的内容;合并参数时,也只允许覆盖此前标记为缺失的字段,不能借补参机会修改已经确定的参数。
参数重新通过门禁后,执行前使用 Redis getAndDelete() 原子取走任务;如果已经被其他请求处理或状态过期,就不会再次执行。这样同一份补充状态不会被并发消费两次。
不过,恢复任务不意味着直接执行。补进来的参数仍要重新经过格式过滤、完整性和权限检查,不能因为它来自"补问回答"就自动被信任。
八、实体消歧:缺的不是字符串,而是一个可信业务对象
有时工具缺少的参数并不适合让用户手动输入。
例如用户说:
查一下我那块手表的保修。
工具需要的是 SN,而用户表达的是"那块手表"。此时真正的问题不是缺少一个字符串,而是需要把自然语言中的设备指代,绑定到当前用户有权访问的真实设备记录。
系统采用的是"工具作用域候选集 + 确定性匹配 + 多轮确认"的显式消歧方式。
当前代码只为三个关键字段建立了这类实体解析契约:getSparePartPrice 对应 productId,getWarrantyStatus 对应 sn,getCouponEligibility 对应 couponId。执行时,即使模型已经输出这些字段,Java 也会先删除,再根据当前工具和登录用户查询可信候选。
1. 候选范围由当前工具决定
同一个"Mate 60",在不同工具中代表的业务实体不同:
-
查询保修时,要在用户已购设备中寻找,最终绑定
sn; -
查询备件价格时,要在商品目录或设备信息中寻找,最终绑定
productId; -
查询优惠券时,只能在当前用户自己的优惠券中寻找,最终绑定
couponId。
因此,系统不会维护一个不分业务场景的全局候选池,而是根据已经选定的 Tool 确定实体类型和候选范围。
2. 候选来自当前用户可访问的数据
以查询保修为例,EntityResolver 会根据登录用户查询其名下设备,再利用商品类型、型号名称等条件筛选:
没有候选
→ NOT_FOUND
只有一个候选
→ 直接绑定可信SN
存在多个候选
→ AMBIGUOUS / NEED_SELECTION
如果存在多台手表,系统会返回:
找到多个符合条件的设备,请选择:
1. HUAWEI WATCH 4 Pro
2. HUAWEI WATCH GT 5
候选项不仅包含展示名称,还携带后端查询得到的受信绑定关系:
{
"displayLabel": "HUAWEI WATCH GT 5",
"bindings": {
"sn": "SN2026XXXX"
}
}
用户选择的是候选项,而不是通过自然语言向工具注入任意 SN。
3. 用户选择后再次查询和校验
用户可以回复"第二个"、2、候选名称或候选 ID。系统解析选择后,不会直接信任 Redis 中缓存的旧业务对象,而是重新查询数据库,确认:
-
实体仍然存在;
-
实体仍然属于当前用户;
-
实体仍满足当前工具的业务范围。
通过以后,才把数据库中的真实 SN 写入工具参数,并重新执行参数门禁。
当前实现属于规则驱动的显式消歧,主要覆盖商品、设备 SN 和优惠券等明确业务实体。它不是通用的语义实体链接系统,但在业务 Agent 中,可控、可解释和可做权限校验往往比"什么都能猜"更重要。
九、工具依赖:缺失参数也可能来自上游工具
并不是所有缺失参数都应该询问用户。有些参数可以由另一个受信工具提供。
例如:
我上个月买的手表还在保吗?
用户通常不知道设备 SN,但订单系统可能保存了购买记录和设备信息。这时可以按照预先定义的依赖契约执行:
getOrder
→ 按当前用户和时间条件查购买记录
→ 返回订单、设备型号和SN
↓
getWarrantyStatus
→ SN从上游结构化结果绑定
这里的关键不是让模型自由规划任意工具链,而是 Java 根据已经确定的业务目标和依赖契约安排执行顺序。当前契约明确包含:从 getOrder 结果中的设备 SN 绑定到 getWarrantyStatus.sn,以及从订单商品信息中提取 productId 绑定到 getSparePartPrice.productId。
在真实执行顺序中,依赖协调早于实体解析和最终缺参门禁。对于契约声明必须来自上游的参数,即使模型已经抽取出 SN 或 productId,Java 也会主动删除,然后由上游工具结果重新覆盖绑定。
上游结果也可能出现三种情况:
唯一结果
→ 自动绑定并继续
多个结果
→ 保存依赖选择Pending,询问用户
没有结果
→ 明确提示无法找到可用业务对象
SN 的来源在 Trace 中应记录为"上游工具结果",而不是"模型抽取"。参数来源一旦结构化记录,后续排查错误时才能知道:值到底是用户提供的、模型抽取的、实体解析得到的,还是由上游工具传递的。
十、身份参数必须从服务端注入
如果用户在问题中说:
帮我查询用户 U002 的订单。
系统不能因为模型提取出了 userId=U002,就用这个身份访问订单数据。
用户访问 Bootstrap 服务时,首先经过 Sa-Token 登录校验,服务端建立 UserContext。后续与身份有关的参数只认这个登录上下文,不接受模型或用户在自然语言中自行声明。
HuaweiMallRuntimeArguments 会执行两件事:
-
删除模型生成或用户注入的身份字段;
-
根据当前登录用户重新映射并注入可信业务主体。
当前需要这套身份上下文的私有工具包括 getOrder、getLogistics、getCouponEligibility、getWarrantyStatus 和 getRepairProgress。商品搜索、库存和备件价格等公开工具不需要伪装成用户私有查询。
因此,身份链路是:
用户登录Token
→ Sa-Token校验
→ UserContext
→ 业务principal映射
→ 注入工具运行时参数
这种设计把"用户想查谁"和"系统确认他是谁"彻底分开。自然语言可以表达查询目标,但不能改变认证主体。
十一、MCP 调用前为什么还要增加 HMAC
商城 RAG Bootstrap 是 MCP Client,业务工具部署在独立的 MCP Server 中。即使 Bootstrap 已经完成用户登录校验,远端 MCP Server 仍然需要确认:这次请求确实来自受信调用方,身份上下文没有在传输过程中被随意伪造。
因此,调用私有工具前,Bootstrap 会附加:
-
业务 principal;
-
当前时间戳;
-
基于共享密钥计算的 HMAC-SHA256 签名。
当前签名内容为 toolName + principal + issuedAt,MCP Server 会验证签名,并检查时间戳是否处于 60 秒有效窗口内。验证通过后,它会移除 _runtimePrincipal、_runtimeIssuedAt 和 _runtimeProof 等内部字段,把可信 principal 作为独立参数传给业务服务:
登录用户
→ Bootstrap绑定principal
→ principal + timestamp + HMAC
→ MCP Server验签
→ 删除内部运行时字段
→ 将principal单独传给业务服务
→ 检查订单、设备等数据的user_id归属
→ 返回当前用户有权访问的数据
MCP Server 还会拒绝业务参数中出现 userId、principalId 或 principal_id 等受保护字段。查询订单或设备后,业务服务继续比较数据中的 user_id 与可信 principal,防止调用者通过业务参数绕过身份上下文。
十二、鉴权、授权和参数校验不是一回事
这三个概念在工具调用中经常被混在一起。
**鉴权(Authentication)**回答"调用方是谁"。例如 Sa-Token 确认登录用户,HMAC 确认 MCP 请求来自受信客户端。
**授权(Authorization)**回答"这个主体能访问什么"。例如订单查询必须限制在当前 principal 名下。
**参数校验(Validation)**回答"输入是否满足工具要求"。例如 SN 格式是否正确、维修单号是否缺失。
一个参数通过格式校验,不代表用户有权访问;请求带有合法登录 Token,也不代表可以查询任意订单。三者必须分层处理。
十三、从一句话到工具执行:完整示例
以用户问题为例:
我上个月买的手表还在保吗?
第一步:路由确定业务目标
IntentResolver
→ WARRANTY_STATUS
ResourceContractGuard
→ MCP / getWarrantyStatus
第二步:根据 Schema 检查参数
getWarrantyStatus 需要 SN,但用户没有提供。模型只能提取"手表"和"上个月"等筛选条件,不能生成可信 SN。
第三步:尝试通过依赖工具补齐
DependencyExecutionCoordinator
→ 调用getOrder
→ 使用服务端身份查询上个月的购买记录
第四步:处理候选结果
如果只找到一块手表,系统直接绑定其 SN;如果找到多块,则保存待选择状态并让用户确认;如果没有结果,则停止后续调用。
第五步:重新校验并绑定身份
选定设备后,系统重新确认设备归属,把可信 SN 写入参数,再删除所有模型身份字段,注入当前登录用户对应的 principal。
第六步:调用 MCP Server
Bootstrap 生成时间戳和 HMAC 签名,通过 MCP Client 发起 tools/call。Server 验签、移除内部字段,并检查设备归属后返回结构化结果。
第七步:生成回答并保存 Trace
模型根据真实工具结果生成自然语言回答,Trace 同时记录:
-
最终工具;
-
参数值和参数来源;
-
是否发生依赖调用;
-
是否经过用户选择;
-
工具执行结果和最终任务状态。
至此,"自然语言中的那块手表"才被安全地转换成一次具体的保修查询。
十四、为什么不能只靠 Prompt 约束
我们当然可以在 Prompt 中告诉模型:
不要编造订单号,不要查询其他用户的数据,参数不足时请先提问。
这些指令有帮助,但不能成为安全边界。Prompt 属于模型行为引导,而不是强制控制。模型仍可能因为上下文干扰、工具描述歧义或输出不稳定而违反要求。
真正的约束需要落在模型外部:
工具白名单 → 模型只能接触当前授权工具 参数门禁 → 缺少必填参数时不能执行 实体解析 → 精确业务ID来自可信数据 身份覆盖 → 删除模型身份,服务端重新注入 MCP验签 → 远端确认请求来自受信客户端 数据归属校验 → 业务服务检查订单、设备等数据是否属于当前principal
Prompt 可以提醒模型怎么做,Java 和数据库则决定它实际上能做什么。
十五、Trace 应该记录参数是从哪里来的
如果 Trace 只记录"调用了 getWarrantyStatus,参数是某个 SN",仍然无法判断这个 SN 为什么可信。
因此,参数记录最好同时携带来源:
USER_QUERY → 模型从原始问题中抽取,经Java过滤 CLARIFICATION_INPUT → 用户第二轮补充,只允许填充缺失字段 AUTH_CONTEXT → 服务端登录身份 ENTITY_RESOLUTION → 从业务候选集中解析 TOOL_OUTPUT → 上游工具结构化结果 DEFAULT → 工具Schema提供的默认值
当工具调用出错时,可以继续追问:
-
工具是不是选错了?
-
参数是不是缺失却被放行了?
-
实体是不是绑定错了?
-
SN 是模型编的,还是订单工具返回的?
-
身份是不是来自服务端上下文?
这种可追溯性,也是后续 Golden Case 能够验证执行链,而不只是比较最终答案的基础。
十六、当前实现需要诚实说明的边界
1. 实体消歧仍然是业务规则驱动
当前主要覆盖商品、设备 SN 和优惠券等明确实体,依赖型号、类别、序号和候选名称等规则,不是通用语义实体链接系统。别名和复杂口语表达的覆盖仍需要持续补充。
2. HMAC 不能替代完整的传输层安全
当前 HMAC 主要保护私有工具的身份上下文,并通过时间窗口降低伪造风险,但它不能完全替代 HTTPS、mTLS、OAuth2、密钥轮换和更严格的防重放机制。
生产部署中,MCP 服务仍应位于内网或受控网关后,签名内容也应覆盖规范化参数并增加 nonce。
3. 同一会话并发还需要进一步治理
不同 conversationId 通过用户和会话维度隔离,不会共用 Pending 状态。但同一用户对同一会话同时提交两个请求时,仍可能并发读取旧状态或竞争同一个 Pending。
生产化时可以使用 userId + conversationId 作为分布式锁或队列分区键,并为 Pending 增加 taskId/version 做 CAS,保证同一会话中的任务按顺序推进。
4. 当前工具以查询类能力为主
查询类工具即使发生误调用,风险通常低于修改订单、退款或支付等写操作。如果未来开放有副作用的工具,还需要增加二次确认、审批、幂等键、操作审计和补偿机制,不能直接沿用只读查询的安全等级。
十七、总结
工具调用真正困难的地方,不是把 JSON 发给 MCP Server,而是让 JSON 中的每一个关键字段都有可信来源。
这套参数与权限治理可以概括为:
Tool Schema → 告诉系统需要什么 模型抽取 → 从自然语言中提取普通条件 格式过滤 → 去掉指代词和格式不合法的候选值 依赖工具与实体解析 → 从可信业务数据补齐精确标识 必填参数门禁 → 根据requiredAll和requiredAny判断能否执行 Pending State → 在多轮对话中恢复未完成任务 服务端身份绑定 → 决定调用主体是谁 HMAC与数据归属校验 → 保护跨服务调用和最终数据访问
最终仍然可以回到商城 Agent 的设计原则:
模型负责理解自然语言和抽取普通参数;Java 负责判断参数能不能使用、精确实体绑定到谁、调用以什么身份发生;MCP 负责把已经通过治理的请求送到业务工具。
路由治理解决"应该调用哪个能力",参数与权限治理解决"这次调用是否具备安全执行条件"。两层合起来,才构成一个真正可控的业务 Agent。